Skip to main content

vtcode_commons/
preview.rs

1#![expect(
2    clippy::indexing_slicing,
3    clippy::string_slice,
4    reason = "Preview counts and UTF-8 offsets are computed from source lengths and boundary helpers."
5)]
6
7//! Shared preview formatting helpers.
8
9use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub struct HeadTailPreview<'a, T> {
13    pub head: &'a [T],
14    pub tail: &'a [T],
15    pub hidden_count: usize,
16    total: usize,
17}
18
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct TextLineExcerpt<'a> {
21    pub head: Vec<&'a str>,
22    pub tail: Vec<&'a str>,
23    pub hidden_count: usize,
24    pub total: usize,
25}
26
27pub fn display_width(text: &str) -> usize {
28    UnicodeWidthStr::width(text)
29}
30
31pub fn truncate_to_display_width(text: &str, max_width: usize) -> &str {
32    if max_width == 0 {
33        return "";
34    }
35    if display_width(text) <= max_width {
36        return text;
37    }
38
39    let mut consumed_width = 0usize;
40    for (idx, ch) in text.char_indices() {
41        let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
42        if consumed_width + char_width > max_width {
43            return &text[..idx];
44        }
45        consumed_width += char_width;
46    }
47
48    text
49}
50
51pub fn truncate_with_ellipsis(text: &str, max_width: usize, ellipsis: &str) -> String {
52    if max_width == 0 {
53        return String::new();
54    }
55    if display_width(text) <= max_width {
56        return text.to_string();
57    }
58
59    let ellipsis_width = display_width(ellipsis);
60    if ellipsis_width >= max_width {
61        return truncate_to_display_width(ellipsis, max_width).to_string();
62    }
63
64    let truncated = truncate_to_display_width(text, max_width - ellipsis_width);
65    format!("{truncated}{ellipsis}")
66}
67
68pub fn pad_to_display_width(text: &str, width: usize, pad_char: char) -> String {
69    let current = display_width(text);
70    if current >= width {
71        return text.to_string();
72    }
73
74    let padding = pad_char.to_string().repeat(width - current);
75    format!("{text}{padding}")
76}
77
78pub fn suffix_for_display_width(value: &str, max_width: usize) -> &str {
79    if display_width(value) <= max_width {
80        return value;
81    }
82    if max_width == 0 {
83        return "";
84    }
85
86    let mut consumed_width = 0usize;
87    let mut start_idx = value.len();
88    for (idx, ch) in value.char_indices().rev() {
89        let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
90        if consumed_width + char_width > max_width {
91            break;
92        }
93        consumed_width += char_width;
94        start_idx = idx;
95    }
96
97    &value[start_idx..]
98}
99
100pub fn format_hidden_lines_summary(hidden: usize) -> String {
101    if hidden == 1 {
102        "… +1 line".to_string()
103    } else {
104        format!("… +{hidden} lines")
105    }
106}
107
108fn split_head_tail_preview<'a, T>(items: &'a [T], head: usize, tail: usize) -> HeadTailPreview<'a, T> {
109    let total = items.len();
110    if total <= head.saturating_add(tail) {
111        return HeadTailPreview {
112            head: items,
113            tail: &items[total..],
114            hidden_count: 0,
115            total,
116        };
117    }
118
119    let head_count = head.min(total);
120    let tail_count = tail.min(total.saturating_sub(head_count));
121    let hidden_count = total.saturating_sub(head_count + tail_count);
122
123    HeadTailPreview {
124        head: &items[..head_count],
125        tail: &items[total - tail_count..],
126        hidden_count,
127        total,
128    }
129}
130
131pub fn split_head_tail_preview_with_limit<'a, T>(
132    items: &'a [T],
133    limit: usize,
134    preferred_head: usize,
135) -> HeadTailPreview<'a, T> {
136    if limit == 0 {
137        return HeadTailPreview {
138            head: &items[..0],
139            tail: &items[..0],
140            hidden_count: items.len(),
141            total: items.len(),
142        };
143    }
144
145    if items.len() <= limit {
146        return HeadTailPreview {
147            head: items,
148            tail: &items[items.len()..],
149            hidden_count: 0,
150            total: items.len(),
151        };
152    }
153
154    let (head, tail) = summary_window(limit, preferred_head);
155    split_head_tail_preview(items, head, tail)
156}
157
158pub fn summary_window(limit: usize, preferred_head: usize) -> (usize, usize) {
159    if limit <= 2 {
160        return (0, limit);
161    }
162
163    let head = preferred_head.min((limit - 1) / 2).max(1);
164    let tail = limit.saturating_sub(head + 1).max(1);
165    (head, tail)
166}
167
168pub fn excerpt_text_lines<'a>(text: &'a str, head: usize, tail: usize) -> TextLineExcerpt<'a> {
169    let lines: Vec<&str> = text.lines().collect();
170    let total = lines.len();
171    if total <= head.saturating_add(tail) {
172        return TextLineExcerpt {
173            head: lines,
174            tail: Vec::new(),
175            hidden_count: 0,
176            total,
177        };
178    }
179
180    let head_count = head.min(total);
181    let tail_count = tail.min(total.saturating_sub(head_count));
182    let hidden_count = total.saturating_sub(head_count + tail_count);
183
184    TextLineExcerpt {
185        head: lines[..head_count].to_vec(),
186        tail: lines[total - tail_count..].to_vec(),
187        hidden_count,
188        total,
189    }
190}
191
192pub fn excerpt_text_lines_with_limit<'a>(text: &'a str, limit: usize, preferred_head: usize) -> TextLineExcerpt<'a> {
193    let lines: Vec<&str> = text.lines().collect();
194    let preview = split_head_tail_preview_with_limit(lines.as_slice(), limit, preferred_head);
195
196    TextLineExcerpt {
197        head: preview.head.to_vec(),
198        tail: preview.tail.to_vec(),
199        hidden_count: preview.hidden_count,
200        total: preview.total,
201    }
202}
203
204fn format_hidden_bytes_summary(hidden: usize) -> String {
205    format!("… [{hidden} bytes omitted] …")
206}
207
208pub fn condense_text_bytes(content: &str, head_bytes: usize, tail_bytes: usize) -> String {
209    let byte_len = content.len();
210    let max_inline = head_bytes + tail_bytes;
211    if byte_len <= max_inline {
212        return content.to_string();
213    }
214
215    let head_end = floor_char_boundary(content, head_bytes);
216    let tail_start_raw = byte_len.saturating_sub(tail_bytes);
217    let tail_start = ceil_char_boundary(content, tail_start_raw);
218
219    let omitted = byte_len.saturating_sub(head_end).saturating_sub(byte_len - tail_start);
220
221    format!("{}\n\n{}\n\n{}", &content[..head_end], format_hidden_bytes_summary(omitted), &content[tail_start..])
222}
223
224/// Bound model-facing error echoes of text the caller already sent
225/// (old_str, patch context, snippets). Threshold is deliberately high:
226/// typical edit mismatches are mid-string whitespace/indent issues, so most
227/// real payloads must stay intact. Only pathological multi-KB pastes are
228/// reduced to a head/tail.
229pub fn condense_error_echo(text: &str) -> String {
230    if text.len() > 2_000 {
231        condense_text_bytes(text, 800, 800)
232    } else {
233        text.to_string()
234    }
235}
236
237pub fn tail_preview_text(content: &str, tail_bytes: usize, max_lines: usize) -> String {
238    if content.is_empty() {
239        return String::new();
240    }
241
242    let tail_start = ceil_char_boundary(content, content.len().saturating_sub(tail_bytes));
243    let tail_slice = &content[tail_start..];
244
245    let mut line_start = 0usize;
246    if max_lines > 0 {
247        let mut seen = 0usize;
248        for (idx, b) in tail_slice.as_bytes().iter().enumerate().rev() {
249            if *b == b'\n' {
250                seen += 1;
251                if seen >= max_lines {
252                    line_start = idx.saturating_add(1);
253                    break;
254                }
255            }
256        }
257    }
258
259    let preview = &tail_slice[line_start..];
260    let omitted = tail_start.saturating_add(line_start);
261    if omitted == 0 {
262        return preview.to_string();
263    }
264
265    format!("{}\n{}", format_hidden_bytes_summary(omitted), preview)
266}
267
268fn floor_char_boundary(value: &str, index: usize) -> usize {
269    if index >= value.len() {
270        return value.len();
271    }
272
273    let mut i = index;
274    while i > 0 && !value.is_char_boundary(i) {
275        i -= 1;
276    }
277    i
278}
279
280fn ceil_char_boundary(value: &str, index: usize) -> usize {
281    if index >= value.len() {
282        return value.len();
283    }
284
285    let mut i = index;
286    while i < value.len() && !value.is_char_boundary(i) {
287        i += 1;
288    }
289    i
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn truncate_to_display_width_respects_wide_chars() {
298        let value = "表表表";
299        assert_eq!(truncate_to_display_width(value, 5), "表表");
300    }
301
302    #[test]
303    fn truncate_with_ellipsis_respects_width_budget() {
304        assert_eq!(truncate_with_ellipsis("abcdef", 4, "…"), "abc…");
305    }
306
307    #[test]
308    fn pad_to_display_width_handles_wide_chars() {
309        let padded = pad_to_display_width("表", 4, ' ');
310        assert_eq!(display_width(padded.as_str()), 4);
311    }
312
313    #[test]
314    fn suffix_for_display_width_preserves_tail() {
315        assert_eq!(suffix_for_display_width("hello/world.rs", 8), "world.rs");
316    }
317
318    #[test]
319    fn split_head_tail_preview_preserves_hidden_count() {
320        let items = [1, 2, 3, 4, 5, 6, 7];
321        let preview = split_head_tail_preview(&items, 2, 2);
322        assert_eq!(preview.head, &[1, 2]);
323        assert_eq!(preview.tail, &[6, 7]);
324        assert_eq!(preview.hidden_count, 3);
325        assert_eq!(preview.total, 7);
326    }
327
328    #[test]
329    fn split_head_tail_preview_keeps_short_input_intact() {
330        let items = [1, 2, 3];
331        let preview = split_head_tail_preview(&items, 2, 2);
332        assert_eq!(preview.head, &[1, 2, 3]);
333        assert!(preview.tail.is_empty());
334        assert_eq!(preview.hidden_count, 0);
335    }
336
337    #[test]
338    fn split_head_tail_preview_with_limit_preserves_total_and_gap() {
339        let items = [1, 2, 3, 4, 5, 6, 7];
340        let preview = split_head_tail_preview_with_limit(&items, 6, 3);
341        assert_eq!(preview.head, &[1, 2]);
342        assert_eq!(preview.tail, &[5, 6, 7]);
343        assert_eq!(preview.hidden_count, 2);
344        assert_eq!(preview.total, 7);
345    }
346
347    #[test]
348    fn summary_window_reserves_gap_row() {
349        assert_eq!(summary_window(6, 3), (2, 3));
350        assert_eq!(summary_window(2, 3), (0, 2));
351    }
352
353    #[test]
354    fn hidden_lines_summary_matches_existing_copy() {
355        assert_eq!(format_hidden_lines_summary(1), "… +1 line");
356        assert_eq!(format_hidden_lines_summary(4), "… +4 lines");
357    }
358
359    #[test]
360    fn excerpt_text_lines_builds_head_tail_vectors() {
361        let preview = excerpt_text_lines("l1\nl2\nl3\nl4\nl5\nl6", 2, 2);
362        assert_eq!(preview.head, vec!["l1", "l2"]);
363        assert_eq!(preview.tail, vec!["l5", "l6"]);
364        assert_eq!(preview.hidden_count, 2);
365        assert_eq!(preview.total, 6);
366    }
367
368    #[test]
369    fn condense_text_bytes_respects_utf8_boundaries() {
370        let mut content = "a".repeat(7);
371        content.push('é');
372        content.push_str("bbbbbbbb");
373
374        let preview = condense_text_bytes(&content, 8, 4);
375        assert!(preview.contains("bytes omitted"));
376        assert!(preview.is_char_boundary(0));
377    }
378
379    #[test]
380    fn tail_preview_text_keeps_last_lines_only() {
381        let input = (0..20).map(|index| format!("line-{index}")).collect::<Vec<_>>().join("\n");
382
383        let preview = tail_preview_text(&input, 40, 3);
384        assert!(preview.contains("bytes omitted"));
385        assert!(preview.contains("line-19"));
386        assert!(!preview.contains("line-1\n"));
387    }
388}