Skip to content

Commit 3e73026

Browse files
Merge pull request #677 from DataDog/dd/fix/table-multibyte-truncation-20260727
fix: char-safe table cell truncation
2 parents 0e5ca6b + 069cd3e commit 3e73026

1 file changed

Lines changed: 35 additions & 17 deletions

File tree

src/formatter.rs

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
475486
fn 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 {
491498
fn 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

Comments
 (0)