use std::ffi::OsString;
use std::fmt;
use std::io::IsTerminal;
use std::sync::OnceLock;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mode {
Plain,
Ansi256,
Truecolor,
}
pub fn resolve(env: impl Fn(&str) -> Option<OsString>, both_streams_tty: bool) -> Mode {
let env = |k: &str| env(k).filter(|v| !v.is_empty());
let colored = || {
if env("COLORTERM").is_some_and(|v| matches!(v.to_str(), Some("truecolor" | "24bit"))) {
Mode::Truecolor
} else {
Mode::Ansi256
}
};
if env("CLICOLOR_FORCE").is_some_and(|v| v != "0") {
return colored();
}
if env("NO_COLOR").is_some() {
return Mode::Plain;
}
match env("TERM") {
None => return Mode::Plain,
Some(t) if t == "dumb" => return Mode::Plain,
Some(_) => {}
}
if !both_streams_tty {
return Mode::Plain;
}
colored()
}
pub fn mode() -> Mode {
static MODE: OnceLock<Mode> = OnceLock::new();
*MODE.get_or_init(|| {
resolve(
|k| std::env::var_os(k),
std::io::stdout().is_terminal() && std::io::stderr().is_terminal(),
)
})
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Token {
Good,
Warn,
Bad,
Accent,
Dim,
Bold,
Reset,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Paint(Token);
pub static GOOD: Paint = Paint(Token::Good);
pub static WARN: Paint = Paint(Token::Warn);
pub static BAD: Paint = Paint(Token::Bad);
pub static ACCENT: Paint = Paint(Token::Accent);
pub static DIM: Paint = Paint(Token::Dim);
pub static BOLD: Paint = Paint(Token::Bold);
pub static RESET: Paint = Paint(Token::Reset);
impl Paint {
#[must_use]
pub const fn in_mode(self, mode: Mode) -> &'static str {
match (mode, self.0) {
(Mode::Plain, _) => "",
(_, Token::Reset) => "\x1b[0m",
(_, Token::Bold) => "\x1b[1m",
(Mode::Truecolor, Token::Good) => "\x1b[38;2;184;187;38m",
(Mode::Truecolor, Token::Warn) => "\x1b[38;2;250;189;47m",
(Mode::Truecolor, Token::Bad) => "\x1b[38;2;251;73;52m",
(Mode::Truecolor, Token::Accent) => "\x1b[38;2;142;192;124m",
(Mode::Truecolor, Token::Dim) => "\x1b[38;2;146;131;116m",
(Mode::Ansi256, Token::Good) => "\x1b[38;5;142m",
(Mode::Ansi256, Token::Warn) => "\x1b[38;5;214m",
(Mode::Ansi256, Token::Bad) => "\x1b[38;5;167m",
(Mode::Ansi256, Token::Accent) => "\x1b[38;5;108m",
(Mode::Ansi256, Token::Dim) => "\x1b[38;5;245m",
}
}
}
impl fmt::Display for Paint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.in_mode(mode()))
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL: [Paint; 7] = [GOOD, WARN, BAD, ACCENT, DIM, BOLD, RESET];
const GRUVBOX_HEX: [(&str, u32); 5] = [
("GOOD", 0xb8bb26),
("WARN", 0xfabd2f),
("BAD", 0xfb4934),
("ACCENT", 0x8ec07c),
("DIM", 0x928374),
];
const COLORS: [Paint; 5] = [GOOD, WARN, BAD, ACCENT, DIM];
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> {
let owned: Vec<(String, OsString)> = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), OsString::from(*v)))
.collect();
move |k| {
owned
.iter()
.find(|(key, _)| key == k)
.map(|(_, v)| v.clone())
}
}
#[test]
fn resolve_no_color_non_utf8_is_plain() {
use std::os::unix::ffi::OsStringExt;
let env = |k: &str| match k {
"NO_COLOR" => Some(OsString::from_vec(vec![0xff])),
"TERM" => Some(OsString::from("xterm")),
"COLORTERM" => Some(OsString::from("truecolor")),
_ => None,
};
assert_eq!(resolve(env, true), Mode::Plain);
}
#[test]
fn resolve_no_color_empty_is_unset() {
let env = env_of(&[
("TERM", "xterm"),
("COLORTERM", "truecolor"),
("NO_COLOR", ""),
]);
assert_eq!(resolve(env, true), Mode::Truecolor);
}
#[test]
fn resolve_clicolor_force_empty_is_unset() {
let env = env_of(&[("CLICOLOR_FORCE", ""), ("TERM", "xterm")]);
assert_eq!(resolve(env, false), Mode::Plain);
}
#[test]
fn resolve_term_empty_is_plain() {
let env = env_of(&[("TERM", ""), ("COLORTERM", "truecolor")]);
assert_eq!(resolve(env, true), Mode::Plain);
}
#[test]
fn resolve_no_color_set_is_plain() {
let env = env_of(&[
("TERM", "xterm"),
("COLORTERM", "truecolor"),
("NO_COLOR", "1"),
]);
assert_eq!(resolve(env, true), Mode::Plain);
}
#[test]
fn resolve_term_dumb_is_plain_even_with_truecolor() {
let env = env_of(&[("TERM", "dumb"), ("COLORTERM", "truecolor")]);
assert_eq!(resolve(env, true), Mode::Plain);
}
#[test]
fn resolve_term_unset_is_plain() {
let env = env_of(&[("COLORTERM", "truecolor")]);
assert_eq!(resolve(env, true), Mode::Plain);
}
#[test]
fn resolve_non_tty_is_plain() {
let env = env_of(&[("TERM", "xterm-256color"), ("COLORTERM", "truecolor")]);
assert_eq!(resolve(env, false), Mode::Plain);
}
#[test]
fn resolve_clicolor_force_overrides_non_tty_and_no_color() {
let env = env_of(&[("CLICOLOR_FORCE", "1"), ("NO_COLOR", "1"), ("TERM", "dumb")]);
assert_eq!(resolve(env, false), Mode::Ansi256);
let env = env_of(&[("CLICOLOR_FORCE", "1"), ("COLORTERM", "24bit")]);
assert_eq!(resolve(env, false), Mode::Truecolor);
}
#[test]
fn resolve_clicolor_force_zero_is_not_force() {
let env = env_of(&[("CLICOLOR_FORCE", "0"), ("TERM", "xterm")]);
assert_eq!(resolve(env, false), Mode::Plain);
}
#[test]
fn resolve_truecolor_from_colorterm() {
for v in ["truecolor", "24bit"] {
let env = env_of(&[("TERM", "xterm"), ("COLORTERM", v)]);
assert_eq!(resolve(env, true), Mode::Truecolor, "COLORTERM={v}");
}
}
#[test]
fn resolve_tty_without_colorterm_is_ansi256() {
let env = env_of(&[("TERM", "xterm-256color")]);
assert_eq!(resolve(env, true), Mode::Ansi256);
let env = env_of(&[("TERM", "xterm"), ("COLORTERM", "yes")]);
assert_eq!(resolve(env, true), Mode::Ansi256);
}
fn rgb_of(escape: &str) -> u32 {
let body = escape
.strip_prefix("\x1b[38;2;")
.and_then(|s| s.strip_suffix('m'))
.expect("truecolor escape shape");
let parts: Vec<u32> = body.split(';').map(|p| p.parse().unwrap()).collect();
assert_eq!(parts.len(), 3);
(parts[0] << 16) | (parts[1] << 8) | parts[2]
}
#[test]
fn truecolor_escapes_match_gruvbox_hex() {
for (paint, (name, hex)) in COLORS.iter().zip(GRUVBOX_HEX) {
assert_eq!(rgb_of(paint.in_mode(Mode::Truecolor)), hex, "{name}");
}
}
#[test]
fn ansi256_indices() {
let expected = [142, 214, 167, 108, 245];
for (paint, n) in COLORS.iter().zip(expected) {
assert_eq!(paint.in_mode(Mode::Ansi256), format!("\x1b[38;5;{n}m"));
}
}
#[test]
fn plain_is_empty_for_every_token() {
for paint in ALL {
assert_eq!(paint.in_mode(Mode::Plain), "");
}
}
#[test]
fn bold_and_reset_are_mode_independent_attributes() {
for mode in [Mode::Ansi256, Mode::Truecolor] {
assert_eq!(BOLD.in_mode(mode), "\x1b[1m");
assert_eq!(RESET.in_mode(mode), "\x1b[0m");
}
}
#[test]
fn dim_is_color_not_faint() {
for mode in [Mode::Ansi256, Mode::Truecolor] {
let esc = DIM.in_mode(mode);
assert_ne!(esc, "\x1b[2m");
assert!(esc.starts_with("\x1b[38;"), "{esc:?}");
}
}
#[test]
fn no_basic_16_color_escapes() {
for paint in ALL {
for mode in [Mode::Ansi256, Mode::Truecolor] {
let esc = paint.in_mode(mode);
assert!(
!matches!(esc, "\x1b[31m" | "\x1b[32m" | "\x1b[33m" | "\x1b[36m"),
"{esc:?}"
);
}
}
}
}
#[cfg(test)]
mod source_scan {
use std::path::Path;
fn rs_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).expect("read src dir") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
rs_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
#[test]
fn no_raw_escapes_outside_theme() {
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
rs_files(&src, &mut files);
assert!(
!files.is_empty(),
"scan walked nothing — CARGO_MANIFEST_DIR wrong?"
);
let exempt = [src.join("theme.rs"), src.join("agent_cli.rs")];
let needles = [
"\\x1b[",
"\\x1B[",
"\\u{1b}[",
"\\u{1B}[",
"\\u{001b}[",
"\u{1b}[",
];
let offenders: Vec<_> = files
.iter()
.filter(|p| !exempt.contains(p))
.filter(|p| {
let src = std::fs::read_to_string(p).expect("read");
needles.iter().any(|needle| src.contains(needle))
})
.collect();
assert!(
offenders.is_empty(),
"raw ANSI escapes outside theme.rs: {offenders:?}"
);
}
}