From 2f76224eb3aae0e116662b814a1e18aebf133e84 Mon Sep 17 00:00:00 2001 From: abhiram304 Date: Fri, 13 Mar 2026 22:25:40 -0700 Subject: [PATCH 1/2] feat(http): add --verbose / -v flag for request/response diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a global --verbose / -v flag that prints HTTP request and response details to stderr without touching stdout, keeping pipe-friendly output intact. When enabled: > GET https://www.googleapis.com/drive/v3/files < 200 OK (143ms) [page 2] ← emitted at the start of each additional page fetch Implementation: - commands.rs: declare --verbose as a global SetTrue arg (mirrors --dry-run) - executor.rs: add verbose: bool parameter to execute_method(); log method + URL before send(), status + elapsed after; log [page N] at top of loop for pages > 1. Also adds stdout/stderr contract doc comment. - main.rs: extract get_flag("verbose") and thread it through - All 8 helper execute_method() call sites: pass false (helpers have no --verbose wiring yet; callers that want it can opt in later) --- .changeset/verbose-flag.md | 5 +++++ src/commands.rs | 25 ++++++++++++++++++++++++- src/executor.rs | 37 +++++++++++++++++++++++++++++++++++++ src/helpers/calendar.rs | 1 + src/helpers/chat.rs | 1 + src/helpers/docs.rs | 1 + src/helpers/drive.rs | 1 + src/helpers/gmail/mod.rs | 1 + src/helpers/script.rs | 1 + src/helpers/sheets.rs | 2 ++ src/main.rs | 7 +++++-- 11 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 .changeset/verbose-flag.md diff --git a/.changeset/verbose-flag.md b/.changeset/verbose-flag.md new file mode 100644 index 00000000..a58b2769 --- /dev/null +++ b/.changeset/verbose-flag.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `--verbose` / `-v` flag to print HTTP request method, URL, response status, and timing to stderr for debugging diff --git a/src/commands.rs b/src/commands.rs index 27324e42..7d274c10 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -43,9 +43,17 @@ pub fn build_cli(doc: &RestDescription) -> Command { .arg( clap::Arg::new("format") .long("format") - .help("Output format: json (default), table, yaml, csv") + .help("Output format: json (default), table, yaml, csv, tsv") .value_name("FORMAT") .global(true), + ) + .arg( + clap::Arg::new("verbose") + .long("verbose") + .short('v') + .help("Print request and response details to stderr (method, URL, status, timing)") + .action(clap::ArgAction::SetTrue) + .global(true), ); // Inject helper commands @@ -279,4 +287,19 @@ mod tests { "--sanitize arg should be present on root command" ); } + + #[test] + fn test_verbose_arg_present_and_global() { + let doc = make_doc(); + let cmd = build_cli(&doc); + + let args: Vec<_> = cmd.get_arguments().collect(); + let verbose_arg = args.iter().find(|a| a.get_id() == "verbose"); + assert!( + verbose_arg.is_some(), + "--verbose arg should be present on root command" + ); + let verbose_arg = verbose_arg.unwrap(); + assert!(verbose_arg.is_global_set(), "--verbose should be global"); + } } diff --git a/src/executor.rs b/src/executor.rs index 73fd772f..4069ef91 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -364,6 +364,14 @@ async fn handle_binary_response( /// 5. Handling various response types (JSON, binary). /// 6. Auto-pagination for list endpoints. /// 7. Model Armor prompt injection scanning. +/// +/// # Stdout / stderr contract +/// +/// * **stdout** — all structured output: JSON responses, dry-run previews, +/// download summaries. Must remain machine-readable so that pipes like +/// `gws ... | jq` work correctly. +/// * **stderr** — all human-readable diagnostics: hints, warnings, verbose +/// request/response details. Never emitted to stdout. #[allow(clippy::too_many_arguments)] pub async fn execute_method( doc: &RestDescription, @@ -376,6 +384,7 @@ pub async fn execute_method( upload_path: Option<&str>, upload_content_type: Option<&str>, dry_run: bool, + verbose: bool, pagination: &PaginationConfig, sanitize_template: Option<&str>, sanitize_mode: &crate::helpers::modelarmor::SanitizeMode, @@ -408,6 +417,10 @@ pub async fn execute_method( let mut captured_values = Vec::new(); loop { + if verbose && pages_fetched > 0 { + eprintln!("[page {}]", pages_fetched + 1); + } + let client = crate::client::build_client()?; let request = build_http_request( &client, @@ -423,11 +436,33 @@ pub async fn execute_method( .await?; let method_id = method.id.as_deref().unwrap_or("unknown"); + if verbose { + let display_url = { + let mut params = input.query_params.clone(); + if let Some(pt) = page_token.as_deref() { + params.push(("pageToken".to_string(), pt.to_string())); + } + if params.is_empty() { + input.full_url.clone() + } else { + let qs = params + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("&"); + format!("{}?{}", input.full_url, qs) + } + }; + eprintln!("> {} {}", method.http_method, display_url); + } let start = std::time::Instant::now(); let response = request.send().await.context("HTTP request failed")?; let latency_ms = start.elapsed().as_millis() as u64; let status = response.status(); + if verbose { + eprintln!("< {} ({}ms)", status, latency_ms); + } let content_type = response .headers() .get("content-type") @@ -2026,6 +2061,7 @@ async fn test_execute_method_dry_run() { None, None, true, // dry_run + false, &pagination, None, &sanitize_mode, @@ -2070,6 +2106,7 @@ async fn test_execute_method_missing_path_param() { None, None, true, + false, &PaginationConfig::default(), None, &sanitize_mode, diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs index 8ac00a50..a8c55145 100644 --- a/src/helpers/calendar.rs +++ b/src/helpers/calendar.rs @@ -181,6 +181,7 @@ TIPS: None, None, matches.get_flag("dry-run"), + matches.get_flag("verbose"), &executor::PaginationConfig::default(), None, &crate::helpers::modelarmor::SanitizeMode::Warn, diff --git a/src/helpers/chat.rs b/src/helpers/chat.rs index 94493e53..980ed412 100644 --- a/src/helpers/chat.rs +++ b/src/helpers/chat.rs @@ -111,6 +111,7 @@ TIPS: None, None, matches.get_flag("dry-run"), + matches.get_flag("verbose"), &pagination, None, &crate::helpers::modelarmor::SanitizeMode::Warn, diff --git a/src/helpers/docs.rs b/src/helpers/docs.rs index 3f6b3896..0368a2b1 100644 --- a/src/helpers/docs.rs +++ b/src/helpers/docs.rs @@ -101,6 +101,7 @@ TIPS: None, None, matches.get_flag("dry-run"), + matches.get_flag("verbose"), &pagination, None, &crate::helpers::modelarmor::SanitizeMode::Warn, diff --git a/src/helpers/drive.rs b/src/helpers/drive.rs index 393e0fde..50c756ca 100644 --- a/src/helpers/drive.rs +++ b/src/helpers/drive.rs @@ -112,6 +112,7 @@ TIPS: Some(file_path), None, matches.get_flag("dry-run"), + matches.get_flag("verbose"), &executor::PaginationConfig::default(), None, &crate::helpers::modelarmor::SanitizeMode::Warn, diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 999d65ce..0eeee612 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -674,6 +674,7 @@ pub(super) async fn send_raw_email( None, None, matches.get_flag("dry-run"), + matches.get_flag("verbose"), &pagination, None, &crate::helpers::modelarmor::SanitizeMode::Warn, diff --git a/src/helpers/script.rs b/src/helpers/script.rs index b0ad3497..f58a661a 100644 --- a/src/helpers/script.rs +++ b/src/helpers/script.rs @@ -124,6 +124,7 @@ TIPS: None, None, matches.get_flag("dry-run"), + matches.get_flag("verbose"), &executor::PaginationConfig::default(), None, &crate::helpers::modelarmor::SanitizeMode::Warn, diff --git a/src/helpers/sheets.rs b/src/helpers/sheets.rs index 76f36ab2..78c2dacf 100644 --- a/src/helpers/sheets.rs +++ b/src/helpers/sheets.rs @@ -138,6 +138,7 @@ TIPS: None, None, matches.get_flag("dry-run"), + matches.get_flag("verbose"), &pagination, None, &crate::helpers::modelarmor::SanitizeMode::Warn, @@ -181,6 +182,7 @@ TIPS: None, None, matches.get_flag("dry-run"), + matches.get_flag("verbose"), &executor::PaginationConfig::default(), None, &crate::helpers::modelarmor::SanitizeMode::Warn, diff --git a/src/main.rs b/src/main.rs index bd72c642..5972792f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -179,7 +179,7 @@ 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)" + "warning: unknown output format '{unknown}'; falling back to json (valid options: json, table, yaml, csv, tsv)" ); formatter::OutputFormat::Json } @@ -228,6 +228,7 @@ async fn run() -> Result<(), GwsError> { .map(|s| s.as_str()); let dry_run = matched_args.get_flag("dry-run"); + let verbose = matched_args.get_flag("verbose"); // Build pagination config from flags let pagination = parse_pagination_config(matched_args); @@ -266,6 +267,7 @@ async fn run() -> Result<(), GwsError> { upload_path, upload_content_type, dry_run, + verbose, &pagination, sanitize_config.template.as_deref(), &sanitize_config.mode, @@ -434,7 +436,8 @@ fn print_usage() { println!(" --upload Local file to upload as media content (multipart)"); println!(" --upload-content-type MIME type of the uploaded file (auto-detected from extension if omitted)"); println!(" --output Output file path for binary responses"); - println!(" --format Output format: json (default), table, yaml, csv"); + println!(" --format Output format: json (default), table, yaml, csv, tsv"); + println!(" --verbose / -v Print request/response details to stderr"); println!(" --api-version Override the API version (e.g., v2, v3)"); println!(" --page-all Auto-paginate, one JSON line per page (NDJSON)"); println!(" --page-limit Max pages to fetch with --page-all (default: 10)"); From 33d885b9c6f7188518de848be4f5a7e1c2ce6276 Mon Sep 17 00:00:00 2001 From: abhiram304 Date: Sat, 14 Mar 2026 08:11:59 -0700 Subject: [PATCH 2/2] fix(verbose): proper URL encoding and revert out-of-scope tsv help text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: - Use url::form_urlencoded for proper percent-encoding of query params in verbose diagnostic output, matching what reqwest actually sends - Revert --format help text to "json, table, yaml, csv" — tsv mention was out of scope for this PR (tracked separately in feat/add-tsv-format) - Add `url` as an explicit dependency (was previously transitive-only) --- Cargo.lock | 1 + Cargo.toml | 1 + src/commands.rs | 2 +- src/executor.rs | 8 +++----- src/main.rs | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d55ea8d..39d6309c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -920,6 +920,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "url", "yup-oauth2", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 24bc253b..8df4f2a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ zeroize = { version = "1.8.2", features = ["derive"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +url = "2" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3.6.3", features = ["apple-native"] } diff --git a/src/commands.rs b/src/commands.rs index 7d274c10..6f515bfc 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -43,7 +43,7 @@ pub fn build_cli(doc: &RestDescription) -> Command { .arg( clap::Arg::new("format") .long("format") - .help("Output format: json (default), table, yaml, csv, tsv") + .help("Output format: json (default), table, yaml, csv") .value_name("FORMAT") .global(true), ) diff --git a/src/executor.rs b/src/executor.rs index 4069ef91..3d28fd4d 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -445,11 +445,9 @@ pub async fn execute_method( if params.is_empty() { input.full_url.clone() } else { - let qs = params - .iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join("&"); + let qs = url::form_urlencoded::Serializer::new(String::new()) + .extend_pairs(¶ms) + .finish(); format!("{}?{}", input.full_url, qs) } }; diff --git a/src/main.rs b/src/main.rs index 5972792f..60d51af8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -179,7 +179,7 @@ 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, tsv)" + "warning: unknown output format '{unknown}'; falling back to json (valid options: json, table, yaml, csv)" ); formatter::OutputFormat::Json } @@ -436,7 +436,7 @@ fn print_usage() { println!(" --upload Local file to upload as media content (multipart)"); println!(" --upload-content-type MIME type of the uploaded file (auto-detected from extension if omitted)"); println!(" --output Output file path for binary responses"); - println!(" --format Output format: json (default), table, yaml, csv, tsv"); + println!(" --format Output format: json (default), table, yaml, csv"); println!(" --verbose / -v Print request/response details to stderr"); println!(" --api-version Override the API version (e.g., v2, v3)"); println!(" --page-all Auto-paginate, one JSON line per page (NDJSON)");