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}