use std::io::IsTerminal;
#[derive(Debug, Clone, Copy, Default)]
pub struct TermContext {
pub stderr_tty: bool,
pub is_ci: bool,
pub is_agent: bool,
pub no_color: bool,
}
impl TermContext {
pub fn color_ok(&self) -> bool {
!self.no_color
}
}
const AGENT_VARS: &[&str] = &[
"LLMLINT_AGENT",
"CLAUDECODE",
"CLAUDE_CODE",
"CURSOR_AGENT",
"CODEX_SANDBOX",
"REPL_ID",
"GEMINI_CLI",
];
const CI_VARS: &[&str] = &[
"GITHUB_ACTIONS",
"GITLAB_CI",
"CIRCLECI",
"TRAVIS",
"JENKINS_URL",
"TF_BUILD",
"BUILDKITE",
];
fn present(get: &impl Fn(&str) -> Option<String>, key: &str) -> bool {
get(key).is_some_and(|v| !v.is_empty())
}
pub fn is_agent(get: &impl Fn(&str) -> Option<String>) -> bool {
AGENT_VARS.iter().any(|k| present(get, k))
}
pub fn is_ci(get: &impl Fn(&str) -> Option<String>) -> bool {
if let Some(v) = get("CI") {
let v = v.trim();
if !v.is_empty() && !v.eq_ignore_ascii_case("false") && v != "0" {
return true;
}
}
CI_VARS.iter().any(|k| present(get, k))
}
pub fn no_color(get: &impl Fn(&str) -> Option<String>) -> bool {
present(get, "NO_COLOR") || get("TERM").as_deref() == Some("dumb")
}
pub fn detect() -> TermContext {
let get = |k: &str| std::env::var(k).ok();
TermContext {
stderr_tty: std::io::stderr().is_terminal(),
is_ci: is_ci(&get),
is_agent: is_agent(&get),
no_color: no_color(&get),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |k: &str| map.get(k).cloned()
}
#[test]
fn agent_detected_from_any_known_marker() {
for key in AGENT_VARS {
assert!(is_agent(&env(&[(key, "1")])), "{key} should mark an agent");
}
assert!(is_agent(&env(&[("CLAUDECODE", "1")])));
assert!(is_agent(&env(&[("CURSOR_AGENT", "true")])));
}
#[test]
fn no_agent_when_unset_or_empty() {
assert!(!is_agent(&env(&[])));
assert!(!is_agent(&env(&[("CLAUDECODE", "")])));
assert!(!is_agent(&env(&[("PATH", "/usr/bin")])));
}
#[test]
fn ci_from_neutral_var_and_vendors() {
assert!(is_ci(&env(&[("CI", "true")])));
assert!(is_ci(&env(&[("CI", "1")])));
assert!(is_ci(&env(&[("GITHUB_ACTIONS", "true")])));
assert!(is_ci(&env(&[("BUILDKITE", "true")])));
}
#[test]
fn ci_false_is_an_opt_out() {
assert!(!is_ci(&env(&[("CI", "false")])));
assert!(!is_ci(&env(&[("CI", "FALSE")])));
assert!(!is_ci(&env(&[("CI", "0")])));
assert!(!is_ci(&env(&[("CI", "")])));
assert!(!is_ci(&env(&[])));
}
#[test]
fn no_color_from_convention_or_dumb_term() {
assert!(no_color(&env(&[("NO_COLOR", "1")])));
assert!(no_color(&env(&[("NO_COLOR", "0")])));
assert!(no_color(&env(&[("TERM", "dumb")])));
assert!(!no_color(&env(&[("TERM", "xterm-256color")])));
assert!(!no_color(&env(&[("NO_COLOR", "")])));
assert!(!no_color(&env(&[])));
}
#[test]
fn color_ok_tracks_no_color() {
let ctx = TermContext {
no_color: true,
..Default::default()
};
assert!(!ctx.color_ok());
assert!(TermContext::default().color_ok());
}
}