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//!
6//! # Terminal Modes
7//!
8//! - **Full**: Full ANSI support including cursor positioning, colors
9//! - **Basic**: Basic TTY with colors but no cursor positioning (e.g., `TERM=dumb`)
10//! - **None**: Non-TTY output (pipes, redirects, CI environments)
11//!
12//! # Environment Variables
13//!
14//! The detection respects standard environment variables:
15//! - `NO_COLOR=1`: Disables all ANSI output
16//! - `TERM=dumb`: Enables Basic mode (colors without cursor positioning)
17//! - `CLICOLOR=0`: Disables colors on macOS
18//! - `CLICOLOR_FORCE=1`: Forces colors even in non-TTY
19
20use std::io::IsTerminal;
21
22/// Terminal capability mode for streaming output.
23///
24/// Determines what level of ANSI escape sequences are appropriate
25/// for the current output destination.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum TerminalMode {
28    /// Full ANSI support: cursor positioning, colors, all escapes
29    ///
30    /// Used when stdout is a TTY with capable terminal (xterm, vt100, etc.)
31    Full,
32    /// Basic TTY: colors without cursor positioning
33    ///
34    /// Used when:
35    /// - `TERM=dumb` (basic terminal with color support)
36    /// - Terminal type is unknown but TTY is detected
37    Basic,
38    /// Non-TTY output: no ANSI sequences
39    ///
40    /// Used when:
41    /// - Output is piped (`ralph | tee log.txt`)
42    /// - Output is redirected (`ralph > output.txt`)
43    /// - CI environment (no TTY detected)
44    /// - `NO_COLOR=1` is set
45    None,
46}
47
48impl TerminalMode {
49    /// Detect the current terminal mode from environment.
50    ///
51    /// This checks:
52    /// 1. `NO_COLOR` environment variable (respects user preference)
53    /// 2. `CLICOLOR_FORCE` (forces colors even in non-TTY)
54    /// 3. `CLICOLOR` (macOS color disable)
55    /// 4. `TERM` environment variable for capability detection
56    /// 5. Whether stdout is a terminal using `IsTerminal` trait
57    ///
58    /// # Environment Variables
59    ///
60    /// - `NO_COLOR=1`: Disables all ANSI output
61    /// - `NO_COLOR=0` or unset: No effect
62    /// - `CLICOLOR_FORCE=1`: Forces colors even in non-TTY
63    /// - `CLICOLOR_FORCE=0` or unset: No effect
64    /// - `CLICOLOR=0`: Disables colors on macOS
65    /// - `CLICOLOR=1` or unset: No effect on macOS
66    /// - `TERM=xterm-256color`: Full ANSI support
67    /// - `TERM=dumb`: Basic TTY with colors but no cursor positioning
68    /// - `TERM=vt100`, `TERM=screen`: Full ANSI support
69    ///
70    /// # Returns
71    ///
72    /// - `Full`: stdout is TTY with capable terminal
73    /// - `Basic`: stdout is TTY but terminal is basic or TERM is unknown
74    /// - `None`: stdout is not a TTY or colors are disabled
75    ///
76    /// # Examples
77    ///
78    /// ```ignore
79    /// use ralph::json_parser::TerminalMode;
80    ///
81    /// let mode = TerminalMode::detect();
82    /// match mode {
83    ///     TerminalMode::Full => println!("Full terminal support"),
84    ///     TerminalMode::Basic => println!("Basic terminal (colors only)"),
85    ///     TerminalMode::None => println!("Non-TTY output"),
86    /// }
87    /// ```
88    pub fn detect() -> Self {
89        // Check NO_COLOR first - this is the strongest user preference
90        // See https://no-color.org/
91        if std::env::var("NO_COLOR").is_ok() {
92            return Self::None;
93        }
94
95        // Check CLICOLOR_FORCE - forces colors even in non-TTY
96        // See https://man.openbsd.org/man1/ls.1#CLICOLOR_FORCE
97        if let Ok(val) = std::env::var("CLICOLOR_FORCE") {
98            if val != "0" {
99                // Force is enabled - check if we're a TTY for cursor support
100                return if std::io::stdout().is_terminal() {
101                    Self::Full
102                } else {
103                    // Non-TTY but colors forced - use Basic (colors only, no cursor)
104                    Self::Basic
105                };
106            }
107        }
108
109        // Check CLICOLOR (macOS) - 0 means disable colors
110        if let Ok(val) = std::env::var("CLICOLOR") {
111            if val == "0" {
112                return Self::None;
113            }
114        }
115
116        // Check if stdout is a terminal
117        if !std::io::stdout().is_terminal() {
118            return Self::None;
119        }
120
121        // We have a TTY - check TERM for capability detection
122        match std::env::var("TERM") {
123            Ok(term) => {
124                // Normalize TERM variable for comparison
125                let term_lower = term.to_lowercase();
126
127                // Dumb terminal - basic color support but no cursor positioning
128                if term_lower == "dumb" {
129                    return Self::Basic;
130                }
131
132                // Check for known capable terminals
133                // These support full ANSI including cursor positioning
134                let capable_terminals = [
135                    "xterm",
136                    "xterm-",
137                    "vt100",
138                    "vt102",
139                    "vt220",
140                    "vt320",
141                    "screen",
142                    "tmux",
143                    "ansi",
144                    "rxvt",
145                    "konsole",
146                    "gnome-terminal",
147                    "iterm",
148                    "alacritty",
149                    "kitty",
150                    "wezterm",
151                    "foot",
152                ];
153
154                for capable in &capable_terminals {
155                    if term_lower.starts_with(capable) {
156                        return Self::Full;
157                    }
158                }
159
160                // Unknown but we're a TTY - conservatively use Basic mode
161                // (colors without cursor positioning)
162                Self::Basic
163            }
164            Err(_) => {
165                // No TERM variable set but we're a TTY
166                // Conservatively use Basic mode
167                Self::Basic
168            }
169        }
170    }
171}
172
173impl Default for TerminalMode {
174    fn default() -> Self {
175        Self::detect()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_terminal_mode_default() {
185        let mode = TerminalMode::default();
186        // The default depends on the test environment, so we just verify
187        // it returns a valid mode without panicking
188        match mode {
189            TerminalMode::Full | TerminalMode::Basic | TerminalMode::None => {
190                // OK - valid mode
191            }
192        }
193    }
194
195    #[test]
196    fn test_terminal_mode_detect_respects_no_color() {
197        // Save original NO_COLOR value
198        let original = std::env::var("NO_COLOR");
199
200        // Set NO_COLOR=1
201        std::env::set_var("NO_COLOR", "1");
202
203        // Should return None regardless of TTY status
204        let mode = TerminalMode::detect();
205        assert_eq!(mode, TerminalMode::None);
206
207        // Restore original value
208        match original {
209            Ok(val) => std::env::set_var("NO_COLOR", val),
210            Err(_) => std::env::remove_var("NO_COLOR"),
211        }
212    }
213
214    #[test]
215    fn test_terminal_mode_partial_eq() {
216        assert_eq!(TerminalMode::Full, TerminalMode::Full);
217        assert_eq!(TerminalMode::Basic, TerminalMode::Basic);
218        assert_eq!(TerminalMode::None, TerminalMode::None);
219
220        assert_ne!(TerminalMode::Full, TerminalMode::Basic);
221        assert_ne!(TerminalMode::Full, TerminalMode::None);
222        assert_ne!(TerminalMode::Basic, TerminalMode::None);
223    }
224}