use std::{
io::IsTerminal,
sync::atomic::{AtomicI8, Ordering},
};
use anstyle::{Color, Style};
use super::cli_args::Cli;
static COLOR_STATE: AtomicI8 = AtomicI8::new(0);
const STATE_OFF: i8 = -1;
const STATE_ON: i8 = 1;
pub fn init_from_cli(cli: &Cli) {
let enabled = decide_color_enabled(cli, &EnvProbe::real());
COLOR_STATE.store(
if enabled { STATE_ON } else { STATE_OFF },
Ordering::Relaxed,
);
}
pub fn color_enabled() -> bool {
COLOR_STATE.load(Ordering::Relaxed) == STATE_ON
}
#[cfg(test)]
pub(crate) fn force_for_test(enabled: bool) {
COLOR_STATE.store(
if enabled { STATE_ON } else { STATE_OFF },
Ordering::Relaxed,
);
}
struct EnvProbe<'a> {
no_color: Option<&'a str>,
clicolor_force: Option<&'a str>,
is_tty: bool,
}
impl EnvProbe<'_> {
fn real() -> EnvProbe<'static> {
let no_color = std::env::var("NO_COLOR").ok().map(|s| {
let leaked: &'static str = Box::leak(s.into_boxed_str());
leaked
});
let clicolor_force = std::env::var("CLICOLOR_FORCE").ok().map(|s| {
let leaked: &'static str = Box::leak(s.into_boxed_str());
leaked
});
EnvProbe {
no_color,
clicolor_force,
is_tty: std::io::stdout().is_terminal(),
}
}
}
fn decide_color_enabled(cli: &Cli, env: &EnvProbe<'_>) -> bool {
if cli.no_color {
return false;
}
if let Some(v) = env.no_color
&& !v.is_empty()
{
return false;
}
if let Some(v) = env.clicolor_force
&& v == "1"
{
return true;
}
env.is_tty
}
const ACCENT_COLOR: Color = Color::Ansi256(anstyle::Ansi256Color(71));
const WARN_COLOR: Color = Color::Ansi256(anstyle::Ansi256Color(178));
const ERROR_COLOR: Color = Color::Ansi256(anstyle::Ansi256Color(167));
fn accent_style() -> Style {
Style::new().fg_color(Some(ACCENT_COLOR))
}
fn warn_style() -> Style {
Style::new().fg_color(Some(WARN_COLOR))
}
fn error_style() -> Style {
Style::new().fg_color(Some(ERROR_COLOR))
}
fn dim_style() -> Style {
Style::new().dimmed()
}
fn bold_style() -> Style {
Style::new().bold()
}
fn paint(style: Style, s: &str) -> String {
if !color_enabled() {
return s.to_string();
}
format!("{}{}{}", style.render(), s, style.render_reset())
}
pub fn accent(s: &str) -> String {
paint(accent_style(), s)
}
pub fn warn(s: &str) -> String {
paint(warn_style(), s)
}
pub fn error(s: &str) -> String {
paint(error_style(), s)
}
pub fn dim(s: &str) -> String {
paint(dim_style(), s)
}
pub fn bold(s: &str) -> String {
paint(bold_style(), s)
}
pub fn section(s: &str) -> String {
bold(s)
}
pub fn ok_marker() -> String {
accent("[ok]")
}
pub fn working_marker() -> String {
warn("[working]")
}
pub fn warn_marker() -> String {
warn("[warn]")
}
pub fn error_marker() -> String {
error("[error]")
}
pub fn field(label: &str, value: &str) -> String {
format!("{} {}", dim(&format!("{label}:")), value)
}
pub fn count(value: usize, noun: &str) -> String {
let suffix = if value == 1 { "" } else { "s" };
format!("{} {noun}{suffix}", bold(&value.to_string()))
}
pub fn confidence(value: Option<f32>, formatted: &str) -> String {
match value {
None => dim(formatted),
Some(v) if v >= 0.9 => accent(formatted),
Some(v) if v >= 0.75 => warn(formatted),
Some(_) => error(formatted),
}
}
pub fn change_id(id: &str) -> String {
dim(id)
}
pub fn principal(name: &str, email: &str) -> String {
if !color_enabled() {
return format!("{} <{}>", name, email);
}
format!("{} <{}>", bold(name), dim(email))
}
pub fn thread_state(state: &str) -> String {
match state.to_ascii_lowercase().as_str() {
"active" | "ready" | "promoted" | "current" => accent(state),
"merged" | "abandoned" => dim(state),
"blocked" | "stale" | "draft" | "diverged" => warn(state),
_ => state.to_string(),
}
}
#[cfg(test)]
mod tests {
use serial_test::serial;
use super::*;
#[test]
#[serial(color_state)]
fn helpers_emit_no_ansi_when_disabled() {
force_for_test(false);
for s in [
accent("ok"),
warn("careful"),
error("boom"),
dim("hd-abc123"),
bold("Capture audit pipeline"),
confidence(Some(0.95), "0.95"),
confidence(None, "—"),
change_id("hd-abc123"),
principal("Ada Lovelace", "ada@analytical.engine"),
thread_state("active"),
] {
assert!(!s.contains('\x1b'), "expected no ANSI escape in {:?}", s);
}
}
#[test]
#[serial(color_state)]
fn helpers_emit_ansi_when_enabled() {
force_for_test(true);
for s in [
accent("ok"),
warn("careful"),
error("boom"),
dim("hd-abc123"),
bold("Capture audit pipeline"),
confidence(Some(0.95), "0.95"),
change_id("hd-abc123"),
principal("Ada Lovelace", "ada@analytical.engine"),
thread_state("active"),
] {
assert!(s.contains('\x1b'), "expected ANSI escape in {:?}", s);
}
}
#[test]
#[serial(color_state)]
fn thread_state_unknown_is_plain() {
force_for_test(true);
let out = thread_state("zorblax");
assert_eq!(out, "zorblax", "unknown state should not be styled");
}
#[test]
#[serial(color_state)]
fn confidence_bands() {
force_for_test(true);
let none = confidence(None, "—");
assert!(
none.contains("\x1b[2m"),
"None should be dimmed: {:?}",
none
);
let high = confidence(Some(0.95), "0.95");
assert!(high.contains("38;5;71"), "high should be sage: {:?}", high);
let mid = confidence(Some(0.80), "0.80");
assert!(mid.contains("38;5;178"), "mid should be amber: {:?}", mid);
let low = confidence(Some(0.50), "0.50");
assert!(low.contains("38;5;167"), "low should be rust: {:?}", low);
}
#[test]
fn decision_no_color_flag_wins() {
let cli = test_cli(true);
let env = EnvProbe {
no_color: None,
clicolor_force: Some("1"),
is_tty: true,
};
assert!(!decide_color_enabled(&cli, &env));
}
#[test]
fn decision_no_color_env_overrides_force() {
let cli = test_cli(false);
let env = EnvProbe {
no_color: Some("1"),
clicolor_force: Some("1"),
is_tty: true,
};
assert!(
!decide_color_enabled(&cli, &env),
"NO_COLOR must beat CLICOLOR_FORCE per no-color.org precedence"
);
}
#[test]
fn decision_force_color_overrides_non_tty() {
let cli = test_cli(false);
let env = EnvProbe {
no_color: None,
clicolor_force: Some("1"),
is_tty: false,
};
assert!(decide_color_enabled(&cli, &env));
}
#[test]
fn decision_non_tty_default_off() {
let cli = test_cli(false);
let env = EnvProbe {
no_color: None,
clicolor_force: None,
is_tty: false,
};
assert!(!decide_color_enabled(&cli, &env));
}
#[test]
fn decision_tty_default_on() {
let cli = test_cli(false);
let env = EnvProbe {
no_color: None,
clicolor_force: None,
is_tty: true,
};
assert!(decide_color_enabled(&cli, &env));
}
#[test]
fn decision_empty_no_color_is_not_disable() {
let cli = test_cli(false);
let env = EnvProbe {
no_color: Some(""),
clicolor_force: None,
is_tty: true,
};
assert!(decide_color_enabled(&cli, &env));
}
fn test_cli(no_color: bool) -> Cli {
use clap::Parser;
let mut argv = vec!["heddle".to_string()];
if no_color {
argv.push("--no-color".to_string());
}
argv.push("status".to_string());
Cli::try_parse_from(argv).expect("parse minimal cli")
}
#[test]
#[serial(color_state)]
fn principal_uncolored_is_identity() {
force_for_test(false);
let out = principal("Ada Lovelace", "ada@analytical.engine");
assert_eq!(out, "Ada Lovelace <ada@analytical.engine>");
}
#[test]
#[serial(color_state)]
fn change_id_uncolored_is_identity() {
force_for_test(false);
assert_eq!(change_id("hd-abc123"), "hd-abc123");
}
}