From a6de0fd107192562b10d56a722c7332feec30290 Mon Sep 17 00:00:00 2001 From: Anshul Garg Date: Fri, 13 Mar 2026 16:41:34 +0530 Subject: [PATCH 1/3] feat(error): display colored error labels on TTY stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #317. When stderr is connected to a terminal, error output now includes a colored label before the JSON dump: error[api]: Not Found error[auth]: Token expired error[validation]: missing arg (yellow for non-fatal) error[discovery]: fetch failed - API, Auth, Discovery, Other errors: bold red - Validation errors: bold yellow (often user-correctable) - accessNotConfigured hint: bold cyan Respects NO_COLOR env var (https://no-color.org/). JSON output on stdout is never colored (machine-readable). No new dependencies — uses std::io::IsTerminal (stable since Rust 1.70) and raw ANSI escape codes. --- src/error.rs | 84 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 7 deletions(-) diff --git a/src/error.rs b/src/error.rs index bb7c086e..8e407278 100644 --- a/src/error.rs +++ b/src/error.rs @@ -148,11 +148,40 @@ impl GwsError { } } +/// Returns true when stderr is connected to an interactive terminal, +/// meaning ANSI color codes will be visible to the user. +fn stderr_supports_color() -> bool { + use std::io::IsTerminal; + std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + +/// Wrap `text` in ANSI bold + the given color code, resetting afterwards. +/// Returns the plain text unchanged when stderr is not a TTY. +fn colorize(text: &str, ansi_color: &str) -> String { + if stderr_supports_color() { + format!("\x1b[1;{ansi_color}m{text}\x1b[0m") + } else { + text.to_string() + } +} + +/// Format a colored error label for the given error variant. +fn error_label(err: &GwsError) -> String { + match err { + GwsError::Api { .. } => colorize("error[api]:", "31"), // red + GwsError::Auth(_) => colorize("error[auth]:", "31"), // red + GwsError::Validation(_) => colorize("error[validation]:", "33"), // yellow + GwsError::Discovery(_) => colorize("error[discovery]:", "31"), // red + GwsError::Other(_) => colorize("error:", "31"), // red + } +} + /// Formats any error as a JSON object and prints to stdout. /// -/// For `accessNotConfigured` errors (HTTP 403, reason `accessNotConfigured`), -/// additional human-readable guidance is printed to stderr explaining how to -/// enable the API in GCP. The JSON output on stdout is unchanged (machine-readable). +/// A human-readable colored label is printed to stderr when connected to a +/// TTY. For `accessNotConfigured` errors (HTTP 403, reason +/// `accessNotConfigured`), additional guidance is printed to stderr. +/// The JSON output on stdout is unchanged (machine-readable). pub fn print_error_json(err: &GwsError) { let json = err.to_json(); println!( @@ -160,6 +189,10 @@ pub fn print_error_json(err: &GwsError) { serde_json::to_string_pretty(&json).unwrap_or_default() ); + // Print a colored summary line to stderr so humans can quickly scan + // the error type without parsing JSON. + eprintln!("{} {}", error_label(err), err); + // Print actionable guidance to stderr for accessNotConfigured errors if let GwsError::Api { reason, enable_url, .. @@ -167,13 +200,14 @@ pub fn print_error_json(err: &GwsError) { { if reason == "accessNotConfigured" { eprintln!(); - eprintln!("💡 API not enabled for your GCP project."); + let hint = colorize("hint:", "36"); // cyan + eprintln!("{hint} API not enabled for your GCP project."); if let Some(url) = enable_url { - eprintln!(" Enable it at: {url}"); + eprintln!(" Enable it at: {url}"); } else { - eprintln!(" Visit the GCP Console → APIs & Services → Library to enable the required API."); + eprintln!(" Visit the GCP Console → APIs & Services → Library to enable the required API."); } - eprintln!(" After enabling, wait a few seconds and retry your command."); + eprintln!(" After enabling, wait a few seconds and retry your command."); } } } @@ -327,4 +361,40 @@ mod tests { // enable_url key should not appear in JSON when None assert!(json["error"]["enable_url"].is_null()); } + + // --- colored output tests --- + + #[test] + fn test_colorize_respects_no_color_env() { + // NO_COLOR is the de-facto standard for disabling colors. + // When set, colorize() should return the plain text. + std::env::set_var("NO_COLOR", "1"); + let result = colorize("hello", "31"); + std::env::remove_var("NO_COLOR"); + assert_eq!(result, "hello"); + } + + #[test] + fn test_error_label_contains_variant_name() { + let api_err = GwsError::Api { + code: 400, + message: "bad".to_string(), + reason: "r".to_string(), + enable_url: None, + }; + let label = error_label(&api_err); + assert!(label.contains("error[api]:")); + + let auth_err = GwsError::Auth("fail".to_string()); + assert!(error_label(&auth_err).contains("error[auth]:")); + + let val_err = GwsError::Validation("bad input".to_string()); + assert!(error_label(&val_err).contains("error[validation]:")); + + let disc_err = GwsError::Discovery("missing".to_string()); + assert!(error_label(&disc_err).contains("error[discovery]:")); + + let other_err = GwsError::Other(anyhow::anyhow!("oops")); + assert!(error_label(&other_err).contains("error:")); + } } From 3fb8e15e8fed20c0223d619e3553425eaf1d4252 Mon Sep 17 00:00:00 2001 From: Anshul Garg Date: Fri, 13 Mar 2026 17:06:26 +0530 Subject: [PATCH 2/3] fix(test): add #[serial] to env-var test to prevent race conditions Address Gemini review: the test_colorize_respects_no_color_env test modifies a global env var (NO_COLOR) which can race with parallel tests. Add serial_test::serial attribute for safe sequential execution. --- src/error.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/error.rs b/src/error.rs index 8e407278..95504cbf 100644 --- a/src/error.rs +++ b/src/error.rs @@ -365,9 +365,11 @@ mod tests { // --- colored output tests --- #[test] + #[serial_test::serial] fn test_colorize_respects_no_color_env() { // NO_COLOR is the de-facto standard for disabling colors. // When set, colorize() should return the plain text. + // Uses #[serial] because it modifies a global env var. std::env::set_var("NO_COLOR", "1"); let result = colorize("hello", "31"); std::env::remove_var("NO_COLOR"); From 96ae271a58969778cb07edac66b4866d49bf4428 Mon Sep 17 00:00:00 2001 From: Anshul Garg Date: Sat, 14 Mar 2026 02:02:22 +0530 Subject: [PATCH 3/3] chore: add changeset --- .changeset/feat-colored-errors.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/feat-colored-errors.md diff --git a/.changeset/feat-colored-errors.md b/.changeset/feat-colored-errors.md new file mode 100644 index 00000000..233ca7cd --- /dev/null +++ b/.changeset/feat-colored-errors.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Display colored error and warning labels on TTY stderr