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#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn truncate_byte_budget_ascii() {
69        assert_eq!(truncate_byte_budget("hello world", 5, "..."), "hello...");
70        assert_eq!(truncate_byte_budget("hi", 10, "..."), "hi");
71    }
72
73    #[test]
74    fn truncate_byte_budget_cjk_no_panic() {
75        // 'こ' = 3 bytes, 'ん' = 3 bytes → "こんにちは" = 15 bytes
76        let jp = "こんにちは";
77        // Cutting at 5 bytes lands inside 'ん' (bytes 3..6); must round down to 3.
78        assert_eq!(truncate_byte_budget(jp, 5, "…"), "こ…");
79        // Cutting at 6 lands on boundary
80        assert_eq!(truncate_byte_budget(jp, 6, "…"), "こん…");
81    }
82
83    #[test]
84    fn truncate_byte_budget_mixed_ascii_cjk() {
85        let mixed = "AB日本語CD";
86        // A=1, B=1, 日=3, 本=3, 語=3, C=1, D=1 → 13 bytes total
87        assert_eq!(truncate_byte_budget(mixed, 4, ".."), "AB.."); // mid-日 rounds to 2
88        assert_eq!(truncate_byte_budget(mixed, 5, ".."), "AB日.."); // 2+3=5 exact
89    }
90
91    #[test]
92    fn truncate_byte_budget_emoji() {
93        let emoji = "👋🌍"; // 4 bytes each = 8 bytes
94        assert_eq!(truncate_byte_budget(emoji, 5, "!"), "👋!");
95    }
96
97    #[test]
98    fn truncate_byte_budget_zero() {
99        assert_eq!(truncate_byte_budget("abc", 0, "..."), "...");
100    }
101
102    #[test]
103    fn truncate_text_counts_chars_not_bytes() {
104        let jp = "あいうえお"; // 5 chars, 15 bytes
105        assert_eq!(truncate_text(jp, 3, "…"), "あいう…");
106        assert_eq!(truncate_text(jp, 5, "…"), "あいうえお");
107    }
108}