Skip to main content

ssh_cli/
terminal.rs

1//! Configuração de output colorido e detecção de terminal interativo.
2//!
3//! Gerencia a escolha de cores via `termcolor` respeitando a precedência:
4//! 1. Flag `--no-color` da CLI (maior prioridade).
5//! 2. Variável de ambiente `NO_COLOR` (padrão <https://no-color.org>).
6//! 3. Variável de ambiente `CLICOLOR_FORCE=1` (forçar cores mesmo sem TTY).
7//! 4. Detecção de TTY (cores apenas se stdout for terminal interativo).
8//! 5. Fallback: sem cor.
9
10use anyhow::Result;
11use std::sync::OnceLock;
12use termcolor::ColorChoice;
13
14/// Cache da escolha de cor (definida uma vez na inicialização).
15static COR_CACHE: OnceLock<ColorChoice> = OnceLock::new();
16
17/// Inicializa a configuração de cor do terminal.
18///
19/// Deve ser chamada uma única vez após o parsing dos argumentos CLI.
20/// O parâmetro `sem_cor` corresponde à flag `--no-color` da CLI.
21pub fn inicializar(sem_cor: bool) -> Result<()> {
22    let escolha = determinar_cor(sem_cor);
23    let _ = COR_CACHE.set(escolha);
24    tracing::debug!("configuração de cor do terminal: {:?}", escolha);
25    Ok(())
26}
27
28/// Retorna a escolha de cor configurada.
29///
30/// Se [`inicializar`] não foi chamada, retorna [`ColorChoice::Never`] como
31/// fallback seguro.
32#[must_use]
33pub fn cor_escolha() -> ColorChoice {
34    *COR_CACHE.get().unwrap_or(&ColorChoice::Never)
35}
36
37/// Retorna `true` se o processo está rodando em um terminal interativo (TTY).
38///
39/// Usa [`std::io::IsTerminal`] (estabilizado no Rust 1.70) para detecção
40/// cross-platform sem dependências externas.
41#[must_use]
42pub fn e_interativo() -> bool {
43    use std::io::IsTerminal;
44
45    // Se TERM=dumb, não é interativo independente do TTY
46    if std::env::var("TERM").as_deref() == Ok("dumb") {
47        return false;
48    }
49
50    std::io::stdout().is_terminal()
51}
52
53/// Determina a escolha de cor com base nas regras de precedência.
54fn determinar_cor(sem_cor_cli: bool) -> ColorChoice {
55    // 1. Flag --no-color da CLI (maior prioridade)
56    if sem_cor_cli {
57        return ColorChoice::Never;
58    }
59
60    // 2. Variável de ambiente NO_COLOR (qualquer valor)
61    if std::env::var("NO_COLOR").is_ok() {
62        return ColorChoice::Never;
63    }
64
65    // 3. CLICOLOR_FORCE=1 força cores mesmo sem TTY
66    if std::env::var("CLICOLOR_FORCE").as_deref() == Ok("1") {
67        return ColorChoice::Always;
68    }
69
70    // 4. Detecção de TTY: cores apenas em terminal interativo
71    if e_interativo() {
72        ColorChoice::Auto
73    } else {
74        ColorChoice::Never
75    }
76}
77
78#[cfg(test)]
79mod testes {
80    use super::*;
81    use serial_test::serial;
82
83    #[test]
84    fn sem_cor_cli_retorna_never() {
85        let escolha = determinar_cor(true);
86        assert!(matches!(escolha, ColorChoice::Never));
87    }
88
89    #[test]
90    #[serial]
91    fn no_color_env_retorna_never() {
92        // Salva e restaura o estado da variável de ambiente
93        let anterior = std::env::var("NO_COLOR").ok();
94        let anterior_force = std::env::var("CLICOLOR_FORCE").ok();
95
96        std::env::set_var("NO_COLOR", "1");
97        std::env::remove_var("CLICOLOR_FORCE");
98
99        let escolha = determinar_cor(false);
100        assert!(matches!(escolha, ColorChoice::Never));
101
102        // Restaura
103        match anterior {
104            Some(v) => std::env::set_var("NO_COLOR", v),
105            None => std::env::remove_var("NO_COLOR"),
106        }
107        match anterior_force {
108            Some(v) => std::env::set_var("CLICOLOR_FORCE", v),
109            None => std::env::remove_var("CLICOLOR_FORCE"),
110        }
111    }
112
113    #[test]
114    #[serial]
115    fn clicolor_force_retorna_always() {
116        let anterior = std::env::var("NO_COLOR").ok();
117        let anterior_force = std::env::var("CLICOLOR_FORCE").ok();
118
119        std::env::remove_var("NO_COLOR");
120        std::env::set_var("CLICOLOR_FORCE", "1");
121
122        let escolha = determinar_cor(false);
123        assert!(matches!(escolha, ColorChoice::Always));
124
125        // Restaura
126        match anterior {
127            Some(v) => std::env::set_var("NO_COLOR", v),
128            None => std::env::remove_var("NO_COLOR"),
129        }
130        match anterior_force {
131            Some(v) => std::env::set_var("CLICOLOR_FORCE", v),
132            None => std::env::remove_var("CLICOLOR_FORCE"),
133        }
134    }
135
136    #[test]
137    fn cor_escolha_retorna_never_sem_inicializar() {
138        // Sem inicializar, o fallback é Never
139        // NOTA: em testes paralelos o OnceLock pode já ter valor.
140        // Apenas verificamos que não panic.
141        let _ = cor_escolha();
142    }
143
144    #[test]
145    fn e_interativo_retorna_bool() {
146        // Apenas verifica que não panic
147        let _ = e_interativo();
148    }
149
150    #[test]
151    fn inicializar_com_sem_cor_true_nao_panica() {
152        // Exercita `inicializar()` que popula o OnceLock e emite debug log.
153        // O OnceLock pode já estar inicializado por testes anteriores —
154        // o teste apenas verifica ausência de panic, ignorando se set() falha.
155        let resultado = inicializar(true);
156        assert!(resultado.is_ok());
157    }
158
159    #[test]
160    fn inicializar_com_sem_cor_false_nao_panica() {
161        // Segundo caminho de `inicializar()`: cobre o branch de delegação
162        // para `determinar_cor(false)` sem panicar.
163        let resultado = inicializar(false);
164        assert!(resultado.is_ok());
165    }
166
167    #[test]
168    #[serial]
169    fn e_interativo_com_term_dumb_retorna_false() {
170        // Salva estado anterior da variável TERM
171        let anterior = std::env::var("TERM").ok();
172
173        std::env::set_var("TERM", "dumb");
174        let resultado = e_interativo();
175        assert!(!resultado, "TERM=dumb deve forçar modo não-interativo");
176
177        // Restaura estado anterior
178        match anterior {
179            Some(v) => std::env::set_var("TERM", v),
180            None => std::env::remove_var("TERM"),
181        }
182    }
183
184    #[test]
185    #[serial]
186    fn determinar_cor_sem_env_vars_retorna_never_em_ambiente_nao_tty() {
187        // Em ambiente de teste, stdout não é TTY e NO_COLOR/CLICOLOR_FORCE
188        // estão ausentes — exercita o branch final de `determinar_cor`.
189        let anterior_no = std::env::var("NO_COLOR").ok();
190        let anterior_force = std::env::var("CLICOLOR_FORCE").ok();
191        let anterior_term = std::env::var("TERM").ok();
192
193        std::env::remove_var("NO_COLOR");
194        std::env::remove_var("CLICOLOR_FORCE");
195        std::env::set_var("TERM", "dumb");
196
197        let escolha = determinar_cor(false);
198        // TERM=dumb força e_interativo() a retornar false,
199        // portanto determinar_cor retorna Never.
200        assert!(matches!(escolha, ColorChoice::Never));
201
202        // Restaura
203        match anterior_no {
204            Some(v) => std::env::set_var("NO_COLOR", v),
205            None => std::env::remove_var("NO_COLOR"),
206        }
207        match anterior_force {
208            Some(v) => std::env::set_var("CLICOLOR_FORCE", v),
209            None => std::env::remove_var("CLICOLOR_FORCE"),
210        }
211        match anterior_term {
212            Some(v) => std::env::set_var("TERM", v),
213            None => std::env::remove_var("TERM"),
214        }
215    }
216}