Skip to main content

ralph_workflow/json_parser/
terminal.rs

1//! Terminal mode detection for streaming output.
2//!
3//! This module provides terminal capability detection to control whether
4//! ANSI escape sequences (cursor positioning, colors) should be emitted.
5
6use crate::logger::ColorEnvironment;
7use std::io::IsTerminal;
8
9/// Terminal capability mode for streaming output.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum TerminalMode {
12    /// Full ANSI support: cursor positioning, colors, all escapes
13    Full,
14    /// Basic TTY: colors without cursor positioning
15    Basic,
16    /// Non-TTY output: no ANSI sequences
17    None,
18}
19
20impl TerminalMode {
21    /// Detect the current terminal mode using the provided environment.
22    pub fn detect_with_env(env: &dyn ColorEnvironment) -> Self {
23        // Check NO_COLOR first
24        if env.get_var("NO_COLOR").is_some() {
25            return Self::None;
26        }
27
28        // Check CLICOLOR_FORCE
29        if let Some(val) = env.get_var("CLICOLOR_FORCE") {
30            if val != "0" {
31                return if env.is_terminal() {
32                    Self::Full
33                } else {
34                    Self::Basic
35                };
36            }
37        }
38
39        // Check CLICOLOR
40        if let Some(val) = env.get_var("CLICOLOR") {
41            if val == "0" {
42                return Self::None;
43            }
44        }
45
46        // Check if stdout is a terminal
47        if !env.is_terminal() {
48            return Self::None;
49        }
50
51        // Check TERM for capability detection
52        match env.get_var("TERM") {
53            Some(term) => {
54                let term_lower = term.to_lowercase();
55
56                if term_lower == "dumb" {
57                    return Self::Basic;
58                }
59
60                let capable_terminals = [
61                    "xterm",
62                    "xterm-",
63                    "vt100",
64                    "vt102",
65                    "vt220",
66                    "vt320",
67                    "screen",
68                    "tmux",
69                    "ansi",
70                    "rxvt",
71                    "konsole",
72                    "gnome-terminal",
73                    "iterm",
74                    "alacritty",
75                    "kitty",
76                    "wezterm",
77                    "foot",
78                ];
79
80                for capable in &capable_terminals {
81                    if term_lower.starts_with(capable) {
82                        return Self::Full;
83                    }
84                }
85
86                Self::Basic
87            }
88            None => Self::Basic,
89        }
90    }
91
92    /// Detect the current terminal mode from environment.
93    #[must_use]
94    pub fn detect() -> Self {
95        Self::detect_with_env(&RealTerminalEnvironment)
96    }
97}
98
99impl Default for TerminalMode {
100    fn default() -> Self {
101        Self::detect()
102    }
103}
104
105/// Real environment for terminal detection.
106struct RealTerminalEnvironment;
107
108impl ColorEnvironment for RealTerminalEnvironment {
109    fn get_var(&self, name: &str) -> Option<String> {
110        std::env::var(name).ok()
111    }
112
113    fn is_terminal(&self) -> bool {
114        std::io::stdout().is_terminal()
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::collections::HashMap;
122
123    struct MockTerminalEnv {
124        vars: HashMap<String, String>,
125        is_tty: bool,
126    }
127
128    impl MockTerminalEnv {
129        fn new() -> Self {
130            Self {
131                vars: HashMap::new(),
132                is_tty: true,
133            }
134        }
135
136        fn with_var(mut self, name: &str, value: &str) -> Self {
137            self.vars.insert(name.to_string(), value.to_string());
138            self
139        }
140
141        fn not_tty(mut self) -> Self {
142            self.is_tty = false;
143            self
144        }
145    }
146
147    impl ColorEnvironment for MockTerminalEnv {
148        fn get_var(&self, name: &str) -> Option<String> {
149            self.vars.get(name).cloned()
150        }
151
152        fn is_terminal(&self) -> bool {
153            self.is_tty
154        }
155    }
156
157    #[test]
158    fn test_terminal_mode_no_color() {
159        let env = MockTerminalEnv::new().with_var("NO_COLOR", "1");
160        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::None);
161    }
162
163    #[test]
164    fn test_terminal_mode_clicolor_force_tty() {
165        let env = MockTerminalEnv::new().with_var("CLICOLOR_FORCE", "1");
166        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::Full);
167    }
168
169    #[test]
170    fn test_terminal_mode_clicolor_force_not_tty() {
171        let env = MockTerminalEnv::new()
172            .with_var("CLICOLOR_FORCE", "1")
173            .not_tty();
174        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::Basic);
175    }
176
177    #[test]
178    fn test_terminal_mode_clicolor_zero() {
179        let env = MockTerminalEnv::new().with_var("CLICOLOR", "0");
180        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::None);
181    }
182
183    #[test]
184    fn test_terminal_mode_term_dumb() {
185        let env = MockTerminalEnv::new().with_var("TERM", "dumb");
186        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::Basic);
187    }
188
189    #[test]
190    fn test_terminal_mode_term_xterm() {
191        let env = MockTerminalEnv::new().with_var("TERM", "xterm-256color");
192        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::Full);
193    }
194
195    #[test]
196    fn test_terminal_mode_not_tty() {
197        let env = MockTerminalEnv::new().not_tty();
198        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::None);
199    }
200
201    #[test]
202    fn test_terminal_mode_unknown_term() {
203        let env = MockTerminalEnv::new().with_var("TERM", "unknown-terminal");
204        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::Basic);
205    }
206
207    #[test]
208    fn test_terminal_mode_partial_eq() {
209        assert_eq!(TerminalMode::Full, TerminalMode::Full);
210        assert_eq!(TerminalMode::Basic, TerminalMode::Basic);
211        assert_eq!(TerminalMode::None, TerminalMode::None);
212        assert_ne!(TerminalMode::Full, TerminalMode::Basic);
213    }
214}