soma_cli_core/terminal.rs
1//! Terminal capability detection and color policy.
2//!
3//! These helpers decide *whether* a stream should receive ANSI styling or
4//! interactive behavior (progress lines, prompts). They do not decide *what*
5//! to print — see [`crate::color`] for that.
6
7use std::io::IsTerminal;
8
9/// Explicit color policy a CLI can expose through a `--color` flag.
10///
11/// `Auto` defers to [`ColorMode::Auto`]'s terminal/`NO_COLOR` detection,
12/// `Always` forces styling on regardless of terminal state, and `Plain`
13/// forces styling off. This mirrors the common `--color=auto|always|never`
14/// convention used across CLIs (and the Aurora CLI token conventions, which
15/// name the "off" state `plain`).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum ColorMode {
18 #[default]
19 Auto,
20 Always,
21 Plain,
22}
23
24impl ColorMode {
25 /// Parse a `--color` flag value. Accepts `auto`, `always`, and
26 /// `plain`/`never` (both spellings are common in the wild).
27 pub fn parse(value: &str) -> Option<Self> {
28 match value {
29 "auto" => Some(Self::Auto),
30 "always" => Some(Self::Always),
31 "plain" | "never" => Some(Self::Plain),
32 _ => None,
33 }
34 }
35}
36
37/// Resolve whether color should be enabled for a stream, given an explicit
38/// [`ColorMode`] policy and whether that stream is a TTY.
39///
40/// `Auto` also honors the `NO_COLOR` convention (<https://no-color.org>):
41/// any non-empty or empty `NO_COLOR` environment variable disables color.
42pub fn resolve_color(mode: ColorMode, stream_is_tty: bool) -> bool {
43 resolve_color_inner(mode, stream_is_tty, std::env::var_os("NO_COLOR").is_some())
44}
45
46/// Pure policy core of [`resolve_color`], with the `NO_COLOR` lookup lifted
47/// out into the `no_color_set` parameter.
48///
49/// Splitting the environment read from the decision keeps the unit tests
50/// free of process-global mutation: `std::env::set_var` is `unsafe` as of
51/// Rust 2024, and this crate is `#![forbid(unsafe_code)]`, so a test that
52/// toggled `NO_COLOR` in-process could not compile. Passing the flag in also
53/// makes the test deterministic under the parallel test harness, where a
54/// sibling test mutating the same variable could otherwise race.
55fn resolve_color_inner(mode: ColorMode, stream_is_tty: bool, no_color_set: bool) -> bool {
56 match mode {
57 ColorMode::Always => true,
58 ColorMode::Plain => false,
59 ColorMode::Auto => stream_is_tty && !no_color_set,
60 }
61}
62
63/// Whether stdin is attached to an interactive terminal.
64pub fn is_stdin_terminal() -> bool {
65 std::io::stdin().is_terminal()
66}
67
68/// Whether stdout is attached to an interactive terminal.
69pub fn is_stdout_terminal() -> bool {
70 std::io::stdout().is_terminal()
71}
72
73/// Whether stderr is attached to an interactive terminal.
74pub fn is_stderr_terminal() -> bool {
75 std::io::stderr().is_terminal()
76}
77
78/// Whether stderr output should be colorized under the `Auto` policy: a TTY
79/// and no `NO_COLOR` override.
80pub fn stderr_supports_color() -> bool {
81 resolve_color(ColorMode::Auto, is_stderr_terminal())
82}
83
84/// Whether stdout output should be colorized under the `Auto` policy: a TTY
85/// and no `NO_COLOR` override.
86pub fn stdout_supports_color() -> bool {
87 resolve_color(ColorMode::Auto, is_stdout_terminal())
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn parse_accepts_known_values() {
96 assert_eq!(ColorMode::parse("auto"), Some(ColorMode::Auto));
97 assert_eq!(ColorMode::parse("always"), Some(ColorMode::Always));
98 assert_eq!(ColorMode::parse("plain"), Some(ColorMode::Plain));
99 assert_eq!(ColorMode::parse("never"), Some(ColorMode::Plain));
100 assert_eq!(ColorMode::parse("bogus"), None);
101 }
102
103 #[test]
104 fn always_and_plain_ignore_tty_state() {
105 assert!(resolve_color(ColorMode::Always, false));
106 assert!(!resolve_color(ColorMode::Plain, true));
107 }
108
109 #[test]
110 fn auto_requires_tty() {
111 assert!(!resolve_color(ColorMode::Auto, false));
112 }
113
114 #[test]
115 fn auto_respects_no_color_even_on_a_tty() {
116 // Drive the policy core directly rather than mutating the real
117 // process environment: `set_var` is `unsafe` in Rust 2024 and this
118 // crate is `#![forbid(unsafe_code)]`.
119 assert!(
120 !resolve_color_inner(ColorMode::Auto, true, true),
121 "NO_COLOR should disable Auto color even on a tty"
122 );
123 assert!(
124 resolve_color_inner(ColorMode::Auto, true, false),
125 "Auto color should stay enabled on a tty without NO_COLOR"
126 );
127 }
128}