Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 8 additions & 1 deletion src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
let content = std::fs::read_to_string(file)
Expand Down Expand Up @@ -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,
Expand Down
57 changes: 56 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
39 changes: 33 additions & 6 deletions src/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,36 @@ 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) {
if config.quiet {
return;
}

println!("{}", green(message));
println!("{}", green(message, config.color_stdout));
}

pub fn error(message: &str, config: &OutputConfig) {
if config.quiet {
return;
}

eprintln!("{}", red(message));
eprintln!("{}", red(message, config.color_stderr));
}

pub fn verbose(message: &str, config: &OutputConfig) {
Expand All @@ -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");
}
}
27 changes: 27 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down