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