Skip to main content

rch_common/ui/
context.rs

1//! Output context detection for RCH.
2//!
3//! Determines when rich terminal output is safe to use vs when plain/JSON output is required.
4//! This is critical for agent compatibility - hook mode must output pure JSON.
5
6use std::io::IsTerminal;
7
8/// Output context determines what level of rich formatting to use.
9///
10/// RCH operates in multiple contexts with vastly different output requirements:
11///
12/// | Context | Rich OK? | Why |
13/// |---------|----------|-----|
14/// | Hook mode | NO | Agent reads JSON from stdout |
15/// | Interactive | YES | Human at terminal |
16/// | Piped output | MAYBE | Depends on FORCE_COLOR |
17/// | Daemon service | NO | No terminal, logs only |
18/// | Worker execute | NO | Output goes to agent |
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum OutputContext {
21    /// Hook mode - JSON protocol only, ZERO decoration.
22    /// Detected when: JSON on stdin, or called as hook subprocess.
23    Hook,
24
25    /// Machine-readable output explicitly requested.
26    /// Detected when: --json flag, RCH_JSON=1, --format=json.
27    Machine,
28
29    /// Interactive terminal session.
30    /// Detected when: stderr is TTY, not hook mode.
31    Interactive,
32
33    /// Colored output but no full rich (no tables/panels).
34    /// Detected when: FORCE_COLOR set but no TTY.
35    Colored,
36
37    /// Plain text only, no ANSI codes.
38    /// Detected when: NO_COLOR set, or no TTY and no FORCE_COLOR.
39    Plain,
40}
41
42impl OutputContext {
43    /// Detect the current output context automatically.
44    ///
45    /// Detection order (first match wins):
46    /// 1. RCH_JSON=1 -> Machine
47    /// 2. Hook invocation detection -> Hook
48    /// 3. NO_COLOR set -> Plain
49    /// 4. FORCE_COLOR=0 -> Plain
50    /// 5. stderr is TTY -> Interactive
51    /// 6. FORCE_COLOR set -> Colored
52    /// 7. Default -> Plain
53    #[must_use]
54    pub fn detect() -> Self {
55        let first_arg = std::env::args().nth(1);
56        Self::detect_with(
57            |key| std::env::var(key).ok(),
58            std::io::stdin().is_terminal(),
59            std::io::stderr().is_terminal(),
60            first_arg.as_deref(),
61        )
62    }
63
64    fn detect_with<F>(
65        get_env: F,
66        stdin_is_tty: bool,
67        stderr_is_tty: bool,
68        first_arg: Option<&str>,
69    ) -> Self
70    where
71        F: Fn(&str) -> Option<String>,
72    {
73        // 1. Check for explicit machine output request
74        if get_env("RCH_JSON").is_some() {
75            return Self::Machine;
76        }
77
78        // 2. Check for hook mode (CRITICAL - must detect first)
79        if Self::is_hook_invocation_with(&get_env, stdin_is_tty, first_arg) {
80            return Self::Hook;
81        }
82
83        // 3. Check NO_COLOR (https://no-color.org/ standard)
84        if get_env("NO_COLOR").is_some() {
85            return Self::Plain;
86        }
87
88        // 4. FORCE_COLOR=0 disables colors even in TTY
89        let force_color = get_env("FORCE_COLOR");
90        let force_color_on = force_color.as_deref().map(|value| value.trim() != "0");
91        if force_color_on == Some(false) {
92            return Self::Plain;
93        }
94
95        // 5. Check if stderr is a terminal (we output rich to stderr!)
96        // Note: NOT stdout! stdout is for machine data
97        if stderr_is_tty {
98            return Self::Interactive;
99        }
100
101        // 6. Check FORCE_COLOR for piped scenarios
102        if force_color_on == Some(true) {
103            return Self::Colored;
104        }
105
106        // 7. Default: plain text
107        Self::Plain
108    }
109
110    /// Create a context that explicitly uses plain text (no colors/formatting).
111    #[must_use]
112    pub const fn plain() -> Self {
113        Self::Plain
114    }
115
116    /// Create a context that explicitly uses interactive mode.
117    #[must_use]
118    pub const fn interactive() -> Self {
119        Self::Interactive
120    }
121
122    /// Create a context for hook/machine output.
123    #[must_use]
124    pub const fn machine() -> Self {
125        Self::Machine
126    }
127
128    /// Detect if this is a hook invocation.
129    ///
130    /// Hook mode is detected when:
131    /// 1. RCH_HOOK_MODE environment variable is set
132    /// 2. Stdin is not a terminal AND no known subcommand is provided
133    #[allow(dead_code)]
134    fn is_hook_invocation() -> bool {
135        let first_arg = std::env::args().nth(1);
136        let get_env = |key: &str| std::env::var(key).ok();
137        Self::is_hook_invocation_with(
138            &get_env,
139            std::io::stdin().is_terminal(),
140            first_arg.as_deref(),
141        )
142    }
143
144    fn is_hook_invocation_with<F>(get_env: &F, stdin_is_tty: bool, first_arg: Option<&str>) -> bool
145    where
146        F: Fn(&str) -> Option<String>,
147    {
148        // Explicit hook mode flag
149        if get_env("RCH_HOOK_MODE").is_some() {
150            return true;
151        }
152
153        // If stdin is not a terminal and we have no subcommand args,
154        // we're likely being called as a hook
155        if !stdin_is_tty {
156            // Check if first arg looks like a subcommand
157            match first_arg {
158                None => return true, // No args = hook mode
159                Some(arg) => {
160                    // If it doesn't start with - and isn't a known subcommand,
161                    // could be hook JSON on stdin
162                    if !arg.starts_with('-') && !Self::is_known_subcommand(arg) {
163                        return true;
164                    }
165                }
166            }
167        }
168
169        false
170    }
171
172    /// Check if an argument is a known RCH subcommand.
173    fn is_known_subcommand(arg: &str) -> bool {
174        matches!(
175            arg,
176            // Main subcommands
177            "init"
178                | "setup" // alias for init
179                | "daemon"
180                | "workers"
181                | "status"
182                | "queue"
183                | "cancel"
184                | "config"
185                | "diagnose"
186                | "hook"
187                | "agents"
188                | "completions"
189                | "doctor"
190                | "self-test"
191                | "update"
192                | "fleet"
193                | "speedscore"
194                | "dashboard"
195                | "web"
196                | "schema"
197                | "capabilities"
198                | "robot-docs"
199                // Clap-provided
200                | "version"
201                | "help"
202        )
203    }
204
205    /// Can we use full rich output (tables, panels, etc)?
206    #[must_use]
207    pub const fn supports_rich(&self) -> bool {
208        matches!(self, Self::Interactive)
209    }
210
211    /// Can we use ANSI color codes?
212    #[must_use]
213    pub const fn supports_color(&self) -> bool {
214        matches!(self, Self::Interactive | Self::Colored)
215    }
216
217    /// Is this machine-readable output mode?
218    #[must_use]
219    pub const fn is_machine(&self) -> bool {
220        matches!(self, Self::Hook | Self::Machine)
221    }
222
223    /// Should we output ANYTHING decorative?
224    #[must_use]
225    pub const fn is_decorated(&self) -> bool {
226        !matches!(self, Self::Plain | Self::Hook | Self::Machine)
227    }
228
229    /// Can we use Unicode characters (box drawing, etc)?
230    ///
231    /// Checks locale environment variables for UTF-8 support.
232    #[must_use]
233    pub fn supports_unicode(&self) -> bool {
234        if !self.supports_rich() {
235            return false;
236        }
237
238        // Check LANG/LC_ALL for UTF-8
239        for var in ["LC_ALL", "LC_CTYPE", "LANG"] {
240            if let Ok(val) = std::env::var(var) {
241                let val_lower = val.to_lowercase();
242                if val_lower.contains("utf-8") || val_lower.contains("utf8") {
243                    return true;
244                }
245            }
246        }
247
248        // Check TERM for known Unicode-capable terminals
249        if let Ok(term) = std::env::var("TERM") {
250            // Most modern terminals support Unicode, except dumb
251            return !term.contains("dumb");
252        }
253
254        false
255    }
256}
257
258impl Default for OutputContext {
259    fn default() -> Self {
260        Self::detect()
261    }
262}
263
264impl std::fmt::Display for OutputContext {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        match self {
267            Self::Hook => write!(f, "hook"),
268            Self::Machine => write!(f, "machine"),
269            Self::Interactive => write!(f, "interactive"),
270            Self::Colored => write!(f, "colored"),
271            Self::Plain => write!(f, "plain"),
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use std::collections::HashMap;
280
281    struct TestEnv {
282        vars: HashMap<&'static str, &'static str>,
283    }
284
285    impl TestEnv {
286        fn new(pairs: &[(&'static str, &'static str)]) -> Self {
287            let vars = pairs.iter().copied().collect();
288            Self { vars }
289        }
290
291        fn get(&self, key: &str) -> Option<String> {
292            self.vars.get(key).map(|value| (*value).to_string())
293        }
294    }
295
296    fn detect_with(
297        env: &TestEnv,
298        stdin_is_tty: bool,
299        stderr_is_tty: bool,
300        first_arg: Option<&str>,
301    ) -> OutputContext {
302        OutputContext::detect_with(|key| env.get(key), stdin_is_tty, stderr_is_tty, first_arg)
303    }
304
305    #[test]
306    fn test_supports_rich_only_interactive() {
307        assert!(OutputContext::Interactive.supports_rich());
308        assert!(!OutputContext::Plain.supports_rich());
309        assert!(!OutputContext::Hook.supports_rich());
310        assert!(!OutputContext::Machine.supports_rich());
311        assert!(!OutputContext::Colored.supports_rich());
312    }
313
314    #[test]
315    fn test_supports_color() {
316        assert!(OutputContext::Interactive.supports_color());
317        assert!(OutputContext::Colored.supports_color());
318        assert!(!OutputContext::Plain.supports_color());
319        assert!(!OutputContext::Hook.supports_color());
320        assert!(!OutputContext::Machine.supports_color());
321    }
322
323    #[test]
324    fn test_is_machine() {
325        assert!(OutputContext::Hook.is_machine());
326        assert!(OutputContext::Machine.is_machine());
327        assert!(!OutputContext::Interactive.is_machine());
328        assert!(!OutputContext::Colored.is_machine());
329        assert!(!OutputContext::Plain.is_machine());
330    }
331
332    #[test]
333    fn test_is_decorated() {
334        assert!(OutputContext::Interactive.is_decorated());
335        assert!(OutputContext::Colored.is_decorated());
336        assert!(!OutputContext::Plain.is_decorated());
337        assert!(!OutputContext::Hook.is_decorated());
338        assert!(!OutputContext::Machine.is_decorated());
339    }
340
341    #[test]
342    fn test_display() {
343        assert_eq!(OutputContext::Hook.to_string(), "hook");
344        assert_eq!(OutputContext::Machine.to_string(), "machine");
345        assert_eq!(OutputContext::Interactive.to_string(), "interactive");
346        assert_eq!(OutputContext::Colored.to_string(), "colored");
347        assert_eq!(OutputContext::Plain.to_string(), "plain");
348    }
349
350    #[test]
351    fn test_constructors() {
352        assert_eq!(OutputContext::plain(), OutputContext::Plain);
353        assert_eq!(OutputContext::interactive(), OutputContext::Interactive);
354        assert_eq!(OutputContext::machine(), OutputContext::Machine);
355    }
356
357    #[test]
358    fn test_known_subcommands() {
359        assert!(OutputContext::is_known_subcommand("status"));
360        assert!(OutputContext::is_known_subcommand("workers"));
361        assert!(OutputContext::is_known_subcommand("daemon"));
362        assert!(OutputContext::is_known_subcommand("capabilities"));
363        assert!(OutputContext::is_known_subcommand("robot-docs"));
364        assert!(OutputContext::is_known_subcommand("help"));
365        assert!(!OutputContext::is_known_subcommand("unknown"));
366        assert!(!OutputContext::is_known_subcommand(""));
367    }
368
369    // Environment-dependent tests - these may behave differently in CI
370    // They're kept simple to avoid flakiness
371
372    #[test]
373    fn test_default_is_detect() {
374        // Just verify default() doesn't panic
375        let _ = OutputContext::default();
376    }
377
378    #[test]
379    fn test_detect_rch_json() {
380        let env = TestEnv::new(&[("RCH_JSON", "1")]);
381        let ctx = detect_with(&env, true, true, Some("status"));
382        assert_eq!(ctx, OutputContext::Machine);
383        assert!(ctx.is_machine());
384    }
385
386    #[test]
387    fn test_detect_hook_mode_env() {
388        let env = TestEnv::new(&[("RCH_HOOK_MODE", "1")]);
389        let ctx = detect_with(&env, true, true, Some("status"));
390        assert_eq!(ctx, OutputContext::Hook);
391        assert!(ctx.is_machine());
392    }
393
394    #[test]
395    fn test_detect_hook_mode_stdin_no_args() {
396        let env = TestEnv::new(&[]);
397        let ctx = detect_with(&env, false, false, None);
398        assert_eq!(ctx, OutputContext::Hook);
399    }
400
401    #[test]
402    fn test_no_color_disables_colors() {
403        let env = TestEnv::new(&[("NO_COLOR", "1")]);
404        let ctx = detect_with(&env, true, true, Some("status"));
405        assert_eq!(ctx, OutputContext::Plain);
406        assert!(!ctx.supports_color());
407    }
408
409    #[test]
410    fn test_no_color_empty_string() {
411        let env = TestEnv::new(&[("NO_COLOR", "")]);
412        let ctx = detect_with(&env, true, true, Some("status"));
413        assert_eq!(ctx, OutputContext::Plain);
414    }
415
416    #[test]
417    fn test_force_color_zero_disables_colors() {
418        let env = TestEnv::new(&[("FORCE_COLOR", "0")]);
419        let ctx = detect_with(&env, true, true, Some("status"));
420        assert_eq!(ctx, OutputContext::Plain);
421    }
422
423    #[test]
424    fn test_force_color_on_without_tty() {
425        let env = TestEnv::new(&[("FORCE_COLOR", "1")]);
426        let ctx = detect_with(&env, true, false, Some("status"));
427        assert_eq!(ctx, OutputContext::Colored);
428    }
429
430    #[test]
431    fn test_force_color_on_with_tty() {
432        let env = TestEnv::new(&[("FORCE_COLOR", "1")]);
433        let ctx = detect_with(&env, true, true, Some("status"));
434        assert_eq!(ctx, OutputContext::Interactive);
435    }
436
437    #[test]
438    fn test_force_color_empty_string_enables() {
439        let env = TestEnv::new(&[("FORCE_COLOR", "")]);
440        let ctx = detect_with(&env, true, false, Some("status"));
441        assert_eq!(ctx, OutputContext::Colored);
442    }
443
444    #[test]
445    fn test_force_color_invalid_value_enables() {
446        let env = TestEnv::new(&[("FORCE_COLOR", "yes")]);
447        let ctx = detect_with(&env, true, false, Some("status"));
448        assert_eq!(ctx, OutputContext::Colored);
449    }
450
451    #[test]
452    fn test_rch_json_takes_priority_over_force_color() {
453        let env = TestEnv::new(&[("RCH_JSON", "1"), ("FORCE_COLOR", "3")]);
454        let ctx = detect_with(&env, true, false, Some("status"));
455        assert_eq!(ctx, OutputContext::Machine);
456    }
457
458    #[test]
459    fn test_hook_mode_takes_priority_over_force_color() {
460        let env = TestEnv::new(&[("RCH_HOOK_MODE", "1"), ("FORCE_COLOR", "3")]);
461        let ctx = detect_with(&env, true, false, Some("status"));
462        assert_eq!(ctx, OutputContext::Hook);
463    }
464
465    #[test]
466    fn test_no_color_takes_priority_over_force_color() {
467        let env = TestEnv::new(&[("NO_COLOR", "1"), ("FORCE_COLOR", "3")]);
468        let ctx = detect_with(&env, true, true, Some("status"));
469        assert_eq!(ctx, OutputContext::Plain);
470    }
471
472    #[test]
473    fn test_interactive_when_tty_and_no_overrides() {
474        let env = TestEnv::new(&[]);
475        let ctx = detect_with(&env, true, true, Some("status"));
476        assert_eq!(ctx, OutputContext::Interactive);
477    }
478
479    #[test]
480    fn test_plain_when_no_tty_and_no_overrides() {
481        let env = TestEnv::new(&[]);
482        let ctx = detect_with(&env, true, false, Some("status"));
483        assert_eq!(ctx, OutputContext::Plain);
484    }
485
486    #[test]
487    fn test_hook_detection_unknown_arg_no_tty() {
488        let env = TestEnv::new(&[]);
489        let ctx = detect_with(&env, false, false, Some("unknown"));
490        assert_eq!(ctx, OutputContext::Hook);
491    }
492}