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() {
75 return Self::Machine;
76 }
77
78 if Self::is_hook_invocation_with(&get_env, stdin_is_tty, first_arg) {
80 return Self::Hook;
81 }
82
83 if get_env("NO_COLOR").is_some() {
85 return Self::Plain;
86 }
87
88 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 if stderr_is_tty {
98 return Self::Interactive;
99 }
100
101 if force_color_on == Some(true) {
103 return Self::Colored;
104 }
105
106 Self::Plain
108 }
109
110 #[must_use]
112 pub const fn plain() -> Self {
113 Self::Plain
114 }
115
116 #[must_use]
118 pub const fn interactive() -> Self {
119 Self::Interactive
120 }
121
122 #[must_use]
124 pub const fn machine() -> Self {
125 Self::Machine
126 }
127
128 #[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 if get_env("RCH_HOOK_MODE").is_some() {
150 return true;
151 }
152
153 if !stdin_is_tty {
156 match first_arg {
158 None => return true, Some(arg) => {
160 if !arg.starts_with('-') && !Self::is_known_subcommand(arg) {
163 return true;
164 }
165 }
166 }
167 }
168
169 false
170 }
171
172 fn is_known_subcommand(arg: &str) -> bool {
174 matches!(
175 arg,
176 "init"
178 | "setup" | "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 | "version"
201 | "help"
202 )
203 }
204
205 #[must_use]
207 pub const fn supports_rich(&self) -> bool {
208 matches!(self, Self::Interactive)
209 }
210
211 #[must_use]
213 pub const fn supports_color(&self) -> bool {
214 matches!(self, Self::Interactive | Self::Colored)
215 }
216
217 #[must_use]
219 pub const fn is_machine(&self) -> bool {
220 matches!(self, Self::Hook | Self::Machine)
221 }
222
223 #[must_use]
225 pub const fn is_decorated(&self) -> bool {
226 !matches!(self, Self::Plain | Self::Hook | Self::Machine)
227 }
228
229 #[must_use]
233 pub fn supports_unicode(&self) -> bool {
234 if !self.supports_rich() {
235 return false;
236 }
237
238 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 if let Ok(term) = std::env::var("TERM") {
250 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 #[test]
373 fn test_default_is_detect() {
374 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}