Skip to main content

vtcode_commons/
formatting.rs

1#![expect(
2    clippy::string_slice,
3    unused_results,
4    reason = "Formatting uses ASCII delimiters and intentionally ignores infallible String mutation results."
5)]
6
7//! Unified formatting utilities for UI and logging
8
9/// Case-insensitive ASCII substring search that does not allocate a lowercased
10/// copy of either input.
11///
12/// Equivalent to
13/// `haystack.to_ascii_lowercase().contains(&needle.to_ascii_lowercase())` for all
14/// inputs, because `[u8]::eq_ignore_ascii_case` is a length-preserving bytewise
15/// fold and byte comparison only matches at the same offset on both sides. Bytes
16/// `>= 0x80` are compared exactly (neither `to_ascii_lowercase` nor
17/// `eq_ignore_ascii_case` folds them), so non-ASCII haystacks and needles behave
18/// identically. An empty `needle` matches (as `str::contains` does).
19#[inline]
20pub fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
21    let needle = needle.as_bytes();
22    if needle.is_empty() {
23        return true;
24    }
25    // `windows` yields nothing when the haystack is shorter than the needle, and
26    // the empty-needle `windows(0)` panic is guarded above.
27    haystack
28        .as_bytes()
29        .windows(needle.len())
30        .any(|window| window.eq_ignore_ascii_case(needle))
31}
32
33/// Format file size in human-readable form (KB, MB, GB, etc.)
34pub fn format_size(size: u64) -> String {
35    const KB: u64 = 1024;
36    const MB: u64 = KB * 1024;
37    const GB: u64 = MB * 1024;
38
39    if size >= GB {
40        format!("{:.1}GB", size as f64 / GB as f64)
41    } else if size >= MB {
42        format!("{:.1}MB", size as f64 / MB as f64)
43    } else if size >= KB {
44        format!("{:.1}KB", size as f64 / KB as f64)
45    } else {
46        format!("{size}B")
47    }
48}
49
50/// Format a duration in seconds as a compact human-readable unit.
51///
52/// Returns `"42s"`, `"5m"`, `"3h"`, or `"2d"`. Callers add their own suffix
53/// (`" ago"`, `"expires in ..."`) so the same core serves past and future
54/// durations. Do not fork this ladder per crate.
55#[must_use]
56pub fn humanize_duration_compact(seconds: u64) -> String {
57    if seconds < 60 {
58        format!("{seconds}s")
59    } else if seconds < 3600 {
60        format!("{}m", seconds / 60)
61    } else if seconds < 86400 {
62        format!("{}h", seconds / 3600)
63    } else {
64        format!("{}d", seconds / 86400)
65    }
66}
67
68/// Indent a block of text with the given prefix
69pub fn indent_block(text: &str, indent: &str) -> String {
70    if indent.is_empty() || text.is_empty() {
71        return text.to_string();
72    }
73    let mut indented = String::with_capacity(text.len() + indent.len() * text.lines().count());
74    for (idx, line) in text.split('\n').enumerate() {
75        if idx > 0 {
76            indented.push('\n');
77        }
78        if !line.is_empty() {
79            indented.push_str(indent);
80        }
81        indented.push_str(line);
82    }
83    indented
84}
85
86/// Truncate text to a maximum length (in chars) with an optional ellipsis.
87pub fn truncate_text(text: &str, max_len: usize, ellipsis: &str) -> String {
88    if text.chars().count() <= max_len {
89        return text.to_string();
90    }
91
92    let mut truncated = text.chars().take(max_len).collect::<String>();
93    truncated.push_str(ellipsis);
94    truncated
95}
96
97/// Truncate text to `max_len` chars, reserving room for `ellipsis` so the
98/// returned string never exceeds `max_len` chars.
99///
100/// This differs from [`truncate_text`], which appends the ellipsis *after*
101/// taking `max_len` chars (yielding up to `max_len + ellipsis.len()` chars).
102/// Use this when the total rendered width must stay within a hard budget.
103///
104/// ```
105/// # use vtcode_commons::formatting::truncate_within;
106/// assert_eq!(truncate_within("hello world", 8, "..."), "hello...");
107/// assert_eq!(truncate_within("hi", 8, "..."), "hi");
108/// assert_eq!(truncate_within("hello", 3, "…"), "he…");
109/// ```
110pub fn truncate_within(text: &str, max_len: usize, ellipsis: &str) -> String {
111    if text.chars().count() <= max_len {
112        return text.to_string();
113    }
114    let keep = max_len.saturating_sub(ellipsis.chars().count());
115    let mut truncated = text.chars().take(keep).collect::<String>();
116    truncated.push_str(ellipsis);
117    truncated
118}
119
120/// Truncate `text` to at most `max_len` chars, keeping a head and a tail joined by
121/// a single `…` so context from both ends is preserved.
122///
123/// Control characters are replaced with spaces before truncation so the result is
124/// safe to render in a terminal/TUI. When the text already fits it is returned
125/// unchanged (after sanitization).
126///
127/// This is the canonical middle-truncation helper, shared so the same logic is not
128/// re-implemented per crate.
129pub fn truncate_middle(text: &str, max_len: usize) -> String {
130    if max_len == 0 {
131        return String::new();
132    }
133    let sanitized: String = text
134        .chars()
135        .map(|c| if matches!(c, '\n' | '\r' | '\t') { ' ' } else { c })
136        .collect();
137    let char_count = sanitized.chars().count();
138    if char_count <= max_len {
139        return sanitized;
140    }
141    if max_len <= 1 {
142        return "…".to_string();
143    }
144    let head_len = max_len / 2;
145    let tail_len = max_len.saturating_sub(head_len + 1);
146
147    let head: String = sanitized.chars().take(head_len).collect();
148    let mut result = String::with_capacity(head.len() + tail_len + 1);
149    result.push_str(&head);
150    result.push('…');
151    if tail_len > 0 {
152        let mut tail_rev: Vec<char> = sanitized.chars().rev().take(tail_len).collect();
153        tail_rev.reverse();
154        let tail: String = tail_rev.into_iter().collect();
155        result.push_str(&tail);
156    }
157    result
158}
159
160/// Truncate a file path in the middle, preferring to break at path separators.
161///
162/// Keeps a head and a tail joined by `…`, choosing break points at `/` so the most
163/// recognizable parts of the path (directories / file name) are preserved. This is
164/// the path-aware sibling of [`truncate_middle`], shared so the same display logic
165/// is not re-implemented per crate.
166pub fn truncate_path_middle(path: &str, max_len: usize) -> String {
167    if max_len == 0 {
168        return String::new();
169    }
170    let char_count = path.chars().count();
171    if char_count <= max_len {
172        return path.to_string();
173    }
174    if max_len <= 1 {
175        return "…".to_string();
176    }
177
178    // Try to find a good break point at a path separator
179    let head_budget = max_len / 2;
180    let tail_budget = max_len.saturating_sub(head_budget + 1);
181
182    // Find the last '/' in the head portion
183    // Collect chars directly into a String — `String: FromIterator<char>`,
184    // so the intermediate `Vec<char>` of the prior two-step collect is redundant.
185    let head_str: String = path.chars().take(head_budget).collect();
186    let head_break = head_str.rfind('/').unwrap_or(head_budget);
187
188    // Find the first '/' in the tail portion (from the end)
189    let tail_chars: Vec<char> = path.chars().rev().take(tail_budget).collect();
190    let tail_str: String = tail_chars.iter().rev().collect();
191    let tail_break_from_end = tail_str.find('/').map(|pos| tail_str.len() - pos).unwrap_or(tail_budget);
192
193    let head: String = path.chars().take(head_break).collect();
194    let tail: String = path
195        .chars()
196        .rev()
197        .take(tail_break_from_end)
198        .collect::<Vec<_>>()
199        .into_iter()
200        .rev()
201        .collect();
202
203    format!("{head}…{tail}")
204}
205
206/// Truncate `value` to `max_chars` chars by keeping a head and a tail joined by
207/// `marker`, preserving context from both ends of the text.
208///
209/// Returns `(text, was_truncated)`. When the budget is too small to fit the
210/// marker plus meaningful context, falls back to a head-only prefix with a
211/// ` [truncated]` suffix, respecting the `max_chars` budget.
212///
213/// ```
214/// # use vtcode_commons::formatting::head_tail_truncate;
215/// let (out, truncated) = head_tail_truncate("short", 64, " ... ");
216/// assert_eq!(out, "short");
217/// assert!(!truncated);
218/// ```
219pub fn head_tail_truncate(value: &str, max_chars: usize, marker: &str) -> (String, bool) {
220    const SUFFIX: &str = " [truncated]";
221
222    let total_chars = value.chars().count();
223    if total_chars <= max_chars {
224        return (value.to_string(), false);
225    }
226
227    let marker_chars = marker.chars().count();
228    if max_chars <= marker_chars + 16 {
229        let suffix_len = SUFFIX.chars().count();
230        let truncated = if max_chars > suffix_len {
231            let available = max_chars - suffix_len;
232            let mut result = value.chars().take(available).collect::<String>();
233            result.push_str(SUFFIX);
234            result
235        } else {
236            value.chars().take(max_chars).collect::<String>()
237        };
238        return (truncated, true);
239    }
240
241    let available = max_chars.saturating_sub(marker_chars);
242    let head_chars = (available * 2) / 3;
243    let tail_chars = available.saturating_sub(head_chars);
244    let head = value.chars().take(head_chars).collect::<String>();
245    let tail = value.chars().skip(total_chars.saturating_sub(tail_chars)).collect::<String>();
246    let mut truncated = String::with_capacity(max_chars + 20);
247    truncated.push_str(&head);
248    truncated.push_str(marker);
249    truncated.push_str(&tail);
250    (truncated, true)
251}
252
253/// Word-wrap `text` into lines, allowing `first_width` chars on the first line
254/// and `continuation_width` chars on subsequent lines. Wrapping prefers
255/// whitespace boundaries and is UTF-8 safe (widths count chars, not bytes).
256///
257/// Returns an empty vec for blank input. Words longer than the width are split
258/// at the width boundary rather than overflowing.
259///
260/// ```
261/// # use vtcode_commons::formatting::wrap_text_words;
262/// let lines = wrap_text_words("the quick brown fox", 9, 9);
263/// assert_eq!(lines, vec!["the quick", "brown fox"]);
264/// assert!(wrap_text_words("   ", 5, 5).is_empty());
265/// ```
266pub fn wrap_text_words(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
267    let trimmed = text.trim();
268    if trimmed.is_empty() {
269        return Vec::new();
270    }
271
272    let mut result = Vec::new();
273    let mut remaining = trimmed;
274    let mut width = first_width.max(1);
275
276    while remaining.chars().take(width + 1).count() > width {
277        let split = split_at_word_boundary(remaining, width);
278        let (head, tail) = remaining.split_at(split);
279        let head = head.trim();
280        if head.is_empty() {
281            break;
282        }
283        result.push(head.to_string());
284        remaining = tail.trim_start();
285        if remaining.is_empty() {
286            break;
287        }
288        width = continuation_width.max(1);
289    }
290
291    if !remaining.is_empty() {
292        result.push(remaining.to_string());
293    }
294    result
295}
296
297fn split_at_word_boundary(input: &str, width: usize) -> usize {
298    let mut last_space: Option<usize> = None;
299    for (seen, (idx, ch)) in input.char_indices().enumerate() {
300        if seen > width {
301            break;
302        }
303        if ch.is_whitespace() {
304            last_space = Some(idx);
305        }
306    }
307    match last_space {
308        Some(pos) => pos,
309        None => byte_index_for_char_count(input, width),
310    }
311}
312
313fn byte_index_for_char_count(input: &str, chars: usize) -> usize {
314    if chars == 0 {
315        return 0;
316    }
317    let mut seen = 0usize;
318    for (idx, ch) in input.char_indices() {
319        seen += 1;
320        if seen == chars {
321            return idx + ch.len_utf8();
322        }
323    }
324    input.len()
325}
326
327/// Split `text` into shell-like words, keeping quoted spans atomic.
328///
329/// Whitespace inside single (`'…'`) or double (`"…"`) quotes never separates
330/// words, and a backslash escapes the next character outside single quotes
331/// (like the TUI tokenizer in `pty_stream/segments.rs`, except an escaped
332/// space stays atomic here so `foo\ bar` wraps as one word). Quote
333/// characters and backslashes are kept verbatim so single-spaced words rejoin
334/// losslessly with single spaces. An unclosed quote runs to the end of input.
335fn split_shell_words(text: &str) -> Vec<String> {
336    let mut words = Vec::new();
337    let mut current = String::new();
338    let mut in_single = false;
339    let mut in_double = false;
340    let mut escaped = false;
341    for ch in text.chars() {
342        if escaped {
343            current.push(ch);
344            escaped = false;
345            continue;
346        }
347        if ch == '\\' && !in_single {
348            current.push(ch);
349            escaped = true;
350            continue;
351        }
352        if ch == '\'' && !in_double {
353            in_single = !in_single;
354            current.push(ch);
355            continue;
356        }
357        if ch == '"' && !in_single {
358            in_double = !in_double;
359            current.push(ch);
360            continue;
361        }
362        if ch.is_whitespace() && !in_single && !in_double {
363            if !current.is_empty() {
364                words.push(std::mem::take(&mut current));
365            }
366            continue;
367        }
368        current.push(ch);
369    }
370    if !current.is_empty() {
371        words.push(current);
372    }
373    words
374}
375
376/// Shared `• Ran` header wrap widths so every tool-call command surface stays
377/// in sync: 62 chars for the first line (`• Ran ` prefix), 58 for
378/// continuations (`  │ ` prefix). All surfaces wrap the full command with
379/// [`wrap_shell_command_with_continuations`] (explicit `\`, no `…`); TUI
380/// reflow owns any residual viewport overflow.
381pub const RAN_COMMAND_FIRST_WIDTH: usize = 62;
382/// Continuation-line budget for [`RAN_COMMAND_FIRST_WIDTH`] headers.
383pub const RAN_COMMAND_CONTINUATION_WIDTH: usize = 58;
384
385/// Word-wrap a shell `command` into lines, allowing `first_width` chars on
386/// the first line and `continuation_width` chars on subsequent lines.
387///
388/// Unlike [`wrap_text_words`], breaks happen only at unquoted whitespace, so
389/// quoted patterns containing spaces (e.g. `grep -rn "a b|c" docs`) stay on
390/// one line when they fit instead of splitting mid-quote and reading as
391/// broken shell. Words longer than the active width are hard-split at the
392/// width boundary rather than overflowing. Widths count chars, not bytes.
393///
394/// Returns an empty vec for blank input. Unquoted whitespace runs collapse to
395/// a single space (the transcript pipeline already normalizes via
396/// `collapse_whitespace`), so joining short-word wraps with single spaces
397/// reproduces single-spaced input; hard-split overlong tokens are the
398/// exception (their chunks gain separators when joined).
399///
400/// ```
401/// # use vtcode_commons::formatting::wrap_shell_command;
402/// let lines = wrap_shell_command("grep -rn \"a b\" docs | grep -v x", 20, 20);
403/// assert_eq!(lines, vec!["grep -rn \"a b\" docs", "| grep -v x"]);
404/// assert!(wrap_shell_command("   ", 5, 5).is_empty());
405/// ```
406pub fn wrap_shell_command(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
407    let trimmed = text.trim();
408    if trimmed.is_empty() {
409        return Vec::new();
410    }
411    let words = split_shell_words(trimmed);
412    if words.is_empty() {
413        return Vec::new();
414    }
415
416    let mut lines = Vec::with_capacity(words.len());
417    let mut current = String::new();
418    let mut width = first_width.max(1);
419    for word in words {
420        let word_len = word.chars().count();
421        if word_len > width {
422            if !current.is_empty() {
423                lines.push(std::mem::take(&mut current));
424                width = continuation_width.max(1);
425            }
426            current = push_split_word(&mut lines, &word, width, continuation_width);
427            width = continuation_width.max(1);
428            continue;
429        }
430        let current_len = current.chars().count();
431        let need = if current_len == 0 {
432            word_len
433        } else {
434            current_len + 1 + word_len
435        };
436        if need <= width {
437            if current_len > 0 {
438                current.push(' ');
439            }
440            current.push_str(&word);
441        } else {
442            lines.push(std::mem::take(&mut current));
443            width = continuation_width.max(1);
444            if word_len > width {
445                // The word fit the wider first line but not the narrower
446                // continuation: hard-split so no emitted row exceeds its
447                // budget (e.g. a 60-char token with 62/58 widths).
448                current = push_split_word(&mut lines, &word, width, continuation_width);
449            } else {
450                current = word;
451            }
452        }
453    }
454    if !current.is_empty() {
455        lines.push(current);
456    }
457    lines
458}
459
460/// Word-wrap a shell `command` into display lines with explicit `\`
461/// continuations, so multi-line tool-call headers read as valid shell.
462///
463/// Operator-aware: when the command does not fit on one line it is first
464/// split at top-level shell list separators (`&&`, `||`, `|`, `|&`, `;`,
465/// `;;`, `&`) — the separator set shared with tree-sitter-bash `list` nodes
466/// and the TUI `is_command_separator` highlighters (verified with
467/// `ast-grep --lang bash`; redirections like `>`, `>>`, `2>` never split).
468/// Each operator chunk starts on a fresh display line with the operator kept
469/// trailing (e.g. `... && \\`), then overlong chunks wrap further with
470/// [`wrap_shell_command`]. Finally `" \\"` is appended to every line except
471/// the last. Commands that fit on one line are returned unchanged: no forced
472/// operator splits, no trailing `\\`.
473///
474/// Joining a multi-line result with a single space does NOT reproduce the
475/// source (use [`wrap_shell_command`] when lossless rejoining is required).
476/// Widths count chars, not bytes, matching [`wrap_shell_command`].
477///
478/// ```
479/// # use vtcode_commons::formatting::wrap_shell_command_with_continuations;
480/// let lines = wrap_shell_command_with_continuations("echo a b c d", 7, 7);
481/// assert_eq!(lines, vec!["echo a \\", "b c d"]);
482/// assert_eq!(wrap_shell_command_with_continuations("git status", 62, 58), vec!["git status"]);
483/// assert!(wrap_shell_command_with_continuations("   ", 5, 5).is_empty());
484/// // Short chains stay on one line; overlong chains break at operators:
485/// assert_eq!(
486///     wrap_shell_command_with_continuations("echo a && echo b", 62, 58),
487///     vec!["echo a && echo b"]
488/// );
489/// let chained = wrap_shell_command_with_continuations("echo a && echo b", 10, 10);
490/// assert_eq!(chained, vec!["echo a && \\", "echo b"]);
491/// ```
492pub fn wrap_shell_command_with_continuations(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
493    let lines = wrap_shell_command_lines(text, first_width, continuation_width);
494    let total = lines.len();
495    if total <= 1 {
496        return lines;
497    }
498    lines
499        .into_iter()
500        .enumerate()
501        .map(|(index, mut line)| {
502            // Last line ends the command; earlier lines continue with `\`.
503            if index + 1 < total {
504                line.push_str(" \\");
505            }
506            line
507        })
508        .collect()
509}
510
511/// Operator-aware word-wrap of a shell `command` without continuation
512/// markers.
513///
514/// Same breaks as [`wrap_shell_command_with_continuations`] but without the
515/// trailing `" \\"` suffixes, for renderers that style the marker
516/// separately (ANSI/TUI highlighting must not feed the marker to the bash
517/// grammar). Prefer this over [`wrap_shell_command`] for `• Ran` headers so
518/// every surface breaks identically.
519pub fn wrap_shell_command_lines(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
520    let trimmed = text.trim();
521    if trimmed.is_empty() {
522        return Vec::new();
523    }
524    // Fast path: fits on one line → return unchanged (no forced operator
525    // splits, so short `a && b` chains stay on a single row).
526    let single = wrap_shell_command(trimmed, first_width.max(1), continuation_width.max(1));
527    if single.len() <= 1 {
528        return single;
529    }
530    // Split into operator chunks first so `&&`/`||`/`|`/`;` boundaries start
531    // a fresh display line; fall back to one chunk when no top-level
532    // separator is present.
533    let chunks = split_shell_operator_chunks(trimmed);
534    let mut lines: Vec<String> = Vec::new();
535    for (chunk_idx, chunk) in chunks.iter().enumerate() {
536        let width = if lines.is_empty() {
537            first_width.max(1)
538        } else {
539            continuation_width.max(1)
540        };
541        // First chunk may use the wider first-line budget; every later chunk
542        // (and every wrapped row within a chunk) uses the continuation width.
543        let wrapped = if chunk_idx == 0 {
544            wrap_shell_command(chunk, first_width.max(1), continuation_width.max(1))
545        } else {
546            wrap_shell_command(chunk, width, continuation_width.max(1))
547        };
548        if wrapped.is_empty() {
549            continue;
550        }
551        lines.extend(wrapped);
552    }
553    lines
554}
555
556/// Whether `word` is a top-level shell list separator that should end a
557/// display chunk.
558///
559/// Covers the `&&`/`||`/`|`/`;`/`&` list separators shared with
560/// tree-sitter-bash `list` nodes and the TUI `is_command_separator`
561/// highlighters, plus `|&` (pipe stdout+stderr) and the case-terminator
562/// forms (`;;`, `;&`, `;;&`). Redirections (`>`, `>>`, `2>`, `<`) are intentionally absent:
563/// they belong to the same simple command and must not force a new line.
564/// A trailing `;` attached without whitespace (e.g. `hi;`) also ends a chunk.
565fn is_shell_list_separator(word: &str) -> bool {
566    matches!(word, "&&" | "||" | "|" | "|&" | ";" | ";;" | ";&" | ";;&" | "&")
567        || (word.len() > 1 && word.ends_with(';') && !word.ends_with(";;"))
568}
569
570/// Split `command` into operator chunks at top-level list separators.
571///
572/// Words come from [`split_shell_words`], so separators inside single/double
573/// quotes or backslash-escaped never split (e.g. the `||` inside
574/// `"a||b"` stays atomic). The separator word is kept trailing on its chunk
575/// (`git add a &&` + `git commit`), so each display line after the first
576/// starts with a fresh command rather than a dangling operator.
577fn split_shell_operator_chunks(command: &str) -> Vec<String> {
578    let words = split_shell_words(command);
579    if words.is_empty() {
580        return Vec::new();
581    }
582    let mut chunks: Vec<String> = Vec::new();
583    let mut current = String::new();
584    for word in words {
585        if !current.is_empty() {
586            current.push(' ');
587        }
588        current.push_str(&word);
589        if is_shell_list_separator(&word) {
590            chunks.push(std::mem::take(&mut current));
591        }
592    }
593    if !current.is_empty() {
594        chunks.push(current);
595    }
596    chunks
597}
598
599/// Push hard-split chunks of an overlong `word` that exceeds `width`,
600/// returning the trailing remainder (which fits `width`) as the new
601/// in-progress line. Subsequent chunks use `continuation_width`.
602fn push_split_word(lines: &mut Vec<String>, word: &str, width: usize, continuation_width: usize) -> String {
603    let mut width = width.max(1);
604    let mut rest = word;
605    while rest.chars().count() > width {
606        let idx = byte_index_for_char_count(rest, width);
607        let (head, tail) = rest.split_at(idx);
608        lines.push(head.to_string());
609        rest = tail;
610        width = continuation_width.max(1);
611    }
612    rest.to_string()
613}
614
615/// Format an `f64` float for display, collapsing decimal artifacts.
616///
617/// Config floats backed by `f32` (temperatures, thresholds) widen to long
618/// `f64` tails (`0.7` becomes `0.699999988079071`) once serialized to
619/// `toml::Value`, and `±0.1` stepper presses accumulate similar tails
620/// (`0.8` becomes `0.799999988079071`). When the value round-trips exactly
621/// through `f32`, the shorter `f32` form is displayed; otherwise a value
622/// within a tiny epsilon of a 1- or 2-decimal grid shows the grid form.
623/// Genuine precision is never rounded. Display-only: the stored value is
624/// untouched.
625///
626/// ```
627/// # use vtcode_commons::formatting::format_float_display;
628/// assert_eq!(format_float_display(0.699999988079071), "0.7");
629/// assert_eq!(format_float_display(0.799999988079071), "0.8");
630/// assert_eq!(format_float_display(0.3333333333333333), "0.3333333333333333");
631/// ```
632#[allow(
633    clippy::cast_possible_truncation,
634    clippy::float_cmp,
635    reason = "Narrowing `f64` to `f32` and comparing exactly is the detection mechanism: only exact round-trips collapse."
636)]
637pub fn format_float_display(value: f64) -> String {
638    if !value.is_finite() {
639        return value.to_string();
640    }
641    let narrowed = value as f32;
642    if (narrowed as f64) == value {
643        return narrowed.to_string();
644    }
645    // Stepper/grid artifacts differ from the grid form by ~1e-8 or less;
646    // anything at or beyond 1e-6 off-grid keeps full precision.
647    const GRID_EPSILON: f64 = 1e-7;
648    for precision in [1i32, 2i32] {
649        let factor = 10f64.powi(precision);
650        let snapped = (value * factor).round() / factor;
651        if (snapped - value).abs() < GRID_EPSILON {
652            return snapped.to_string();
653        }
654    }
655    value.to_string()
656}
657
658/// Borrow the longest prefix of `text` that is at most `max_bytes` bytes,
659/// rounded down to the nearest UTF-8 char boundary.
660///
661/// This is the canonical byte-bounded prefix used by provider previews, PTY
662/// capture limits, and bounded WebMCP prompts; do not fork the boundary loop.
663///
664/// ```
665/// # use vtcode_commons::formatting::truncate_utf8_prefix;
666/// assert_eq!(truncate_utf8_prefix("hello world", 5), "hello");
667/// assert_eq!(truncate_utf8_prefix("hi", 10), "hi");
668/// // Never splits a multi-byte char: 4 bytes lands mid-`日`, so only 2 survive.
669/// assert_eq!(truncate_utf8_prefix("AB日", 4), "AB");
670/// ```
671#[inline]
672pub fn truncate_utf8_prefix(text: &str, max_bytes: usize) -> &str {
673    let mut end = max_bytes.min(text.len());
674    while end > 0 && !text.is_char_boundary(end) {
675        end -= 1;
676    }
677    &text[..end]
678}
679
680/// Truncate a string so that the retained prefix is at most `max_bytes` bytes,
681/// rounded down to the nearest UTF-8 char boundary.  Returns the truncated
682/// prefix with `suffix` appended, or the original string when it already fits.
683pub fn truncate_byte_budget(text: &str, max_bytes: usize, suffix: &str) -> String {
684    if text.len() <= max_bytes {
685        return text.to_string();
686    }
687    format!("{}{suffix}", truncate_utf8_prefix(text, max_bytes))
688}
689
690/// Whether `line` opens or closes a fenced markdown code block.
691///
692/// Matches CommonMark-style ```` ``` ```` / `~~~` fences with up to three
693/// leading spaces. Shared so plan-markup stripping in the binary and markdown
694/// rendering in `vtcode-ui` cannot drift.
695///
696/// ```
697/// # use vtcode_commons::formatting::is_markdown_fence_delimiter;
698/// assert!(is_markdown_fence_delimiter("```text"));
699/// assert!(is_markdown_fence_delimiter("   ~~~"));
700/// assert!(!is_markdown_fence_delimiter("    ```"));
701/// assert!(!is_markdown_fence_delimiter("`inline`"));
702/// ```
703#[inline]
704pub fn is_markdown_fence_delimiter(line: &str) -> bool {
705    let indent = line.len() - line.trim_start().len();
706    if indent > 3 {
707        return false;
708    }
709    let trimmed = line.trim_start();
710    trimmed.starts_with("```") || trimmed.starts_with("~~~")
711}
712
713/// Lowercase a leading capitalized plain word so `text` reads as a clause
714/// continuing a sentence (for example after a colon). Acronyms and
715/// identifiers (a second uppercase letter, a digit, or punctuation after the
716/// first letter) and the pronoun "I" keep their case.
717///
718/// ```
719/// # use vtcode_commons::formatting::lowercase_leading_word;
720/// assert_eq!(lowercase_leading_word("Tool calls were rejected"), "tool calls were rejected");
721/// assert_eq!(lowercase_leading_word("MCP server failed"), "MCP server failed");
722/// assert_eq!(lowercase_leading_word("I cannot help"), "I cannot help");
723/// assert_eq!(lowercase_leading_word("A"), "a");
724/// ```
725pub fn lowercase_leading_word(text: &str) -> String {
726    let mut chars = text.chars();
727    let Some(first) = chars.next() else {
728        return String::new();
729    };
730    let second = chars.clone().next();
731    let plain_word =
732        first.is_uppercase() && first != 'I' && second.is_none_or(|c| c.is_lowercase() || c.is_whitespace());
733    if plain_word {
734        let mut lowered: String = first.to_lowercase().collect();
735        lowered.push_str(chars.as_str());
736        lowered
737    } else {
738        text.to_string()
739    }
740}
741
742/// Collapse consecutive whitespace into single spaces, trimming leading/trailing.
743///
744/// ```
745/// # use vtcode_commons::formatting::collapse_whitespace;
746/// assert_eq!(collapse_whitespace("  hello   world  "), "hello world");
747/// assert_eq!(collapse_whitespace(""), "");
748/// ```
749#[inline]
750pub fn collapse_whitespace(text: &str) -> String {
751    let mut result = String::with_capacity(text.len());
752    let mut pending_space = false;
753    for ch in text.chars() {
754        if ch.is_whitespace() {
755            pending_space = true;
756        } else {
757            if pending_space && !result.is_empty() {
758                result.push(' ');
759            }
760            result.push(ch);
761            pending_space = false;
762        }
763    }
764    result
765}
766
767/// Strip an optional leading `word` (case-insensitive) that is separated from the
768/// rest by whitespace, returning the trimmed remainder, or `input` unchanged
769/// when the prefix is absent or not word-boundary separated.
770///
771/// Canonical home for shell/file operand prefixes such as `on`/`from`.
772///
773/// ```
774/// # use vtcode_commons::formatting::strip_optional_word_prefix;
775/// assert_eq!(strip_optional_word_prefix("on src/main.rs", "on"), "src/main.rs");
776/// assert_eq!(strip_optional_word_prefix("From here", "from"), "here");
777/// // No whitespace boundary: left untouched.
778/// assert_eq!(strip_optional_word_prefix("onto x", "on"), "onto x");
779/// ```
780#[inline]
781pub fn strip_optional_word_prefix<'a>(input: &'a str, word: &str) -> &'a str {
782    let Some(prefix) = input.get(..word.len()) else {
783        return input;
784    };
785    if !prefix.eq_ignore_ascii_case(word) {
786        return input;
787    }
788    let remainder = &input[word.len()..];
789    if remainder.chars().next().is_some_and(char::is_whitespace) {
790        remainder.trim_start()
791    } else {
792        input
793    }
794}
795
796/// Trim surrounding whitespace, matched wrapping quotes (`"`/`'`), and trailing
797/// sentence punctuation (`. , ; ! ?`), repeating until the value is stable.
798///
799/// ```
800/// # use vtcode_commons::formatting::trim_wrapping_quotes_and_punctuation;
801/// assert_eq!(trim_wrapping_quotes_and_punctuation("'src/main.rs',"), "src/main.rs");
802/// assert_eq!(trim_wrapping_quotes_and_punctuation("  plain  "), "plain");
803/// ```
804#[inline]
805pub fn trim_wrapping_quotes_and_punctuation(target: &str) -> &str {
806    let mut normalized = target.trim();
807    loop {
808        let previous = normalized;
809        normalized = normalized.trim();
810        normalized = normalized.strip_prefix('"').unwrap_or(normalized);
811        normalized = normalized.strip_suffix('"').unwrap_or(normalized);
812        normalized = normalized.strip_prefix('\'').unwrap_or(normalized);
813        normalized = normalized.strip_suffix('\'').unwrap_or(normalized);
814        normalized = normalized.trim_end_matches(['.', ',', ';', '!', '?']).trim();
815        if normalized == previous {
816            return normalized;
817        }
818    }
819}
820
821/// Clean reasoning text by trimming trailing whitespace on each line and
822/// removing blank lines.
823///
824/// ```
825/// # use vtcode_commons::formatting::clean_reasoning_text;
826/// assert_eq!(clean_reasoning_text("line1\n\n\nline2\n"), "line1\nline2");
827/// assert_eq!(clean_reasoning_text(""), "");
828/// ```
829pub fn clean_reasoning_text(text: &str) -> String {
830    text.lines()
831        .map(str::trim_end)
832        .filter(|line| !line.trim().is_empty())
833        .collect::<Vec<_>>()
834        .join("\n")
835}
836
837/// Compact reasoning text for on-screen display.
838///
839/// Unlike [`clean_reasoning_text`], which removes *all* blank lines, this
840/// collapses runs of two or more blank/whitespace-only lines into a single
841/// blank line so paragraph structure is preserved while "blank-line spam"
842/// from the model is removed. Leading/trailing whitespace on every line is
843/// trimmed and leading/trailing blank lines of the whole block are dropped.
844///
845/// ```
846/// # use vtcode_commons::formatting::compact_reasoning_text;
847/// assert_eq!(compact_reasoning_text("line1\n\n\n\nline2\n"), "line1\n\nline2");
848/// assert_eq!(compact_reasoning_text("  a  \n\n\n  b  \n"), "a\n\nb");
849/// assert_eq!(compact_reasoning_text("\n\n\n"), "");
850/// assert_eq!(compact_reasoning_text(""), "");
851/// ```
852pub fn compact_reasoning_text(text: &str) -> String {
853    let mut out: Vec<&str> = Vec::with_capacity(text.lines().count());
854    let mut prev_blank = false;
855    for line in text.lines() {
856        let trimmed = line.trim();
857        let is_blank = trimmed.is_empty();
858        if is_blank {
859            if prev_blank {
860                continue;
861            }
862            out.push("");
863            prev_blank = true;
864        } else {
865            out.push(trimmed);
866            prev_blank = false;
867        }
868    }
869    while out.first().is_some_and(|l| l.trim().is_empty()) {
870        out.remove(0);
871    }
872    while out.last().is_some_and(|l| l.trim().is_empty()) {
873        out.pop();
874    }
875    out.join("\n")
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881
882    #[test]
883    fn humanize_duration_compact_ladder() {
884        assert_eq!(humanize_duration_compact(0), "0s");
885        assert_eq!(humanize_duration_compact(42), "42s");
886        assert_eq!(humanize_duration_compact(59), "59s");
887        assert_eq!(humanize_duration_compact(60), "1m");
888        assert_eq!(humanize_duration_compact(300), "5m");
889        assert_eq!(humanize_duration_compact(3599), "59m");
890        assert_eq!(humanize_duration_compact(3600), "1h");
891        assert_eq!(humanize_duration_compact(3 * 3600), "3h");
892        assert_eq!(humanize_duration_compact(86399), "23h");
893        assert_eq!(humanize_duration_compact(86400), "1d");
894        assert_eq!(humanize_duration_compact(2 * 86400), "2d");
895    }
896
897    #[test]
898    fn format_float_display_collapses_f32_widening() {
899        // `0.7f32` widened to `f64` is the exact artifact seen in the settings
900        // value column; asymmetric counterpart must keep full precision.
901        assert_eq!(format_float_display(f64::from(0.7f32)), "0.7");
902        assert_eq!(format_float_display(f64::from(0.3f32)), "0.3");
903        assert_eq!(format_float_display(f64::from(-0.7f32)), "-0.7");
904        assert_eq!(format_float_display(0.3333333333333333), "0.3333333333333333");
905    }
906
907    #[test]
908    fn format_float_display_snaps_stepper_artifacts_to_grid() {
909        // One `+0.1` press on the widened `0.7` default lands at
910        // `0.799999988079071`; the classic `0.1 + 0.2` sum snaps the same way.
911        // Asymmetric counterpart keeps genuine off-grid precision.
912        assert_eq!(format_float_display(f64::from(0.7f32) + 0.1), "0.8");
913        assert_eq!(format_float_display(0.799999988079071), "0.8");
914        assert_eq!(format_float_display(0.1 + 0.2), "0.3");
915        assert_eq!(format_float_display(-0.799999988079071), "-0.8");
916        assert_eq!(format_float_display(0.123456789), "0.123456789");
917    }
918
919    #[test]
920    fn format_float_display_keeps_plain_floats() {
921        assert_eq!(format_float_display(1.0), "1");
922        assert_eq!(format_float_display(0.0), "0");
923        assert_eq!(format_float_display(0.05), "0.05");
924        assert_eq!(format_float_display(0.75), "0.75");
925    }
926
927    #[test]
928    fn truncate_byte_budget_ascii() {
929        assert_eq!(truncate_byte_budget("hello world", 5, "..."), "hello...");
930        assert_eq!(truncate_byte_budget("hi", 10, "..."), "hi");
931    }
932
933    #[test]
934    fn contains_ignore_ascii_case_matches_str_contains_semantics() {
935        // Mixed case on both sides.
936        assert!(contains_ignore_ascii_case("MiniMax-M2.5", "minimax-m2.5"));
937        assert!(contains_ignore_ascii_case("minimax-m2.5", "MINIMAX"));
938        // Uppercase needle only (no lowercase form provided).
939        assert!(contains_ignore_ascii_case("openai/gpt-5", "GPT-5"));
940        // Empty needle matches, as `str::contains("")` does.
941        assert!(contains_ignore_ascii_case("anything", ""));
942        // Needle longer than the haystack.
943        assert!(!contains_ignore_ascii_case("glm", "glm-4.5"));
944        // No match.
945        assert!(!contains_ignore_ascii_case("openai/gpt-5", "minimax"));
946    }
947
948    #[test]
949    fn contains_ignore_ascii_case_matches_lowercased_contains_for_unicode() {
950        // ASCII needle inside a non-ASCII haystack: `to_ascii_lowercase` leaves
951        // multi-byte bytes untouched, so results must agree exactly.
952        for (haystack, needle) in [
953            ("café GLM-5", "glm-5"),
954            ("日本語 <THINK", "<think"),
955            ("straße", "STRASSE"),
956            ("naïve", "naïve"),
957        ] {
958            assert_eq!(
959                contains_ignore_ascii_case(haystack, needle),
960                haystack.to_ascii_lowercase().contains(&needle.to_ascii_lowercase()),
961                "mismatch for haystack={haystack:?} needle={needle:?}"
962            );
963        }
964    }
965
966    #[test]
967    fn truncate_byte_budget_cjk_no_panic() {
968        // 'こ' = 3 bytes, 'ん' = 3 bytes → "こんにちは" = 15 bytes
969        let jp = "こんにちは";
970        // Cutting at 5 bytes lands inside 'ん' (bytes 3..6); must round down to 3.
971        assert_eq!(truncate_byte_budget(jp, 5, "…"), "こ…");
972        // Cutting at 6 lands on boundary
973        assert_eq!(truncate_byte_budget(jp, 6, "…"), "こん…");
974    }
975
976    #[test]
977    fn truncate_byte_budget_mixed_ascii_cjk() {
978        let mixed = "AB日本語CD";
979        // A=1, B=1, 日=3, 本=3, 語=3, C=1, D=1 → 13 bytes total
980        assert_eq!(truncate_byte_budget(mixed, 4, ".."), "AB.."); // mid-日 rounds to 2
981        assert_eq!(truncate_byte_budget(mixed, 5, ".."), "AB日.."); // 2+3=5 exact
982    }
983
984    #[test]
985    fn truncate_byte_budget_emoji() {
986        let emoji = "👋🌍"; // 4 bytes each = 8 bytes
987        assert_eq!(truncate_byte_budget(emoji, 5, "!"), "👋!");
988    }
989
990    #[test]
991    fn truncate_byte_budget_zero() {
992        assert_eq!(truncate_byte_budget("abc", 0, "..."), "...");
993    }
994
995    #[test]
996    fn compact_reasoning_text_collapses_blank_runs() {
997        assert_eq!(compact_reasoning_text("line1\n\n\n\nline2\n"), "line1\n\nline2");
998        assert_eq!(compact_reasoning_text("a\n\n\n\n\n\nb"), "a\n\nb");
999    }
1000
1001    #[test]
1002    fn compact_reasoning_text_preserves_single_paragraph_breaks() {
1003        assert_eq!(compact_reasoning_text("para one\n\npara two\n"), "para one\n\npara two");
1004    }
1005
1006    #[test]
1007    fn compact_reasoning_text_trims_trailing_whitespace() {
1008        assert_eq!(compact_reasoning_text("  a  \n\n\n  b  \n"), "a\n\nb");
1009    }
1010
1011    #[test]
1012    fn compact_reasoning_text_strips_leading_trailing_blanks() {
1013        assert_eq!(compact_reasoning_text("\n\n\nmid\n\n\n"), "mid");
1014        assert_eq!(compact_reasoning_text("\n\n\n"), "");
1015        assert_eq!(compact_reasoning_text(""), "");
1016    }
1017
1018    #[test]
1019    fn wrap_text_words_basic_and_continuation_width() {
1020        assert_eq!(wrap_text_words("the quick brown fox", 9, 9), vec!["the quick", "brown fox"]);
1021        // First line wider than continuation lines.
1022        assert_eq!(wrap_text_words("alpha beta gamma delta", 11, 5), vec!["alpha beta", "gamma", "delta"]);
1023    }
1024
1025    #[test]
1026    fn wrap_text_words_blank_and_unicode() {
1027        assert!(wrap_text_words("   ", 5, 5).is_empty());
1028        // Must not panic on multi-byte chars and counts chars, not bytes.
1029        let wrapped = wrap_text_words("あいう えお かきく", 3, 3);
1030        assert_eq!(wrapped, vec!["あいう", "えお", "かきく"]);
1031    }
1032
1033    #[test]
1034    fn wrap_shell_command_keeps_screenshot_pipeline_in_full() {
1035        // Screenshot 2026-09-24 16:37: the quoted grep pattern holds spaces
1036        // and pipes but must never split mid-quote; every pipe segment must
1037        // survive with no `…`, and rejoining restores the source exactly.
1038        let command = "grep -rn \"@vinhnx/vtcode|npm install -g||npx @vinhnx\" docs | grep -v node_modules | grep -v package-lock | grep -v \"\\.backup\"";
1039        let wrapped = wrap_shell_command(command, 62, 58);
1040        assert_eq!(
1041            wrapped,
1042            vec![
1043                "grep -rn \"@vinhnx/vtcode|npm install -g||npx @vinhnx\" docs |",
1044                "grep -v node_modules | grep -v package-lock | grep -v",
1045                "\"\\.backup\"",
1046            ]
1047        );
1048        assert!(wrapped.iter().all(|line| !line.contains('…')));
1049        assert_eq!(wrapped.join(" "), command);
1050        assert_eq!(wrapped.join("\n").matches('|').count(), 6);
1051    }
1052
1053    #[test]
1054    fn wrap_shell_command_breaks_only_at_unquoted_spaces() {
1055        // The quoted span is atomic: `echo` overflows alone rather than the
1056        // pattern splitting across lines.
1057        assert_eq!(wrap_shell_command("echo \"a b c\" d", 10, 10), vec!["echo", "\"a b c\" d"]);
1058        assert_eq!(wrap_shell_command("   ", 5, 5), Vec::<String>::new());
1059    }
1060
1061    #[test]
1062    fn wrap_shell_command_splits_overlong_token_at_width() {
1063        assert_eq!(wrap_shell_command("abcdefghij", 4, 4), vec!["abcd", "efgh", "ij"]);
1064    }
1065
1066    #[test]
1067    fn wrap_shell_command_with_continuations_marks_wrapped_lines() {
1068        // Multi-line headers read as valid shell: every line except the last
1069        // ends with ` \`, single-line commands stay bare, blanks stay empty.
1070        assert_eq!(wrap_shell_command_with_continuations("echo a b c d", 7, 7), vec!["echo a \\", "b c d"]);
1071        assert_eq!(wrap_shell_command_with_continuations("git status --short", 62, 58), vec!["git status --short"]);
1072        assert!(wrap_shell_command_with_continuations("   ", 5, 5).is_empty());
1073        // Chained git commands (issue screenshot) keep every segment with no
1074        // `…` and mark each wrapped row as a continuation.
1075        let command = "git add a b && git commit -m \"msg\" && git status --short";
1076        let wrapped = wrap_shell_command_with_continuations(command, 20, 20);
1077        assert!(wrapped.len() > 1, "expected wrapping: {wrapped:?}");
1078        assert!(wrapped[..wrapped.len() - 1].iter().all(|line| line.ends_with(" \\")));
1079        assert!(!wrapped.last().unwrap().ends_with(" \\"));
1080        assert!(wrapped.iter().all(|line| !line.contains('…')));
1081        // Overlong chains break at operators with `&&` trailing, while
1082        // quoted `||` never splits.
1083        assert_eq!(wrap_shell_command_with_continuations("echo a && echo b", 10, 10), vec!["echo a && \\", "echo b"]);
1084        assert_eq!(wrap_shell_command_with_continuations("a | b | c", 7, 5), vec!["a | \\", "b | \\", "c"]);
1085        assert_eq!(
1086            wrap_shell_command_with_continuations("grep -rn \"a||b\" docs", 62, 58),
1087            vec!["grep -rn \"a||b\" docs"]
1088        );
1089        // Short chains that fit stay on one line (no forced splits).
1090        assert_eq!(wrap_shell_command_with_continuations("echo a && echo b", 62, 58), vec!["echo a && echo b"]);
1091        assert_eq!(wrap_shell_command_with_continuations("a | b | c", 62, 58), vec!["a | b | c"]);
1092        // The marker-free helper breaks identically minus suffixes.
1093        assert_eq!(wrap_shell_command_lines("echo a && echo b", 10, 10), vec!["echo a &&", "echo b"]);
1094        assert_eq!(wrap_shell_command_lines("git status", 62, 58), vec!["git status"]);
1095    }
1096
1097    #[test]
1098    fn wrap_shell_command_narrow_continuation_never_overflows() {
1099        // A token that fits the wider first line but not the narrower
1100        // continuation must hard-split after the line break instead of
1101        // emitting an over-budget row (62/58 production widths).
1102        let token = "b".repeat(60);
1103        let command = format!("aa {token} cc");
1104        let wrapped = wrap_shell_command(&command, 62, 58);
1105        assert!(wrapped.len() >= 3, "expected a split continuation: {wrapped:?}");
1106        assert!(wrapped[0].chars().count() <= 62, "first line budget: {wrapped:?}");
1107        for line in wrapped.iter().skip(1) {
1108            assert!(line.chars().count() <= 58, "continuation budget: {wrapped:?}");
1109        }
1110        // Same shape with a tiny budget for a fast unit check.
1111        let wrapped = wrap_shell_command("aa bbbbbbbb cc", 10, 5);
1112        assert_eq!(wrapped, vec!["aa", "bbbbb", "bbb", "cc"]);
1113    }
1114
1115    #[test]
1116    fn truncate_within_reserves_ellipsis_budget() {
1117        // Matches former runner::orchestration::truncate_chars behavior.
1118        assert_eq!(truncate_within("hello world", 8, "..."), "hello...");
1119        assert_eq!(truncate_within("hi", 8, "..."), "hi");
1120        // Single-char ellipsis reserves exactly one char (former snapshots /
1121        // session_archive behavior).
1122        assert_eq!(truncate_within("abcdef", 4, "…"), "abc…");
1123    }
1124
1125    #[test]
1126    fn truncate_within_counts_chars() {
1127        let jp = "あいうえお"; // 5 chars
1128        assert_eq!(truncate_within(jp, 5, "…"), jp);
1129        assert_eq!(truncate_within(jp, 3, "…"), "あい…");
1130    }
1131
1132    #[test]
1133    fn head_tail_truncate_keeps_both_ends() {
1134        let value = "0123456789".repeat(10); // 100 chars
1135        let (out, truncated) = head_tail_truncate(&value, 40, " ... [truncated] ... ");
1136        assert!(truncated);
1137        assert!(out.chars().count() <= 40);
1138        assert!(out.starts_with("012"));
1139        assert!(out.contains("[truncated]"));
1140        assert!(out.ends_with('9'));
1141    }
1142
1143    #[test]
1144    fn head_tail_truncate_passes_through_when_short() {
1145        let (out, truncated) = head_tail_truncate("short", 64, " ... ");
1146        assert_eq!(out, "short");
1147        assert!(!truncated);
1148    }
1149
1150    #[test]
1151    fn head_tail_truncate_small_budget_falls_back_to_prefix() {
1152        let marker = " ... [truncated] ... ";
1153        // max_chars <= marker_chars + 16 triggers the prefix fallback.
1154        // When max_chars (5) <= suffix_len (12), return just the prefix without suffix.
1155        let (out, truncated) = head_tail_truncate("abcdefghij", 5, marker);
1156        assert!(truncated);
1157        assert_eq!(out, "abcde");
1158
1159        // When max_chars allows room for suffix, include it in the fallback branch.
1160        // Use max_chars=17 which is <= 21+16=37 (triggers fallback).
1161        let long_text = "abcdefghijklmnopqrstuvwxyz";
1162        let (out2, truncated2) = head_tail_truncate(long_text, 17, marker);
1163        assert!(truncated2);
1164        assert_eq!(out2, "abcde [truncated]");
1165        assert_eq!(out2.chars().count(), 17);
1166    }
1167
1168    #[test]
1169    fn truncate_text_counts_chars_not_bytes() {
1170        let jp = "あいうえお"; // 5 chars, 15 bytes
1171        assert_eq!(truncate_text(jp, 3, "…"), "あいう…");
1172        assert_eq!(truncate_text(jp, 5, "…"), "あいうえお");
1173    }
1174
1175    #[test]
1176    fn truncate_middle_keeps_both_ends() {
1177        assert_eq!(truncate_middle("short", 80), "short");
1178        assert_eq!(truncate_middle("abcdefghij", 5), "ab…ij");
1179        assert_eq!(truncate_middle("a b c", 80), "a b c");
1180        // Zero/one-char budgets.
1181        assert_eq!(truncate_middle("abc", 0), "");
1182        assert_eq!(truncate_middle("abc", 1), "…");
1183        // Control characters are sanitized to spaces before truncating.
1184        assert_eq!(truncate_middle("a\nb\tc", 80), "a b c");
1185    }
1186
1187    #[test]
1188    fn truncate_path_middle_breaks_at_separator() {
1189        assert_eq!(truncate_path_middle("src/lib.rs", 80), "src/lib.rs");
1190        assert_eq!(truncate_path_middle("foo/bar/baz/qux", 12), "foo…/qux");
1191        assert_eq!(truncate_path_middle("abc", 0), "");
1192    }
1193}