diff --git a/Cargo.lock b/Cargo.lock index 1656096a2..ba1f86547 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,7 @@ dependencies = [ "llm", "serde", "serde_json", + "terminal", "thiserror 2.0.18", "tokio", "uuid", @@ -170,6 +171,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "terminal", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -2109,6 +2111,10 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal" +version = "0.1.0" + [[package]] name = "termtree" version = "0.5.1" diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 43de297f5..c98f4935c 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -21,6 +21,7 @@ clap.workspace = true anyhow.workspace = true dotenvy.workspace = true llm = { path = "../llm" } +terminal = { path = "../terminal" } thiserror.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/agent/src/cli.rs b/crates/agent/src/cli.rs index 0795314a4..84f00934e 100644 --- a/crates/agent/src/cli.rs +++ b/crates/agent/src/cli.rs @@ -8,38 +8,7 @@ use std::io::{IsTerminal, Write}; use std::path::PathBuf; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; - -/// Pre-resolved ANSI escape codes for styled terminal output. -/// All fields are empty strings when color is disabled (non-TTY stderr). -struct Styles { - bold: &'static str, - dim: &'static str, - cyan: &'static str, - red: &'static str, - reset: &'static str, -} - -impl Styles { - fn new(use_color: bool) -> Self { - if use_color { - Self { - bold: "\x1b[1m", - dim: "\x1b[2m", - cyan: "\x1b[36m", - red: "\x1b[31m", - reset: "\x1b[0m", - } - } else { - Self { - bold: "", - dim: "", - cyan: "", - red: "", - reset: "", - } - } - } -} +use terminal::Styles; /// Minimal CLI for the agent agentic loop. #[derive(Parser)] @@ -316,8 +285,7 @@ pub async fn run() -> anyhow::Result<()> { let cli = Cli::parse(); // Resolve color support once, leak to get 'static lifetime for use across threads - let styles: &'static Styles = - Box::leak(Box::new(Styles::new(std::io::stderr().is_terminal()))); + let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); // Validate provider API key if !validate_api_key(&cli.provider) { @@ -428,13 +396,7 @@ mod tests { use super::*; use serde_json::json; - static NO_COLOR: Styles = Styles { - bold: "", - dim: "", - cyan: "", - red: "", - reset: "", - }; + static NO_COLOR: Styles = Styles::new(false); // tool_category tests diff --git a/crates/attractor/Cargo.toml b/crates/attractor/Cargo.toml index e06162c50..cb49a0fe4 100644 --- a/crates/attractor/Cargo.toml +++ b/crates/attractor/Cargo.toml @@ -25,6 +25,7 @@ clap.workspace = true anyhow.workspace = true dotenvy.workspace = true agent = { path = "../agent" } +terminal = { path = "../terminal" } llm = { path = "../llm" } thiserror.workspace = true serde.workspace = true diff --git a/crates/attractor/src/cli/mod.rs b/crates/attractor/src/cli/mod.rs index 09b8dc236..a55b86046 100644 --- a/crates/attractor/src/cli/mod.rs +++ b/crates/attractor/src/cli/mod.rs @@ -6,6 +6,7 @@ use std::path::Path; use clap::{Args, Parser, Subcommand}; use std::path::PathBuf; +use terminal::Styles; use crate::event::PipelineEvent; use crate::validation::{Diagnostic, Severity}; @@ -75,27 +76,38 @@ pub fn read_dot_file(path: &Path) -> anyhow::Result { .map_err(|e| anyhow::anyhow!("Failed to read {}: {e}", path.display())) } -/// Print diagnostics to stderr, grouped by severity. -pub fn print_diagnostics(diagnostics: &[Diagnostic]) { +/// Print diagnostics to stderr, colored by severity. +pub fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) { for d in diagnostics { - let prefix = match d.severity { - Severity::Error => "error", - Severity::Warning => "warning", - Severity::Info => "info", - }; let location = match (&d.node_id, &d.edge) { (Some(node), _) => format!(" [node: {node}]"), (_, Some((from, to))) => format!(" [edge: {from} -> {to}]"), _ => String::new(), }; - eprintln!("{prefix}{location}: {} ({})", d.message, d.rule); + match d.severity { + Severity::Error => eprintln!( + "{red}error{reset}{location}: {} ({dim}{}{reset})", + d.message, d.rule, + red = styles.red, dim = styles.dim, reset = styles.reset, + ), + Severity::Warning => eprintln!( + "{yellow}warning{reset}{location}: {} ({dim}{}{reset})", + d.message, d.rule, + yellow = styles.yellow, dim = styles.dim, reset = styles.reset, + ), + Severity::Info => eprintln!( + "{dim}info{location}: {} ({}){reset}", + d.message, d.rule, + dim = styles.dim, reset = styles.reset, + ), + } } } -/// One-line summary of a pipeline event for `-v` output. +/// One-line summary of a pipeline event for `-v` output (dimmed). #[must_use] -pub fn format_event_summary(event: &PipelineEvent) -> String { - match event { +pub fn format_event_summary(event: &PipelineEvent, styles: &Styles) -> String { + let body = match event { PipelineEvent::PipelineStarted { name, id } => { format!("[PIPELINE_STARTED] name={name} id={id}") } @@ -179,30 +191,35 @@ pub fn format_event_summary(event: &PipelineEvent) -> String { PipelineEvent::CheckpointSaved { node_id } => { format!("[CHECKPOINT_SAVED] node={node_id}") } - } + }; + format!("{dim}{body}{reset}", dim = styles.dim, reset = styles.reset) } /// Multi-line detail view of a pipeline event for `-vv` output. +/// Box-drawing is dimmed; values are normal. #[must_use] -pub fn format_event_detail(event: &PipelineEvent) -> String { +pub fn format_event_detail(event: &PipelineEvent, styles: &Styles) -> String { + let d = styles.dim; + let r = styles.reset; + match event { PipelineEvent::PipelineStarted { name, id } => { format!( - "── PIPELINE_STARTED ─────────────────────────\n name: {name}\n id: {id}\n" + "{d}── PIPELINE_STARTED ─────────────────────────{r}\n {d}name:{r} {name}\n {d}id:{r} {id}\n" ) } PipelineEvent::PipelineCompleted { duration_ms, artifact_count, } => { - format!("── PIPELINE_COMPLETED ───────────────────────\n duration_ms: {duration_ms}\n artifact_count: {artifact_count}\n") + format!("{d}── PIPELINE_COMPLETED ───────────────────────{r}\n {d}duration_ms:{r} {duration_ms}\n {d}artifact_count:{r} {artifact_count}\n") } PipelineEvent::PipelineFailed { error, duration_ms } => { - format!("── PIPELINE_FAILED ──────────────────────────\n error: {error}\n duration_ms: {duration_ms}\n") + format!("{d}── PIPELINE_FAILED ──────────────────────────{r}\n {d}error:{r} {error}\n {d}duration_ms:{r} {duration_ms}\n") } PipelineEvent::StageStarted { name, index } => { format!( - "── STAGE_STARTED ────────────────────────────\n name: {name}\n index: {index}\n" + "{d}── STAGE_STARTED ────────────────────────────{r}\n {d}name:{r} {name}\n {d}index:{r} {index}\n" ) } PipelineEvent::StageCompleted { @@ -210,7 +227,7 @@ pub fn format_event_detail(event: &PipelineEvent) -> String { index, duration_ms, } => { - format!("── STAGE_COMPLETED ──────────────────────────\n name: {name}\n index: {index}\n duration_ms: {duration_ms}\n") + format!("{d}── STAGE_COMPLETED ──────────────────────────{r}\n {d}name:{r} {name}\n {d}index:{r} {index}\n {d}duration_ms:{r} {duration_ms}\n") } PipelineEvent::StageFailed { name, @@ -218,7 +235,7 @@ pub fn format_event_detail(event: &PipelineEvent) -> String { error, will_retry, } => { - format!("── STAGE_FAILED ─────────────────────────────\n name: {name}\n index: {index}\n error: {error}\n will_retry: {will_retry}\n") + format!("{d}── STAGE_FAILED ─────────────────────────────{r}\n {d}name:{r} {name}\n {d}index:{r} {index}\n {d}error:{r} {error}\n {d}will_retry:{r} {will_retry}\n") } PipelineEvent::StageRetrying { name, @@ -226,13 +243,13 @@ pub fn format_event_detail(event: &PipelineEvent) -> String { attempt, delay_ms, } => { - format!("── STAGE_RETRYING ───────────────────────────\n name: {name}\n index: {index}\n attempt: {attempt}\n delay_ms: {delay_ms}\n") + format!("{d}── STAGE_RETRYING ───────────────────────────{r}\n {d}name:{r} {name}\n {d}index:{r} {index}\n {d}attempt:{r} {attempt}\n {d}delay_ms:{r} {delay_ms}\n") } PipelineEvent::ParallelStarted { branch_count } => { - format!("── PARALLEL_STARTED ─────────────────────────\n branch_count: {branch_count}\n") + format!("{d}── PARALLEL_STARTED ─────────────────────────{r}\n {d}branch_count:{r} {branch_count}\n") } PipelineEvent::ParallelBranchStarted { branch, index } => { - format!("── PARALLEL_BRANCH_STARTED ──────────────────\n branch: {branch}\n index: {index}\n") + format!("{d}── PARALLEL_BRANCH_STARTED ──────────────────{r}\n {d}branch:{r} {branch}\n {d}index:{r} {index}\n") } PipelineEvent::ParallelBranchCompleted { branch, @@ -240,35 +257,35 @@ pub fn format_event_detail(event: &PipelineEvent) -> String { duration_ms, success, } => { - format!("── PARALLEL_BRANCH_COMPLETED ────────────────\n branch: {branch}\n index: {index}\n duration_ms: {duration_ms}\n success: {success}\n") + format!("{d}── PARALLEL_BRANCH_COMPLETED ────────────────{r}\n {d}branch:{r} {branch}\n {d}index:{r} {index}\n {d}duration_ms:{r} {duration_ms}\n {d}success:{r} {success}\n") } PipelineEvent::ParallelCompleted { duration_ms, success_count, failure_count, } => { - format!("── PARALLEL_COMPLETED ───────────────────────\n duration_ms: {duration_ms}\n success_count: {success_count}\n failure_count: {failure_count}\n") + format!("{d}── PARALLEL_COMPLETED ───────────────────────{r}\n {d}duration_ms:{r} {duration_ms}\n {d}success_count:{r} {success_count}\n {d}failure_count:{r} {failure_count}\n") } PipelineEvent::InterviewStarted { question, stage } => { - format!("── INTERVIEW_STARTED ────────────────────────\n stage: {stage}\n question: {question}\n") + format!("{d}── INTERVIEW_STARTED ────────────────────────{r}\n {d}stage:{r} {stage}\n {d}question:{r} {question}\n") } PipelineEvent::InterviewCompleted { question, answer, duration_ms, } => { - format!("── INTERVIEW_COMPLETED ──────────────────────\n question: {question}\n answer: {answer}\n duration_ms: {duration_ms}\n") + format!("{d}── INTERVIEW_COMPLETED ──────────────────────{r}\n {d}question:{r} {question}\n {d}answer:{r} {answer}\n {d}duration_ms:{r} {duration_ms}\n") } PipelineEvent::InterviewTimeout { question, stage, duration_ms, } => { - format!("── INTERVIEW_TIMEOUT ────────────────────────\n question: {question}\n stage: {stage}\n duration_ms: {duration_ms}\n") + format!("{d}── INTERVIEW_TIMEOUT ────────────────────────{r}\n {d}question:{r} {question}\n {d}stage:{r} {stage}\n {d}duration_ms:{r} {duration_ms}\n") } PipelineEvent::CheckpointSaved { node_id } => { format!( - "── CHECKPOINT_SAVED ─────────────────────────\n node_id: {node_id}\n" + "{d}── CHECKPOINT_SAVED ─────────────────────────{r}\n {d}node_id:{r} {node_id}\n" ) } } diff --git a/crates/attractor/src/cli/run.rs b/crates/attractor/src/cli/run.rs index fcbd51a4b..66664e52c 100644 --- a/crates/attractor/src/cli/run.rs +++ b/crates/attractor/src/cli/run.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::bail; use chrono::Local; +use terminal::Styles; use crate::checkpoint::Checkpoint; use crate::engine::{PipelineEngine, RunConfig}; @@ -24,24 +25,25 @@ use super::{format_event_detail, format_event_summary, print_diagnostics, read_d /// /// Returns an error if the pipeline cannot be read, parsed, validated, or executed. #[allow(clippy::too_many_lines)] -pub async fn run_command(args: RunArgs) -> anyhow::Result<()> { +pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Result<()> { // 1. Parse and validate pipeline let source = read_dot_file(&args.pipeline)?; let (graph, diagnostics) = PipelineBuilder::new().prepare(&source)?; - println!( - "Parsed pipeline: {} ({} nodes, {} edges)", + eprintln!( + "{bold}Parsed pipeline:{reset} {} ({dim}{} nodes, {} edges{reset})", graph.name, graph.nodes.len(), graph.edges.len(), + bold = styles.bold, dim = styles.dim, reset = styles.reset, ); let goal = graph.goal(); if !goal.is_empty() { - println!("Goal: {goal}"); + eprintln!("{bold}Goal:{reset} {goal}", bold = styles.bold, reset = styles.reset); } - print_diagnostics(&diagnostics); + print_diagnostics(&diagnostics, styles); if diagnostics.iter().any(|d| d.severity == Severity::Error) { bail!("Validation failed"); @@ -59,12 +61,12 @@ pub async fn run_command(args: RunArgs) -> anyhow::Result<()> { // 3. Build event emitter let mut emitter = EventEmitter::new(); if args.verbose >= 2 { - emitter.on_event(|event| { - eprint!("{}", format_event_detail(event)); + emitter.on_event(move |event| { + eprint!("{}", format_event_detail(event, styles)); }); } else if args.verbose >= 1 { - emitter.on_event(|event| { - eprintln!("{}", format_event_summary(event)); + emitter.on_event(move |event| { + eprintln!("{}", format_event_summary(event, styles)); }); } @@ -72,7 +74,7 @@ pub async fn run_command(args: RunArgs) -> anyhow::Result<()> { let interviewer: Arc = if args.auto_approve { Arc::new(AutoApproveInterviewer) } else { - Arc::new(ConsoleInterviewer) + Arc::new(ConsoleInterviewer::new(styles)) }; // 5. Resolve backend, model, and provider @@ -81,12 +83,18 @@ pub async fn run_command(args: RunArgs) -> anyhow::Result<()> { } else { match llm::client::Client::from_env().await { Ok(c) if c.provider_names().is_empty() => { - eprintln!("Warning: No LLM providers configured. Running in dry-run mode."); + eprintln!( + "{yellow}Warning:{reset} No LLM providers configured. Running in dry-run mode.", + yellow = styles.yellow, reset = styles.reset, + ); true } Ok(_) => false, Err(e) => { - eprintln!("Warning: Failed to initialize LLM client: {e}. Running in dry-run mode."); + eprintln!( + "{yellow}Warning:{reset} Failed to initialize LLM client: {e}. Running in dry-run mode.", + yellow = styles.yellow, reset = styles.reset, + ); true } } @@ -144,15 +152,32 @@ pub async fn run_command(args: RunArgs) -> anyhow::Result<()> { }; // 8. Print result - println!("\n=== Pipeline Result ==="); - println!("Status: {}", outcome.status.to_string().to_uppercase()); + eprintln!( + "\n{bold}=== Pipeline Result ==={reset}", + bold = styles.bold, reset = styles.reset, + ); + + let status_str = outcome.status.to_string().to_uppercase(); + let status_color = match outcome.status { + StageStatus::Success | StageStatus::PartialSuccess => styles.green, + _ => styles.red, + }; + eprintln!("Status: {status_color}{status_str}{reset}", reset = styles.reset); + if let Some(notes) = &outcome.notes { - println!("Notes: {notes}"); + eprintln!("Notes: {notes}"); } if let Some(failure) = &outcome.failure_reason { - println!("Failure: {failure}"); + eprintln!( + "{red}Failure: {failure}{reset}", + red = styles.red, reset = styles.reset, + ); } - println!("Logs: {}", logs_dir.display()); + eprintln!( + "{dim}Logs: {}{reset}", + logs_dir.display(), + dim = styles.dim, reset = styles.reset, + ); // 9. Exit code match outcome.status { diff --git a/crates/attractor/src/cli/validate.rs b/crates/attractor/src/cli/validate.rs index 6d325450e..49b9f51bb 100644 --- a/crates/attractor/src/cli/validate.rs +++ b/crates/attractor/src/cli/validate.rs @@ -1,4 +1,5 @@ use anyhow::bail; +use terminal::Styles; use crate::pipeline::PipelineBuilder; use crate::validation::Severity; @@ -10,23 +11,27 @@ use super::{print_diagnostics, read_dot_file, ValidateArgs}; /// # Errors /// /// Returns an error if the file cannot be read, parsed, or has validation errors. -pub fn validate_command(args: &ValidateArgs) -> anyhow::Result<()> { +pub fn validate_command(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<()> { let source = read_dot_file(&args.pipeline)?; let (graph, diagnostics) = PipelineBuilder::new().prepare(&source)?; - println!( - "Parsed pipeline: {} ({} nodes, {} edges)", + eprintln!( + "{bold}Parsed pipeline:{reset} {} ({dim}{} nodes, {} edges{reset})", graph.name, graph.nodes.len(), graph.edges.len(), + bold = styles.bold, dim = styles.dim, reset = styles.reset, ); - print_diagnostics(&diagnostics); + print_diagnostics(&diagnostics, styles); if diagnostics.iter().any(|d| d.severity == Severity::Error) { bail!("Validation failed"); } - println!("Validation: OK"); + eprintln!( + "Validation: {green}OK{reset}", + green = styles.green, reset = styles.reset, + ); Ok(()) } diff --git a/crates/attractor/src/interviewer/console.rs b/crates/attractor/src/interviewer/console.rs index 255b947d5..766eec994 100644 --- a/crates/attractor/src/interviewer/console.rs +++ b/crates/attractor/src/interviewer/console.rs @@ -1,10 +1,20 @@ use async_trait::async_trait; +use terminal::Styles; use tokio::io::{AsyncBufReadExt, BufReader}; use super::{Answer, AnswerValue, Interviewer, Question, QuestionType}; /// Reads from stdin to collect answers. Displays formatted prompts per spec 6.4. -pub struct ConsoleInterviewer; +pub struct ConsoleInterviewer { + styles: &'static Styles, +} + +impl ConsoleInterviewer { + #[must_use] + pub const fn new(styles: &'static Styles) -> Self { + Self { styles } + } +} fn find_matching_option( response: &str, @@ -48,12 +58,21 @@ async fn read_line(prompt: &str) -> std::io::Result { #[async_trait] impl Interviewer for ConsoleInterviewer { async fn ask(&self, question: Question) -> Answer { - eprintln!("[?] {}", question.text); + let s = self.styles; + eprintln!( + "{bold}{cyan}?{reset} {}", + question.text, + bold = s.bold, cyan = s.cyan, reset = s.reset, + ); match question.question_type { QuestionType::MultipleChoice => { for (i, opt) in question.options.iter().enumerate() { - eprintln!(" [{}] {} - {}", i + 1, opt.key, opt.label); + eprintln!( + " {dim}[{reset}{bold}{}{reset}{dim}]{reset} {} - {}", + i + 1, opt.key, opt.label, + dim = s.dim, bold = s.bold, reset = s.reset, + ); } if question.allow_freeform { eprintln!(" Or type a free-text response"); @@ -86,7 +105,11 @@ impl Interviewer for ConsoleInterviewer { } async fn inform(&self, message: &str, stage: &str) { - eprintln!("[{stage}] {message}"); + let s = self.styles; + eprintln!( + "{dim}[{stage}]{reset} {message}", + dim = s.dim, reset = s.reset, + ); } } diff --git a/crates/attractor/src/main.rs b/crates/attractor/src/main.rs index 6d254f4b9..7f648dce9 100644 --- a/crates/attractor/src/main.rs +++ b/crates/attractor/src/main.rs @@ -1,18 +1,25 @@ use clap::Parser; +use terminal::Styles; #[tokio::main] async fn main() { dotenvy::dotenv().ok(); + let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); let cli = attractor::cli::Cli::parse(); let result = match cli.command { - attractor::cli::Command::Run(args) => attractor::cli::run::run_command(args).await, - attractor::cli::Command::Validate(args) => attractor::cli::validate::validate_command(&args), + attractor::cli::Command::Run(args) => attractor::cli::run::run_command(args, styles).await, + attractor::cli::Command::Validate(args) => { + attractor::cli::validate::validate_command(&args, styles) + } }; if let Err(e) = result { - eprintln!("Error: {e:#}"); + eprintln!( + "{red}Error:{reset} {e:#}", + red = styles.red, reset = styles.reset, + ); std::process::exit(1); } } diff --git a/crates/attractor/tests/cli.rs b/crates/attractor/tests/cli.rs index 25ad28610..a6144d4e9 100644 --- a/crates/attractor/tests/cli.rs +++ b/crates/attractor/tests/cli.rs @@ -14,7 +14,7 @@ fn validate_simple() { .args(["validate", "../../test/simple.dot"]) .assert() .success() - .stdout(predicate::str::contains("Validation: OK")); + .stderr(predicate::str::contains("Validation: OK")); } #[test] @@ -23,7 +23,7 @@ fn validate_branching() { .args(["validate", "../../test/branching.dot"]) .assert() .success() - .stdout(predicate::str::contains("Validation: OK")); + .stderr(predicate::str::contains("Validation: OK")); } #[test] @@ -32,7 +32,7 @@ fn validate_conditions() { .args(["validate", "../../test/conditions.dot"]) .assert() .success() - .stdout(predicate::str::contains("Validation: OK")); + .stderr(predicate::str::contains("Validation: OK")); } #[test] @@ -41,7 +41,7 @@ fn validate_parallel() { .args(["validate", "../../test/parallel.dot"]) .assert() .success() - .stdout(predicate::str::contains("Validation: OK")); + .stderr(predicate::str::contains("Validation: OK")); } #[test] @@ -50,7 +50,7 @@ fn validate_styled() { .args(["validate", "../../test/styled.dot"]) .assert() .success() - .stdout(predicate::str::contains("Validation: OK")); + .stderr(predicate::str::contains("Validation: OK")); } #[test] diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml new file mode 100644 index 000000000..44c94c191 --- /dev/null +++ b/crates/terminal/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "terminal" +edition.workspace = true +version.workspace = true +license.workspace = true +description = "Shared ANSI terminal styling for CLI binaries" + +[lib] +doctest = false + +[lints] +workspace = true diff --git a/crates/terminal/src/lib.rs b/crates/terminal/src/lib.rs new file mode 100644 index 000000000..2c442026c --- /dev/null +++ b/crates/terminal/src/lib.rs @@ -0,0 +1,89 @@ +use std::io::IsTerminal; + +/// Pre-resolved ANSI escape codes for styled terminal output. +/// All fields are empty strings when color is disabled (non-TTY or `NO_COLOR`). +pub struct Styles { + pub bold: &'static str, + pub dim: &'static str, + pub cyan: &'static str, + pub green: &'static str, + pub yellow: &'static str, + pub red: &'static str, + pub reset: &'static str, +} + +// SAFETY: Styles contains only `&'static str` fields, which are inherently Send + Sync. +unsafe impl Send for Styles {} +unsafe impl Sync for Styles {} + +impl Styles { + #[must_use] + pub const fn new(use_color: bool) -> Self { + if use_color { + Self { + bold: "\x1b[1m", + dim: "\x1b[2m", + cyan: "\x1b[36m", + green: "\x1b[32m", + yellow: "\x1b[33m", + red: "\x1b[31m", + reset: "\x1b[0m", + } + } else { + Self { + bold: "", + dim: "", + cyan: "", + green: "", + yellow: "", + red: "", + reset: "", + } + } + } + + /// Create styles based on whether stderr is a TTY. + /// Respects `NO_COLOR` environment variable. + #[must_use] + pub fn detect_stderr() -> Self { + let use_color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + Self::new(use_color) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn styles_with_color() { + let s = Styles::new(true); + assert_eq!(s.bold, "\x1b[1m"); + assert_eq!(s.dim, "\x1b[2m"); + assert_eq!(s.cyan, "\x1b[36m"); + assert_eq!(s.green, "\x1b[32m"); + assert_eq!(s.yellow, "\x1b[33m"); + assert_eq!(s.red, "\x1b[31m"); + assert_eq!(s.reset, "\x1b[0m"); + } + + #[test] + fn styles_without_color() { + let s = Styles::new(false); + assert!(s.bold.is_empty()); + assert!(s.dim.is_empty()); + assert!(s.cyan.is_empty()); + assert!(s.green.is_empty()); + assert!(s.yellow.is_empty()); + assert!(s.red.is_empty()); + assert!(s.reset.is_empty()); + } + + static NO_COLOR: Styles = Styles::new(false); + + #[test] + fn no_color_static_is_empty() { + assert!(NO_COLOR.bold.is_empty()); + assert!(NO_COLOR.reset.is_empty()); + } +}