use std::io::IsTerminal;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputContext {
Hook,
Machine,
Interactive,
Colored,
Plain,
}
impl OutputContext {
#[must_use]
pub fn detect() -> Self {
let first_arg = std::env::args().nth(1);
Self::detect_with(
|key| std::env::var(key).ok(),
std::io::stdin().is_terminal(),
std::io::stderr().is_terminal(),
first_arg.as_deref(),
)
}
fn detect_with<F>(
get_env: F,
stdin_is_tty: bool,
stderr_is_tty: bool,
first_arg: Option<&str>,
) -> Self
where
F: Fn(&str) -> Option<String>,
{
if get_env("RCH_JSON").is_some() {
return Self::Machine;
}
if Self::is_hook_invocation_with(&get_env, stdin_is_tty, first_arg) {
return Self::Hook;
}
if get_env("NO_COLOR").is_some() {
return Self::Plain;
}
let force_color = get_env("FORCE_COLOR");
let force_color_on = force_color.as_deref().map(|value| value.trim() != "0");
if force_color_on == Some(false) {
return Self::Plain;
}
if stderr_is_tty {
return Self::Interactive;
}
if force_color_on == Some(true) {
return Self::Colored;
}
Self::Plain
}
#[must_use]
pub const fn plain() -> Self {
Self::Plain
}
#[must_use]
pub const fn interactive() -> Self {
Self::Interactive
}
#[must_use]
pub const fn machine() -> Self {
Self::Machine
}
#[allow(dead_code)]
fn is_hook_invocation() -> bool {
let first_arg = std::env::args().nth(1);
let get_env = |key: &str| std::env::var(key).ok();
Self::is_hook_invocation_with(
&get_env,
std::io::stdin().is_terminal(),
first_arg.as_deref(),
)
}
fn is_hook_invocation_with<F>(get_env: &F, stdin_is_tty: bool, first_arg: Option<&str>) -> bool
where
F: Fn(&str) -> Option<String>,
{
if get_env("RCH_HOOK_MODE").is_some() {
return true;
}
if !stdin_is_tty {
match first_arg {
None => return true, Some(arg) => {
if !arg.starts_with('-') && !Self::is_known_subcommand(arg) {
return true;
}
}
}
}
false
}
fn is_known_subcommand(arg: &str) -> bool {
matches!(
arg,
"init"
| "setup" | "daemon"
| "workers"
| "status"
| "queue"
| "cancel"
| "config"
| "diagnose"
| "hook"
| "agents"
| "completions"
| "doctor"
| "self-test"
| "update"
| "fleet"
| "speedscore"
| "dashboard"
| "web"
| "schema"
| "capabilities"
| "robot-docs"
| "version"
| "help"
)
}
#[must_use]
pub const fn supports_rich(&self) -> bool {
matches!(self, Self::Interactive)
}
#[must_use]
pub const fn supports_color(&self) -> bool {
matches!(self, Self::Interactive | Self::Colored)
}
#[must_use]
pub const fn is_machine(&self) -> bool {
matches!(self, Self::Hook | Self::Machine)
}
#[must_use]
pub const fn is_decorated(&self) -> bool {
!matches!(self, Self::Plain | Self::Hook | Self::Machine)
}
#[must_use]
pub fn supports_unicode(&self) -> bool {
if !self.supports_rich() {
return false;
}
for var in ["LC_ALL", "LC_CTYPE", "LANG"] {
if let Ok(val) = std::env::var(var) {
let val_lower = val.to_lowercase();
if val_lower.contains("utf-8") || val_lower.contains("utf8") {
return true;
}
}
}
if let Ok(term) = std::env::var("TERM") {
return !term.contains("dumb");
}
false
}
}
impl Default for OutputContext {
fn default() -> Self {
Self::detect()
}
}
impl std::fmt::Display for OutputContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Hook => write!(f, "hook"),
Self::Machine => write!(f, "machine"),
Self::Interactive => write!(f, "interactive"),
Self::Colored => write!(f, "colored"),
Self::Plain => write!(f, "plain"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
struct TestEnv {
vars: HashMap<&'static str, &'static str>,
}
impl TestEnv {
fn new(pairs: &[(&'static str, &'static str)]) -> Self {
let vars = pairs.iter().copied().collect();
Self { vars }
}
fn get(&self, key: &str) -> Option<String> {
self.vars.get(key).map(|value| (*value).to_string())
}
}
fn detect_with(
env: &TestEnv,
stdin_is_tty: bool,
stderr_is_tty: bool,
first_arg: Option<&str>,
) -> OutputContext {
OutputContext::detect_with(|key| env.get(key), stdin_is_tty, stderr_is_tty, first_arg)
}
#[test]
fn test_supports_rich_only_interactive() {
assert!(OutputContext::Interactive.supports_rich());
assert!(!OutputContext::Plain.supports_rich());
assert!(!OutputContext::Hook.supports_rich());
assert!(!OutputContext::Machine.supports_rich());
assert!(!OutputContext::Colored.supports_rich());
}
#[test]
fn test_supports_color() {
assert!(OutputContext::Interactive.supports_color());
assert!(OutputContext::Colored.supports_color());
assert!(!OutputContext::Plain.supports_color());
assert!(!OutputContext::Hook.supports_color());
assert!(!OutputContext::Machine.supports_color());
}
#[test]
fn test_is_machine() {
assert!(OutputContext::Hook.is_machine());
assert!(OutputContext::Machine.is_machine());
assert!(!OutputContext::Interactive.is_machine());
assert!(!OutputContext::Colored.is_machine());
assert!(!OutputContext::Plain.is_machine());
}
#[test]
fn test_is_decorated() {
assert!(OutputContext::Interactive.is_decorated());
assert!(OutputContext::Colored.is_decorated());
assert!(!OutputContext::Plain.is_decorated());
assert!(!OutputContext::Hook.is_decorated());
assert!(!OutputContext::Machine.is_decorated());
}
#[test]
fn test_display() {
assert_eq!(OutputContext::Hook.to_string(), "hook");
assert_eq!(OutputContext::Machine.to_string(), "machine");
assert_eq!(OutputContext::Interactive.to_string(), "interactive");
assert_eq!(OutputContext::Colored.to_string(), "colored");
assert_eq!(OutputContext::Plain.to_string(), "plain");
}
#[test]
fn test_constructors() {
assert_eq!(OutputContext::plain(), OutputContext::Plain);
assert_eq!(OutputContext::interactive(), OutputContext::Interactive);
assert_eq!(OutputContext::machine(), OutputContext::Machine);
}
#[test]
fn test_known_subcommands() {
assert!(OutputContext::is_known_subcommand("status"));
assert!(OutputContext::is_known_subcommand("workers"));
assert!(OutputContext::is_known_subcommand("daemon"));
assert!(OutputContext::is_known_subcommand("capabilities"));
assert!(OutputContext::is_known_subcommand("robot-docs"));
assert!(OutputContext::is_known_subcommand("help"));
assert!(!OutputContext::is_known_subcommand("unknown"));
assert!(!OutputContext::is_known_subcommand(""));
}
#[test]
fn test_default_is_detect() {
let _ = OutputContext::default();
}
#[test]
fn test_detect_rch_json() {
let env = TestEnv::new(&[("RCH_JSON", "1")]);
let ctx = detect_with(&env, true, true, Some("status"));
assert_eq!(ctx, OutputContext::Machine);
assert!(ctx.is_machine());
}
#[test]
fn test_detect_hook_mode_env() {
let env = TestEnv::new(&[("RCH_HOOK_MODE", "1")]);
let ctx = detect_with(&env, true, true, Some("status"));
assert_eq!(ctx, OutputContext::Hook);
assert!(ctx.is_machine());
}
#[test]
fn test_detect_hook_mode_stdin_no_args() {
let env = TestEnv::new(&[]);
let ctx = detect_with(&env, false, false, None);
assert_eq!(ctx, OutputContext::Hook);
}
#[test]
fn test_no_color_disables_colors() {
let env = TestEnv::new(&[("NO_COLOR", "1")]);
let ctx = detect_with(&env, true, true, Some("status"));
assert_eq!(ctx, OutputContext::Plain);
assert!(!ctx.supports_color());
}
#[test]
fn test_no_color_empty_string() {
let env = TestEnv::new(&[("NO_COLOR", "")]);
let ctx = detect_with(&env, true, true, Some("status"));
assert_eq!(ctx, OutputContext::Plain);
}
#[test]
fn test_force_color_zero_disables_colors() {
let env = TestEnv::new(&[("FORCE_COLOR", "0")]);
let ctx = detect_with(&env, true, true, Some("status"));
assert_eq!(ctx, OutputContext::Plain);
}
#[test]
fn test_force_color_on_without_tty() {
let env = TestEnv::new(&[("FORCE_COLOR", "1")]);
let ctx = detect_with(&env, true, false, Some("status"));
assert_eq!(ctx, OutputContext::Colored);
}
#[test]
fn test_force_color_on_with_tty() {
let env = TestEnv::new(&[("FORCE_COLOR", "1")]);
let ctx = detect_with(&env, true, true, Some("status"));
assert_eq!(ctx, OutputContext::Interactive);
}
#[test]
fn test_force_color_empty_string_enables() {
let env = TestEnv::new(&[("FORCE_COLOR", "")]);
let ctx = detect_with(&env, true, false, Some("status"));
assert_eq!(ctx, OutputContext::Colored);
}
#[test]
fn test_force_color_invalid_value_enables() {
let env = TestEnv::new(&[("FORCE_COLOR", "yes")]);
let ctx = detect_with(&env, true, false, Some("status"));
assert_eq!(ctx, OutputContext::Colored);
}
#[test]
fn test_rch_json_takes_priority_over_force_color() {
let env = TestEnv::new(&[("RCH_JSON", "1"), ("FORCE_COLOR", "3")]);
let ctx = detect_with(&env, true, false, Some("status"));
assert_eq!(ctx, OutputContext::Machine);
}
#[test]
fn test_hook_mode_takes_priority_over_force_color() {
let env = TestEnv::new(&[("RCH_HOOK_MODE", "1"), ("FORCE_COLOR", "3")]);
let ctx = detect_with(&env, true, false, Some("status"));
assert_eq!(ctx, OutputContext::Hook);
}
#[test]
fn test_no_color_takes_priority_over_force_color() {
let env = TestEnv::new(&[("NO_COLOR", "1"), ("FORCE_COLOR", "3")]);
let ctx = detect_with(&env, true, true, Some("status"));
assert_eq!(ctx, OutputContext::Plain);
}
#[test]
fn test_interactive_when_tty_and_no_overrides() {
let env = TestEnv::new(&[]);
let ctx = detect_with(&env, true, true, Some("status"));
assert_eq!(ctx, OutputContext::Interactive);
}
#[test]
fn test_plain_when_no_tty_and_no_overrides() {
let env = TestEnv::new(&[]);
let ctx = detect_with(&env, true, false, Some("status"));
assert_eq!(ctx, OutputContext::Plain);
}
#[test]
fn test_hook_detection_unknown_arg_no_tty() {
let env = TestEnv::new(&[]);
let ctx = detect_with(&env, false, false, Some("unknown"));
assert_eq!(ctx, OutputContext::Hook);
}
}