Skip to main content

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}