Skip to content

Commit 972a8f3

Browse files
committed
fix: truncate user text on char boundaries to stop lint panic
A multi-byte char straddling the truncation index made truncated_text panic (#70). Adds lash_types::text helpers and fixes five more sites with the same byte-index slicing.
1 parent 314f3d8 commit 972a8f3

11 files changed

Lines changed: 183 additions & 33 deletions

File tree

‎crates/lash-agent/src/tokens.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,10 @@ pub fn truncate_to_budget(text: &str, token_budget: usize) -> String {
143143
return "...".to_string();
144144
}
145145

146-
// Truncate to character budget, accounting for ellipsis
147-
let truncate_at = char_budget.saturating_sub(3).min(text.len());
146+
// Truncate to character budget, accounting for ellipsis; clamp to a char
147+
// boundary so slicing can't land inside a multi-byte character
148+
let truncate_at =
149+
lash_types::text::floor_char_boundary(text, char_budget.saturating_sub(3).min(text.len()));
148150

149151
// Try to truncate at a word boundary
150152
let truncated = if let Some(last_space) = text[..truncate_at].rfind(char::is_whitespace) {

‎crates/lash-cli/src/commands/ascii_graph.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,7 @@ impl<'a> AsciiGraphRenderer<'a> {
537537
let max_title_len = terminal_width.saturating_sub(20);
538538

539539
if title.len() > max_title_len && max_title_len > 3 {
540-
format!("{}...", &title[..max_title_len - 3])
540+
lash_types::text::truncate_with_ellipsis(title, max_title_len)
541541
} else {
542542
title.to_string()
543543
}

‎crates/lash-cli/src/commands/list.rs‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -656,11 +656,7 @@ fn truncate_description(description: &str, max_len: usize) -> String {
656656
if collapsed.len() <= max_len {
657657
collapsed
658658
} else {
659-
// Find a valid char boundary at or before max_len
660-
let mut boundary = max_len;
661-
while !collapsed.is_char_boundary(boundary) && boundary > 0 {
662-
boundary -= 1;
663-
}
659+
let boundary = lash_types::text::floor_char_boundary(&collapsed, max_len);
664660
format!("{}...", &collapsed[..boundary])
665661
}
666662
}

‎crates/lash-cli/src/diff_display.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ impl DiffDisplay {
385385

386386
fn format_line(line: &str) -> String {
387387
if line.len() > MAX_LINE_LENGTH {
388-
let truncated = &line[..MAX_LINE_LENGTH];
388+
let truncated = &line[..lash_types::text::floor_char_boundary(line, MAX_LINE_LENGTH)];
389389
format!("{truncated}...")
390390
} else {
391391
line.to_string()

‎crates/lash-db/src/search.rs‎

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -820,13 +820,7 @@ fn generate_snippet(
820820

821821
// Find a safe character boundary for truncation
822822
if body_text.len() > 100 {
823-
// Find the last character boundary at or before index 100
824-
let truncate_at = body_text
825-
.char_indices()
826-
.take_while(|(idx, _)| *idx <= 100)
827-
.last()
828-
.map_or(0, |(idx, ch)| idx + ch.len_utf8());
829-
823+
let truncate_at = lash_types::text::floor_char_boundary(body_text, 100);
830824
snippet.push_str(&body_text[..truncate_at]);
831825
snippet.push_str("...");
832826
} else {
@@ -842,12 +836,7 @@ fn generate_snippet(
842836

843837
// Truncate long notes
844838
if note_text.len() > 100 {
845-
let truncate_at = note_text
846-
.char_indices()
847-
.take_while(|(idx, _)| *idx <= 100)
848-
.last()
849-
.map_or(0, |(idx, ch)| idx + ch.len_utf8());
850-
839+
let truncate_at = lash_types::text::floor_char_boundary(&note_text, 100);
851840
snippet.push_str(&note_text[..truncate_at]);
852841
snippet.push_str("...");
853842
} else {

‎crates/lash-tui/src/ui/logo.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ fn truncate_title(title: &str, max_width: usize) -> String {
9494
if title.len() <= max_width {
9595
title.to_string()
9696
} else if max_width > 3 {
97-
format!("{}...", &title[..max_width - 3])
97+
lash_types::text::truncate_with_ellipsis(title, max_width)
9898
} else {
9999
title.chars().take(max_width).collect()
100100
}

‎crates/lash-tui/src/ui/theme_selector.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ fn render_scheme_list(
105105

106106
// Scheme name (truncate if too long)
107107
let display_name = if name.len() > 28 {
108-
format!("{}… ", &name[..27])
108+
format!(
109+
"{}… ",
110+
&name[..lash_types::text::floor_char_boundary(name, 27)]
111+
)
109112
} else {
110113
format!("{name:<30}")
111114
};

‎crates/lash-types/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pub mod path_utils;
3333
pub mod report;
3434
pub mod status;
3535
pub mod task;
36+
pub mod text;
3637
pub mod tree;
3738

3839
pub use config::{ConfigBuilder, LashConfig, TreeViewConfig, UserConfig};

‎crates/lash-types/src/task.rs‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -209,18 +209,15 @@ impl ContextualNote {
209209

210210
/// Get a truncated version of the text for display purposes.
211211
///
212+
/// Truncation is UTF-8-safe: a multi-byte character straddling the cut
213+
/// point is dropped rather than split.
214+
///
212215
/// # Arguments
213216
///
214-
/// * `max_len` - Maximum length before truncation (including ellipsis)
217+
/// * `max_len` - Maximum length in bytes before truncation (including ellipsis)
215218
#[must_use]
216219
pub fn truncated_text(&self, max_len: usize) -> String {
217-
if self.text.len() <= max_len {
218-
self.text.clone()
219-
} else if max_len <= 3 {
220-
"...".to_string()
221-
} else {
222-
format!("{}...", &self.text[..max_len - 3])
223-
}
220+
crate::text::truncate_with_ellipsis(&self.text, max_len)
224221
}
225222
}
226223

@@ -1171,6 +1168,23 @@ mod tests {
11711168
assert_eq!(note.truncated_text(2), "...");
11721169
}
11731170

1171+
#[test]
1172+
fn test_contextual_note_truncated_text_multibyte_boundary() {
1173+
// Regression test for issue #70: a note long enough to trigger
1174+
// W_NOTE_TOO_LONG with an em dash straddling the truncation point
1175+
// (bytes 55..58) made truncated_text(60) panic on a byte slice.
1176+
let text = format!("{}— trailing text {}", "a".repeat(55), "b".repeat(150));
1177+
let note = ContextualNote::new(&text, 1);
1178+
assert!(note.exceeds_warning_threshold());
1179+
assert_eq!(note.truncated_text(60), format!("{}...", "a".repeat(55)));
1180+
1181+
// No cut point may panic, whatever the multi-byte layout.
1182+
let note = ContextualNote::new("héllo wörld — ünïcode täsk nôte", 1);
1183+
for max_len in 0..=note.text.len() + 3 {
1184+
let _ = note.truncated_text(max_len);
1185+
}
1186+
}
1187+
11741188
#[test]
11751189
fn test_contextual_note_serialization_roundtrip() {
11761190
let note = ContextualNote::new("Test note", 42);

‎crates/lash-types/src/text.rs‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//! UTF-8-safe text truncation helpers.
2+
//!
3+
//! Byte-index slicing (`&s[..n]`) panics when `n` falls inside a multi-byte
4+
//! character. These helpers clamp indices to char boundaries so display
5+
//! truncation never panics on user text (em dashes, accents, CJK, emoji).
6+
7+
/// Find the largest index at or below `index` that is a char boundary of `s`.
8+
///
9+
/// Returns `s.len()` if `index` is past the end of the string. This mirrors
10+
/// the unstable `str::floor_char_boundary`; swap to the std method once it
11+
/// stabilizes.
12+
///
13+
/// # Examples
14+
///
15+
/// ```
16+
/// use lash_types::text::floor_char_boundary;
17+
///
18+
/// let s = "a—b"; // '—' occupies bytes 1..4
19+
/// assert_eq!(floor_char_boundary(s, 2), 1);
20+
/// assert_eq!(floor_char_boundary(s, 4), 4);
21+
/// assert_eq!(floor_char_boundary(s, 99), s.len());
22+
/// ```
23+
#[must_use]
24+
pub fn floor_char_boundary(s: &str, index: usize) -> usize {
25+
if index >= s.len() {
26+
return s.len();
27+
}
28+
let mut i = index;
29+
while !s.is_char_boundary(i) {
30+
i -= 1;
31+
}
32+
i
33+
}
34+
35+
/// Truncate `s` to at most `max_len` bytes, appending `"..."` when truncated.
36+
///
37+
/// The ellipsis counts toward `max_len`, so the result is never longer than
38+
/// `max_len` bytes (except when `max_len < 3`, where `"..."` is returned
39+
/// as-is). Truncation lands on a char boundary, so multi-byte characters at
40+
/// the cut point are dropped rather than split.
41+
///
42+
/// # Examples
43+
///
44+
/// ```
45+
/// use lash_types::text::truncate_with_ellipsis;
46+
///
47+
/// assert_eq!(truncate_with_ellipsis("short", 10), "short");
48+
/// assert_eq!(truncate_with_ellipsis("Hello, World!", 10), "Hello, ...");
49+
/// assert_eq!(truncate_with_ellipsis("Hello", 2), "...");
50+
///
51+
/// // A multi-byte char straddling the cut point is dropped, not split.
52+
/// let s = "aaaa—bbbb"; // '—' occupies bytes 4..7
53+
/// assert_eq!(truncate_with_ellipsis(s, 8), "aaaa...");
54+
/// ```
55+
#[must_use]
56+
pub fn truncate_with_ellipsis(s: &str, max_len: usize) -> String {
57+
if s.len() <= max_len {
58+
s.to_string()
59+
} else if max_len <= 3 {
60+
"...".to_string()
61+
} else {
62+
format!("{}...", &s[..floor_char_boundary(s, max_len - 3)])
63+
}
64+
}
65+
66+
#[cfg(test)]
67+
mod tests {
68+
use super::*;
69+
70+
#[test]
71+
fn floor_boundary_on_ascii_is_identity() {
72+
let s = "abcdef";
73+
for i in 0..=s.len() {
74+
assert_eq!(floor_char_boundary(s, i), i);
75+
}
76+
}
77+
78+
#[test]
79+
fn floor_boundary_walks_back_inside_multibyte_char() {
80+
let s = "a—b"; // bytes: a=0, — =1..4, b=4
81+
assert_eq!(floor_char_boundary(s, 1), 1);
82+
assert_eq!(floor_char_boundary(s, 2), 1);
83+
assert_eq!(floor_char_boundary(s, 3), 1);
84+
assert_eq!(floor_char_boundary(s, 4), 4);
85+
}
86+
87+
#[test]
88+
fn floor_boundary_clamps_past_end() {
89+
assert_eq!(floor_char_boundary("abc", 100), 3);
90+
assert_eq!(floor_char_boundary("", 5), 0);
91+
}
92+
93+
#[test]
94+
fn truncate_short_string_unchanged() {
95+
assert_eq!(truncate_with_ellipsis("hi", 10), "hi");
96+
assert_eq!(truncate_with_ellipsis("exact", 5), "exact");
97+
}
98+
99+
#[test]
100+
fn truncate_tiny_budget_returns_bare_ellipsis() {
101+
assert_eq!(truncate_with_ellipsis("hello", 3), "...");
102+
assert_eq!(truncate_with_ellipsis("hello", 0), "...");
103+
}
104+
105+
#[test]
106+
fn truncate_never_splits_multibyte_chars() {
107+
// '—' is 3 bytes; place it so every cut point from 4..=9 lands
108+
// somewhere interesting and none of them panic.
109+
let s = "aaaa—bbbb";
110+
for max_len in 4..=s.len() + 3 {
111+
let out = truncate_with_ellipsis(s, max_len);
112+
assert!(out.len() <= max_len.max(3));
113+
assert!(out.is_char_boundary(out.len()));
114+
}
115+
}
116+
117+
#[test]
118+
fn truncate_handles_emoji_and_cjk() {
119+
let s = "日本語のテキストです、長いので切り詰められます";
120+
for max_len in 0..=s.len() + 3 {
121+
let _ = truncate_with_ellipsis(s, max_len); // must not panic
122+
}
123+
let e = "🦀🦀🦀🦀🦀";
124+
for max_len in 0..=e.len() + 3 {
125+
let _ = truncate_with_ellipsis(e, max_len);
126+
}
127+
}
128+
}

0 commit comments

Comments
 (0)