Skip to content

Commit e41bd89

Browse files
authored
Merge pull request #51 from googleworkspace/fix/table-flatten-and-unicode
fix(table): flatten nested objects to dot-notation, safe multi-byte truncation (fixes #40 #43)
2 parents ab6c45e + f91f9e0 commit e41bd89

File tree

2 files changed

+144
-27
lines changed

2 files changed

+144
-27
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gws": patch
3+
---
4+
5+
fix: flatten nested objects in table output and fix multi-byte char truncation panic

src/formatter.rs

Lines changed: 139 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,30 @@ fn format_table(value: &Value) -> String {
110110
format_table_page(value, true)
111111
}
112112

113+
/// Recursively flatten a JSON object into `(dot.notation.key, string_value)` pairs.
114+
///
115+
/// Nested objects become `parent.child` key names so that `--format table` can
116+
/// render them as individual columns instead of raw JSON blobs.
117+
fn flatten_object(obj: &serde_json::Map<String, Value>, prefix: &str) -> Vec<(String, String)> {
118+
let mut out = Vec::new();
119+
for (key, val) in obj {
120+
let full_key = if prefix.is_empty() {
121+
key.clone()
122+
} else {
123+
format!("{prefix}.{key}")
124+
};
125+
match val {
126+
Value::Object(nested) => {
127+
out.extend(flatten_object(nested, &full_key));
128+
}
129+
_ => {
130+
out.push((full_key, value_to_cell(val)));
131+
}
132+
}
133+
}
134+
out
135+
}
136+
113137
/// Format as a text table, optionally omitting the header row.
114138
///
115139
/// Pass `emit_header = false` for continuation pages when using `--page-all`
@@ -124,11 +148,11 @@ fn format_table_page(value: &Value, emit_header: bool) -> String {
124148
} else if let Value::Array(arr) = value {
125149
format_array_as_table(arr, emit_header)
126150
} else if let Value::Object(obj) = value {
127-
// Single object: key/value table
151+
// Single object: key/value table — flatten nested objects first
128152
let mut output = String::new();
129-
let max_key_len = obj.keys().map(|k| k.len()).max().unwrap_or(0);
130-
for (key, val) in obj {
131-
let val_str = value_to_cell(val);
153+
let flat = flatten_object(obj, "");
154+
let max_key_len = flat.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
155+
for (key, val_str) in &flat {
132156
let _ = writeln!(output, "{:width$} {}", key, val_str, width = max_key_len);
133157
}
134158
output
@@ -142,14 +166,21 @@ fn format_array_as_table(arr: &[Value], emit_header: bool) -> String {
142166
return "(empty)\n".to_string();
143167
}
144168

145-
// Collect all unique keys across all objects
169+
// Flatten each row so nested objects become dot-notation columns.
170+
let flat_rows: Vec<Vec<(String, String)>> = arr
171+
.iter()
172+
.map(|item| match item {
173+
Value::Object(obj) => flatten_object(obj, ""),
174+
_ => vec![(String::new(), value_to_cell(item))],
175+
})
176+
.collect();
177+
178+
// Collect all unique column names (preserving insertion order).
146179
let mut columns: Vec<String> = Vec::new();
147-
for item in arr {
148-
if let Value::Object(obj) = item {
149-
for key in obj.keys() {
150-
if !columns.contains(key) {
151-
columns.push(key.clone());
152-
}
180+
for row in &flat_rows {
181+
for (key, _) in row {
182+
if !columns.contains(key) {
183+
columns.push(key.clone());
153184
}
154185
}
155186
}
@@ -163,24 +194,32 @@ fn format_array_as_table(arr: &[Value], emit_header: bool) -> String {
163194
return output;
164195
}
165196

166-
// Calculate column widths
167-
let mut widths: Vec<usize> = columns.iter().map(|c| c.len()).collect();
168-
let rows: Vec<Vec<String>> = arr
197+
// Build lookup: row_index -> column_name -> cell_value
198+
let row_maps: Vec<std::collections::HashMap<&str, &str>> = flat_rows
169199
.iter()
170-
.map(|item| {
200+
.map(|pairs| {
201+
pairs
202+
.iter()
203+
.map(|(k, v)| (k.as_str(), v.as_str()))
204+
.collect()
205+
})
206+
.collect();
207+
208+
// Calculate column widths (char-count, not byte-count).
209+
let mut widths: Vec<usize> = columns.iter().map(|c| c.chars().count()).collect();
210+
let rows: Vec<Vec<String>> = row_maps
211+
.iter()
212+
.map(|row| {
171213
columns
172214
.iter()
173215
.enumerate()
174216
.map(|(i, col)| {
175-
let cell = if let Value::Object(obj) = item {
176-
obj.get(col).map(value_to_cell).unwrap_or_default()
177-
} else {
178-
String::new()
179-
};
180-
if cell.len() > widths[i] {
181-
widths[i] = cell.len();
217+
let cell = row.get(col.as_str()).copied().unwrap_or("").to_string();
218+
let char_len = cell.chars().count();
219+
if char_len > widths[i] {
220+
widths[i] = char_len;
182221
}
183-
// Cap column width at 60
222+
// Cap column width at 60 chars
184223
if widths[i] > 60 {
185224
widths[i] = 60;
186225
}
@@ -206,18 +245,23 @@ fn format_array_as_table(arr: &[Value], emit_header: bool) -> String {
206245
let _ = writeln!(output, "{}", sep.join(" "));
207246
}
208247

209-
// Rows
248+
// Rows — truncate by char count to avoid panicking on multi-byte UTF-8.
210249
for row in &rows {
211250
let cells: Vec<String> = row
212251
.iter()
213252
.enumerate()
214253
.map(|(i, c)| {
215-
let truncated = if c.len() > widths[i] {
216-
format!("{}…", &c[..widths[i] - 1])
254+
let char_len = c.chars().count();
255+
let truncated = if char_len > widths[i] {
256+
// Safe char-boundary slice: take widths[i]-1 chars, then append ellipsis.
257+
let truncated_str: String = c.chars().take(widths[i] - 1).collect();
258+
format!("{truncated_str}…")
217259
} else {
218260
c.clone()
219261
};
220-
format!("{:width$}", truncated, width = widths[i])
262+
// Pad to column width (by char count)
263+
let pad = widths[i].saturating_sub(truncated.chars().count());
264+
format!("{truncated}{}", " ".repeat(pad))
221265
})
222266
.collect();
223267
let _ = writeln!(output, "{}", cells.join(" "));
@@ -438,6 +482,74 @@ mod tests {
438482
assert!(output.contains("abc"));
439483
}
440484

485+
#[test]
486+
fn test_format_table_nested_object_flattened() {
487+
// Nested objects should become dot-notation columns, not raw JSON blobs.
488+
let val = json!({
489+
"user": {
490+
"displayName": "Alice",
491+
"emailAddress": "alice@example.com"
492+
},
493+
"storageQuota": {
494+
"limit": "1000",
495+
"usage": "500"
496+
}
497+
});
498+
let output = format_value(&val, &OutputFormat::Table);
499+
// Should contain dot-notation keys
500+
assert!(
501+
output.contains("user.displayName"),
502+
"expected flattened key in output:\n{output}"
503+
);
504+
assert!(
505+
output.contains("user.emailAddress"),
506+
"expected flattened key in output:\n{output}"
507+
);
508+
assert!(
509+
output.contains("Alice"),
510+
"expected value in output:\n{output}"
511+
);
512+
// Should NOT contain raw JSON blobs
513+
assert!(
514+
!output.contains("{\"displayName"),
515+
"should not have raw JSON blob:\n{output}"
516+
);
517+
}
518+
519+
#[test]
520+
fn test_format_table_nested_objects_in_array() {
521+
let val = json!([
522+
{"id": "1", "owner": {"name": "Alice"}},
523+
{"id": "2", "owner": {"name": "Bob"}}
524+
]);
525+
let output = format_value(&val, &OutputFormat::Table);
526+
assert!(
527+
output.contains("owner.name"),
528+
"expected flattened column:\n{output}"
529+
);
530+
assert!(output.contains("Alice"), "expected value:\n{output}");
531+
assert!(output.contains("Bob"), "expected value:\n{output}");
532+
}
533+
534+
#[test]
535+
fn test_format_table_multibyte_truncation_does_not_panic() {
536+
// Column width cap is 60 chars, so a long string with multi-byte chars
537+
// must be safely truncated without a byte-boundary panic.
538+
let long_emoji = "😀".repeat(70); // each emoji is 4 bytes
539+
let val = json!([{"col": long_emoji}]);
540+
// Should not panic
541+
let output = format_value(&val, &OutputFormat::Table);
542+
assert!(output.contains("col"), "column name must appear:\n{output}");
543+
}
544+
545+
#[test]
546+
fn test_format_table_multibyte_exact_boundary() {
547+
// Multi-byte chars at various positions must not panic or produce garbled output.
548+
let val = json!([{"name": "café résumé naïve"}]);
549+
let output = format_value(&val, &OutputFormat::Table);
550+
assert!(output.contains("name"), "column must appear:\n{output}");
551+
}
552+
441553
#[test]
442554
fn test_format_csv() {
443555
let val = json!({

0 commit comments

Comments
 (0)