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/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 08d4d287..c935d0ed 100644 --- a/crates/google-workspace-cli/src/formatter.rs +++ b/crates/google-workspace-cli/src/formatter.rs @@ -58,12 +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 => serde_json::to_string_pretty(value).unwrap_or_default(), - 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)), } } @@ -76,14 +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 => serde_json::to_string(value).unwrap_or_default(), - 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))), } } @@ -469,11 +473,18 @@ 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\"")); } + #[test] + fn test_format_value_json_non_empty() { + let val = json!({"key": "value"}); + let result = format_value(&val, &OutputFormat::Json).unwrap(); + assert!(!result.is_empty()); + } + #[test] fn test_format_table_array_of_objects() { let val = json!({ @@ -482,7 +493,7 @@ 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")); @@ -494,7 +505,7 @@ mod tests { #[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")); } @@ -512,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"), @@ -539,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}" @@ -555,7 +566,7 @@ mod tests { 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}"); } @@ -563,7 +574,7 @@ mod tests { 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 output = format_value(&val, &OutputFormat::Table).unwrap(); assert!(output.contains("name"), "column must appear:\n{output}"); } @@ -575,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")); @@ -591,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"); @@ -602,7 +613,7 @@ mod tests { fn test_format_csv_flat_scalars() { // 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"); @@ -614,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"); @@ -632,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")); } @@ -641,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")); } @@ -666,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\""), @@ -681,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}" @@ -692,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: |"), @@ -710,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"); @@ -723,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"); @@ -740,7 +751,7 @@ 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" @@ -755,7 +766,7 @@ 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("──"), @@ -766,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(()) }