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 diff --git a/src/error.rs b/src/error.rs index bb7c086e..95504cbf 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,42 @@ mod tests { // enable_url key should not appear in JSON when None assert!(json["error"]["enable_url"].is_null()); } + + // --- 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"); + 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:")); + } }