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    pub fn detect() -> Self {
94        Self::detect_with_env(&RealTerminalEnvironment)
95    }
96}
97
98impl Default for TerminalMode {
99    fn default() -> Self {
100        Self::detect()
101    }
102}
103
104/// Real environment for terminal detection.
105struct RealTerminalEnvironment;
106
107impl ColorEnvironment for RealTerminalEnvironment {
108    fn get_var(&self, name: &str) -> Option<String> {
109        std::env::var(name).ok()
110    }
111
112    fn is_terminal(&self) -> bool {
113        std::io::stdout().is_terminal()
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use std::collections::HashMap;
121
122    struct MockTerminalEnv {
123        vars: HashMap<String, String>,
124        is_tty: bool,
125    }
126
127    impl MockTerminalEnv {
128        fn new() -> Self {
129            Self {
130                vars: HashMap::new(),
131                is_tty: true,
132            }
133        }
134
135        fn with_var(mut self, name: &str, value: &str) -> Self {
136            self.vars.insert(name.to_string(), value.to_string());
137            self
138        }
139
140        fn not_tty(mut self) -> Self {
141            self.is_tty = false;
142            self
143        }
144    }
145
146    impl ColorEnvironment for MockTerminalEnv {
147        fn get_var(&self, name: &str) -> Option<String> {
148            self.vars.get(name).cloned()
149        }
150
151        fn is_terminal(&self) -> bool {
152            self.is_tty
153        }
154    }
155
156    #[test]
157    fn test_terminal_mode_no_color() {
158        let env = MockTerminalEnv::new().with_var("NO_COLOR", "1");
159        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::None);
160    }
161
162    #[test]
163    fn test_terminal_mode_clicolor_force_tty() {
164        let env = MockTerminalEnv::new().with_var("CLICOLOR_FORCE", "1");
165        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::Full);
166    }
167
168    #[test]
169    fn test_terminal_mode_clicolor_force_not_tty() {
170        let env = MockTerminalEnv::new()
171            .with_var("CLICOLOR_FORCE", "1")
172            .not_tty();
173        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::Basic);
174    }
175
176    #[test]
177    fn test_terminal_mode_clicolor_zero() {
178        let env = MockTerminalEnv::new().with_var("CLICOLOR", "0");
179        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::None);
180    }
181
182    #[test]
183    fn test_terminal_mode_term_dumb() {
184        let env = MockTerminalEnv::new().with_var("TERM", "dumb");
185        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::Basic);
186    }
187
188    #[test]
189    fn test_terminal_mode_term_xterm() {
190        let env = MockTerminalEnv::new().with_var("TERM", "xterm-256color");
191        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::Full);
192    }
193
194    #[test]
195    fn test_terminal_mode_not_tty() {
196        let env = MockTerminalEnv::new().not_tty();
197        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::None);
198    }
199
200    #[test]
201    fn test_terminal_mode_unknown_term() {
202        let env = MockTerminalEnv::new().with_var("TERM", "unknown-terminal");
203        assert_eq!(TerminalMode::detect_with_env(&env), TerminalMode::Basic);
204    }
205
206    #[test]
207    fn test_terminal_mode_partial_eq() {
208        assert_eq!(TerminalMode::Full, TerminalMode::Full);
209        assert_eq!(TerminalMode::Basic, TerminalMode::Basic);
210        assert_eq!(TerminalMode::None, TerminalMode::None);
211        assert_ne!(TerminalMode::Full, TerminalMode::Basic);
212    }
213}