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        //
75        // The VALUE is honored, not merely the variable's presence: `RCH_JSON=0`
76        // asked for no JSON, and treating it as a request was a trap for anyone
77        // who exports the variable once and toggles it.
78        if get_env("RCH_JSON").is_some_and(|value| crate::placement::env_truthy(&value)) {
79            return Self::Machine;
80        }
81
82        // 2. Check for hook mode (CRITICAL - must detect first)
83        if Self::is_hook_invocation_with(&get_env, stdin_is_tty, first_arg) {
84            return Self::Hook;
85        }
86
87        // 3. Check NO_COLOR (https://no-color.org/ standard)
88        if get_env("NO_COLOR").is_some() {
89            return Self::Plain;
90        }
91
92        // 4. FORCE_COLOR=0 disables colors even in TTY
93        let force_color = get_env("FORCE_COLOR");
94        let force_color_on = force_color.as_deref().map(force_color_enables);
95        if force_color_on == Some(false) {
96            return Self::Plain;
97        }
98
99        // 5. Check if stderr is a terminal (we output rich to stderr!)
100        // Note: NOT stdout! stdout is for machine data
101        if stderr_is_tty {
102            return Self::Interactive;
103        }
104
105        // 6. Check FORCE_COLOR for piped scenarios
106        if force_color_on == Some(true) {
107            return Self::Colored;
108        }
109
110        // 7. Default: plain text
111        Self::Plain
112    }
113
114    /// Create a context that explicitly uses plain text (no colors/formatting).
115    #[must_use]
116    pub const fn plain() -> Self {
117        Self::Plain
118    }
119
120    /// Create a context that explicitly uses interactive mode.
121    #[must_use]
122    pub const fn interactive() -> Self {
123        Self::Interactive
124    }
125
126    /// Create a context for hook/machine output.
127    #[must_use]
128    pub const fn machine() -> Self {
129        Self::Machine
130    }
131
132    /// Detect if this is a hook invocation.
133    ///
134    /// Hook mode is detected when:
135    /// 1. RCH_HOOK_MODE environment variable is set
136    /// 2. Stdin is not a terminal AND no known subcommand is provided
137    #[allow(dead_code)]
138    fn is_hook_invocation() -> bool {
139        let first_arg = std::env::args().nth(1);
140        let get_env = |key: &str| std::env::var(key).ok();
141        Self::is_hook_invocation_with(
142            &get_env,
143            std::io::stdin().is_terminal(),
144            first_arg.as_deref(),
145        )
146    }
147
148    fn is_hook_invocation_with<F>(get_env: &F, stdin_is_tty: bool, first_arg: Option<&str>) -> bool
149    where
150        F: Fn(&str) -> Option<String>,
151    {
152        // Explicit hook mode flag
153        if get_env("RCH_HOOK_MODE").is_some() {
154            return true;
155        }
156
157        // If stdin is not a terminal and we have no subcommand args,
158        // we're likely being called as a hook
159        if !stdin_is_tty {
160            // Check if first arg looks like a subcommand
161            match first_arg {
162                None => return true, // No args = hook mode
163                Some(arg) => {
164                    // If it doesn't start with - and isn't a known subcommand,
165                    // could be hook JSON on stdin
166                    if !arg.starts_with('-') && !Self::is_known_subcommand(arg) {
167                        return true;
168                    }
169                }
170            }
171        }
172
173        false
174    }
175
176    /// Check if an argument is a known RCH subcommand.
177    fn is_known_subcommand(arg: &str) -> bool {
178        matches!(
179            arg,
180            // Main subcommands
181            "init"
182                | "setup" // alias for init
183                | "daemon"
184                | "workers"
185                | "status"
186                | "queue"
187                | "cancel"
188                | "config"
189                | "diagnose"
190                | "hook"
191                | "agents"
192                | "completions"
193                | "doctor"
194                | "self-test"
195                | "update"
196                | "fleet"
197                | "speedscore"
198                | "dashboard"
199                | "web"
200                | "schema"
201                | "capabilities"
202                | "robot-docs"
203                // Clap-provided
204                | "version"
205                | "help"
206        )
207    }
208
209    /// Can we use full rich output (tables, panels, etc)?
210    #[must_use]
211    pub const fn supports_rich(&self) -> bool {
212        matches!(self, Self::Interactive)
213    }
214
215    /// Can we use ANSI color codes?
216    #[must_use]
217    pub const fn supports_color(&self) -> bool {
218        matches!(self, Self::Interactive | Self::Colored)
219    }
220
221    /// Is this machine-readable output mode?
222    #[must_use]
223    pub const fn is_machine(&self) -> bool {
224        matches!(self, Self::Hook | Self::Machine)
225    }
226
227    /// Should we output ANYTHING decorative?
228    #[must_use]
229    pub const fn is_decorated(&self) -> bool {
230        !matches!(self, Self::Plain | Self::Hook | Self::Machine)
231    }
232
233    /// Can we use Unicode characters (box drawing, etc)?
234    ///
235    /// Checks locale environment variables for UTF-8 support.
236    #[must_use]
237    pub fn supports_unicode(&self) -> bool {
238        if !self.supports_rich() {
239            return false;
240        }
241
242        // Check LANG/LC_ALL for UTF-8
243        for var in ["LC_ALL", "LC_CTYPE", "LANG"] {
244            if let Ok(val) = std::env::var(var) {
245                let val_lower = val.to_lowercase();
246                if val_lower.contains("utf-8") || val_lower.contains("utf8") {
247                    return true;
248                }
249            }
250        }
251
252        // Check TERM for known Unicode-capable terminals
253        if let Ok(term) = std::env::var("TERM") {
254            // Most modern terminals support Unicode, except dumb
255            return !term.contains("dumb");
256        }
257
258        false
259    }
260}
261
262/// Whether a set `FORCE_COLOR` value turns color on. `0` and `false` turn it
263/// off, as in the supports-color convention; any other value (including an
264/// empty one) turns it on.
265#[must_use]
266pub fn force_color_enables(value: &str) -> bool {
267    let value = value.trim();
268    value != "0" && !value.eq_ignore_ascii_case("false")
269}
270
271impl Default for OutputContext {
272    fn default() -> Self {
273        Self::detect()
274    }
275}
276
277impl std::fmt::Display for OutputContext {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        match self {
280            Self::Hook => write!(f, "hook"),
281            Self::Machine => write!(f, "machine"),
282            Self::Interactive => write!(f, "interactive"),
283            Self::Colored => write!(f, "colored"),
284            Self::Plain => write!(f, "plain"),
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use std::collections::HashMap;
293
294    struct TestEnv {
295        vars: HashMap<&'static str, &'static str>,
296    }
297
298    impl TestEnv {
299        fn new(pairs: &[(&'static str, &'static str)]) -> Self {
300            let vars = pairs.iter().copied().collect();
301            Self { vars }
302        }
303
304        fn get(&self, key: &str) -> Option<String> {
305            self.vars.get(key).map(|value| (*value).to_string())
306        }
307    }
308
309    fn detect_with(
310        env: &TestEnv,
311        stdin_is_tty: bool,
312        stderr_is_tty: bool,
313        first_arg: Option<&str>,
314    ) -> OutputContext {
315        OutputContext::detect_with(|key| env.get(key), stdin_is_tty, stderr_is_tty, first_arg)
316    }
317
318    #[test]
319    fn test_supports_rich_only_interactive() {
320        assert!(OutputContext::Interactive.supports_rich());
321        assert!(!OutputContext::Plain.supports_rich());
322        assert!(!OutputContext::Hook.supports_rich());
323        assert!(!OutputContext::Machine.supports_rich());
324        assert!(!OutputContext::Colored.supports_rich());
325    }
326
327    #[test]
328    fn test_supports_color() {
329        assert!(OutputContext::Interactive.supports_color());
330        assert!(OutputContext::Colored.supports_color());
331        assert!(!OutputContext::Plain.supports_color());
332        assert!(!OutputContext::Hook.supports_color());
333        assert!(!OutputContext::Machine.supports_color());
334    }
335
336    #[test]
337    fn test_is_machine() {
338        assert!(OutputContext::Hook.is_machine());
339        assert!(OutputContext::Machine.is_machine());
340        assert!(!OutputContext::Interactive.is_machine());
341        assert!(!OutputContext::Colored.is_machine());
342        assert!(!OutputContext::Plain.is_machine());
343    }
344
345    #[test]
346    fn test_is_decorated() {
347        assert!(OutputContext::Interactive.is_decorated());
348        assert!(OutputContext::Colored.is_decorated());
349        assert!(!OutputContext::Plain.is_decorated());
350        assert!(!OutputContext::Hook.is_decorated());
351        assert!(!OutputContext::Machine.is_decorated());
352    }
353
354    #[test]
355    fn test_display() {
356        assert_eq!(OutputContext::Hook.to_string(), "hook");
357        assert_eq!(OutputContext::Machine.to_string(), "machine");
358        assert_eq!(OutputContext::Interactive.to_string(), "interactive");
359        assert_eq!(OutputContext::Colored.to_string(), "colored");
360        assert_eq!(OutputContext::Plain.to_string(), "plain");
361    }
362
363    #[test]
364    fn test_constructors() {
365        assert_eq!(OutputContext::plain(), OutputContext::Plain);
366        assert_eq!(OutputContext::interactive(), OutputContext::Interactive);
367        assert_eq!(OutputContext::machine(), OutputContext::Machine);
368    }
369
370    #[test]
371    fn test_known_subcommands() {
372        assert!(OutputContext::is_known_subcommand("status"));
373        assert!(OutputContext::is_known_subcommand("workers"));
374        assert!(OutputContext::is_known_subcommand("daemon"));
375        assert!(OutputContext::is_known_subcommand("capabilities"));
376        assert!(OutputContext::is_known_subcommand("robot-docs"));
377        assert!(OutputContext::is_known_subcommand("help"));
378        assert!(!OutputContext::is_known_subcommand("unknown"));
379        assert!(!OutputContext::is_known_subcommand(""));
380    }
381
382    // Environment-dependent tests - these may behave differently in CI
383    // They're kept simple to avoid flakiness
384
385    #[test]
386    fn test_default_is_detect() {
387        // Just verify default() doesn't panic
388        let _ = OutputContext::default();
389    }
390
391    #[test]
392    fn test_detect_rch_json() {
393        let env = TestEnv::new(&[("RCH_JSON", "1")]);
394        let ctx = detect_with(&env, true, true, Some("status"));
395        assert_eq!(ctx, OutputContext::Machine);
396        assert!(ctx.is_machine());
397    }
398
399    #[test]
400    fn test_detect_rch_json_honors_the_value_not_just_presence() {
401        // Truthy spellings all request machine output...
402        for value in ["1", "true", "TRUE", "yes", "on", "enabled"] {
403            let env = TestEnv::new(&[("RCH_JSON", value)]);
404            assert_eq!(
405                detect_with(&env, true, true, Some("status")),
406                OutputContext::Machine,
407                "RCH_JSON={value}"
408            );
409        }
410        // ...and a falsy one does not. Exporting RCH_JSON=0 asked for NO JSON;
411        // treating the variable's mere presence as a request was a trap for
412        // anyone who exports it once and toggles the value (bd-e92eh).
413        for value in ["0", "false", "no", "off", "disabled", ""] {
414            let env = TestEnv::new(&[("RCH_JSON", value)]);
415            assert_ne!(
416                detect_with(&env, true, true, Some("status")),
417                OutputContext::Machine,
418                "RCH_JSON={value:?}"
419            );
420        }
421        // A falsy RCH_JSON must not shadow hook detection either.
422        let env = TestEnv::new(&[("RCH_JSON", "0"), ("RCH_HOOK_MODE", "1")]);
423        assert_eq!(
424            detect_with(&env, true, true, Some("status")),
425            OutputContext::Hook
426        );
427    }
428
429    #[test]
430    fn test_detect_hook_mode_env() {
431        let env = TestEnv::new(&[("RCH_HOOK_MODE", "1")]);
432        let ctx = detect_with(&env, true, true, Some("status"));
433        assert_eq!(ctx, OutputContext::Hook);
434        assert!(ctx.is_machine());
435    }
436
437    #[test]
438    fn test_detect_hook_mode_stdin_no_args() {
439        let env = TestEnv::new(&[]);
440        let ctx = detect_with(&env, false, false, None);
441        assert_eq!(ctx, OutputContext::Hook);
442    }
443
444    #[test]
445    fn test_no_color_disables_colors() {
446        let env = TestEnv::new(&[("NO_COLOR", "1")]);
447        let ctx = detect_with(&env, true, true, Some("status"));
448        assert_eq!(ctx, OutputContext::Plain);
449        assert!(!ctx.supports_color());
450    }
451
452    #[test]
453    fn test_no_color_empty_string() {
454        let env = TestEnv::new(&[("NO_COLOR", "")]);
455        let ctx = detect_with(&env, true, true, Some("status"));
456        assert_eq!(ctx, OutputContext::Plain);
457    }
458
459    #[test]
460    fn test_force_color_zero_disables_colors() {
461        let env = TestEnv::new(&[("FORCE_COLOR", "0")]);
462        let ctx = detect_with(&env, true, true, Some("status"));
463        assert_eq!(ctx, OutputContext::Plain);
464    }
465
466    #[test]
467    fn test_force_color_false_disables_colors() {
468        let env = TestEnv::new(&[("FORCE_COLOR", "false")]);
469        let ctx = detect_with(&env, true, true, Some("status"));
470        assert_eq!(ctx, OutputContext::Plain);
471    }
472
473    #[test]
474    fn test_force_color_on_without_tty() {
475        let env = TestEnv::new(&[("FORCE_COLOR", "1")]);
476        let ctx = detect_with(&env, true, false, Some("status"));
477        assert_eq!(ctx, OutputContext::Colored);
478    }
479
480    #[test]
481    fn test_force_color_on_with_tty() {
482        let env = TestEnv::new(&[("FORCE_COLOR", "1")]);
483        let ctx = detect_with(&env, true, true, Some("status"));
484        assert_eq!(ctx, OutputContext::Interactive);
485    }
486
487    #[test]
488    fn test_force_color_empty_string_enables() {
489        let env = TestEnv::new(&[("FORCE_COLOR", "")]);
490        let ctx = detect_with(&env, true, false, Some("status"));
491        assert_eq!(ctx, OutputContext::Colored);
492    }
493
494    #[test]
495    fn test_force_color_invalid_value_enables() {
496        let env = TestEnv::new(&[("FORCE_COLOR", "yes")]);
497        let ctx = detect_with(&env, true, false, Some("status"));
498        assert_eq!(ctx, OutputContext::Colored);
499    }
500
501    #[test]
502    fn test_rch_json_takes_priority_over_force_color() {
503        let env = TestEnv::new(&[("RCH_JSON", "1"), ("FORCE_COLOR", "3")]);
504        let ctx = detect_with(&env, true, false, Some("status"));
505        assert_eq!(ctx, OutputContext::Machine);
506    }
507
508    #[test]
509    fn test_hook_mode_takes_priority_over_force_color() {
510        let env = TestEnv::new(&[("RCH_HOOK_MODE", "1"), ("FORCE_COLOR", "3")]);
511        let ctx = detect_with(&env, true, false, Some("status"));
512        assert_eq!(ctx, OutputContext::Hook);
513    }
514
515    #[test]
516    fn test_no_color_takes_priority_over_force_color() {
517        let env = TestEnv::new(&[("NO_COLOR", "1"), ("FORCE_COLOR", "3")]);
518        let ctx = detect_with(&env, true, true, Some("status"));
519        assert_eq!(ctx, OutputContext::Plain);
520    }
521
522    #[test]
523    fn test_interactive_when_tty_and_no_overrides() {
524        let env = TestEnv::new(&[]);
525        let ctx = detect_with(&env, true, true, Some("status"));
526        assert_eq!(ctx, OutputContext::Interactive);
527    }
528
529    #[test]
530    fn test_plain_when_no_tty_and_no_overrides() {
531        let env = TestEnv::new(&[]);
532        let ctx = detect_with(&env, true, false, Some("status"));
533        assert_eq!(ctx, OutputContext::Plain);
534    }
535
536    #[test]
537    fn test_hook_detection_unknown_arg_no_tty() {
538        let env = TestEnv::new(&[]);
539        let ctx = detect_with(&env, false, false, Some("unknown"));
540        assert_eq!(ctx, OutputContext::Hook);
541    }
542}