ssh-cli 0.3.2

Native Rust CLI that gives LLMs (Claude Code, Cursor, Windsurf) the ability to operate remote servers via SSH over stdin/stdout
Documentation
//! Configuração de output colorido e detecção de terminal interativo.
//!
//! Gerencia a escolha de cores via `termcolor` respeitando a precedência:
//! 1. Flag `--no-color` da CLI (maior prioridade).
//! 2. Variável de ambiente `NO_COLOR` (padrão <https://no-color.org>).
//! 3. Variável de ambiente `CLICOLOR_FORCE=1` (forçar cores mesmo sem TTY).
//! 4. Detecção de TTY (cores apenas se stdout for terminal interativo).
//! 5. Fallback: sem cor.

use anyhow::Result;
use std::sync::OnceLock;
use termcolor::ColorChoice;

/// Cache da escolha de cor (definida uma vez na inicialização).
static COR_CACHE: OnceLock<ColorChoice> = OnceLock::new();

/// Inicializa a configuração de cor do terminal.
///
/// Deve ser chamada uma única vez após o parsing dos argumentos CLI.
/// O parâmetro `sem_cor` corresponde à flag `--no-color` da CLI.
pub fn inicializar(sem_cor: bool) -> Result<()> {
    let escolha = determinar_cor(sem_cor);
    let _ = COR_CACHE.set(escolha);
    tracing::debug!("configuração de cor do terminal: {:?}", escolha);
    Ok(())
}

/// Retorna a escolha de cor configurada.
///
/// Se [`inicializar`] não foi chamada, retorna [`ColorChoice::Never`] como
/// fallback seguro.
#[must_use]
pub fn cor_escolha() -> ColorChoice {
    *COR_CACHE.get().unwrap_or(&ColorChoice::Never)
}

/// Retorna `true` se o processo está rodando em um terminal interativo (TTY).
///
/// Usa [`std::io::IsTerminal`] (estabilizado no Rust 1.70) para detecção
/// cross-platform sem dependências externas.
#[must_use]
pub fn e_interativo() -> bool {
    use std::io::IsTerminal;

    // Se TERM=dumb, não é interativo independente do TTY
    if std::env::var("TERM").as_deref() == Ok("dumb") {
        return false;
    }

    std::io::stdout().is_terminal()
}

/// Determina a escolha de cor com base nas regras de precedência.
fn determinar_cor(sem_cor_cli: bool) -> ColorChoice {
    // 1. Flag --no-color da CLI (maior prioridade)
    if sem_cor_cli {
        return ColorChoice::Never;
    }

    // 2. Variável de ambiente NO_COLOR (qualquer valor)
    if std::env::var("NO_COLOR").is_ok() {
        return ColorChoice::Never;
    }

    // 3. CLICOLOR_FORCE=1 força cores mesmo sem TTY
    if std::env::var("CLICOLOR_FORCE").as_deref() == Ok("1") {
        return ColorChoice::Always;
    }

    // 4. Detecção de TTY: cores apenas em terminal interativo
    if e_interativo() {
        ColorChoice::Auto
    } else {
        ColorChoice::Never
    }
}

#[cfg(test)]
mod testes {
    use super::*;
    use serial_test::serial;

    #[test]
    fn sem_cor_cli_retorna_never() {
        let escolha = determinar_cor(true);
        assert!(matches!(escolha, ColorChoice::Never));
    }

    #[test]
    #[serial]
    fn no_color_env_retorna_never() {
        // Salva e restaura o estado da variável de ambiente
        let anterior = std::env::var("NO_COLOR").ok();
        let anterior_force = std::env::var("CLICOLOR_FORCE").ok();

        std::env::set_var("NO_COLOR", "1");
        std::env::remove_var("CLICOLOR_FORCE");

        let escolha = determinar_cor(false);
        assert!(matches!(escolha, ColorChoice::Never));

        // Restaura
        match anterior {
            Some(v) => std::env::set_var("NO_COLOR", v),
            None => std::env::remove_var("NO_COLOR"),
        }
        match anterior_force {
            Some(v) => std::env::set_var("CLICOLOR_FORCE", v),
            None => std::env::remove_var("CLICOLOR_FORCE"),
        }
    }

    #[test]
    #[serial]
    fn clicolor_force_retorna_always() {
        let anterior = std::env::var("NO_COLOR").ok();
        let anterior_force = std::env::var("CLICOLOR_FORCE").ok();

        std::env::remove_var("NO_COLOR");
        std::env::set_var("CLICOLOR_FORCE", "1");

        let escolha = determinar_cor(false);
        assert!(matches!(escolha, ColorChoice::Always));

        // Restaura
        match anterior {
            Some(v) => std::env::set_var("NO_COLOR", v),
            None => std::env::remove_var("NO_COLOR"),
        }
        match anterior_force {
            Some(v) => std::env::set_var("CLICOLOR_FORCE", v),
            None => std::env::remove_var("CLICOLOR_FORCE"),
        }
    }

    #[test]
    fn cor_escolha_retorna_never_sem_inicializar() {
        // Sem inicializar, o fallback é Never
        // NOTA: em testes paralelos o OnceLock pode já ter valor.
        // Apenas verificamos que não panic.
        let _ = cor_escolha();
    }

    #[test]
    fn e_interativo_retorna_bool() {
        // Apenas verifica que não panic
        let _ = e_interativo();
    }

    #[test]
    fn inicializar_com_sem_cor_true_nao_panica() {
        // Exercita `inicializar()` que popula o OnceLock e emite debug log.
        // O OnceLock pode já estar inicializado por testes anteriores —
        // o teste apenas verifica ausência de panic, ignorando se set() falha.
        let resultado = inicializar(true);
        assert!(resultado.is_ok());
    }

    #[test]
    fn inicializar_com_sem_cor_false_nao_panica() {
        // Segundo caminho de `inicializar()`: cobre o branch de delegação
        // para `determinar_cor(false)` sem panicar.
        let resultado = inicializar(false);
        assert!(resultado.is_ok());
    }

    #[test]
    #[serial]
    fn e_interativo_com_term_dumb_retorna_false() {
        // Salva estado anterior da variável TERM
        let anterior = std::env::var("TERM").ok();

        std::env::set_var("TERM", "dumb");
        let resultado = e_interativo();
        assert!(!resultado, "TERM=dumb deve forçar modo não-interativo");

        // Restaura estado anterior
        match anterior {
            Some(v) => std::env::set_var("TERM", v),
            None => std::env::remove_var("TERM"),
        }
    }

    #[test]
    #[serial]
    fn determinar_cor_sem_env_vars_retorna_never_em_ambiente_nao_tty() {
        // Em ambiente de teste, stdout não é TTY e NO_COLOR/CLICOLOR_FORCE
        // estão ausentes — exercita o branch final de `determinar_cor`.
        let anterior_no = std::env::var("NO_COLOR").ok();
        let anterior_force = std::env::var("CLICOLOR_FORCE").ok();
        let anterior_term = std::env::var("TERM").ok();

        std::env::remove_var("NO_COLOR");
        std::env::remove_var("CLICOLOR_FORCE");
        std::env::set_var("TERM", "dumb");

        let escolha = determinar_cor(false);
        // TERM=dumb força e_interativo() a retornar false,
        // portanto determinar_cor retorna Never.
        assert!(matches!(escolha, ColorChoice::Never));

        // Restaura
        match anterior_no {
            Some(v) => std::env::set_var("NO_COLOR", v),
            None => std::env::remove_var("NO_COLOR"),
        }
        match anterior_force {
            Some(v) => std::env::set_var("CLICOLOR_FORCE", v),
            None => std::env::remove_var("CLICOLOR_FORCE"),
        }
        match anterior_term {
            Some(v) => std::env::set_var("TERM", v),
            None => std::env::remove_var("TERM"),
        }
    }
}