diff --git a/CHANGELOG.md b/CHANGELOG.md index 415e1d9..f183427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - A request that gets no response says why: a timeout (for the auth service, its fixed 30-second limit, which `--timeout` does not change), a connection not made within the 10-second connect limit (which `--timeout` does not change), a failed TLS connection, a connection that could not be made, or one that closed early. A hint names the next step. A write that timed out or closed early may have been applied, so its hint says to look up the resource ID before running the command again, never to retry. This covers API, auth, price, and checkout stream requests, which before said only that the request failed. - `--output-file` on a path that already exists says so and suggests a new path, instead of showing an OS error; any other refusal keeps its cause and gets the same hint. Both say that no change was sent. - `-o` is short for `--output`, as in `voltage wallets list -o json`. +- Color on a terminal: help, `error:`, `hint:`, and `details:` labels. `--color auto|always|never` (or `--no-color`) decides, else `NO_COLOR`, `CLICOLOR_FORCE`, and `CLICOLOR`. JSON is never colored. ## 0.2.0 - 2026-10-22 diff --git a/Cargo.lock b/Cargo.lock index 6bbcaa1..33d1721 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2540,6 +2540,7 @@ dependencies = [ name = "voltage-cli" version = "0.2.0" dependencies = [ + "anstream", "arboard", "assert_cmd", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 1582a58..8460796 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ path = "src/main.rs" [dependencies] clap = { version = "=4.6.7", features = ["derive", "string"] } clap_complete = "=4.6.11" +# Colors the CLI's own labels with clap's policy: clap already depends on it, so it adds no +# crate. It decides per stream (NO_COLOR, CLICOLOR, terminal) and strips styles otherwise. +anstream = "=1.0.0" serde = { version = "=1.0.229", features = ["derive"] } serde_json = { version = "=1.0.154", features = ["arbitrary_precision"] } toml = "=1.1.6" diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 46deb88..f9aa246 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -14,5 +14,8 @@ A flag beats a variable. A selected profile is a complete configuration, so `VOL | `VOLTAGE_CONFIG_DIR` | The configuration directory, as an absolute path | `--config-dir` | | `XDG_CONFIG_HOME` | Parent of the default configuration directory, `$XDG_CONFIG_HOME/voltage` (else `~/.config/voltage`) | `--config-dir`, `VOLTAGE_CONFIG_DIR` | | `COLUMNS` | Terminal width for human tables; otherwise the terminal's own width | — | +| `NO_COLOR` | Any non-empty value turns off color in human output and help | `--color` | +| `CLICOLOR_FORCE` | Any non-zero value colors human output and help even when redirected | `--color`, `NO_COLOR` | +| `CLICOLOR` | `0` turns off color on a terminal | `--color`, `NO_COLOR`, `CLICOLOR_FORCE` | Related: [configuration](configuration.md), [global flags](global-flags.md), [scripting](../guides/scripting.md). diff --git a/docs/reference/global-flags.md b/docs/reference/global-flags.md index 4354655..86a2576 100644 --- a/docs/reference/global-flags.md +++ b/docs/reference/global-flags.md @@ -23,6 +23,8 @@ Generated from the command definitions by `VOLTAGE_BLESS=1 cargo test`; do not e | `-o`, `--output OUTPUT` | Output format (`table`, `json`, `ndjson`) | | `--output-file PATH` | Save the complete response to a new owner-only file | | `--show-secrets` | Explicitly allow secrets in result output | +| `--color WHEN` | When to color human output and help (`auto`, `always`, `never`); default `auto` | +| `--no-color` | Same as --color never | | `-q`, `--quiet` | Hide optional progress and notices, not errors, results, or recovery IDs | ## Safety diff --git a/docs/reference/output.md b/docs/reference/output.md index d740433..560c811 100644 --- a/docs/reference/output.md +++ b/docs/reference/output.md @@ -27,6 +27,8 @@ API fields stay inside `data`. With `--all`, JSON output collects the pages into Diagnostics, prompts, and progress go to stderr. Interactive stderr shows immediate, cancellable status for network requests and waits, with the time waited so far on payment waits and device approval; redirected stderr gets no spinner/control codes. `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs. +Human output and help are colored on a terminal: `error:` is red, `hint:` cyan, and `details:` dim, and color only marks a label, so the words read the same without it. `--color always|never` (or `--no-color`) decides; otherwise `NO_COLOR`, `CLICOLOR_FORCE`, `CLICOLOR`, `TERM=dumb`, and whether each stream is a terminal decide, separately for stdout and stderr. JSON, NDJSON, `--output-file`, and completions are never colored, even with `--color always`. + Human errors start with `error:`, which leads with the API's reason when it rejects a request with a client error (HTTP 4xx), and may include a safe `hint:` and an indented `details:` block of `field value` lines. `--json` reports the complete API response in `error.detail`, and makes command-line parse errors machine-readable as well as runtime errors. A downstream consumer that closes stdout early is treated as successful pipeline completion. Related: [scripting](../guides/scripting.md), [safety model](../concepts/safety-model.md#secrets-and-redaction). diff --git a/src/cli.rs b/src/cli.rs index a6915c0..6fd34d5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -13,6 +13,7 @@ use crate::{ payment::{AmountUnit, Currency, Network, PaymentDirection, ReceiveKind, WaitTarget}, price::ConversionRequest, registry::{Method, OPERATIONS, Operation, OperationId, Parameter, ResourceTarget, operation}, + style::{self, ColorChoice}, terminal::Terminal, }; use clap::{ @@ -66,6 +67,25 @@ pub struct GlobalFlags { /// Never prompt for a secret; supply it with --stdin or the environment instead #[arg(long, global = true, help_heading = "Safety")] pub no_input: bool, + /// When to color human output and help + #[arg( + long, + global = true, + value_enum, + value_name = "WHEN", + default_value_t, + help_heading = "Output" + )] + pub color: ColorChoice, + /// Same as --color never + #[arg( + long, + global = true, + hide = true, + conflicts_with = "color", + help_heading = "Output" + )] + pub no_color: bool, /// Hide optional progress and notices, not errors, results, or recovery IDs #[arg(short = 'q', long, global = true, help_heading = "Output")] pub quiet: bool, @@ -111,6 +131,14 @@ impl GlobalFlags { OutputFormat::select(self.json, self.output) } + pub fn color(&self) -> ColorChoice { + if self.no_color { + ColorChoice::Never + } else { + self.color + } + } + pub fn terminal(&self) -> Terminal { Terminal::new(self.quiet, self.no_input) } @@ -636,9 +664,13 @@ pub struct Invocation { impl Invocation { /// Parse process arguments without letting clap terminate the process. Startup decides - /// whether parse failures need clap's human rendering or the JSON error envelope. - pub fn from_env() -> std::result::Result { - let matches = command().try_get_matches().map_err(ParseFailure::Clap)?; + /// whether parse failures need clap's human rendering or the JSON error envelope; `color` + /// colors clap's help and errors, since `--color` itself is not parsed yet. + pub fn from_env(color: ColorChoice) -> std::result::Result { + let matches = command() + .color(color.as_clap()) + .try_get_matches() + .map_err(ParseFailure::Clap)?; Self::from_matches(&matches).map_err(ParseFailure::Validation) } @@ -868,7 +900,8 @@ pub fn command() -> ClapCommand { let root = ClapCommand::new("voltage") .version(env!("VOLTAGE_VERSION")) .subcommand_required(true) - .arg_required_else_help(true); + .arg_required_else_help(true) + .styles(style::HELP); let root = GlobalFlags::augment_args(root); let mut root = LocalCommand::augment_subcommands(root); for name in root diff --git a/src/lib.rs b/src/lib.rs index b180a86..46dee1b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ mod price; pub mod registry; pub mod secret; mod startup; +mod style; mod terminal; mod wallet_currency; mod wallet_shortlist; diff --git a/src/output.rs b/src/output.rs index 54b69de..02ab39d 100644 --- a/src/output.rs +++ b/src/output.rs @@ -9,6 +9,7 @@ use crate::{ Error, Result, config::new_private, error::ErrorDetail, + style::{self, ColorChoice, Role}, terminal::{self, scrub_line}, }; use clap::ValueEnum; @@ -316,8 +317,9 @@ struct ErrorBody<'a> { detail: Option<&'a ErrorDetail>, } -/// Print an error to stderr in the format the selected output uses. -pub fn report_error(error: &Error, format: OutputFormat) { +/// Print an error to stderr in the format the selected output uses; `color` styles only the +/// human report's labels. +pub fn report_error(error: &Error, format: OutputFormat, color: ColorChoice) { let report = ErrorReport { error: ErrorBody { message: &error.message, @@ -332,25 +334,34 @@ pub fn report_error(error: &Error, format: OutputFormat) { if format.is_machine_readable() { eprintln!("{body}"); } else { - // Messages can interpolate untrusted input; keep control characters off the terminal. - eprintln!("error: {}", scrub_line(&error.message)); - if let Some(hint) = &error.hint { - // Hints can name saved credentials and profiles, so they are scrubbed like messages. - eprintln!("hint: {}", scrub_line(hint)); - } - let repeated = error.detail.as_ref().is_some_and(ErrorDetail::repeats_hint); - if let Some(detail) = - human_detail(&body["error"]["detail"], &error.message).filter(|_| !repeated) - { - let mut lines = Vec::new(); - if human::detail(&mut lines, &detail).is_ok() { - eprintln!("details:"); - for line in String::from_utf8_lossy(&lines).lines() { - eprintln!(" {line}"); - } + style::write_stderr(color, &human_error(error, &body["error"]["detail"])); + } +} + +/// The human error report with painted labels, which the stream strips when color is off. +fn human_error(error: &Error, detail: &Value) -> String { + // Messages can interpolate untrusted input; keep control characters off the terminal. + let mut report = format!( + "{} {}\n", + style::paint(Role::Failure, "error:"), + scrub_line(&error.message) + ); + if let Some(hint) = &error.hint { + // Hints can name saved credentials and profiles, so they are scrubbed like messages. + let hint = scrub_line(hint); + report.push_str(&format!("{} {hint}\n", style::paint(Role::Hint, "hint:"))); + } + let repeated = error.detail.as_ref().is_some_and(ErrorDetail::repeats_hint); + if let Some(detail) = human_detail(detail, &error.message).filter(|_| !repeated) { + let mut lines = Vec::new(); + if human::detail(&mut lines, &detail).is_ok() { + report.push_str(&format!("{}\n", style::paint(Role::Muted, "details:"))); + for line in String::from_utf8_lossy(&lines).lines() { + report.push_str(&format!(" {line}\n")); } } } + report } /// The part of an error's detail a person needs. A client error (4xx) shows the API's error @@ -442,6 +453,28 @@ mod tests { assert_eq!(human_detail(&Value::Null, ""), None); } + #[test] + fn human_errors_paint_only_their_labels() { + let hostile = "bad\u{1b}[2J\u{1b}]8;;https://evil.test\u{7}key"; + let mut error = Error::usage(format!("Rejected {hostile}")); + error.hint = Some(format!("Try {hostile}").into()); + let report = human_error(&error, &json!({"request_id": hostile})); + let plain = "error: Rejected bad [2J ]8;;https://evil.test key\n\ + hint: Try bad [2J ]8;;https://evil.test key\n\ + details:\n request_id bad [2J ]8;;https://evil.test key\n"; + assert_eq!(anstream::adapter::strip_str(&report).to_string(), plain); + // Every escape sequence left is one of the three labels' styles. + let mut rest = report.clone(); + for (role, label) in [ + (Role::Failure, "error:"), + (Role::Hint, "hint:"), + (Role::Muted, "details:"), + ] { + rest = rest.replacen(&style::paint(role, label), label, 1); + } + assert_eq!(rest, plain); + } + #[test] fn secrets_are_redacted_recursively() { let mut value = json!({"data":{"shared_secret":"abc","description":"token abc"}}); diff --git a/src/startup.rs b/src/startup.rs index d73d353..ba81dac 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -14,6 +14,7 @@ use crate::{ output::{Envelope, Output, OutputFormat, report_error, write_stdout}, pick, price::{PRICE_URL, PriceService}, + style::ColorChoice, terminal::Terminal, wallet_currency, wallet_shortlist, }; @@ -24,11 +25,13 @@ const BINARY_NAME: &str = "voltage"; pub async fn run() -> ExitCode { let json_errors = cli::json_errors_requested(std::env::args_os()); - let invocation = match Invocation::from_env() { + let color = ColorChoice::requested(std::env::args_os()); + let invocation = match Invocation::from_env(color) { Ok(invocation) => invocation, - Err(failure) => return fail_parse(failure, json_errors), + Err(failure) => return fail_parse(failure, json_errors, color), }; let format = invocation.global.output_format(); + let color = invocation.global.color(); let submission = SubmissionState::default(); // Biased, with the interrupt first: tokio installs the SIGINT handler when `ctrl_c` is // first polled. Polled second, a command that reaches a prompt without awaiting would leave @@ -52,15 +55,17 @@ pub async fn run() -> ExitCode { match result { Ok(()) => ExitCode::SUCCESS, Err(error) if error.is_stdout_closed() => ExitCode::SUCCESS, - Err(error) => fail(error, format), + Err(error) => fail(error, format, color), } } -fn fail_parse(failure: ParseFailure, json_errors: bool) -> ExitCode { +fn fail_parse(failure: ParseFailure, json_errors: bool, color: ColorChoice) -> ExitCode { match failure { - ParseFailure::Validation(error) => fail(error, OutputFormat::select(json_errors, None)), + ParseFailure::Validation(error) => { + fail(error, OutputFormat::select(json_errors, None), color) + } ParseFailure::Clap(error) if error.use_stderr() && json_errors => { - fail(Error::usage(error.to_string()), OutputFormat::Json) + fail(Error::usage(error.to_string()), OutputFormat::Json, color) } ParseFailure::Clap(error) => { let writes_stdout = !error.use_stderr(); @@ -70,14 +75,14 @@ fn fail_parse(failure: ParseFailure, json_errors: bool) -> ExitCode { Err(io) if writes_stdout && io.kind() == std::io::ErrorKind::BrokenPipe => { ExitCode::SUCCESS } - Err(io) => fail(io.into(), OutputFormat::select(false, None)), + Err(io) => fail(io.into(), OutputFormat::select(false, None), color), } } } } -fn fail(error: Error, format: OutputFormat) -> ExitCode { - report_error(&error, format); +fn fail(error: Error, format: OutputFormat, color: ColorChoice) -> ExitCode { + report_error(&error, format, color); process_exit(error.kind.exit_code()) } diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 0000000..e2c790d --- /dev/null +++ b/src/style.rs @@ -0,0 +1,198 @@ +//! Terminal color: one policy for clap's help and parse errors and for the CLI's own labels. +//! +//! `--color always|never` decides; otherwise each stream follows anstream's automatic choice, +//! which honors `NO_COLOR`, `CLICOLOR_FORCE`, `CLICOLOR`, `TERM=dumb`, and whether that +//! stream is a terminal. Only static labels are painted. Untrusted text stays scrubbed and +//! plain, so every escape sequence the CLI writes comes from this palette. Machine-readable +//! output never passes through here. + +use clap::{ + ValueEnum, + builder::{ + Styles, + styling::{AnsiColor, Style}, + }, +}; +use std::{ffi::OsStr, io::Write}; + +/// When to color terminal output, from `--color` or `--no-color`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +pub enum ColorChoice { + /// Color a stream only when it is a terminal and the environment allows it + #[default] + Auto, + /// Color even when redirected; JSON output is never colored + Always, + /// Never color + Never, +} + +impl ColorChoice { + /// The choice on a raw command line, for output written before clap has parsed it: clap's + /// own help and parse errors. Stops at `--`, and the last `--color` or `--no-color` wins; + /// a value clap would reject reads as `Auto`, and clap then reports it. + pub fn requested(args: I) -> Self + where + I: IntoIterator, + T: AsRef, + { + let mut choice = Self::Auto; + let mut args = args + .into_iter() + .skip(1) + .take_while(|arg| arg.as_ref() != OsStr::new("--")); + while let Some(arg) = args.next() { + let Some(arg) = arg.as_ref().to_str() else { + continue; + }; + let value = match arg { + "--no-color" => { + choice = Self::Never; + continue; + } + "--color" => args + .next() + .and_then(|v| v.as_ref().to_str().map(str::to_owned)), + _ => arg.strip_prefix("--color=").map(str::to_owned), + }; + if let Some(value) = value { + choice = Self::from_str(&value, false).unwrap_or_default(); + } + } + choice + } + + pub fn as_clap(self) -> clap::ColorChoice { + match self { + Self::Auto => clap::ColorChoice::Auto, + Self::Always => clap::ColorChoice::Always, + Self::Never => clap::ColorChoice::Never, + } + } + + fn as_stream(self) -> anstream::ColorChoice { + match self { + Self::Auto => anstream::ColorChoice::Auto, + Self::Always => anstream::ColorChoice::Always, + Self::Never => anstream::ColorChoice::Never, + } + } +} + +/// What a painted label means. Color never carries the meaning alone: each label is a word. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Role { + /// `error:` + Failure, + /// `hint:` + Hint, + /// `details:`, which introduces lines a person may skip + Muted, +} + +impl Role { + const fn style(self) -> Style { + match self { + Self::Failure => AnsiColor::Red.on_default().bold(), + Self::Hint => AnsiColor::Cyan.on_default().bold(), + Self::Muted => Style::new().dimmed(), + } + } +} + +/// `label` wrapped in its role's style. Labels are fixed text, never API or user data. +pub fn paint(role: Role, label: &'static str) -> String { + let style = role.style(); + format!("{style}{label}{style:#}") +} + +/// Write `text` to stderr, keeping its styles only when `choice` allows color there. A +/// stderr write failure has nowhere to be reported, so it is ignored. +pub fn write_stderr(choice: ColorChoice, text: &str) { + let mut stderr = anstream::AutoStream::new(std::io::stderr().lock(), choice.as_stream()); + let _ = stderr.write_all(text.as_bytes()); + let _ = stderr.flush(); +} + +/// Help and parse-error styles in cargo's manner, using only the 16 basic colors so the +/// terminal's theme keeps them legible. +pub const HELP: Styles = Styles::styled() + .header(AnsiColor::Green.on_default().bold()) + .usage(AnsiColor::Green.on_default().bold()) + .literal(AnsiColor::Cyan.on_default().bold()) + .placeholder(AnsiColor::Cyan.on_default()) + .error(AnsiColor::Red.on_default().bold()) + .valid(AnsiColor::Green.on_default().bold()) + .invalid(AnsiColor::Yellow.on_default().bold()); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_last_color_option_before_a_separator_wins() { + let requested = |args: &[&str]| ColorChoice::requested(args); + assert_eq!( + requested(&["voltage", "wallets", "list"]), + ColorChoice::Auto + ); + assert_eq!( + requested(&["voltage", "--color", "always"]), + ColorChoice::Always + ); + assert_eq!( + requested(&["voltage", "x", "--color=never"]), + ColorChoice::Never + ); + assert_eq!(requested(&["voltage", "--no-color"]), ColorChoice::Never); + assert_eq!( + requested(&["voltage", "--no-color", "--color", "always"]), + ColorChoice::Always + ); + // After `--` the spelling is a positional value, and a bad value is clap's to reject. + assert_eq!( + requested(&["voltage", "--", "--color=always"]), + ColorChoice::Auto + ); + assert_eq!( + requested(&["voltage", "--color", "loud"]), + ColorChoice::Auto + ); + assert_eq!(requested(&["voltage", "--color"]), ColorChoice::Auto); + // The program name is not an option. + assert_eq!(requested(&["--no-color"]), ColorChoice::Auto); + } + + #[test] + fn a_painted_label_is_the_label_once_styles_are_stripped() { + for role in [Role::Failure, Role::Hint, Role::Muted] { + let painted = paint(role, "error:"); + assert!(painted.starts_with('\u{1b}') && painted.ends_with("\u{1b}[0m")); + assert_eq!(anstream::adapter::strip_str(&painted).to_string(), "error:"); + } + } + + #[test] + fn choices_map_one_to_one_onto_clap_and_anstream() { + for (choice, clap, stream) in [ + ( + ColorChoice::Auto, + clap::ColorChoice::Auto, + anstream::ColorChoice::Auto, + ), + ( + ColorChoice::Always, + clap::ColorChoice::Always, + anstream::ColorChoice::Always, + ), + ( + ColorChoice::Never, + clap::ColorChoice::Never, + anstream::ColorChoice::Never, + ), + ] { + assert_eq!(choice.as_clap(), clap); + assert_eq!(choice.as_stream(), stream); + } + } +} diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index c602079..7466dc7 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -128,6 +128,74 @@ fn human_runtime_errors_have_a_prefix_and_safe_hint() { assert!(!error.contains("api_key")); } +#[test] +fn color_follows_the_flag_then_the_environment_and_never_reaches_json() { + let dir = private_tempdir(); + let missing_org = |args: &[&str], env: &[(&str, &str)]| { + let mut cmd = Command::from_std(human_process_at(dir.path(), "http://127.0.0.1:9")); + let result = cmd + .envs(env.iter().copied()) + .args(args) + .args(["wallets", "list"]) + .assert() + .code(2); + stderr(&result) + }; + let plain = missing_org(&["--output", "table"], &[]); + assert!(plain.starts_with("error: --org is required") && !plain.contains('\u{1b}')); + for (args, env, colored) in [ + (&["--color", "always"][..], &[][..], true), + (&[], &[("CLICOLOR_FORCE", "1")], true), + // A command-line choice beats the environment either way. + (&["--color", "always"], &[("NO_COLOR", "1")], true), + (&["--color=never"], &[("CLICOLOR_FORCE", "1")], false), + (&["--no-color"], &[("CLICOLOR_FORCE", "1")], false), + (&[], &[("CLICOLOR_FORCE", "1"), ("NO_COLOR", "1")], false), + (&["--color", "auto"], &[], false), + ] { + let shown = missing_org(&[&["--output", "table"], args].concat(), env); + assert_eq!(shown.contains('\u{1b}'), colored, "{args:?} {env:?}"); + // Color decorates labels; the words are unchanged. + assert_eq!(strip_styles(&shown), plain, "{args:?} {env:?}"); + } + // JSON errors are byte-identical whatever color asks for. + let json = missing_org(&["--json"], &[]); + assert_eq!( + missing_org(&["--json", "--color", "always"], &[("CLICOLOR_FORCE", "1")]), + json + ); + assert!(!json.contains('\u{1b}')); +} + +#[test] +fn help_and_parse_errors_follow_the_color_choice() { + let voltage = || { + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("voltage")); + cmd.env_remove("NO_COLOR").env_remove("CLICOLOR_FORCE"); + cmd + }; + let help = |args: &[&str]| stdout(&voltage().args(args).arg("--help").assert().success()); + assert!(!help(&[]).contains('\u{1b}')); + let colored = help(&["--color", "always"]); + assert!(colored.contains('\u{1b}')); + assert_eq!(strip_styles(&colored), help(&[])); + let typo = stderr( + &voltage() + .args(["--color=always", "paymnts"]) + .assert() + .code(2), + ); + assert!(typo.contains('\u{1b}') && strip_styles(&typo).starts_with("error:")); + let typo = stderr( + &voltage() + .args(["paymnts", "--no-color"]) + .env("CLICOLOR_FORCE", "1") + .assert() + .code(2), + ); + assert!(!typo.contains('\u{1b}')); +} + #[cfg(unix)] #[test] fn completion_output_treats_an_early_closing_pipe_as_success() { @@ -201,6 +269,10 @@ fn human_process_at(dir: &Path, api_url: &str) -> std::process::Command { "VOLTAGE_STREAM_TOKEN", "VOLTAGE_CONFIG_DIR", "VOLTAGE_EXECUTE", + // A developer's color settings must not change the bytes tests compare. + "NO_COLOR", + "CLICOLOR", + "CLICOLOR_FORCE", ] { cmd.env_remove(name); } @@ -227,6 +299,19 @@ fn stderr(assertion: &Assert) -> String { String::from_utf8_lossy(&assertion.get_output().stderr).into_owned() } +/// Text without SGR styles (`ESC [ ... m`), the only escape sequences color adds. +fn strip_styles(text: &str) -> String { + let mut plain = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find("\u{1b}[") { + plain.push_str(&rest[..start]); + let end = rest[start..].find('m').unwrap(); + rest = &rest[start + end + 1..]; + } + plain.push_str(rest); + plain +} + fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)