1use std::io::IsTerminal;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum OutputContext {
21 Hook,
24
25 Machine,
28
29 Interactive,
32
33 Colored,
36
37 Plain,
40}
41
42impl OutputContext {
43 #[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 if get_env("RCH_JSON").is_some_and(|value| crate::placement::env_truthy(&value)) {
79 return Self::Machine;
80 }
81
82 if Self::is_hook_invocation_with(&get_env, stdin_is_tty, first_arg) {
84 return Self::Hook;
85 }
86
87 if get_env("NO_COLOR").is_some() {
89 return Self::Plain;
90 }
91
92 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 if stderr_is_tty {
102 return Self::Interactive;
103 }
104
105 if force_color_on == Some(true) {
107 return Self::Colored;
108 }
109
110 Self::Plain
112 }
113
114 #[must_use]
116 pub const fn plain() -> Self {
117 Self::Plain
118 }
119
120 #[must_use]
122 pub const fn interactive() -> Self {
123 Self::Interactive
124 }
125
126 #[must_use]
128 pub const fn machine() -> Self {
129 Self::Machine
130 }
131
132 #[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 if get_env("RCH_HOOK_MODE").is_some() {
154 return true;
155 }
156
157 if !stdin_is_tty {
160 match first_arg {
162 None => return true, Some(arg) => {
164 if !arg.starts_with('-') && !Self::is_known_subcommand(arg) {
167 return true;
168 }
169 }
170 }
171 }
172
173 false
174 }
175
176 fn is_known_subcommand(arg: &str) -> bool {
178 matches!(
179 arg,
180 "init"
182 | "setup" | "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 | "version"
205 | "help"
206 )
207 }
208
209 #[must_use]
211 pub const fn supports_rich(&self) -> bool {
212 matches!(self, Self::Interactive)
213 }
214
215 #[must_use]
217 pub const fn supports_color(&self) -> bool {
218 matches!(self, Self::Interactive | Self::Colored)
219 }
220
221 #[must_use]
223 pub const fn is_machine(&self) -> bool {
224 matches!(self, Self::Hook | Self::Machine)
225 }
226
227 #[must_use]
229 pub const fn is_decorated(&self) -> bool {
230 !matches!(self, Self::Plain | Self::Hook | Self::Machine)
231 }
232
233 #[must_use]
237 pub fn supports_unicode(&self) -> bool {
238 if !self.supports_rich() {
239 return false;
240 }
241
242 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 if let Ok(term) = std::env::var("TERM") {
254 return !term.contains("dumb");
256 }
257
258 false
259 }
260}
261
262#[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 #[test]
386 fn test_default_is_detect() {
387 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 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 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 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}