Skip to main content

ssh_cli/
terminal.rs

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