Skip to main content

vtcode_commons/formatting/
mod.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    // Work in char offsets throughout: byte offsets from `rfind`/`find` would
179    // overshoot the budget on multi-byte paths.
180    let chars: Vec<char> = path.chars().collect();
181    let head_budget = max_len / 2;
182    let tail_budget = max_len.saturating_sub(head_budget + 1);
183
184    let head_break = chars.iter().take(head_budget).rposition(|c| *c == '/').unwrap_or(head_budget);
185
186    let tail: Vec<char> = chars.iter().rev().take(tail_budget).rev().copied().collect();
187    let tail_len = tail.iter().position(|c| *c == '/').map_or(tail_budget, |pos| tail_budget - pos);
188
189    let head: String = chars.iter().take(head_break).collect();
190    let tail: String = tail.iter().skip(tail_budget - tail_len).collect();
191
192    format!("{head}…{tail}")
193}
194
195/// Truncate `value` to `max_chars` chars by keeping a head and a tail joined by
196/// `marker`, preserving context from both ends of the text.
197///
198/// Returns `(text, was_truncated)`. When the budget is too small to fit the
199/// marker plus meaningful context, falls back to a head-only prefix with a
200/// ` [truncated]` suffix, respecting the `max_chars` budget.
201///
202/// ```
203/// # use vtcode_commons::formatting::head_tail_truncate;
204/// let (out, truncated) = head_tail_truncate("short", 64, " ... ");
205/// assert_eq!(out, "short");
206/// assert!(!truncated);
207/// ```
208pub fn head_tail_truncate(value: &str, max_chars: usize, marker: &str) -> (String, bool) {
209    const SUFFIX: &str = " [truncated]";
210
211    let total_chars = value.chars().count();
212    if total_chars <= max_chars {
213        return (value.to_string(), false);
214    }
215
216    let marker_chars = marker.chars().count();
217    if max_chars <= marker_chars + 16 {
218        let suffix_len = SUFFIX.chars().count();
219        let truncated = if max_chars > suffix_len {
220            let available = max_chars - suffix_len;
221            let mut result = value.chars().take(available).collect::<String>();
222            result.push_str(SUFFIX);
223            result
224        } else {
225            value.chars().take(max_chars).collect::<String>()
226        };
227        return (truncated, true);
228    }
229
230    let available = max_chars.saturating_sub(marker_chars);
231    let head_chars = (available * 2) / 3;
232    let tail_chars = available.saturating_sub(head_chars);
233    let head = value.chars().take(head_chars).collect::<String>();
234    let tail = value.chars().skip(total_chars.saturating_sub(tail_chars)).collect::<String>();
235    let mut truncated = String::with_capacity(max_chars + 20);
236    truncated.push_str(&head);
237    truncated.push_str(marker);
238    truncated.push_str(&tail);
239    (truncated, true)
240}
241
242/// Word-wrap `text` into lines, allowing `first_width` chars on the first line
243/// and `continuation_width` chars on subsequent lines. Wrapping prefers
244/// whitespace boundaries and is UTF-8 safe (widths count chars, not bytes).
245///
246/// Returns an empty vec for blank input. Words longer than the width are split
247/// at the width boundary rather than overflowing.
248///
249/// ```
250/// # use vtcode_commons::formatting::wrap_text_words;
251/// let lines = wrap_text_words("the quick brown fox", 9, 9);
252/// assert_eq!(lines, vec!["the quick", "brown fox"]);
253/// assert!(wrap_text_words("   ", 5, 5).is_empty());
254/// ```
255pub fn wrap_text_words(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
256    let trimmed = text.trim();
257    if trimmed.is_empty() {
258        return Vec::new();
259    }
260
261    let mut result = Vec::new();
262    let mut remaining = trimmed;
263    let mut width = first_width.max(1);
264
265    while remaining.chars().take(width + 1).count() > width {
266        let split = split_at_word_boundary(remaining, width);
267        let (head, tail) = remaining.split_at(split);
268        let head = head.trim();
269        if head.is_empty() {
270            break;
271        }
272        result.push(head.to_string());
273        remaining = tail.trim_start();
274        if remaining.is_empty() {
275            break;
276        }
277        width = continuation_width.max(1);
278    }
279
280    if !remaining.is_empty() {
281        result.push(remaining.to_string());
282    }
283    result
284}
285
286fn split_at_word_boundary(input: &str, width: usize) -> usize {
287    let mut last_space: Option<usize> = None;
288    for (seen, (idx, ch)) in input.char_indices().enumerate() {
289        if seen > width {
290            break;
291        }
292        if ch.is_whitespace() {
293            last_space = Some(idx);
294        }
295    }
296    match last_space {
297        Some(pos) => pos,
298        None => byte_index_for_char_count(input, width),
299    }
300}
301
302fn byte_index_for_char_count(input: &str, chars: usize) -> usize {
303    if chars == 0 {
304        return 0;
305    }
306    let mut seen = 0usize;
307    for (idx, ch) in input.char_indices() {
308        seen += 1;
309        if seen == chars {
310            return idx + ch.len_utf8();
311        }
312    }
313    input.len()
314}
315
316/// Split `text` into shell-like words, keeping quoted spans atomic.
317///
318/// Whitespace inside single (`'…'`) or double (`"…"`) quotes never separates
319/// words, and a backslash escapes the next character outside single quotes
320/// (like the TUI tokenizer in `pty_stream/segments.rs`, except an escaped
321/// space stays atomic here so `foo\ bar` wraps as one word). Quote
322/// characters and backslashes are kept verbatim so single-spaced words rejoin
323/// losslessly with single spaces. An unclosed quote runs to the end of input.
324fn split_shell_words(text: &str) -> Vec<String> {
325    let mut words = Vec::new();
326    let mut current = String::new();
327    let mut in_single = false;
328    let mut in_double = false;
329    let mut escaped = false;
330    for ch in text.chars() {
331        if escaped {
332            current.push(ch);
333            escaped = false;
334            continue;
335        }
336        if ch == '\\' && !in_single {
337            current.push(ch);
338            escaped = true;
339            continue;
340        }
341        if ch == '\'' && !in_double {
342            in_single = !in_single;
343            current.push(ch);
344            continue;
345        }
346        if ch == '"' && !in_single {
347            in_double = !in_double;
348            current.push(ch);
349            continue;
350        }
351        if ch.is_whitespace() && !in_single && !in_double {
352            if !current.is_empty() {
353                words.push(std::mem::take(&mut current));
354            }
355            continue;
356        }
357        current.push(ch);
358    }
359    if !current.is_empty() {
360        words.push(current);
361    }
362    words
363}
364
365/// Shared `• Ran` header wrap widths so every tool-call command surface stays
366/// in sync: 62 chars for the first line (`• Ran ` prefix), 58 for
367/// continuations (`  │ ` prefix). All surfaces wrap the full command with
368/// [`wrap_shell_command_with_continuations`] (explicit `\`, no `…`); TUI
369/// reflow owns any residual viewport overflow.
370pub const RAN_COMMAND_FIRST_WIDTH: usize = 62;
371/// Continuation-line budget for [`RAN_COMMAND_FIRST_WIDTH`] headers.
372pub const RAN_COMMAND_CONTINUATION_WIDTH: usize = 58;
373
374/// Word-wrap a shell `command` into lines, allowing `first_width` chars on
375/// the first line and `continuation_width` chars on subsequent lines.
376///
377/// Unlike [`wrap_text_words`], breaks happen only at unquoted whitespace, so
378/// quoted patterns containing spaces (e.g. `grep -rn "a b|c" docs`) stay on
379/// one line when they fit instead of splitting mid-quote and reading as
380/// broken shell. Words longer than the active width are hard-split at the
381/// width boundary rather than overflowing. Widths count chars, not bytes.
382///
383/// Returns an empty vec for blank input. Unquoted whitespace runs collapse to
384/// a single space (the transcript pipeline already normalizes via
385/// `collapse_whitespace`), so joining short-word wraps with single spaces
386/// reproduces single-spaced input; hard-split overlong tokens are the
387/// exception (their chunks gain separators when joined).
388///
389/// ```
390/// # use vtcode_commons::formatting::wrap_shell_command;
391/// let lines = wrap_shell_command("grep -rn \"a b\" docs | grep -v x", 20, 20);
392/// assert_eq!(lines, vec!["grep -rn \"a b\" docs", "| grep -v x"]);
393/// assert!(wrap_shell_command("   ", 5, 5).is_empty());
394/// ```
395pub fn wrap_shell_command(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
396    let trimmed = text.trim();
397    if trimmed.is_empty() {
398        return Vec::new();
399    }
400    let words = split_shell_words(trimmed);
401    if words.is_empty() {
402        return Vec::new();
403    }
404
405    let mut lines = Vec::with_capacity(words.len());
406    let mut current = String::new();
407    let mut width = first_width.max(1);
408    for word in words {
409        let word_len = word.chars().count();
410        if word_len > width {
411            if !current.is_empty() {
412                lines.push(std::mem::take(&mut current));
413                width = continuation_width.max(1);
414            }
415            current = push_split_word(&mut lines, &word, width, continuation_width);
416            width = continuation_width.max(1);
417            continue;
418        }
419        let current_len = current.chars().count();
420        let need = if current_len == 0 {
421            word_len
422        } else {
423            current_len + 1 + word_len
424        };
425        if need <= width {
426            if current_len > 0 {
427                current.push(' ');
428            }
429            current.push_str(&word);
430        } else {
431            lines.push(std::mem::take(&mut current));
432            width = continuation_width.max(1);
433            if word_len > width {
434                // The word fit the wider first line but not the narrower
435                // continuation: hard-split so no emitted row exceeds its
436                // budget (e.g. a 60-char token with 62/58 widths).
437                current = push_split_word(&mut lines, &word, width, continuation_width);
438            } else {
439                current = word;
440            }
441        }
442    }
443    if !current.is_empty() {
444        lines.push(current);
445    }
446    lines
447}
448
449/// Word-wrap a shell `command` into display lines with explicit `\`
450/// continuations, so multi-line tool-call headers read as valid shell.
451///
452/// Operator-aware: when the command does not fit on one line it is first
453/// split at top-level shell list separators (`&&`, `||`, `|`, `|&`, `;`,
454/// `;;`, `&`) — the separator set shared with tree-sitter-bash `list` nodes
455/// and the TUI `is_command_separator` highlighters (verified with
456/// `ast-grep --lang bash`; redirections like `>`, `>>`, `2>` never split).
457/// Each operator chunk starts on a fresh display line with the operator kept
458/// trailing (e.g. `... && \\`), then overlong chunks wrap further with
459/// [`wrap_shell_command`]. Finally `" \\"` is appended to every line except
460/// the last. Commands that fit on one line are returned unchanged: no forced
461/// operator splits, no trailing `\\`.
462///
463/// Joining a multi-line result with a single space does NOT reproduce the
464/// source (use [`wrap_shell_command`] when lossless rejoining is required).
465/// Widths count chars, not bytes, matching [`wrap_shell_command`].
466///
467/// ```
468/// # use vtcode_commons::formatting::wrap_shell_command_with_continuations;
469/// let lines = wrap_shell_command_with_continuations("echo a b c d", 7, 7);
470/// assert_eq!(lines, vec!["echo a \\", "b c d"]);
471/// assert_eq!(wrap_shell_command_with_continuations("git status", 62, 58), vec!["git status"]);
472/// assert!(wrap_shell_command_with_continuations("   ", 5, 5).is_empty());
473/// // Short chains stay on one line; overlong chains break at operators:
474/// assert_eq!(
475///     wrap_shell_command_with_continuations("echo a && echo b", 62, 58),
476///     vec!["echo a && echo b"]
477/// );
478/// let chained = wrap_shell_command_with_continuations("echo a && echo b", 10, 10);
479/// assert_eq!(chained, vec!["echo a && \\", "echo b"]);
480/// ```
481pub fn wrap_shell_command_with_continuations(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
482    let lines = wrap_shell_command_lines(text, first_width, continuation_width);
483    let total = lines.len();
484    if total <= 1 {
485        return lines;
486    }
487    lines
488        .into_iter()
489        .enumerate()
490        .map(|(index, mut line)| {
491            // Last line ends the command; earlier lines continue with `\`.
492            if index + 1 < total {
493                line.push_str(" \\");
494            }
495            line
496        })
497        .collect()
498}
499
500/// Operator-aware word-wrap of a shell `command` without continuation
501/// markers.
502///
503/// Same breaks as [`wrap_shell_command_with_continuations`] but without the
504/// trailing `" \\"` suffixes, for renderers that style the marker
505/// separately (ANSI/TUI highlighting must not feed the marker to the bash
506/// grammar). Prefer this over [`wrap_shell_command`] for `• Ran` headers so
507/// every surface breaks identically.
508pub fn wrap_shell_command_lines(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
509    let trimmed = text.trim();
510    if trimmed.is_empty() {
511        return Vec::new();
512    }
513    // Fast path: fits on one line → return unchanged (no forced operator
514    // splits, so short `a && b` chains stay on a single row).
515    let single = wrap_shell_command(trimmed, first_width.max(1), continuation_width.max(1));
516    if single.len() <= 1 {
517        return single;
518    }
519    // Split into operator chunks first so `&&`/`||`/`|`/`;` boundaries start
520    // a fresh display line; fall back to one chunk when no top-level
521    // separator is present.
522    let chunks = split_shell_operator_chunks(trimmed);
523    let mut lines: Vec<String> = Vec::new();
524    for (chunk_idx, chunk) in chunks.iter().enumerate() {
525        let width = if lines.is_empty() {
526            first_width.max(1)
527        } else {
528            continuation_width.max(1)
529        };
530        // First chunk may use the wider first-line budget; every later chunk
531        // (and every wrapped row within a chunk) uses the continuation width.
532        let wrapped = if chunk_idx == 0 {
533            wrap_shell_command(chunk, first_width.max(1), continuation_width.max(1))
534        } else {
535            wrap_shell_command(chunk, width, continuation_width.max(1))
536        };
537        if wrapped.is_empty() {
538            continue;
539        }
540        lines.extend(wrapped);
541    }
542    lines
543}
544
545/// Whether `word` is a top-level shell list separator that should end a
546/// display chunk.
547///
548/// Covers the `&&`/`||`/`|`/`;`/`&` list separators shared with
549/// tree-sitter-bash `list` nodes and the TUI `is_command_separator`
550/// highlighters, plus `|&` (pipe stdout+stderr) and the case-terminator
551/// forms (`;;`, `;&`, `;;&`). Redirections (`>`, `>>`, `2>`, `<`) are intentionally absent:
552/// they belong to the same simple command and must not force a new line.
553/// A trailing `;` attached without whitespace (e.g. `hi;`) also ends a chunk.
554fn is_shell_list_separator(word: &str) -> bool {
555    matches!(word, "&&" | "||" | "|" | "|&" | ";" | ";;" | ";&" | ";;&" | "&")
556        || (word.len() > 1 && word.ends_with(';') && !word.ends_with(";;"))
557}
558
559/// Split `command` into operator chunks at top-level list separators.
560///
561/// Words come from [`split_shell_words`], so separators inside single/double
562/// quotes or backslash-escaped never split (e.g. the `||` inside
563/// `"a||b"` stays atomic). The separator word is kept trailing on its chunk
564/// (`git add a &&` + `git commit`), so each display line after the first
565/// starts with a fresh command rather than a dangling operator.
566fn split_shell_operator_chunks(command: &str) -> Vec<String> {
567    let words = split_shell_words(command);
568    if words.is_empty() {
569        return Vec::new();
570    }
571    let mut chunks: Vec<String> = Vec::new();
572    let mut current = String::new();
573    for word in words {
574        if !current.is_empty() {
575            current.push(' ');
576        }
577        current.push_str(&word);
578        if is_shell_list_separator(&word) {
579            chunks.push(std::mem::take(&mut current));
580        }
581    }
582    if !current.is_empty() {
583        chunks.push(current);
584    }
585    chunks
586}
587
588/// Push hard-split chunks of an overlong `word` that exceeds `width`,
589/// returning the trailing remainder (which fits `width`) as the new
590/// in-progress line. Subsequent chunks use `continuation_width`.
591fn push_split_word(lines: &mut Vec<String>, word: &str, width: usize, continuation_width: usize) -> String {
592    let mut width = width.max(1);
593    let mut rest = word;
594    while rest.chars().count() > width {
595        let idx = byte_index_for_char_count(rest, width);
596        let (head, tail) = rest.split_at(idx);
597        lines.push(head.to_string());
598        rest = tail;
599        width = continuation_width.max(1);
600    }
601    rest.to_string()
602}
603
604/// Format an `f64` float for display, collapsing decimal artifacts.
605///
606/// Config floats backed by `f32` (temperatures, thresholds) widen to long
607/// `f64` tails (`0.7` becomes `0.699999988079071`) once serialized to
608/// `toml::Value`, and `±0.1` stepper presses accumulate similar tails
609/// (`0.8` becomes `0.799999988079071`). When the value round-trips exactly
610/// through `f32`, the shorter `f32` form is displayed; otherwise a value
611/// within a tiny epsilon of a 1- or 2-decimal grid shows the grid form.
612/// Genuine precision is never rounded. Display-only: the stored value is
613/// untouched.
614///
615/// ```
616/// # use vtcode_commons::formatting::format_float_display;
617/// assert_eq!(format_float_display(0.699999988079071), "0.7");
618/// assert_eq!(format_float_display(0.799999988079071), "0.8");
619/// assert_eq!(format_float_display(0.3333333333333333), "0.3333333333333333");
620/// ```
621#[allow(
622    clippy::cast_possible_truncation,
623    clippy::float_cmp,
624    reason = "Narrowing `f64` to `f32` and comparing exactly is the detection mechanism: only exact round-trips collapse."
625)]
626pub fn format_float_display(value: f64) -> String {
627    if !value.is_finite() {
628        return value.to_string();
629    }
630    let narrowed = value as f32;
631    if (narrowed as f64) == value {
632        return narrowed.to_string();
633    }
634    // Stepper/grid artifacts differ from the grid form by ~1e-8 or less;
635    // anything at or beyond 1e-6 off-grid keeps full precision.
636    const GRID_EPSILON: f64 = 1e-7;
637    for precision in [1i32, 2i32] {
638        let factor = 10f64.powi(precision);
639        let snapped = (value * factor).round() / factor;
640        if (snapped - value).abs() < GRID_EPSILON {
641            return snapped.to_string();
642        }
643    }
644    value.to_string()
645}
646
647/// Borrow the longest prefix of `text` that is at most `max_bytes` bytes,
648/// rounded down to the nearest UTF-8 char boundary.
649///
650/// This is the canonical byte-bounded prefix used by provider previews, PTY
651/// capture limits, and bounded WebMCP prompts; do not fork the boundary loop.
652///
653/// ```
654/// # use vtcode_commons::formatting::truncate_utf8_prefix;
655/// assert_eq!(truncate_utf8_prefix("hello world", 5), "hello");
656/// assert_eq!(truncate_utf8_prefix("hi", 10), "hi");
657/// // Never splits a multi-byte char: 4 bytes lands mid-`日`, so only 2 survive.
658/// assert_eq!(truncate_utf8_prefix("AB日", 4), "AB");
659/// ```
660#[inline]
661pub fn truncate_utf8_prefix(text: &str, max_bytes: usize) -> &str {
662    let mut end = max_bytes.min(text.len());
663    while end > 0 && !text.is_char_boundary(end) {
664        end -= 1;
665    }
666    &text[..end]
667}
668
669/// Truncate a string so that the retained prefix is at most `max_bytes` bytes,
670/// rounded down to the nearest UTF-8 char boundary.  Returns the truncated
671/// prefix with `suffix` appended, or the original string when it already fits.
672pub fn truncate_byte_budget(text: &str, max_bytes: usize, suffix: &str) -> String {
673    if text.len() <= max_bytes {
674        return text.to_string();
675    }
676    format!("{}{suffix}", truncate_utf8_prefix(text, max_bytes))
677}
678
679/// Whether `line` opens or closes a fenced markdown code block.
680///
681/// Matches CommonMark-style ```` ``` ```` / `~~~` fences with up to three
682/// leading spaces. Shared so plan-markup stripping in the binary and markdown
683/// rendering in `vtcode-ui` cannot drift.
684///
685/// ```
686/// # use vtcode_commons::formatting::is_markdown_fence_delimiter;
687/// assert!(is_markdown_fence_delimiter("```text"));
688/// assert!(is_markdown_fence_delimiter("   ~~~"));
689/// assert!(!is_markdown_fence_delimiter("    ```"));
690/// assert!(!is_markdown_fence_delimiter("`inline`"));
691/// ```
692#[inline]
693pub fn is_markdown_fence_delimiter(line: &str) -> bool {
694    let indent = line.len() - line.trim_start().len();
695    if indent > 3 {
696        return false;
697    }
698    let trimmed = line.trim_start();
699    trimmed.starts_with("```") || trimmed.starts_with("~~~")
700}
701
702/// Lowercase a leading capitalized plain word so `text` reads as a clause
703/// continuing a sentence (for example after a colon). Acronyms and
704/// identifiers (a second uppercase letter, a digit, or punctuation after the
705/// first letter) and the pronoun "I" keep their case.
706///
707/// ```
708/// # use vtcode_commons::formatting::lowercase_leading_word;
709/// assert_eq!(lowercase_leading_word("Tool calls were rejected"), "tool calls were rejected");
710/// assert_eq!(lowercase_leading_word("MCP server failed"), "MCP server failed");
711/// assert_eq!(lowercase_leading_word("I cannot help"), "I cannot help");
712/// assert_eq!(lowercase_leading_word("A"), "a");
713/// ```
714pub fn lowercase_leading_word(text: &str) -> String {
715    let mut chars = text.chars();
716    let Some(first) = chars.next() else {
717        return String::new();
718    };
719    let second = chars.clone().next();
720    let plain_word =
721        first.is_uppercase() && first != 'I' && second.is_none_or(|c| c.is_lowercase() || c.is_whitespace());
722    if plain_word {
723        let mut lowered: String = first.to_lowercase().collect();
724        lowered.push_str(chars.as_str());
725        lowered
726    } else {
727        text.to_string()
728    }
729}
730
731/// Collapse consecutive whitespace into single spaces, trimming leading/trailing.
732///
733/// ```
734/// # use vtcode_commons::formatting::collapse_whitespace;
735/// assert_eq!(collapse_whitespace("  hello   world  "), "hello world");
736/// assert_eq!(collapse_whitespace(""), "");
737/// ```
738#[inline]
739pub fn collapse_whitespace(text: &str) -> String {
740    let mut result = String::with_capacity(text.len());
741    let mut pending_space = false;
742    for ch in text.chars() {
743        if ch.is_whitespace() {
744            pending_space = true;
745        } else {
746            if pending_space && !result.is_empty() {
747                result.push(' ');
748            }
749            result.push(ch);
750            pending_space = false;
751        }
752    }
753    result
754}
755
756/// Strip an optional leading `word` (case-insensitive) that is separated from the
757/// rest by whitespace, returning the trimmed remainder, or `input` unchanged
758/// when the prefix is absent or not word-boundary separated.
759///
760/// Canonical home for shell/file operand prefixes such as `on`/`from`.
761///
762/// ```
763/// # use vtcode_commons::formatting::strip_optional_word_prefix;
764/// assert_eq!(strip_optional_word_prefix("on src/main.rs", "on"), "src/main.rs");
765/// assert_eq!(strip_optional_word_prefix("From here", "from"), "here");
766/// // No whitespace boundary: left untouched.
767/// assert_eq!(strip_optional_word_prefix("onto x", "on"), "onto x");
768/// ```
769#[inline]
770pub fn strip_optional_word_prefix<'a>(input: &'a str, word: &str) -> &'a str {
771    let Some(prefix) = input.get(..word.len()) else {
772        return input;
773    };
774    if !prefix.eq_ignore_ascii_case(word) {
775        return input;
776    }
777    let remainder = &input[word.len()..];
778    if remainder.chars().next().is_some_and(char::is_whitespace) {
779        remainder.trim_start()
780    } else {
781        input
782    }
783}
784
785/// Trim surrounding whitespace, matched wrapping quotes (`"`/`'`), and trailing
786/// sentence punctuation (`. , ; ! ?`), repeating until the value is stable.
787///
788/// ```
789/// # use vtcode_commons::formatting::trim_wrapping_quotes_and_punctuation;
790/// assert_eq!(trim_wrapping_quotes_and_punctuation("'src/main.rs',"), "src/main.rs");
791/// assert_eq!(trim_wrapping_quotes_and_punctuation("  plain  "), "plain");
792/// ```
793#[inline]
794pub fn trim_wrapping_quotes_and_punctuation(target: &str) -> &str {
795    let mut normalized = target.trim();
796    loop {
797        let previous = normalized;
798        normalized = normalized.trim();
799        normalized = normalized.strip_prefix('"').unwrap_or(normalized);
800        normalized = normalized.strip_suffix('"').unwrap_or(normalized);
801        normalized = normalized.strip_prefix('\'').unwrap_or(normalized);
802        normalized = normalized.strip_suffix('\'').unwrap_or(normalized);
803        normalized = normalized.trim_end_matches(['.', ',', ';', '!', '?']).trim();
804        if normalized == previous {
805            return normalized;
806        }
807    }
808}
809
810/// Clean reasoning text by trimming trailing whitespace on each line and
811/// removing blank lines.
812///
813/// ```
814/// # use vtcode_commons::formatting::clean_reasoning_text;
815/// assert_eq!(clean_reasoning_text("line1\n\n\nline2\n"), "line1\nline2");
816/// assert_eq!(clean_reasoning_text(""), "");
817/// ```
818pub fn clean_reasoning_text(text: &str) -> String {
819    text.lines()
820        .map(str::trim_end)
821        .filter(|line| !line.trim().is_empty())
822        .collect::<Vec<_>>()
823        .join("\n")
824}
825
826/// Compact reasoning text for on-screen display.
827///
828/// Unlike [`clean_reasoning_text`], which removes *all* blank lines, this
829/// collapses runs of two or more blank/whitespace-only lines into a single
830/// blank line so paragraph structure is preserved while "blank-line spam"
831/// from the model is removed. Leading/trailing whitespace on every line is
832/// trimmed and leading/trailing blank lines of the whole block are dropped.
833///
834/// ```
835/// # use vtcode_commons::formatting::compact_reasoning_text;
836/// assert_eq!(compact_reasoning_text("line1\n\n\n\nline2\n"), "line1\n\nline2");
837/// assert_eq!(compact_reasoning_text("  a  \n\n\n  b  \n"), "a\n\nb");
838/// assert_eq!(compact_reasoning_text("\n\n\n"), "");
839/// assert_eq!(compact_reasoning_text(""), "");
840/// ```
841pub fn compact_reasoning_text(text: &str) -> String {
842    let mut out: Vec<&str> = Vec::with_capacity(text.lines().count());
843    let mut prev_blank = false;
844    for line in text.lines() {
845        let trimmed = line.trim();
846        let is_blank = trimmed.is_empty();
847        if is_blank {
848            if prev_blank {
849                continue;
850            }
851            out.push("");
852            prev_blank = true;
853        } else {
854            out.push(trimmed);
855            prev_blank = false;
856        }
857    }
858    while out.first().is_some_and(|l| l.trim().is_empty()) {
859        out.remove(0);
860    }
861    while out.last().is_some_and(|l| l.trim().is_empty()) {
862        out.pop();
863    }
864    out.join("\n")
865}
866
867#[cfg(test)]
868mod generative_tests;
869#[cfg(test)]
870mod tests;