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/// Format file size in human-readable form (KB, MB, GB, etc.)
10pub fn format_size(size: u64) -> String {
11    const KB: u64 = 1024;
12    const MB: u64 = KB * 1024;
13    const GB: u64 = MB * 1024;
14
15    if size >= GB {
16        format!("{:.1}GB", size as f64 / GB as f64)
17    } else if size >= MB {
18        format!("{:.1}MB", size as f64 / MB as f64)
19    } else if size >= KB {
20        format!("{:.1}KB", size as f64 / KB as f64)
21    } else {
22        format!("{size}B")
23    }
24}
25
26/// Indent a block of text with the given prefix
27pub fn indent_block(text: &str, indent: &str) -> String {
28    if indent.is_empty() || text.is_empty() {
29        return text.to_string();
30    }
31    let mut indented = String::with_capacity(text.len() + indent.len() * text.lines().count());
32    for (idx, line) in text.split('\n').enumerate() {
33        if idx > 0 {
34            indented.push('\n');
35        }
36        if !line.is_empty() {
37            indented.push_str(indent);
38        }
39        indented.push_str(line);
40    }
41    indented
42}
43
44/// Truncate text to a maximum length (in chars) with an optional ellipsis.
45pub fn truncate_text(text: &str, max_len: usize, ellipsis: &str) -> String {
46    if text.chars().count() <= max_len {
47        return text.to_string();
48    }
49
50    let mut truncated = text.chars().take(max_len).collect::<String>();
51    truncated.push_str(ellipsis);
52    truncated
53}
54
55/// Truncate text to `max_len` chars, reserving room for `ellipsis` so the
56/// returned string never exceeds `max_len` chars.
57///
58/// This differs from [`truncate_text`], which appends the ellipsis *after*
59/// taking `max_len` chars (yielding up to `max_len + ellipsis.len()` chars).
60/// Use this when the total rendered width must stay within a hard budget.
61///
62/// ```
63/// # use vtcode_commons::formatting::truncate_within;
64/// assert_eq!(truncate_within("hello world", 8, "..."), "hello...");
65/// assert_eq!(truncate_within("hi", 8, "..."), "hi");
66/// assert_eq!(truncate_within("hello", 3, "…"), "he…");
67/// ```
68pub fn truncate_within(text: &str, max_len: usize, ellipsis: &str) -> String {
69    if text.chars().count() <= max_len {
70        return text.to_string();
71    }
72    let keep = max_len.saturating_sub(ellipsis.chars().count());
73    let mut truncated = text.chars().take(keep).collect::<String>();
74    truncated.push_str(ellipsis);
75    truncated
76}
77
78/// Truncate `text` to at most `max_len` chars, keeping a head and a tail joined by
79/// a single `…` so context from both ends is preserved.
80///
81/// Control characters are replaced with spaces before truncation so the result is
82/// safe to render in a terminal/TUI. When the text already fits it is returned
83/// unchanged (after sanitization).
84///
85/// This is the canonical middle-truncation helper, shared so the same logic is not
86/// re-implemented per crate.
87pub fn truncate_middle(text: &str, max_len: usize) -> String {
88    if max_len == 0 {
89        return String::new();
90    }
91    let sanitized: String = text
92        .chars()
93        .map(|c| if matches!(c, '\n' | '\r' | '\t') { ' ' } else { c })
94        .collect();
95    let char_count = sanitized.chars().count();
96    if char_count <= max_len {
97        return sanitized;
98    }
99    if max_len <= 1 {
100        return "…".to_string();
101    }
102    let head_len = max_len / 2;
103    let tail_len = max_len.saturating_sub(head_len + 1);
104
105    let head: String = sanitized.chars().take(head_len).collect();
106    let mut result = String::with_capacity(head.len() + tail_len + 1);
107    result.push_str(&head);
108    result.push('…');
109    if tail_len > 0 {
110        let mut tail_rev: Vec<char> = sanitized.chars().rev().take(tail_len).collect();
111        tail_rev.reverse();
112        let tail: String = tail_rev.into_iter().collect();
113        result.push_str(&tail);
114    }
115    result
116}
117
118/// Truncate a file path in the middle, preferring to break at path separators.
119///
120/// Keeps a head and a tail joined by `…`, choosing break points at `/` so the most
121/// recognizable parts of the path (directories / file name) are preserved. This is
122/// the path-aware sibling of [`truncate_middle`], shared so the same display logic
123/// is not re-implemented per crate.
124pub fn truncate_path_middle(path: &str, max_len: usize) -> String {
125    if max_len == 0 {
126        return String::new();
127    }
128    let char_count = path.chars().count();
129    if char_count <= max_len {
130        return path.to_string();
131    }
132    if max_len <= 1 {
133        return "…".to_string();
134    }
135
136    // Try to find a good break point at a path separator
137    let head_budget = max_len / 2;
138    let tail_budget = max_len.saturating_sub(head_budget + 1);
139
140    // Find the last '/' in the head portion
141    // Collect chars directly into a String — `String: FromIterator<char>`,
142    // so the intermediate `Vec<char>` of the prior two-step collect is redundant.
143    let head_str: String = path.chars().take(head_budget).collect();
144    let head_break = head_str.rfind('/').unwrap_or(head_budget);
145
146    // Find the first '/' in the tail portion (from the end)
147    let tail_chars: Vec<char> = path.chars().rev().take(tail_budget).collect();
148    let tail_str: String = tail_chars.iter().rev().collect();
149    let tail_break_from_end = tail_str.find('/').map(|pos| tail_str.len() - pos).unwrap_or(tail_budget);
150
151    let head: String = path.chars().take(head_break).collect();
152    let tail: String = path
153        .chars()
154        .rev()
155        .take(tail_break_from_end)
156        .collect::<Vec<_>>()
157        .into_iter()
158        .rev()
159        .collect();
160
161    format!("{head}…{tail}")
162}
163
164/// Truncate `value` to `max_chars` chars by keeping a head and a tail joined by
165/// `marker`, preserving context from both ends of the text.
166///
167/// Returns `(text, was_truncated)`. When the budget is too small to fit the
168/// marker plus meaningful context, falls back to a head-only prefix with a
169/// ` [truncated]` suffix, respecting the `max_chars` budget.
170///
171/// ```
172/// # use vtcode_commons::formatting::head_tail_truncate;
173/// let (out, truncated) = head_tail_truncate("short", 64, " ... ");
174/// assert_eq!(out, "short");
175/// assert!(!truncated);
176/// ```
177pub fn head_tail_truncate(value: &str, max_chars: usize, marker: &str) -> (String, bool) {
178    const SUFFIX: &str = " [truncated]";
179
180    let total_chars = value.chars().count();
181    if total_chars <= max_chars {
182        return (value.to_string(), false);
183    }
184
185    let marker_chars = marker.chars().count();
186    if max_chars <= marker_chars + 16 {
187        let suffix_len = SUFFIX.chars().count();
188        let truncated = if max_chars > suffix_len {
189            let available = max_chars - suffix_len;
190            let mut result = value.chars().take(available).collect::<String>();
191            result.push_str(SUFFIX);
192            result
193        } else {
194            value.chars().take(max_chars).collect::<String>()
195        };
196        return (truncated, true);
197    }
198
199    let available = max_chars.saturating_sub(marker_chars);
200    let head_chars = (available * 2) / 3;
201    let tail_chars = available.saturating_sub(head_chars);
202    let head = value.chars().take(head_chars).collect::<String>();
203    let tail = value.chars().skip(total_chars.saturating_sub(tail_chars)).collect::<String>();
204    let mut truncated = String::with_capacity(max_chars + 20);
205    truncated.push_str(&head);
206    truncated.push_str(marker);
207    truncated.push_str(&tail);
208    (truncated, true)
209}
210
211/// Word-wrap `text` into lines, allowing `first_width` chars on the first line
212/// and `continuation_width` chars on subsequent lines. Wrapping prefers
213/// whitespace boundaries and is UTF-8 safe (widths count chars, not bytes).
214///
215/// Returns an empty vec for blank input. Words longer than the width are split
216/// at the width boundary rather than overflowing.
217///
218/// ```
219/// # use vtcode_commons::formatting::wrap_text_words;
220/// let lines = wrap_text_words("the quick brown fox", 9, 9);
221/// assert_eq!(lines, vec!["the quick", "brown fox"]);
222/// assert!(wrap_text_words("   ", 5, 5).is_empty());
223/// ```
224pub fn wrap_text_words(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
225    let trimmed = text.trim();
226    if trimmed.is_empty() {
227        return Vec::new();
228    }
229
230    let mut result = Vec::new();
231    let mut remaining = trimmed;
232    let mut width = first_width.max(1);
233
234    while remaining.chars().take(width + 1).count() > width {
235        let split = split_at_word_boundary(remaining, width);
236        let (head, tail) = remaining.split_at(split);
237        let head = head.trim();
238        if head.is_empty() {
239            break;
240        }
241        result.push(head.to_string());
242        remaining = tail.trim_start();
243        if remaining.is_empty() {
244            break;
245        }
246        width = continuation_width.max(1);
247    }
248
249    if !remaining.is_empty() {
250        result.push(remaining.to_string());
251    }
252    result
253}
254
255fn split_at_word_boundary(input: &str, width: usize) -> usize {
256    let mut last_space: Option<usize> = None;
257    for (seen, (idx, ch)) in input.char_indices().enumerate() {
258        if seen > width {
259            break;
260        }
261        if ch.is_whitespace() {
262            last_space = Some(idx);
263        }
264    }
265    match last_space {
266        Some(pos) => pos,
267        None => byte_index_for_char_count(input, width),
268    }
269}
270
271fn byte_index_for_char_count(input: &str, chars: usize) -> usize {
272    if chars == 0 {
273        return 0;
274    }
275    let mut seen = 0usize;
276    for (idx, ch) in input.char_indices() {
277        seen += 1;
278        if seen == chars {
279            return idx + ch.len_utf8();
280        }
281    }
282    input.len()
283}
284
285/// Truncate a string so that the retained prefix is at most `max_bytes` bytes,
286/// rounded down to the nearest UTF-8 char boundary.  Returns the truncated
287/// prefix with `suffix` appended, or the original string when it already fits.
288pub fn truncate_byte_budget(text: &str, max_bytes: usize, suffix: &str) -> String {
289    if text.len() <= max_bytes {
290        return text.to_string();
291    }
292    let mut end = max_bytes.min(text.len());
293    while end > 0 && !text.is_char_boundary(end) {
294        end -= 1;
295    }
296    format!("{}{suffix}", &text[..end])
297}
298
299/// Whether `line` opens or closes a fenced markdown code block.
300///
301/// Matches CommonMark-style ```` ``` ```` / `~~~` fences with up to three
302/// leading spaces. Shared so plan-markup stripping in the binary and markdown
303/// rendering in `vtcode-ui` cannot drift.
304///
305/// ```
306/// # use vtcode_commons::formatting::is_markdown_fence_delimiter;
307/// assert!(is_markdown_fence_delimiter("```text"));
308/// assert!(is_markdown_fence_delimiter("   ~~~"));
309/// assert!(!is_markdown_fence_delimiter("    ```"));
310/// assert!(!is_markdown_fence_delimiter("`inline`"));
311/// ```
312#[inline]
313pub fn is_markdown_fence_delimiter(line: &str) -> bool {
314    let indent = line.len() - line.trim_start().len();
315    if indent > 3 {
316        return false;
317    }
318    let trimmed = line.trim_start();
319    trimmed.starts_with("```") || trimmed.starts_with("~~~")
320}
321
322/// Lowercase a leading capitalized plain word so `text` reads as a clause
323/// continuing a sentence (for example after a colon). Acronyms and
324/// identifiers (a second uppercase letter, a digit, or punctuation after the
325/// first letter) and the pronoun "I" keep their case.
326///
327/// ```
328/// # use vtcode_commons::formatting::lowercase_leading_word;
329/// assert_eq!(lowercase_leading_word("Tool calls were rejected"), "tool calls were rejected");
330/// assert_eq!(lowercase_leading_word("MCP server failed"), "MCP server failed");
331/// assert_eq!(lowercase_leading_word("I cannot help"), "I cannot help");
332/// assert_eq!(lowercase_leading_word("A"), "a");
333/// ```
334pub fn lowercase_leading_word(text: &str) -> String {
335    let mut chars = text.chars();
336    let Some(first) = chars.next() else {
337        return String::new();
338    };
339    let second = chars.clone().next();
340    let plain_word =
341        first.is_uppercase() && first != 'I' && second.is_none_or(|c| c.is_lowercase() || c.is_whitespace());
342    if plain_word {
343        let mut lowered: String = first.to_lowercase().collect();
344        lowered.push_str(chars.as_str());
345        lowered
346    } else {
347        text.to_string()
348    }
349}
350
351/// Collapse consecutive whitespace into single spaces, trimming leading/trailing.
352///
353/// ```
354/// # use vtcode_commons::formatting::collapse_whitespace;
355/// assert_eq!(collapse_whitespace("  hello   world  "), "hello world");
356/// assert_eq!(collapse_whitespace(""), "");
357/// ```
358#[inline]
359pub fn collapse_whitespace(text: &str) -> String {
360    let mut result = String::with_capacity(text.len());
361    let mut pending_space = false;
362    for ch in text.chars() {
363        if ch.is_whitespace() {
364            pending_space = true;
365        } else {
366            if pending_space && !result.is_empty() {
367                result.push(' ');
368            }
369            result.push(ch);
370            pending_space = false;
371        }
372    }
373    result
374}
375
376/// Clean reasoning text by trimming trailing whitespace on each line and
377/// removing blank lines.
378///
379/// ```
380/// # use vtcode_commons::formatting::clean_reasoning_text;
381/// assert_eq!(clean_reasoning_text("line1\n\n\nline2\n"), "line1\nline2");
382/// assert_eq!(clean_reasoning_text(""), "");
383/// ```
384pub fn clean_reasoning_text(text: &str) -> String {
385    text.lines()
386        .map(str::trim_end)
387        .filter(|line| !line.trim().is_empty())
388        .collect::<Vec<_>>()
389        .join("\n")
390}
391
392/// Compact reasoning text for on-screen display.
393///
394/// Unlike [`clean_reasoning_text`], which removes *all* blank lines, this
395/// collapses runs of two or more blank/whitespace-only lines into a single
396/// blank line so paragraph structure is preserved while "blank-line spam"
397/// from the model is removed. Leading/trailing whitespace on every line is
398/// trimmed and leading/trailing blank lines of the whole block are dropped.
399///
400/// ```
401/// # use vtcode_commons::formatting::compact_reasoning_text;
402/// assert_eq!(compact_reasoning_text("line1\n\n\n\nline2\n"), "line1\n\nline2");
403/// assert_eq!(compact_reasoning_text("  a  \n\n\n  b  \n"), "a\n\nb");
404/// assert_eq!(compact_reasoning_text("\n\n\n"), "");
405/// assert_eq!(compact_reasoning_text(""), "");
406/// ```
407pub fn compact_reasoning_text(text: &str) -> String {
408    let mut out: Vec<&str> = Vec::with_capacity(text.lines().count());
409    let mut prev_blank = false;
410    for line in text.lines() {
411        let trimmed = line.trim();
412        let is_blank = trimmed.is_empty();
413        if is_blank {
414            if prev_blank {
415                continue;
416            }
417            out.push("");
418            prev_blank = true;
419        } else {
420            out.push(trimmed);
421            prev_blank = false;
422        }
423    }
424    while out.first().is_some_and(|l| l.trim().is_empty()) {
425        out.remove(0);
426    }
427    while out.last().is_some_and(|l| l.trim().is_empty()) {
428        out.pop();
429    }
430    out.join("\n")
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn truncate_byte_budget_ascii() {
439        assert_eq!(truncate_byte_budget("hello world", 5, "..."), "hello...");
440        assert_eq!(truncate_byte_budget("hi", 10, "..."), "hi");
441    }
442
443    #[test]
444    fn truncate_byte_budget_cjk_no_panic() {
445        // 'こ' = 3 bytes, 'ん' = 3 bytes → "こんにちは" = 15 bytes
446        let jp = "こんにちは";
447        // Cutting at 5 bytes lands inside 'ん' (bytes 3..6); must round down to 3.
448        assert_eq!(truncate_byte_budget(jp, 5, "…"), "こ…");
449        // Cutting at 6 lands on boundary
450        assert_eq!(truncate_byte_budget(jp, 6, "…"), "こん…");
451    }
452
453    #[test]
454    fn truncate_byte_budget_mixed_ascii_cjk() {
455        let mixed = "AB日本語CD";
456        // A=1, B=1, 日=3, 本=3, 語=3, C=1, D=1 → 13 bytes total
457        assert_eq!(truncate_byte_budget(mixed, 4, ".."), "AB.."); // mid-日 rounds to 2
458        assert_eq!(truncate_byte_budget(mixed, 5, ".."), "AB日.."); // 2+3=5 exact
459    }
460
461    #[test]
462    fn truncate_byte_budget_emoji() {
463        let emoji = "👋🌍"; // 4 bytes each = 8 bytes
464        assert_eq!(truncate_byte_budget(emoji, 5, "!"), "👋!");
465    }
466
467    #[test]
468    fn truncate_byte_budget_zero() {
469        assert_eq!(truncate_byte_budget("abc", 0, "..."), "...");
470    }
471
472    #[test]
473    fn compact_reasoning_text_collapses_blank_runs() {
474        assert_eq!(compact_reasoning_text("line1\n\n\n\nline2\n"), "line1\n\nline2");
475        assert_eq!(compact_reasoning_text("a\n\n\n\n\n\nb"), "a\n\nb");
476    }
477
478    #[test]
479    fn compact_reasoning_text_preserves_single_paragraph_breaks() {
480        assert_eq!(compact_reasoning_text("para one\n\npara two\n"), "para one\n\npara two");
481    }
482
483    #[test]
484    fn compact_reasoning_text_trims_trailing_whitespace() {
485        assert_eq!(compact_reasoning_text("  a  \n\n\n  b  \n"), "a\n\nb");
486    }
487
488    #[test]
489    fn compact_reasoning_text_strips_leading_trailing_blanks() {
490        assert_eq!(compact_reasoning_text("\n\n\nmid\n\n\n"), "mid");
491        assert_eq!(compact_reasoning_text("\n\n\n"), "");
492        assert_eq!(compact_reasoning_text(""), "");
493    }
494
495    #[test]
496    fn wrap_text_words_basic_and_continuation_width() {
497        assert_eq!(wrap_text_words("the quick brown fox", 9, 9), vec!["the quick", "brown fox"]);
498        // First line wider than continuation lines.
499        assert_eq!(wrap_text_words("alpha beta gamma delta", 11, 5), vec!["alpha beta", "gamma", "delta"]);
500    }
501
502    #[test]
503    fn wrap_text_words_blank_and_unicode() {
504        assert!(wrap_text_words("   ", 5, 5).is_empty());
505        // Must not panic on multi-byte chars and counts chars, not bytes.
506        let wrapped = wrap_text_words("あいう えお かきく", 3, 3);
507        assert_eq!(wrapped, vec!["あいう", "えお", "かきく"]);
508    }
509
510    #[test]
511    fn truncate_within_reserves_ellipsis_budget() {
512        // Matches former runner::orchestration::truncate_chars behavior.
513        assert_eq!(truncate_within("hello world", 8, "..."), "hello...");
514        assert_eq!(truncate_within("hi", 8, "..."), "hi");
515        // Single-char ellipsis reserves exactly one char (former snapshots /
516        // session_archive behavior).
517        assert_eq!(truncate_within("abcdef", 4, "…"), "abc…");
518    }
519
520    #[test]
521    fn truncate_within_counts_chars() {
522        let jp = "あいうえお"; // 5 chars
523        assert_eq!(truncate_within(jp, 5, "…"), jp);
524        assert_eq!(truncate_within(jp, 3, "…"), "あい…");
525    }
526
527    #[test]
528    fn head_tail_truncate_keeps_both_ends() {
529        let value = "0123456789".repeat(10); // 100 chars
530        let (out, truncated) = head_tail_truncate(&value, 40, " ... [truncated] ... ");
531        assert!(truncated);
532        assert!(out.chars().count() <= 40);
533        assert!(out.starts_with("012"));
534        assert!(out.contains("[truncated]"));
535        assert!(out.ends_with('9'));
536    }
537
538    #[test]
539    fn head_tail_truncate_passes_through_when_short() {
540        let (out, truncated) = head_tail_truncate("short", 64, " ... ");
541        assert_eq!(out, "short");
542        assert!(!truncated);
543    }
544
545    #[test]
546    fn head_tail_truncate_small_budget_falls_back_to_prefix() {
547        let marker = " ... [truncated] ... ";
548        // max_chars <= marker_chars + 16 triggers the prefix fallback.
549        // When max_chars (5) <= suffix_len (12), return just the prefix without suffix.
550        let (out, truncated) = head_tail_truncate("abcdefghij", 5, marker);
551        assert!(truncated);
552        assert_eq!(out, "abcde");
553
554        // When max_chars allows room for suffix, include it in the fallback branch.
555        // Use max_chars=17 which is <= 21+16=37 (triggers fallback).
556        let long_text = "abcdefghijklmnopqrstuvwxyz";
557        let (out2, truncated2) = head_tail_truncate(long_text, 17, marker);
558        assert!(truncated2);
559        assert_eq!(out2, "abcde [truncated]");
560        assert_eq!(out2.chars().count(), 17);
561    }
562
563    #[test]
564    fn truncate_text_counts_chars_not_bytes() {
565        let jp = "あいうえお"; // 5 chars, 15 bytes
566        assert_eq!(truncate_text(jp, 3, "…"), "あいう…");
567        assert_eq!(truncate_text(jp, 5, "…"), "あいうえお");
568    }
569
570    #[test]
571    fn truncate_middle_keeps_both_ends() {
572        assert_eq!(truncate_middle("short", 80), "short");
573        assert_eq!(truncate_middle("abcdefghij", 5), "ab…ij");
574        assert_eq!(truncate_middle("a b c", 80), "a b c");
575        // Zero/one-char budgets.
576        assert_eq!(truncate_middle("abc", 0), "");
577        assert_eq!(truncate_middle("abc", 1), "…");
578        // Control characters are sanitized to spaces before truncating.
579        assert_eq!(truncate_middle("a\nb\tc", 80), "a b c");
580    }
581
582    #[test]
583    fn truncate_path_middle_breaks_at_separator() {
584        assert_eq!(truncate_path_middle("src/lib.rs", 80), "src/lib.rs");
585        assert_eq!(truncate_path_middle("foo/bar/baz/qux", 12), "foo…/qux");
586        assert_eq!(truncate_path_middle("abc", 0), "");
587    }
588}