diff --git a/README.md b/README.md index 7145617..22ac2cc 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,5 @@ Options: -V, --version Print version ``` + +ANSI colors are emitted only when the corresponding output stream is a terminal. A non-empty `NO_COLOR` environment variable disables them. diff --git a/src/command.rs b/src/command.rs index 47ed09f..5617e1f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -6,6 +6,8 @@ use crate::linter::{LintOptions, LintOutcome, lint_commit_message_with_errors}; use crate::messages::{VALIDATION_FAILED, VALIDATION_SUCCESSFUL}; use crate::utils::{normalize_newlines, remove_diff_from_commit_message}; use anyhow::{Context, Result}; +use std::env; +use std::io::{self, IsTerminal}; fn read_file(file: &str) -> Result { let content = std::fs::read_to_string(file) @@ -108,7 +110,12 @@ fn handle_multiple_commit_messages( } pub fn run(args: Cli) -> Result<()> { - let output = OutputConfig::new(args.quiet, args.verbose); + let no_color = env::var_os("NO_COLOR"); + let output = OutputConfig { + color_stdout: crate::config::should_color(io::stdout().is_terminal(), no_color.as_deref()), + color_stderr: crate::config::should_color(io::stderr().is_terminal(), no_color.as_deref()), + ..OutputConfig::new(args.quiet, args.verbose) + }; let lint_options = LintOptions { skip_detail: args.skip_detail, hide_input: args.hide_input, diff --git a/src/config.rs b/src/config.rs index 20f41fd..e4d6cdd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,10 +2,65 @@ pub struct OutputConfig { pub quiet: bool, pub verbose: bool, + pub color_stdout: bool, + pub color_stderr: bool, } impl OutputConfig { pub fn new(quiet: bool, verbose: bool) -> Self { - Self { quiet, verbose } + Self { + quiet, + verbose, + color_stdout: false, + color_stderr: false, + } + } +} + +pub(crate) fn should_color(is_terminal: bool, no_color: Option<&std::ffi::OsStr>) -> bool { + is_terminal && no_color.is_none_or(|value| value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsStr; + + #[test] + fn color_requires_terminal_and_respects_no_color() { + let cases = [ + ("terminal", true, None, true), + ("redirected output", false, None, false), + ("non-empty NO_COLOR", true, Some(OsStr::new("1")), false), + ("empty NO_COLOR", true, Some(OsStr::new("")), true), + ( + "redirected with empty NO_COLOR", + false, + Some(OsStr::new("")), + false, + ), + ( + "redirected with non-empty NO_COLOR", + false, + Some(OsStr::new("1")), + false, + ), + ]; + + for (name, is_terminal, no_color, expected) in cases { + assert_eq!(should_color(is_terminal, no_color), expected, "{name}"); + } + } + + #[test] + fn non_empty_no_color_disables_terminal_color_in_resolved_config() { + let config = OutputConfig { + color_stdout: should_color(true, Some(OsStr::new("1"))), + color_stderr: should_color(true, Some(OsStr::new("1"))), + ..OutputConfig::new(false, false) + }; + + assert!(!config.color_stdout); + assert!(!config.color_stderr); } } diff --git a/src/console.rs b/src/console.rs index 70436cc..9027523 100644 --- a/src/console.rs +++ b/src/console.rs @@ -4,12 +4,20 @@ const GREEN: &str = "\x1b[92m"; const RED: &str = "\x1b[91m"; const RESET: &str = "\x1b[0m"; -fn green(text: &str) -> String { - format!("{GREEN}{text}{RESET}") +fn color(text: &str, color: &str, enabled: bool) -> String { + if enabled { + format!("{color}{text}{RESET}") + } else { + text.to_owned() + } +} + +fn green(text: &str, enabled: bool) -> String { + color(text, GREEN, enabled) } -fn red(text: &str) -> String { - format!("{RED}{text}{RESET}") +fn red(text: &str, enabled: bool) -> String { + color(text, RED, enabled) } pub fn success(message: &str, config: &OutputConfig) { @@ -17,7 +25,7 @@ pub fn success(message: &str, config: &OutputConfig) { return; } - println!("{}", green(message)); + println!("{}", green(message, config.color_stdout)); } pub fn error(message: &str, config: &OutputConfig) { @@ -25,7 +33,7 @@ pub fn error(message: &str, config: &OutputConfig) { return; } - eprintln!("{}", red(message)); + eprintln!("{}", red(message, config.color_stderr)); } pub fn verbose(message: &str, config: &OutputConfig) { @@ -35,3 +43,22 @@ pub fn verbose(message: &str, config: &OutputConfig) { println!("{message}"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_formats_when_enabled() { + assert_eq!(color("test", GREEN, true), format!("{GREEN}test{RESET}")); + assert_eq!(color("test", GREEN, false), "test"); + } + + #[test] + fn green_and_red_delegate_correctly() { + assert_eq!(green("ok", true), format!("{GREEN}ok{RESET}")); + assert_eq!(green("ok", false), "ok"); + assert_eq!(red("err", true), format!("{RED}err{RESET}")); + assert_eq!(red("err", false), "err"); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index dd2d4de..382cc71 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -53,6 +53,33 @@ fn valid_message_with_body_succeeds() { .success(); } +#[test] +fn redirected_output_disables_ansi_colors() { + let success = cocox() + .arg("feat: plain output") + .output() + .expect("run cocox with redirected output"); + assert!(success.status.success()); + let stdout = String::from_utf8_lossy(&success.stdout); + assert!(stdout.contains(VALIDATION_SUCCESSFUL)); + assert!( + !stdout.contains("\x1b["), + "stdout contains ANSI color codes: {stdout:?}" + ); + + let failure = cocox() + .arg("not a conventional commit") + .output() + .expect("run cocox with redirected error output"); + assert_eq!(failure.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&failure.stderr); + assert!(stderr.contains(INCORRECT_FORMAT_ERROR)); + assert!( + !stderr.contains("\x1b["), + "stderr contains ANSI color codes: {stderr:?}" + ); +} + #[test] fn invalid_message_fails() { cocox()