@@ -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