@@ -470,17 +470,24 @@ fn extract_rows(value: &serde_json::Value) -> Vec<&serde_json::Value> {
470470 }
471471}
472472
473+ /// Truncate `s` to at most `max` characters, appending "..." when shortened.
474+ /// Cuts on character boundaries so multi-byte UTF-8 text never panics.
475+ fn truncate_ellipsis ( s : & str , max : usize ) -> String {
476+ if s. chars ( ) . count ( ) > max {
477+ let keep: String = s. chars ( ) . take ( max. saturating_sub ( 3 ) ) . collect ( ) ;
478+ format ! ( "{keep}..." )
479+ } else {
480+ s. to_string ( )
481+ }
482+ }
483+
473484/// Compact label for a single array element, used when previewing arrays in table cells.
474485/// For objects, tries id/name/title/type in order; falls back to format_cell for primitives.
475486fn format_array_item ( value : & serde_json:: Value ) -> String {
476487 if let serde_json:: Value :: Object ( map) = value {
477488 for key in & [ "name" , "title" , "id" , "type" ] {
478489 if let Some ( serde_json:: Value :: String ( s) ) = map. get ( * key) {
479- return if s. len ( ) > 16 {
480- format ! ( "{}..." , & s[ ..13 ] )
481- } else {
482- s. clone ( )
483- } ;
490+ return truncate_ellipsis ( s, 16 ) ;
484491 }
485492 }
486493 return format ! ( "{{{} fields}}" , map. len( ) ) ;
@@ -491,13 +498,7 @@ fn format_array_item(value: &serde_json::Value) -> String {
491498fn format_cell ( value : Option < & serde_json:: Value > ) -> String {
492499 match value {
493500 None | Some ( serde_json:: Value :: Null ) => String :: new ( ) ,
494- Some ( serde_json:: Value :: String ( s) ) => {
495- if s. len ( ) > 50 {
496- format ! ( "{}..." , & s[ ..47 ] )
497- } else {
498- s. clone ( )
499- }
500- }
501+ Some ( serde_json:: Value :: String ( s) ) => truncate_ellipsis ( s, 50 ) ,
501502 Some ( serde_json:: Value :: Number ( n) ) => n. to_string ( ) ,
502503 Some ( serde_json:: Value :: Bool ( b) ) => b. to_string ( ) ,
503504 Some ( serde_json:: Value :: Array ( arr) ) => {
@@ -509,11 +510,7 @@ fn format_cell(value: Option<&serde_json::Value>) -> String {
509510 parts. push ( format ! ( "+{} more" , arr. len( ) - 4 ) ) ;
510511 }
511512 let result = format ! ( "[{}]" , parts. join( ", " ) ) ;
512- if result. len ( ) > 50 {
513- format ! ( "{}..." , & result[ ..47 ] )
514- } else {
515- result
516- }
513+ truncate_ellipsis ( & result, 50 )
517514 }
518515 Some ( serde_json:: Value :: Object ( map) ) => format ! ( "{{{} fields}}" , map. len( ) ) ,
519516 }
@@ -569,6 +566,27 @@ mod tests {
569566 assert ! ( result. ends_with( "..." ) ) ;
570567 }
571568
569+ #[ test]
570+ fn test_format_cell_long_multibyte_string_char_boundary ( ) {
571+ // Regression: truncating by byte index used to panic when the cut point
572+ // landed inside a multi-byte UTF-8 character (issue #676).
573+ let name = "Resx V4 ;-) (vérifier que c'est bien un problème de resx avant de recycler)" ;
574+ let result = format_cell ( Some ( & serde_json:: json!( name) ) ) ;
575+ // 47 kept chars + the ellipsis, counted by characters not bytes.
576+ let expected: String = name. chars ( ) . take ( 47 ) . collect ( ) ;
577+ assert_eq ! ( result, format!( "{expected}..." ) ) ;
578+ assert_eq ! ( result. chars( ) . count( ) , 50 , "got: {result}" ) ;
579+ }
580+
581+ #[ test]
582+ fn test_format_array_item_multibyte_no_panic ( ) {
583+ // The array-preview path (16-char cap) is also char-boundary safe.
584+ let arr = serde_json:: json!( [ { "name" : "problème récurrent de résolution" } ] ) ;
585+ let result = format_cell ( Some ( & arr) ) ;
586+ assert ! ( result. contains( "..." ) , "got: {result}" ) ;
587+ assert ! ( result. starts_with( "[problème réc" ) , "got: {result}" ) ;
588+ }
589+
572590 #[ test]
573591 fn test_format_cell_number ( ) {
574592 assert_eq ! ( format_cell( Some ( & serde_json:: json!( 42 ) ) ) , "42" ) ;
0 commit comments