Skip to main content

vtcode_commons/
formatting.rs

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