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!("{}B", size)
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 `value` to `max_chars` chars by keeping a head and a tail joined by
73/// `marker`, preserving context from both ends of the text.
74///
75/// Returns `(text, was_truncated)`. When the budget is too small to fit the
76/// marker plus meaningful context, falls back to a head-only prefix with a
77/// ` [truncated]` suffix, respecting the `max_chars` budget.
78///
79/// ```
80/// # use vtcode_commons::formatting::head_tail_truncate;
81/// let (out, truncated) = head_tail_truncate("short", 64, " ... ");
82/// assert_eq!(out, "short");
83/// assert!(!truncated);
84/// ```
85pub fn head_tail_truncate(value: &str, max_chars: usize, marker: &str) -> (String, bool) {
86    const SUFFIX: &str = " [truncated]";
87
88    let total_chars = value.chars().count();
89    if total_chars <= max_chars {
90        return (value.to_string(), false);
91    }
92
93    let marker_chars = marker.chars().count();
94    if max_chars <= marker_chars + 16 {
95        let suffix_len = SUFFIX.chars().count();
96        let truncated = if max_chars > suffix_len {
97            let available = max_chars - suffix_len;
98            let mut result = value.chars().take(available).collect::<String>();
99            result.push_str(SUFFIX);
100            result
101        } else {
102            value.chars().take(max_chars).collect::<String>()
103        };
104        return (truncated, true);
105    }
106
107    let available = max_chars.saturating_sub(marker_chars);
108    let head_chars = (available * 2) / 3;
109    let tail_chars = available.saturating_sub(head_chars);
110    let head = value.chars().take(head_chars).collect::<String>();
111    let tail = value
112        .chars()
113        .skip(total_chars.saturating_sub(tail_chars))
114        .collect::<String>();
115    let mut truncated = String::with_capacity(max_chars + 20);
116    truncated.push_str(&head);
117    truncated.push_str(marker);
118    truncated.push_str(&tail);
119    (truncated, true)
120}
121
122/// Word-wrap `text` into lines, allowing `first_width` chars on the first line
123/// and `continuation_width` chars on subsequent lines. Wrapping prefers
124/// whitespace boundaries and is UTF-8 safe (widths count chars, not bytes).
125///
126/// Returns an empty vec for blank input. Words longer than the width are split
127/// at the width boundary rather than overflowing.
128///
129/// ```
130/// # use vtcode_commons::formatting::wrap_text_words;
131/// let lines = wrap_text_words("the quick brown fox", 9, 9);
132/// assert_eq!(lines, vec!["the quick", "brown fox"]);
133/// assert!(wrap_text_words("   ", 5, 5).is_empty());
134/// ```
135pub fn wrap_text_words(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
136    let trimmed = text.trim();
137    if trimmed.is_empty() {
138        return Vec::new();
139    }
140
141    let mut result = Vec::new();
142    let mut remaining = trimmed;
143    let mut width = first_width.max(1);
144
145    while remaining.chars().count() > width {
146        let split = split_at_word_boundary(remaining, width);
147        let (head, tail) = remaining.split_at(split);
148        let head = head.trim();
149        if head.is_empty() {
150            break;
151        }
152        result.push(head.to_string());
153        remaining = tail.trim_start();
154        if remaining.is_empty() {
155            break;
156        }
157        width = continuation_width.max(1);
158    }
159
160    if !remaining.is_empty() {
161        result.push(remaining.to_string());
162    }
163    result
164}
165
166fn split_at_word_boundary(input: &str, width: usize) -> usize {
167    let mut last_space: Option<usize> = None;
168    for (seen, (idx, ch)) in input.char_indices().enumerate() {
169        if seen > width {
170            break;
171        }
172        if ch.is_whitespace() {
173            last_space = Some(idx);
174        }
175    }
176    match last_space {
177        Some(pos) => pos,
178        None => byte_index_for_char_count(input, width),
179    }
180}
181
182fn byte_index_for_char_count(input: &str, chars: usize) -> usize {
183    if chars == 0 {
184        return 0;
185    }
186    let mut seen = 0usize;
187    for (idx, ch) in input.char_indices() {
188        seen += 1;
189        if seen == chars {
190            return idx + ch.len_utf8();
191        }
192    }
193    input.len()
194}
195
196/// Truncate a string so that the retained prefix is at most `max_bytes` bytes,
197/// rounded down to the nearest UTF-8 char boundary.  Returns the truncated
198/// prefix with `suffix` appended, or the original string when it already fits.
199pub fn truncate_byte_budget(text: &str, max_bytes: usize, suffix: &str) -> String {
200    if text.len() <= max_bytes {
201        return text.to_string();
202    }
203    let mut end = max_bytes.min(text.len());
204    while end > 0 && !text.is_char_boundary(end) {
205        end -= 1;
206    }
207    format!("{}{suffix}", &text[..end])
208}
209
210/// Collapse consecutive whitespace into single spaces, trimming leading/trailing.
211///
212/// ```
213/// # use vtcode_commons::formatting::collapse_whitespace;
214/// assert_eq!(collapse_whitespace("  hello   world  "), "hello world");
215/// assert_eq!(collapse_whitespace(""), "");
216/// ```
217#[inline]
218pub fn collapse_whitespace(text: &str) -> String {
219    let mut result = String::with_capacity(text.len());
220    let mut pending_space = false;
221    for ch in text.chars() {
222        if ch.is_whitespace() {
223            pending_space = true;
224        } else {
225            if pending_space && !result.is_empty() {
226                result.push(' ');
227            }
228            result.push(ch);
229            pending_space = false;
230        }
231    }
232    result
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn truncate_byte_budget_ascii() {
241        assert_eq!(truncate_byte_budget("hello world", 5, "..."), "hello...");
242        assert_eq!(truncate_byte_budget("hi", 10, "..."), "hi");
243    }
244
245    #[test]
246    fn truncate_byte_budget_cjk_no_panic() {
247        // 'こ' = 3 bytes, 'ん' = 3 bytes → "こんにちは" = 15 bytes
248        let jp = "こんにちは";
249        // Cutting at 5 bytes lands inside 'ん' (bytes 3..6); must round down to 3.
250        assert_eq!(truncate_byte_budget(jp, 5, "…"), "こ…");
251        // Cutting at 6 lands on boundary
252        assert_eq!(truncate_byte_budget(jp, 6, "…"), "こん…");
253    }
254
255    #[test]
256    fn truncate_byte_budget_mixed_ascii_cjk() {
257        let mixed = "AB日本語CD";
258        // A=1, B=1, 日=3, 本=3, 語=3, C=1, D=1 → 13 bytes total
259        assert_eq!(truncate_byte_budget(mixed, 4, ".."), "AB.."); // mid-日 rounds to 2
260        assert_eq!(truncate_byte_budget(mixed, 5, ".."), "AB日.."); // 2+3=5 exact
261    }
262
263    #[test]
264    fn truncate_byte_budget_emoji() {
265        let emoji = "👋🌍"; // 4 bytes each = 8 bytes
266        assert_eq!(truncate_byte_budget(emoji, 5, "!"), "👋!");
267    }
268
269    #[test]
270    fn truncate_byte_budget_zero() {
271        assert_eq!(truncate_byte_budget("abc", 0, "..."), "...");
272    }
273
274    #[test]
275    fn wrap_text_words_basic_and_continuation_width() {
276        assert_eq!(
277            wrap_text_words("the quick brown fox", 9, 9),
278            vec!["the quick", "brown fox"]
279        );
280        // First line wider than continuation lines.
281        assert_eq!(
282            wrap_text_words("alpha beta gamma delta", 11, 5),
283            vec!["alpha beta", "gamma", "delta"]
284        );
285    }
286
287    #[test]
288    fn wrap_text_words_blank_and_unicode() {
289        assert!(wrap_text_words("   ", 5, 5).is_empty());
290        // Must not panic on multi-byte chars and counts chars, not bytes.
291        let wrapped = wrap_text_words("あいう えお かきく", 3, 3);
292        assert_eq!(wrapped, vec!["あいう", "えお", "かきく"]);
293    }
294
295    #[test]
296    fn truncate_within_reserves_ellipsis_budget() {
297        // Matches former runner::orchestration::truncate_chars behavior.
298        assert_eq!(truncate_within("hello world", 8, "..."), "hello...");
299        assert_eq!(truncate_within("hi", 8, "..."), "hi");
300        // Single-char ellipsis reserves exactly one char (former snapshots /
301        // session_archive behavior).
302        assert_eq!(truncate_within("abcdef", 4, "…"), "abc…");
303    }
304
305    #[test]
306    fn truncate_within_counts_chars() {
307        let jp = "あいうえお"; // 5 chars
308        assert_eq!(truncate_within(jp, 5, "…"), jp);
309        assert_eq!(truncate_within(jp, 3, "…"), "あい…");
310    }
311
312    #[test]
313    fn head_tail_truncate_keeps_both_ends() {
314        let value = "0123456789".repeat(10); // 100 chars
315        let (out, truncated) = head_tail_truncate(&value, 40, " ... [truncated] ... ");
316        assert!(truncated);
317        assert!(out.chars().count() <= 40);
318        assert!(out.starts_with("012"));
319        assert!(out.contains("[truncated]"));
320        assert!(out.ends_with('9'));
321    }
322
323    #[test]
324    fn head_tail_truncate_passes_through_when_short() {
325        let (out, truncated) = head_tail_truncate("short", 64, " ... ");
326        assert_eq!(out, "short");
327        assert!(!truncated);
328    }
329
330    #[test]
331    fn head_tail_truncate_small_budget_falls_back_to_prefix() {
332        let marker = " ... [truncated] ... ";
333        // max_chars <= marker_chars + 16 triggers the prefix fallback.
334        // When max_chars (5) <= suffix_len (12), return just the prefix without suffix.
335        let (out, truncated) = head_tail_truncate("abcdefghij", 5, marker);
336        assert!(truncated);
337        assert_eq!(out, "abcde");
338
339        // When max_chars allows room for suffix, include it in the fallback branch.
340        // Use max_chars=17 which is <= 21+16=37 (triggers fallback).
341        let long_text = "abcdefghijklmnopqrstuvwxyz";
342        let (out2, truncated2) = head_tail_truncate(long_text, 17, marker);
343        assert!(truncated2);
344        assert_eq!(out2, "abcde [truncated]");
345        assert_eq!(out2.chars().count(), 17);
346    }
347
348    #[test]
349    fn truncate_text_counts_chars_not_bytes() {
350        let jp = "あいうえお"; // 5 chars, 15 bytes
351        assert_eq!(truncate_text(jp, 3, "…"), "あいう…");
352        assert_eq!(truncate_text(jp, 5, "…"), "あいうえお");
353    }
354}