use std::{
io::IsTerminal,
sync::atomic::{AtomicI8, Ordering},
};
use anstyle::{Color, Style};
use heddle_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(any(test, feature = "test-utils"))]
pub 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 state_id(id: &str) -> String {
dim(&human_text(id))
}
pub fn human_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some((start, end)) = verbs::machine_identity_span(rest) {
out.push_str(&rest[..start]);
let id = &rest[start..end];
if !["hs-", "hc-", "id-"]
.iter()
.any(|prefix| out.ends_with(prefix))
{
out.push_str(if id.len() >= 64 { "hs-" } else { "id-" });
}
out.push_str(&id[..8]);
rest = &rest[end..];
}
out.push_str(rest);
out
}
pub fn thread_label(name: &str, task: Option<&str>) -> String {
if !verbs::looks_like_machine_identity(name) {
return human_text(name);
}
task.filter(|task| !verbs::looks_like_machine_identity(task))
.map(human_text)
.unwrap_or_else(|| "Untitled thread".to_string())
}
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("hs-abc123"),
bold("Capture audit pipeline"),
confidence(Some(0.95), "0.95"),
confidence(None, "—"),
state_id("hs-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("hs-abc123"),
bold("Capture audit pipeline"),
confidence(Some(0.95), "0.95"),
state_id("hs-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 state_id_uncolored_is_identity() {
force_for_test(false);
assert_eq!(state_id("hs-abc123"), "hs-abc123");
}
#[test]
fn embedded_machine_ids_are_shortened_in_human_text() {
let hex = "a".repeat(64);
let git_oid = "b".repeat(40);
let uuid = "12345678-1234-1234-1234-123456789abc";
let rendered = human_text(&format!(
"state=hs-{hex}; git={git_oid}; thread={uuid}; x-{uuid}"
));
assert!(!rendered.contains(&hex), "{rendered}");
assert!(!rendered.contains(&git_oid), "{rendered}");
assert!(!rendered.contains(uuid), "{rendered}");
assert!(rendered.contains("state=hs-aaaaaaaa"), "{rendered}");
}
}