Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
97 changes: 54 additions & 43 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 @@ -148,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);
Expand Down Expand Up @@ -241,11 +245,11 @@ fn format_array_as_table(arr: &[Value], emit_header: bool) -> String {
let _ = writeln!(output, "{}", header.join(" "));

// Separator
let sep: Vec<String> = widths.iter().map(|w| "".repeat(*w)).collect();
let sep: Vec<String> = widths.iter().map(|w| "─".repeat(*w)).collect();
Comment thread
nuthalapativarun marked this conversation as resolved.
Outdated
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<String> = row
.iter()
Expand All @@ -255,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}…")
Comment thread
nuthalapativarun marked this conversation as resolved.
Outdated
} else {
c.clone()
};
Expand Down Expand Up @@ -348,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);
};

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,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"));
}
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 @@ -552,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
Comment thread
nuthalapativarun marked this conversation as resolved.
Outdated
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"}]);
Comment thread
nuthalapativarun marked this conversation as resolved.
Outdated
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 @@ -600,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");
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,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]
Expand All @@ -755,19 +766,19 @@ 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"
);
}

#[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
Loading
Loading