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
5 changes: 5 additions & 0 deletions .changeset/feat-colored-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": minor
---

Display colored error and warning labels on TTY stderr
86 changes: 79 additions & 7 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,32 +148,66 @@ 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!(
"{}",
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, ..
} = err
{
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.");
}
}
}
Expand Down Expand Up @@ -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");
Comment on lines +373 to +376
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test modifies a global environment variable, which can lead to flaky tests if not handled carefully. If colorize() panics, std::env::remove_var("NO_COLOR") will not be called, leaving the environment variable set for subsequent tests. Using std::panic::catch_unwind ensures that the cleanup code is executed even if the tested function panics, making the test more robust.

Suggested change
std::env::set_var("NO_COLOR", "1");
let result = colorize("hello", "31");
std::env::remove_var("NO_COLOR");
assert_eq!(result, "hello");
std::env::set_var("NO_COLOR", "1");
let result = std::panic::catch_unwind(|| colorize("hello", "31"));
std::env::remove_var("NO_COLOR");
assert_eq!(result.expect("colorize panicked"), "hello");

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah good point, though in practice colorize() is a simple string format that won't panic. the test also runs in a single thread with serial_test so no parallel interference. catch_unwind feels like overkill here but happy to add if the maintainer wants it.

Comment on lines +373 to +376
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test modifies a global environment variable, which can lead to flaky tests if not handled carefully. If the call to colorize() were to panic, std::env::remove_var("NO_COLOR") would not be executed, potentially causing other tests to fail unexpectedly.

To make this test more robust, you can wrap the fallible operation in std::panic::catch_unwind. This ensures that the environment variable is cleaned up regardless of whether the tested function panics.

Suggested change
std::env::set_var("NO_COLOR", "1");
let result = colorize("hello", "31");
std::env::remove_var("NO_COLOR");
assert_eq!(result, "hello");
std::env::set_var("NO_COLOR", "1");
let result = std::panic::catch_unwind(|| colorize("hello", "31"));
std::env::remove_var("NO_COLOR");
assert_eq!(result.expect("colorize() panicked"), "hello");

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above — colorize is trivial and won't panic, but can add the guard if preferred

}

#[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:"));
}
}
Loading