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(|value| value.trim() != "0");
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
262impl Default for OutputContext {
263    fn default() -> Self {
264        Self::detect()
265    }
266}
267
268impl std::fmt::Display for OutputContext {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        match self {
271            Self::Hook => write!(f, "hook"),
272            Self::Machine => write!(f, "machine"),
273            Self::Interactive => write!(f, "interactive"),
274            Self::Colored => write!(f, "colored"),
275            Self::Plain => write!(f, "plain"),
276        }
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use std::collections::HashMap;
284
285    struct TestEnv {
286        vars: HashMap<&'static str, &'static str>,
287    }
288
289    impl TestEnv {
290        fn new(pairs: &[(&'static str, &'static str)]) -> Self {
291            let vars = pairs.iter().copied().collect();
292            Self { vars }
293        }
294
295        fn get(&self, key: &str) -> Option<String> {
296            self.vars.get(key).map(|value| (*value).to_string())
297        }
298    }
299
300    fn detect_with(
301        env: &TestEnv,
302        stdin_is_tty: bool,
303        stderr_is_tty: bool,
304        first_arg: Option<&str>,
305    ) -> OutputContext {
306        OutputContext::detect_with(|key| env.get(key), stdin_is_tty, stderr_is_tty, first_arg)
307    }
308
309    #[test]
310    fn test_supports_rich_only_interactive() {
311        assert!(OutputContext::Interactive.supports_rich());
312        assert!(!OutputContext::Plain.supports_rich());
313        assert!(!OutputContext::Hook.supports_rich());
314        assert!(!OutputContext::Machine.supports_rich());
315        assert!(!OutputContext::Colored.supports_rich());
316    }
317
318    #[test]
319    fn test_supports_color() {
320        assert!(OutputContext::Interactive.supports_color());
321        assert!(OutputContext::Colored.supports_color());
322        assert!(!OutputContext::Plain.supports_color());
323        assert!(!OutputContext::Hook.supports_color());
324        assert!(!OutputContext::Machine.supports_color());
325    }
326
327    #[test]
328    fn test_is_machine() {
329        assert!(OutputContext::Hook.is_machine());
330        assert!(OutputContext::Machine.is_machine());
331        assert!(!OutputContext::Interactive.is_machine());
332        assert!(!OutputContext::Colored.is_machine());
333        assert!(!OutputContext::Plain.is_machine());
334    }
335
336    #[test]
337    fn test_is_decorated() {
338        assert!(OutputContext::Interactive.is_decorated());
339        assert!(OutputContext::Colored.is_decorated());
340        assert!(!OutputContext::Plain.is_decorated());
341        assert!(!OutputContext::Hook.is_decorated());
342        assert!(!OutputContext::Machine.is_decorated());
343    }
344
345    #[test]
346    fn test_display() {
347        assert_eq!(OutputContext::Hook.to_string(), "hook");
348        assert_eq!(OutputContext::Machine.to_string(), "machine");
349        assert_eq!(OutputContext::Interactive.to_string(), "interactive");
350        assert_eq!(OutputContext::Colored.to_string(), "colored");
351        assert_eq!(OutputContext::Plain.to_string(), "plain");
352    }
353
354    #[test]
355    fn test_constructors() {
356        assert_eq!(OutputContext::plain(), OutputContext::Plain);
357        assert_eq!(OutputContext::interactive(), OutputContext::Interactive);
358        assert_eq!(OutputContext::machine(), OutputContext::Machine);
359    }
360
361    #[test]
362    fn test_known_subcommands() {
363        assert!(OutputContext::is_known_subcommand("status"));
364        assert!(OutputContext::is_known_subcommand("workers"));
365        assert!(OutputContext::is_known_subcommand("daemon"));
366        assert!(OutputContext::is_known_subcommand("capabilities"));
367        assert!(OutputContext::is_known_subcommand("robot-docs"));
368        assert!(OutputContext::is_known_subcommand("help"));
369        assert!(!OutputContext::is_known_subcommand("unknown"));
370        assert!(!OutputContext::is_known_subcommand(""));
371    }
372
373    // Environment-dependent tests - these may behave differently in CI
374    // They're kept simple to avoid flakiness
375
376    #[test]
377    fn test_default_is_detect() {
378        // Just verify default() doesn't panic
379        let _ = OutputContext::default();
380    }
381
382    #[test]
383    fn test_detect_rch_json() {
384        let env = TestEnv::new(&[("RCH_JSON", "1")]);
385        let ctx = detect_with(&env, true, true, Some("status"));
386        assert_eq!(ctx, OutputContext::Machine);
387        assert!(ctx.is_machine());
388    }
389
390    #[test]
391    fn test_detect_rch_json_honors_the_value_not_just_presence() {
392        // Truthy spellings all request machine output...
393        for value in ["1", "true", "TRUE", "yes", "on", "enabled"] {
394            let env = TestEnv::new(&[("RCH_JSON", value)]);
395            assert_eq!(
396                detect_with(&env, true, true, Some("status")),
397                OutputContext::Machine,
398                "RCH_JSON={value}"
399            );
400        }
401        // ...and a falsy one does not. Exporting RCH_JSON=0 asked for NO JSON;
402        // treating the variable's mere presence as a request was a trap for
403        // anyone who exports it once and toggles the value (bd-e92eh).
404        for value in ["0", "false", "no", "off", "disabled", ""] {
405            let env = TestEnv::new(&[("RCH_JSON", value)]);
406            assert_ne!(
407                detect_with(&env, true, true, Some("status")),
408                OutputContext::Machine,
409                "RCH_JSON={value:?}"
410            );
411        }
412        // A falsy RCH_JSON must not shadow hook detection either.
413        let env = TestEnv::new(&[("RCH_JSON", "0"), ("RCH_HOOK_MODE", "1")]);
414        assert_eq!(
415            detect_with(&env, true, true, Some("status")),
416            OutputContext::Hook
417        );
418    }
419
420    #[test]
421    fn test_detect_hook_mode_env() {
422        let env = TestEnv::new(&[("RCH_HOOK_MODE", "1")]);
423        let ctx = detect_with(&env, true, true, Some("status"));
424        assert_eq!(ctx, OutputContext::Hook);
425        assert!(ctx.is_machine());
426    }
427
428    #[test]
429    fn test_detect_hook_mode_stdin_no_args() {
430        let env = TestEnv::new(&[]);
431        let ctx = detect_with(&env, false, false, None);
432        assert_eq!(ctx, OutputContext::Hook);
433    }
434
435    #[test]
436    fn test_no_color_disables_colors() {
437        let env = TestEnv::new(&[("NO_COLOR", "1")]);
438        let ctx = detect_with(&env, true, true, Some("status"));
439        assert_eq!(ctx, OutputContext::Plain);
440        assert!(!ctx.supports_color());
441    }
442
443    #[test]
444    fn test_no_color_empty_string() {
445        let env = TestEnv::new(&[("NO_COLOR", "")]);
446        let ctx = detect_with(&env, true, true, Some("status"));
447        assert_eq!(ctx, OutputContext::Plain);
448    }
449
450    #[test]
451    fn test_force_color_zero_disables_colors() {
452        let env = TestEnv::new(&[("FORCE_COLOR", "0")]);
453        let ctx = detect_with(&env, true, true, Some("status"));
454        assert_eq!(ctx, OutputContext::Plain);
455    }
456
457    #[test]
458    fn test_force_color_on_without_tty() {
459        let env = TestEnv::new(&[("FORCE_COLOR", "1")]);
460        let ctx = detect_with(&env, true, false, Some("status"));
461        assert_eq!(ctx, OutputContext::Colored);
462    }
463
464    #[test]
465    fn test_force_color_on_with_tty() {
466        let env = TestEnv::new(&[("FORCE_COLOR", "1")]);
467        let ctx = detect_with(&env, true, true, Some("status"));
468        assert_eq!(ctx, OutputContext::Interactive);
469    }
470
471    #[test]
472    fn test_force_color_empty_string_enables() {
473        let env = TestEnv::new(&[("FORCE_COLOR", "")]);
474        let ctx = detect_with(&env, true, false, Some("status"));
475        assert_eq!(ctx, OutputContext::Colored);
476    }
477
478    #[test]
479    fn test_force_color_invalid_value_enables() {
480        let env = TestEnv::new(&[("FORCE_COLOR", "yes")]);
481        let ctx = detect_with(&env, true, false, Some("status"));
482        assert_eq!(ctx, OutputContext::Colored);
483    }
484
485    #[test]
486    fn test_rch_json_takes_priority_over_force_color() {
487        let env = TestEnv::new(&[("RCH_JSON", "1"), ("FORCE_COLOR", "3")]);
488        let ctx = detect_with(&env, true, false, Some("status"));
489        assert_eq!(ctx, OutputContext::Machine);
490    }
491
492    #[test]
493    fn test_hook_mode_takes_priority_over_force_color() {
494        let env = TestEnv::new(&[("RCH_HOOK_MODE", "1"), ("FORCE_COLOR", "3")]);
495        let ctx = detect_with(&env, true, false, Some("status"));
496        assert_eq!(ctx, OutputContext::Hook);
497    }
498
499    #[test]
500    fn test_no_color_takes_priority_over_force_color() {
501        let env = TestEnv::new(&[("NO_COLOR", "1"), ("FORCE_COLOR", "3")]);
502        let ctx = detect_with(&env, true, true, Some("status"));
503        assert_eq!(ctx, OutputContext::Plain);
504    }
505
506    #[test]
507    fn test_interactive_when_tty_and_no_overrides() {
508        let env = TestEnv::new(&[]);
509        let ctx = detect_with(&env, true, true, Some("status"));
510        assert_eq!(ctx, OutputContext::Interactive);
511    }
512
513    #[test]
514    fn test_plain_when_no_tty_and_no_overrides() {
515        let env = TestEnv::new(&[]);
516        let ctx = detect_with(&env, true, false, Some("status"));
517        assert_eq!(ctx, OutputContext::Plain);
518    }
519
520    #[test]
521    fn test_hook_detection_unknown_arg_no_tty() {
522        let env = TestEnv::new(&[]);
523        let ctx = detect_with(&env, false, false, Some("unknown"));
524        assert_eq!(ctx, OutputContext::Hook);
525    }
526}