Skip to main content

ssh_cli/
terminal.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! Colored output configuration and interactive terminal detection.
5//!
6//! Manages color choice via `termcolor` honoring precedence:
7//! 1. CLI flag `--no-color` (highest priority).
8//! 2. `NO_COLOR` environment variable (see <https://no-color.org>).
9//! 3. `CLICOLOR_FORCE=1` environment variable (force colors even without TTY).
10//! 4. TTY detection (colors only if stdout is an interactive terminal).
11//! 5. Fallback: no color.
12
13use anyhow::Result;
14use std::sync::OnceLock;
15use termcolor::ColorChoice;
16
17/// Color choice cache (set once at initialization).
18///
19/// Concurrent access: `OnceLock` — written once from `initialize` after parse;
20/// readers clone via `Copy`. Safe across threads (`ColorChoice: Sync`).
21static COLOR_CACHE: OnceLock<ColorChoice> = OnceLock::new();
22
23/// Initializes terminal color configuration.
24///
25/// Must be called once after CLI argument parsing.
26/// The `no_color` parameter matches the CLI `--no-color` flag.
27pub fn initialize(no_color: bool) -> Result<()> {
28    let choice = determine_color(no_color);
29    let _ = COLOR_CACHE.set(choice);
30    tracing::debug!("terminal color configuration: {:?}", choice);
31    Ok(())
32}
33
34/// Returns the configured color choice.
35///
36/// If [`initialize`] was not called, returns [`ColorChoice::Never`] as
37/// safe fallback.
38#[must_use]
39pub fn color_choice() -> ColorChoice {
40    *COLOR_CACHE.get().unwrap_or(&ColorChoice::Never)
41}
42
43/// Returns `true` if the process is running in an interactive terminal (TTY).
44///
45/// Uses [`std::io::IsTerminal`] (stabilized in Rust 1.70) for detection
46/// cross-platform without external dependencies.
47#[must_use]
48pub fn is_interactive() -> bool {
49    use std::io::IsTerminal;
50
51    // If TERM=dumb, not interactive regardless of TTY
52    if std::env::var("TERM").as_deref() == Ok("dumb") {
53        return false;
54    }
55
56    std::io::stdout().is_terminal()
57}
58
59/// Determines color choice based on precedence rules.
60fn determine_color(no_color_cli: bool) -> ColorChoice {
61    // 1. CLI flag --no-color (highest priority)
62    if no_color_cli {
63        return ColorChoice::Never;
64    }
65
66    // 2. NO_COLOR environment variable (any value)
67    if std::env::var("NO_COLOR").is_ok() {
68        return ColorChoice::Never;
69    }
70
71    // 3. CLICOLOR_FORCE=1 forces colors even without TTY
72    if std::env::var("CLICOLOR_FORCE").as_deref() == Ok("1") {
73        return ColorChoice::Always;
74    }
75
76    // 4. TTY detection: colors only on interactive terminal
77    if is_interactive() {
78        ColorChoice::Auto
79    } else {
80        ColorChoice::Never
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn no_color_cli_returns_never() {
90        let choice = determine_color(true);
91        assert!(matches!(choice, ColorChoice::Never));
92    }
93
94    #[test]
95    #[serial_test::serial]
96    fn no_color_env_returns_never() {
97        // Saves and restores environment variable state
98        let previous = std::env::var("NO_COLOR").ok();
99        let previous_force = std::env::var("CLICOLOR_FORCE").ok();
100
101        crate::test_util::env::set_var("NO_COLOR", "1");
102        crate::test_util::env::remove_var("CLICOLOR_FORCE");
103
104        let choice = determine_color(false);
105        assert!(matches!(choice, ColorChoice::Never));
106
107        // Restaura
108        match previous {
109            Some(v) => crate::test_util::env::set_var("NO_COLOR", v),
110            None => crate::test_util::env::remove_var("NO_COLOR"),
111        }
112        match previous_force {
113            Some(v) => crate::test_util::env::set_var("CLICOLOR_FORCE", v),
114            None => crate::test_util::env::remove_var("CLICOLOR_FORCE"),
115        }
116    }
117
118    #[test]
119    #[serial_test::serial]
120    fn clicolor_force_returns_always() {
121        let previous = std::env::var("NO_COLOR").ok();
122        let previous_force = std::env::var("CLICOLOR_FORCE").ok();
123
124        crate::test_util::env::remove_var("NO_COLOR");
125        crate::test_util::env::set_var("CLICOLOR_FORCE", "1");
126
127        let choice = determine_color(false);
128        assert!(matches!(choice, ColorChoice::Always));
129
130        // Restaura
131        match previous {
132            Some(v) => crate::test_util::env::set_var("NO_COLOR", v),
133            None => crate::test_util::env::remove_var("NO_COLOR"),
134        }
135        match previous_force {
136            Some(v) => crate::test_util::env::set_var("CLICOLOR_FORCE", v),
137            None => crate::test_util::env::remove_var("CLICOLOR_FORCE"),
138        }
139    }
140
141    #[test]
142    fn color_choice_returns_never_without_init() {
143        // Without initialize, fallback is Never
144        // NOTE: in parallel tests OnceLock may already hold a value.
145        // Only check that it does not panic.
146        let _ = color_choice();
147    }
148
149    #[test]
150    fn is_interactive_returns_bool() {
151        // Only checks that it does not panic
152        let _ = is_interactive();
153    }
154}