use std::io::IsTerminal;
use std::sync::OnceLock;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Stream {
Out,
Err,
}
const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const CYAN: &str = "\x1b[36m";
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const RED: &str = "\x1b[31m";
fn decide(no_color: Option<&str>, term: Option<&str>, force: Option<&str>, is_tty: bool) -> bool {
if no_color.is_some_and(|v| !v.is_empty()) {
return false;
}
if term == Some("dumb") {
return false;
}
if force.is_some_and(|v| !v.is_empty() && v != "0") {
return true;
}
is_tty
}
fn is_terminal(stream: Stream) -> bool {
match stream {
Stream::Out => std::io::stdout().is_terminal(),
Stream::Err => std::io::stderr().is_terminal(),
}
}
#[cfg(windows)]
mod windows_vt {
use super::Stream;
use std::ffi::c_void;
#[link(name = "kernel32")]
extern "system" {
fn GetStdHandle(n: u32) -> *mut c_void;
fn GetConsoleMode(h: *mut c_void, m: *mut u32) -> i32;
fn SetConsoleMode(h: *mut c_void, m: u32) -> i32;
}
const STD_OUTPUT_HANDLE: u32 = -11i32 as u32;
const STD_ERROR_HANDLE: u32 = -12i32 as u32;
const ENABLE_VIRTUAL_TERMINAL_PROCESSING: u32 = 0x0004;
pub(super) fn enabled(stream: Stream) -> bool {
let which = match stream {
Stream::Out => STD_OUTPUT_HANDLE,
Stream::Err => STD_ERROR_HANDLE,
};
let handle = unsafe { GetStdHandle(which) };
if handle.is_null() {
return false;
}
let mut mode: u32 = 0;
if unsafe { GetConsoleMode(handle, &mut mode) } == 0 {
return false;
}
if mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 {
return true;
}
unsafe { SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0 }
}
}
pub fn enabled(stream: Stream) -> bool {
static OUT: OnceLock<bool> = OnceLock::new();
static ERR: OnceLock<bool> = OnceLock::new();
let cell = match stream {
Stream::Out => &OUT,
Stream::Err => &ERR,
};
*cell.get_or_init(|| compute(stream))
}
fn compute(stream: Stream) -> bool {
let no_color = std::env::var("NO_COLOR").ok();
let term = std::env::var("TERM").ok();
let force = std::env::var("CLICOLOR_FORCE").ok();
let is_tty = is_terminal(stream);
if !decide(
no_color.as_deref(),
term.as_deref(),
force.as_deref(),
is_tty,
) {
return false;
}
if force.as_deref().is_some_and(|v| !v.is_empty() && v != "0") {
return true;
}
#[cfg(windows)]
{
windows_vt::enabled(stream)
}
#[cfg(not(windows))]
{
true
}
}
fn span(stream: Stream, code: &str, text: &str) -> String {
if enabled(stream) {
format!("{code}{text}{RESET}")
} else {
text.to_string()
}
}
pub fn bold(stream: Stream, text: &str) -> String {
span(stream, BOLD, text)
}
pub fn dim(stream: Stream, text: &str) -> String {
span(stream, DIM, text)
}
pub fn path(stream: Stream, text: &str) -> String {
span(stream, CYAN, text)
}
pub fn good(stream: Stream, text: &str) -> String {
span(stream, GREEN, text)
}
pub fn change(stream: Stream, text: &str) -> String {
span(stream, YELLOW, text)
}
pub fn gone(stream: Stream, text: &str) -> String {
span(stream, RED, text)
}
pub fn warn(stream: Stream, text: &str) -> String {
span(stream, YELLOW, text)
}
pub fn verb(stream: Stream, word: &str) -> String {
match word {
"create" | "add" | "plant" | "write" => good(stream, word),
"replace" | "update" | "lock" => change(stream, word),
"remove" => gone(stream, word),
"keep" | "kept" | "already there" | "left as it is" | "unchanged" => dim(stream, word),
_ => word.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_color_wins_over_everything_else() {
assert!(!decide(Some("1"), None, Some("1"), true));
assert!(!decide(Some("1"), None, Some("1"), false));
}
#[test]
fn an_empty_no_color_does_not_count() {
assert!(decide(Some(""), None, Some("1"), false));
}
#[test]
fn term_dumb_disables_regardless_of_force() {
assert!(!decide(None, Some("dumb"), Some("1"), true));
}
#[test]
fn clicolor_force_enables_with_no_terminal_at_all() {
assert!(decide(None, None, Some("1"), false));
assert!(decide(None, None, Some("yes"), false));
}
#[test]
fn clicolor_force_zero_does_not_count() {
assert!(!decide(None, None, Some("0"), false));
}
#[test]
fn with_no_env_the_terminal_alone_decides() {
assert!(decide(None, None, None, true));
assert!(!decide(None, None, None, false));
}
#[test]
fn verb_colours_come_from_the_word_not_a_flag() {
assert_eq!(verb(Stream::Out, "create"), good(Stream::Out, "create"));
assert_eq!(verb(Stream::Out, "replace"), change(Stream::Out, "replace"));
assert_eq!(verb(Stream::Out, "remove"), gone(Stream::Out, "remove"));
assert_eq!(verb(Stream::Out, "keep"), dim(Stream::Out, "keep"));
assert_eq!(verb(Stream::Out, "in"), "in");
}
#[test]
fn spans_carry_the_word_untouched_either_way() {
for f in [bold, dim, path, good, change, gone, warn] as [fn(Stream, &str) -> String; 7] {
assert!(f(Stream::Out, "hook").contains("hook"));
}
}
}