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/fix-serialization-error-exit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---

Exit non-zero with a clear error message when JSON serialization fails instead of printing empty stdout
9 changes: 8 additions & 1 deletion crates/google-workspace-cli/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)))?
);
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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);
}
Expand Down
75 changes: 43 additions & 32 deletions crates/google-workspace-cli/src/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, serde_json::Error> {
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)),
}
}

Expand All @@ -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<String, serde_json::Error> {
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))),
}
}

Expand Down Expand Up @@ -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!({
Expand All @@ -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"));
Expand All @@ -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"));
}
Expand All @@ -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"),
Expand All @@ -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}"
Expand All @@ -555,15 +566,15 @@ 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}");
}

#[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 output = format_value(&val, &OutputFormat::Table).unwrap();
assert!(output.contains("name"), "column must appear:\n{output}");
}

Expand All @@ -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"));
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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"));
}
Expand All @@ -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"));
}

Expand All @@ -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\""),
Expand All @@ -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}"
Expand All @@ -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: |"),
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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"
Expand All @@ -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("──"),
Expand All @@ -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 ---"
Expand Down
1 change: 1 addition & 0 deletions crates/google-workspace-cli/src/helpers/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
1 change: 1 addition & 0 deletions crates/google-workspace-cli/src/helpers/gmail/triage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
21 changes: 13 additions & 8 deletions crates/google-workspace-cli/src/helpers/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String>("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> {
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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(());
}

Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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(())
}

Expand Down
Loading