From 5bae78e335c758932e6d35878e5b60447a627c98 Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Sun, 31 May 2026 20:15:59 -0700 Subject: [PATCH 1/4] fix(formatter): exit non-zero on JSON serialization failure instead of silent empty output --- .changeset/fix-serialization-error-exit.md | 5 +++++ crates/google-workspace-cli/src/formatter.rs | 23 ++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-serialization-error-exit.md diff --git a/.changeset/fix-serialization-error-exit.md b/.changeset/fix-serialization-error-exit.md new file mode 100644 index 00000000..d1e9cb9d --- /dev/null +++ b/.changeset/fix-serialization-error-exit.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Exit non-zero with a clear error message when JSON serialization fails instead of printing empty stdout diff --git a/crates/google-workspace-cli/src/formatter.rs b/crates/google-workspace-cli/src/formatter.rs index 08d4d287..56cc373b 100644 --- a/crates/google-workspace-cli/src/formatter.rs +++ b/crates/google-workspace-cli/src/formatter.rs @@ -60,7 +60,13 @@ impl OutputFormat { /// Format a JSON value according to the specified output format. pub fn format_value(value: &Value, format: &OutputFormat) -> String { match format { - OutputFormat::Json => serde_json::to_string_pretty(value).unwrap_or_default(), + OutputFormat::Json => match serde_json::to_string_pretty(value) { + Ok(s) => s, + Err(e) => { + eprintln!("error: failed to serialize response to JSON: {e}"); + std::process::exit(1); + } + }, OutputFormat::Table => format_table(value), OutputFormat::Yaml => format_yaml(value), OutputFormat::Csv => format_csv(value), @@ -78,7 +84,13 @@ pub fn format_value(value: &Value, format: &OutputFormat) -> String { /// combined stream is a valid YAML multi-document file. pub fn format_value_paginated(value: &Value, format: &OutputFormat, is_first_page: bool) -> String { match format { - OutputFormat::Json => serde_json::to_string(value).unwrap_or_default(), + OutputFormat::Json => match serde_json::to_string(value) { + Ok(s) => s, + Err(e) => { + eprintln!("error: failed to serialize response to JSON: {e}"); + std::process::exit(1); + } + }, OutputFormat::Csv => format_csv_page(value, is_first_page), OutputFormat::Table => format_table_page(value, is_first_page), // Prefix every page with a YAML document separator so that the @@ -474,6 +486,13 @@ mod tests { assert!(output.contains("\"test\"")); } + #[test] + fn test_format_value_json_non_empty() { + let val = json!({"key": "value"}); + let result = format_value(&val, &OutputFormat::Json); + assert!(!result.is_empty()); + } + #[test] fn test_format_table_array_of_objects() { let val = json!({ From 41f8a6f4a35ddb05eb95c3b12729694a35a69678 Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Sun, 31 May 2026 21:21:59 -0700 Subject: [PATCH 2/4] fix(formatter): sanitize error messages to prevent terminal escape injection Wrap the serialization error string with sanitize_for_terminal before printing to stderr, as the error text could theoretically contain user-controlled data with embedded escape sequences. --- crates/google-workspace-cli/src/formatter.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/google-workspace-cli/src/formatter.rs b/crates/google-workspace-cli/src/formatter.rs index 56cc373b..28a1ead0 100644 --- a/crates/google-workspace-cli/src/formatter.rs +++ b/crates/google-workspace-cli/src/formatter.rs @@ -63,7 +63,7 @@ pub fn format_value(value: &Value, format: &OutputFormat) -> String { OutputFormat::Json => match serde_json::to_string_pretty(value) { Ok(s) => s, Err(e) => { - eprintln!("error: failed to serialize response to JSON: {e}"); + eprintln!("{}", crate::output::sanitize_for_terminal(&format!("error: failed to serialize response to JSON: {e}"))); std::process::exit(1); } }, @@ -87,7 +87,7 @@ pub fn format_value_paginated(value: &Value, format: &OutputFormat, is_first_pag OutputFormat::Json => match serde_json::to_string(value) { Ok(s) => s, Err(e) => { - eprintln!("error: failed to serialize response to JSON: {e}"); + eprintln!("{}", crate::output::sanitize_for_terminal(&format!("error: failed to serialize response to JSON: {e}"))); std::process::exit(1); } }, From b069a274500aa00d49e7d52f8164d615be09e0bf Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Mon, 1 Jun 2026 09:23:39 -0700 Subject: [PATCH 3/4] refactor(formatter): return Result instead of calling process::exit(1) Change format_value and format_value_paginated to return Result so that serialization failures propagate to callers rather than aborting the process mid-flight. All call sites now use .map_err(|e| GwsError::Other(...))? or (in format_and_print) return Result<(), GwsError>. This fixes the RAII bypass and makes both functions testable for error paths without killing the test runner. Resolves Gemini r2 comment on PR #828. --- crates/google-workspace-cli/src/executor.rs | 9 +- crates/google-workspace-cli/src/formatter.rs | 104 ++++++++---------- .../src/helpers/calendar.rs | 1 + .../src/helpers/gmail/triage.rs | 1 + .../src/helpers/workflows.rs | 21 ++-- 5 files changed, 71 insertions(+), 65 deletions(-) diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index 46f31ac4..07a6652f 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -303,11 +303,13 @@ async fn handle_json_response( println!( "{}", crate::formatter::format_value_paginated(&json_val, output_format, is_first_page) + .map_err(|e| GwsError::Other(anyhow::Error::from(e)))? ); } else { println!( "{}", crate::formatter::format_value(&json_val, output_format) + .map_err(|e| GwsError::Other(anyhow::Error::from(e)))? ); } @@ -379,7 +381,11 @@ async fn handle_binary_response( return Ok(Some(result)); } - println!("{}", crate::formatter::format_value(&result, output_format)); + println!( + "{}", + crate::formatter::format_value(&result, output_format) + .map_err(|e| GwsError::Other(anyhow::Error::from(e)))? + ); Ok(None) } @@ -428,6 +434,7 @@ pub async fn execute_method( println!( "{}", crate::formatter::format_value(&dry_run_info, output_format) + .map_err(|e| GwsError::Other(anyhow::Error::from(e)))? ); return Ok(None); } diff --git a/crates/google-workspace-cli/src/formatter.rs b/crates/google-workspace-cli/src/formatter.rs index 28a1ead0..0f340930 100644 --- a/crates/google-workspace-cli/src/formatter.rs +++ b/crates/google-workspace-cli/src/formatter.rs @@ -58,18 +58,12 @@ impl OutputFormat { } /// Format a JSON value according to the specified output format. -pub fn format_value(value: &Value, format: &OutputFormat) -> String { +pub fn format_value(value: &Value, format: &OutputFormat) -> Result { match format { - OutputFormat::Json => match serde_json::to_string_pretty(value) { - Ok(s) => s, - Err(e) => { - eprintln!("{}", crate::output::sanitize_for_terminal(&format!("error: failed to serialize response to JSON: {e}"))); - std::process::exit(1); - } - }, - OutputFormat::Table => format_table(value), - OutputFormat::Yaml => format_yaml(value), - OutputFormat::Csv => format_csv(value), + OutputFormat::Json => serde_json::to_string_pretty(value), + OutputFormat::Table => Ok(format_table(value)), + OutputFormat::Yaml => Ok(format_yaml(value)), + OutputFormat::Csv => Ok(format_csv(value)), } } @@ -82,20 +76,18 @@ pub fn format_value(value: &Value, format: &OutputFormat) -> String { /// For JSON the output is compact (one JSON object per line / NDJSON). /// For YAML each page is prefixed with a `---` document separator so the /// combined stream is a valid YAML multi-document file. -pub fn format_value_paginated(value: &Value, format: &OutputFormat, is_first_page: bool) -> String { +pub fn format_value_paginated( + value: &Value, + format: &OutputFormat, + is_first_page: bool, +) -> Result { match format { - OutputFormat::Json => match serde_json::to_string(value) { - Ok(s) => s, - Err(e) => { - eprintln!("{}", crate::output::sanitize_for_terminal(&format!("error: failed to serialize response to JSON: {e}"))); - std::process::exit(1); - } - }, - OutputFormat::Csv => format_csv_page(value, is_first_page), - OutputFormat::Table => format_table_page(value, is_first_page), + OutputFormat::Json => serde_json::to_string(value), + OutputFormat::Csv => Ok(format_csv_page(value, is_first_page)), + OutputFormat::Table => Ok(format_table_page(value, is_first_page)), // Prefix every page with a YAML document separator so that the // concatenated stream is parseable as a multi-document YAML file. - OutputFormat::Yaml => format!("---\n{}", format_yaml(value)), + OutputFormat::Yaml => Ok(format!("---\n{}", format_yaml(value))), } } @@ -160,7 +152,7 @@ fn format_table_page(value: &Value, emit_header: bool) -> String { } else if let Value::Array(arr) = value { format_array_as_table(arr, emit_header) } else if let Value::Object(obj) = value { - // Single object: key/value table — flatten nested objects first + // Single object: key/value table — flatten nested objects first let mut output = String::new(); let flat = flatten_object(obj, ""); let max_key_len = flat.iter().map(|(k, _)| k.len()).max().unwrap_or(0); @@ -253,11 +245,11 @@ fn format_array_as_table(arr: &[Value], emit_header: bool) -> String { let _ = writeln!(output, "{}", header.join(" ")); // Separator - let sep: Vec = widths.iter().map(|w| "─".repeat(*w)).collect(); + let sep: Vec = widths.iter().map(|w| "─".repeat(*w)).collect(); let _ = writeln!(output, "{}", sep.join(" ")); } - // Rows — truncate by char count to avoid panicking on multi-byte UTF-8. + // Rows — truncate by char count to avoid panicking on multi-byte UTF-8. for row in &rows { let cells: Vec = row .iter() @@ -267,7 +259,7 @@ fn format_array_as_table(arr: &[Value], emit_header: bool) -> String { let truncated = if char_len > widths[i] { // Safe char-boundary slice: take widths[i]-1 chars, then append ellipsis. let truncated_str: String = c.chars().take(widths[i] - 1).collect(); - format!("{truncated_str}…") + format!("{truncated_str}…") } else { c.clone() }; @@ -360,7 +352,7 @@ fn format_csv_page(value: &Value, emit_header: bool) -> String { } else if let Value::Array(arr) = value { arr.as_slice() } else { - // Single value — just output it + // Single value — just output it return value_to_cell(value); }; @@ -481,7 +473,7 @@ mod tests { #[test] fn test_format_json() { let val = json!({"name": "test"}); - let output = format_value(&val, &OutputFormat::Json); + let output = format_value(&val, &OutputFormat::Json).unwrap(); assert!(output.contains("\"name\"")); assert!(output.contains("\"test\"")); } @@ -489,7 +481,7 @@ mod tests { #[test] fn test_format_value_json_non_empty() { let val = json!({"key": "value"}); - let result = format_value(&val, &OutputFormat::Json); + let result = format_value(&val, &OutputFormat::Json).unwrap(); assert!(!result.is_empty()); } @@ -501,19 +493,19 @@ mod tests { {"id": "2", "name": "world.txt"} ] }); - let output = format_value(&val, &OutputFormat::Table); + let output = format_value(&val, &OutputFormat::Table).unwrap(); assert!(output.contains("id")); assert!(output.contains("name")); assert!(output.contains("hello.txt")); assert!(output.contains("world.txt")); // Check separator line - assert!(output.contains("──")); + assert!(output.contains("──")); } #[test] fn test_format_table_single_object() { let val = json!({"id": "abc", "name": "test"}); - let output = format_value(&val, &OutputFormat::Table); + let output = format_value(&val, &OutputFormat::Table).unwrap(); assert!(output.contains("id")); assert!(output.contains("abc")); } @@ -531,7 +523,7 @@ mod tests { "usage": "500" } }); - let output = format_value(&val, &OutputFormat::Table); + let output = format_value(&val, &OutputFormat::Table).unwrap(); // Should contain dot-notation keys assert!( output.contains("user.displayName"), @@ -558,7 +550,7 @@ mod tests { {"id": "1", "owner": {"name": "Alice"}}, {"id": "2", "owner": {"name": "Bob"}} ]); - let output = format_value(&val, &OutputFormat::Table); + let output = format_value(&val, &OutputFormat::Table).unwrap(); assert!( output.contains("owner.name"), "expected flattened column:\n{output}" @@ -571,18 +563,18 @@ mod tests { fn test_format_table_multibyte_truncation_does_not_panic() { // Column width cap is 60 chars, so a long string with multi-byte chars // must be safely truncated without a byte-boundary panic. - let long_emoji = "😀".repeat(70); // each emoji is 4 bytes + let long_emoji = "😀".repeat(70); // each emoji is 4 bytes let val = json!([{"col": long_emoji}]); // Should not panic - let output = format_value(&val, &OutputFormat::Table); + let output = format_value(&val, &OutputFormat::Table).unwrap(); assert!(output.contains("col"), "column name must appear:\n{output}"); } #[test] fn test_format_table_multibyte_exact_boundary() { // Multi-byte chars at various positions must not panic or produce garbled output. - let val = json!([{"name": "café résumé naïve"}]); - let output = format_value(&val, &OutputFormat::Table); + let val = json!([{"name": "café résumé naïve"}]); + let output = format_value(&val, &OutputFormat::Table).unwrap(); assert!(output.contains("name"), "column must appear:\n{output}"); } @@ -594,7 +586,7 @@ mod tests { {"id": "2", "name": "world"} ] }); - let output = format_value(&val, &OutputFormat::Csv); + let output = format_value(&val, &OutputFormat::Csv).unwrap(); assert!(output.contains("id,name")); assert!(output.contains("1,hello")); assert!(output.contains("2,world")); @@ -610,7 +602,7 @@ mod tests { ["Andrew", "Male", "1. Freshman"] ] }); - let output = format_value(&val, &OutputFormat::Csv); + let output = format_value(&val, &OutputFormat::Csv).unwrap(); let lines: Vec<&str> = output.lines().collect(); assert_eq!(lines[0], "Student Name,Gender,Class Level"); assert_eq!(lines[1], "Alexandra,Female,4. Senior"); @@ -619,9 +611,9 @@ mod tests { #[test] fn test_format_csv_flat_scalars() { - // Flat array of non-object, non-array values → one value per line + // Flat array of non-object, non-array values → one value per line let val = json!(["apple", "banana", "cherry"]); - let output = format_value(&val, &OutputFormat::Csv); + let output = format_value(&val, &OutputFormat::Csv).unwrap(); let lines: Vec<&str> = output.lines().collect(); assert_eq!(lines.len(), 3); assert_eq!(lines[0], "apple"); @@ -633,7 +625,7 @@ mod tests { fn test_format_csv_flat_scalars_with_escaping() { // Scalars that contain commas/quotes must be CSV-escaped let val = json!(["plain", "has,comma", "has\"quote"]); - let output = format_value(&val, &OutputFormat::Csv); + let output = format_value(&val, &OutputFormat::Csv).unwrap(); let lines: Vec<&str> = output.lines().collect(); assert_eq!(lines.len(), 3); assert_eq!(lines[0], "plain"); @@ -651,7 +643,7 @@ mod tests { #[test] fn test_format_yaml() { let val = json!({"name": "test", "count": 42}); - let output = format_value(&val, &OutputFormat::Yaml); + let output = format_value(&val, &OutputFormat::Yaml).unwrap(); assert!(output.contains("name: \"test\"")); assert!(output.contains("count: 42")); } @@ -660,7 +652,7 @@ mod tests { fn test_format_table_empty_array() { let val = json!({"files": []}); // No items to extract, falls back to single-object table - let output = format_value(&val, &OutputFormat::Table); + let output = format_value(&val, &OutputFormat::Table).unwrap(); assert!(output.contains("files")); } @@ -685,7 +677,7 @@ mod tests { // `drive#file` contains `#` which is a YAML comment marker; the // serialiser must quote it rather than emit a block scalar. let val = json!({"kind": "drive#file", "id": "123"}); - let output = format_value(&val, &OutputFormat::Yaml); + let output = format_value(&val, &OutputFormat::Yaml).unwrap(); // Must be a double-quoted string, not a block scalar (`|`). assert!( output.contains("kind: \"drive#file\""), @@ -700,7 +692,7 @@ mod tests { #[test] fn test_format_yaml_colon_in_string_is_quoted() { let val = json!({"url": "https://example.com/path"}); - let output = format_value(&val, &OutputFormat::Yaml); + let output = format_value(&val, &OutputFormat::Yaml).unwrap(); assert!( output.contains("url: \"https://example.com/path\""), "expected double-quoted url, got:\n{output}" @@ -711,7 +703,7 @@ mod tests { #[test] fn test_format_yaml_multiline_still_uses_block() { let val = json!({"body": "line one\nline two"}); - let output = format_value(&val, &OutputFormat::Yaml); + let output = format_value(&val, &OutputFormat::Yaml).unwrap(); // Multi-line content should still use block scalar. assert!( output.contains("body: |"), @@ -729,7 +721,7 @@ mod tests { {"id": "2", "name": "b.txt"} ] }); - let output = format_value_paginated(&val, &OutputFormat::Csv, true); + let output = format_value_paginated(&val, &OutputFormat::Csv, true).unwrap(); let lines: Vec<&str> = output.lines().collect(); assert_eq!(lines[0], "id,name", "first page must start with header"); assert_eq!(lines[1], "1,a.txt"); @@ -742,7 +734,7 @@ mod tests { {"id": "3", "name": "c.txt"} ] }); - let output = format_value_paginated(&val, &OutputFormat::Csv, false); + let output = format_value_paginated(&val, &OutputFormat::Csv, false).unwrap(); let lines: Vec<&str> = output.lines().collect(); // The first (and only) line must be a data row, not the header. assert_eq!(lines[0], "3,c.txt", "continuation page must have no header"); @@ -759,12 +751,12 @@ mod tests { {"id": "1", "name": "foo"} ] }); - let output = format_value_paginated(&val, &OutputFormat::Table, true); + let output = format_value_paginated(&val, &OutputFormat::Table, true).unwrap(); assert!( output.contains("id"), "table header must appear on first page" ); - assert!(output.contains("──"), "separator must appear on first page"); + assert!(output.contains("──"), "separator must appear on first page"); } #[test] @@ -774,10 +766,10 @@ mod tests { {"id": "2", "name": "bar"} ] }); - let output = format_value_paginated(&val, &OutputFormat::Table, false); + let output = format_value_paginated(&val, &OutputFormat::Table, false).unwrap(); assert!(output.contains("bar"), "data row must be present"); assert!( - !output.contains("──"), + !output.contains("──"), "separator must be absent on continuation pages" ); } @@ -785,8 +777,8 @@ mod tests { #[test] fn test_format_value_paginated_yaml_has_document_separator() { let val = json!({"files": [{"id": "1", "name": "foo"}]}); - let first = format_value_paginated(&val, &OutputFormat::Yaml, true); - let second = format_value_paginated(&val, &OutputFormat::Yaml, false); + let first = format_value_paginated(&val, &OutputFormat::Yaml, true).unwrap(); + let second = format_value_paginated(&val, &OutputFormat::Yaml, false).unwrap(); assert!( first.starts_with("---\n"), "first YAML page must start with ---" diff --git a/crates/google-workspace-cli/src/helpers/calendar.rs b/crates/google-workspace-cli/src/helpers/calendar.rs index cf28b249..ac45a72d 100644 --- a/crates/google-workspace-cli/src/helpers/calendar.rs +++ b/crates/google-workspace-cli/src/helpers/calendar.rs @@ -415,6 +415,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { println!( "{}", crate::formatter::format_value(&output, &output_format) + .map_err(|e| GwsError::Other(anyhow::Error::from(e)))? ); Ok(()) } diff --git a/crates/google-workspace-cli/src/helpers/gmail/triage.rs b/crates/google-workspace-cli/src/helpers/gmail/triage.rs index 3c275e1f..2c34e4dc 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/triage.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/triage.rs @@ -173,6 +173,7 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> { println!( "{}", crate::formatter::format_value(&output, &output_format) + .map_err(|e| GwsError::Other(anyhow::Error::from(e)))? ); Ok(()) diff --git a/crates/google-workspace-cli/src/helpers/workflows.rs b/crates/google-workspace-cli/src/helpers/workflows.rs index b921d864..36f0e925 100644 --- a/crates/google-workspace-cli/src/helpers/workflows.rs +++ b/crates/google-workspace-cli/src/helpers/workflows.rs @@ -259,12 +259,17 @@ async fn get_json( .map_err(|e| GwsError::Other(anyhow::anyhow!("JSON parse failed: {e}"))) } -fn format_and_print(value: &Value, matches: &ArgMatches) { +fn format_and_print(value: &Value, matches: &ArgMatches) -> Result<(), GwsError> { let fmt = matches .get_one::("format") .map(|s| crate::formatter::OutputFormat::from_str(s)) .unwrap_or_default(); - println!("{}", crate::formatter::format_value(value, &fmt)); + println!( + "{}", + crate::formatter::format_value(value, &fmt) + .map_err(|e| GwsError::Other(anyhow::Error::from(e)))? + ); + Ok(()) } async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> { @@ -361,7 +366,7 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> { "date": now_in_tz.format("%Y-%m-%d").to_string(), }); - format_and_print(&output, matches); + format_and_print(&output, matches)?; Ok(()) } @@ -405,7 +410,7 @@ async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> { if items.is_empty() { let output = json!({ "message": "No upcoming meetings found." }); - format_and_print(&output, matches); + format_and_print(&output, matches)?; return Ok(()); } @@ -438,7 +443,7 @@ async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> { "attendeeCount": attendee_list.len(), }); - format_and_print(&output, matches); + format_and_print(&output, matches)?; Ok(()) } @@ -528,7 +533,7 @@ async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> { "sourceMessageId": message_id, }); - format_and_print(&output, matches); + format_and_print(&output, matches)?; Ok(()) } @@ -613,7 +618,7 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> { "periodEnd": time_max, }); - format_and_print(&output, matches); + format_and_print(&output, matches)?; Ok(()) } @@ -686,7 +691,7 @@ async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> { "space": space, }); - format_and_print(&output, matches); + format_and_print(&output, matches)?; Ok(()) } From 08643d93aa30dc5ff910b02a3f5c05d669b07561 Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Tue, 2 Jun 2026 08:31:52 -0700 Subject: [PATCH 4/4] fix(formatter): restore UTF-8 characters corrupted during refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refactor-to-return-Result commit double-encoded several Unicode literals via a Windows-1252/Latin-1 round-trip. Restore the original code points: â"€ (mojibake) → ─ (U+2500 box drawing horizontal) â€" (mojibake) → — (U+2014 em dash) … (mojibake) → … (U+2026 horizontal ellipsis) â†' (mojibake) → → (U+2192 right arrow) 😀 (mojibake) → 😀 (U+1F600 emoji) é (mojibake) → é (U+00E9) ï (mojibake) → ï (U+00EF) --- crates/google-workspace-cli/src/formatter.rs | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/google-workspace-cli/src/formatter.rs b/crates/google-workspace-cli/src/formatter.rs index 0f340930..c935d0ed 100644 --- a/crates/google-workspace-cli/src/formatter.rs +++ b/crates/google-workspace-cli/src/formatter.rs @@ -152,7 +152,7 @@ fn format_table_page(value: &Value, emit_header: bool) -> String { } else if let Value::Array(arr) = value { format_array_as_table(arr, emit_header) } else if let Value::Object(obj) = value { - // Single object: key/value table — flatten nested objects first + // Single object: key/value table — flatten nested objects first let mut output = String::new(); let flat = flatten_object(obj, ""); let max_key_len = flat.iter().map(|(k, _)| k.len()).max().unwrap_or(0); @@ -245,11 +245,11 @@ fn format_array_as_table(arr: &[Value], emit_header: bool) -> String { let _ = writeln!(output, "{}", header.join(" ")); // Separator - let sep: Vec = widths.iter().map(|w| "─".repeat(*w)).collect(); + let sep: Vec = widths.iter().map(|w| "─".repeat(*w)).collect(); let _ = writeln!(output, "{}", sep.join(" ")); } - // Rows — truncate by char count to avoid panicking on multi-byte UTF-8. + // Rows — truncate by char count to avoid panicking on multi-byte UTF-8. for row in &rows { let cells: Vec = row .iter() @@ -259,7 +259,7 @@ fn format_array_as_table(arr: &[Value], emit_header: bool) -> String { let truncated = if char_len > widths[i] { // Safe char-boundary slice: take widths[i]-1 chars, then append ellipsis. let truncated_str: String = c.chars().take(widths[i] - 1).collect(); - format!("{truncated_str}…") + format!("{truncated_str}…") } else { c.clone() }; @@ -352,7 +352,7 @@ fn format_csv_page(value: &Value, emit_header: bool) -> String { } else if let Value::Array(arr) = value { arr.as_slice() } else { - // Single value — just output it + // Single value — just output it return value_to_cell(value); }; @@ -499,7 +499,7 @@ mod tests { assert!(output.contains("hello.txt")); assert!(output.contains("world.txt")); // Check separator line - assert!(output.contains("──")); + assert!(output.contains("──")); } #[test] @@ -563,7 +563,7 @@ mod tests { fn test_format_table_multibyte_truncation_does_not_panic() { // Column width cap is 60 chars, so a long string with multi-byte chars // must be safely truncated without a byte-boundary panic. - let long_emoji = "😀".repeat(70); // each emoji is 4 bytes + let long_emoji = "😀".repeat(70); // each emoji is 4 bytes let val = json!([{"col": long_emoji}]); // Should not panic let output = format_value(&val, &OutputFormat::Table).unwrap(); @@ -573,7 +573,7 @@ mod tests { #[test] fn test_format_table_multibyte_exact_boundary() { // Multi-byte chars at various positions must not panic or produce garbled output. - let val = json!([{"name": "café résumé naïve"}]); + let val = json!([{"name": "café résumé naïve"}]); let output = format_value(&val, &OutputFormat::Table).unwrap(); assert!(output.contains("name"), "column must appear:\n{output}"); } @@ -611,7 +611,7 @@ mod tests { #[test] fn test_format_csv_flat_scalars() { - // Flat array of non-object, non-array values → one value per line + // Flat array of non-object, non-array values → one value per line let val = json!(["apple", "banana", "cherry"]); let output = format_value(&val, &OutputFormat::Csv).unwrap(); let lines: Vec<&str> = output.lines().collect(); @@ -756,7 +756,7 @@ mod tests { output.contains("id"), "table header must appear on first page" ); - assert!(output.contains("──"), "separator must appear on first page"); + assert!(output.contains("──"), "separator must appear on first page"); } #[test] @@ -769,7 +769,7 @@ mod tests { let output = format_value_paginated(&val, &OutputFormat::Table, false).unwrap(); assert!(output.contains("bar"), "data row must be present"); assert!( - !output.contains("──"), + !output.contains("──"), "separator must be absent on continuation pages" ); }