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 a string so that the retained prefix is at most `max_bytes` bytes,
50/// rounded down to the nearest UTF-8 char boundary.  Returns the truncated
51/// prefix with `suffix` appended, or the original string when it already fits.
52pub fn truncate_byte_budget(text: &str, max_bytes: usize, suffix: &str) -> String {
53    if text.len() <= max_bytes {
54        return text.to_string();
55    }
56    let mut end = max_bytes.min(text.len());
57    while end > 0 && !text.is_char_boundary(end) {
58        end -= 1;
59    }
60    format!("{}{suffix}", &text[..end])
61}
62
63/// Collapse consecutive whitespace into single spaces, trimming leading/trailing.
64///
65/// ```
66/// # use vtcode_commons::formatting::collapse_whitespace;
67/// assert_eq!(collapse_whitespace("  hello   world  "), "hello world");
68/// assert_eq!(collapse_whitespace(""), "");
69/// ```
70#[inline]
71pub fn collapse_whitespace(text: &str) -> String {
72    let mut result = String::with_capacity(text.len());
73    let mut pending_space = false;
74    for ch in text.chars() {
75        if ch.is_whitespace() {
76            pending_space = true;
77        } else {
78            if pending_space && !result.is_empty() {
79                result.push(' ');
80            }
81            result.push(ch);
82            pending_space = false;
83        }
84    }
85    result
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn truncate_byte_budget_ascii() {
94        assert_eq!(truncate_byte_budget("hello world", 5, "..."), "hello...");
95        assert_eq!(truncate_byte_budget("hi", 10, "..."), "hi");
96    }
97
98    #[test]
99    fn truncate_byte_budget_cjk_no_panic() {
100        // 'こ' = 3 bytes, 'ん' = 3 bytes → "こんにちは" = 15 bytes
101        let jp = "こんにちは";
102        // Cutting at 5 bytes lands inside 'ん' (bytes 3..6); must round down to 3.
103        assert_eq!(truncate_byte_budget(jp, 5, "…"), "こ…");
104        // Cutting at 6 lands on boundary
105        assert_eq!(truncate_byte_budget(jp, 6, "…"), "こん…");
106    }
107
108    #[test]
109    fn truncate_byte_budget_mixed_ascii_cjk() {
110        let mixed = "AB日本語CD";
111        // A=1, B=1, 日=3, 本=3, 語=3, C=1, D=1 → 13 bytes total
112        assert_eq!(truncate_byte_budget(mixed, 4, ".."), "AB.."); // mid-日 rounds to 2
113        assert_eq!(truncate_byte_budget(mixed, 5, ".."), "AB日.."); // 2+3=5 exact
114    }
115
116    #[test]
117    fn truncate_byte_budget_emoji() {
118        let emoji = "👋🌍"; // 4 bytes each = 8 bytes
119        assert_eq!(truncate_byte_budget(emoji, 5, "!"), "👋!");
120    }
121
122    #[test]
123    fn truncate_byte_budget_zero() {
124        assert_eq!(truncate_byte_budget("abc", 0, "..."), "...");
125    }
126
127    #[test]
128    fn truncate_text_counts_chars_not_bytes() {
129        let jp = "あいうえお"; // 5 chars, 15 bytes
130        assert_eq!(truncate_text(jp, 3, "…"), "あいう…");
131        assert_eq!(truncate_text(jp, 5, "…"), "あいうえお");
132    }
133}