diff --git a/.changeset/colorize-stderr-tty.md b/.changeset/colorize-stderr-tty.md new file mode 100644 index 00000000..d269f8ec --- /dev/null +++ b/.changeset/colorize-stderr-tty.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Colorize stderr warnings and errors with ANSI codes when output is a TTY terminal diff --git a/src/error.rs b/src/error.rs index 25cc9f59..9ec16ef3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,6 +15,29 @@ use serde_json::json; use thiserror::Error; +pub(crate) fn is_tty() -> bool { + use std::io::IsTerminal; + std::io::stderr().is_terminal() +} + +fn colorize(s: &str, code: &str) -> String { + if is_tty() { + // Strip control characters to prevent terminal escape injection. + // We allow newline and tab for basic formatting. + let sanitized: String = s + .chars() + .filter(|&c| !c.is_control() || c == '\n' || c == '\t') + .collect(); + format!("\x1b[{code}m{sanitized}\x1b[0m") + } else { + s.to_string() + } +} + +pub(crate) fn yellow(s: &str) -> String { colorize(s, "33") } +pub(crate) fn red(s: &str) -> String { colorize(s, "31") } +pub(crate) fn bold(s: &str) -> String { colorize(s, "1") } + #[derive(Error, Debug)] pub enum GwsError { #[error("{message}")] @@ -111,7 +134,7 @@ pub fn print_error_json(err: &GwsError) { { if reason == "accessNotConfigured" { eprintln!(); - eprintln!("💡 API not enabled for your GCP project."); + eprintln!("{}", yellow("💡 API not enabled for your GCP project.")); if let Some(url) = enable_url { eprintln!(" Enable it at: {url}"); } else { @@ -126,6 +149,16 @@ pub fn print_error_json(err: &GwsError) { mod tests { use super::*; + #[test] + fn test_color_helpers_no_ansi_when_non_tty() { + // In test runners, stderr is not a TTY — helpers must return the raw string. + if !is_tty() { + assert_eq!(yellow("x"), "x"); + assert_eq!(red("x"), "x"); + assert_eq!(bold("x"), "x"); + } + } + #[test] fn test_error_to_json_api() { let err = GwsError::Api { diff --git a/src/executor.rs b/src/executor.rs index 49101ece..a9d8c007 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -228,7 +228,7 @@ async fn handle_json_response( Ok(result) => { let is_match = result.filter_match_state == "MATCH_FOUND"; if is_match { - eprintln!("⚠️ Model Armor: prompt injection detected (filterMatchState: MATCH_FOUND)"); + eprintln!("{}", crate::error::yellow("⚠️ Model Armor: prompt injection detected (filterMatchState: MATCH_FOUND)")); } if is_match && *sanitize_mode == crate::helpers::modelarmor::SanitizeMode::Block @@ -254,7 +254,7 @@ async fn handle_json_response( } } Err(e) => { - eprintln!("⚠️ Model Armor sanitization failed: {e}"); + eprintln!("{} {e}", crate::error::yellow("⚠️ Model Armor sanitization failed:")); } } } diff --git a/src/main.rs b/src/main.rs index 22259a44..246dac3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -174,7 +174,8 @@ async fn run() -> Result<(), GwsError> { Ok(fmt) => fmt, Err(unknown) => { eprintln!( - "warning: unknown output format '{unknown}'; falling back to json (valid options: json, table, yaml, csv)" + "{} unknown output format '{unknown}'; falling back to json (valid options: json, table, yaml, csv)", + crate::error::yellow("warning:") ); formatter::OutputFormat::Json }