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
7use 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
224pub fn tail_preview_text(content: &str, tail_bytes: usize, max_lines: usize) -> String {
225 if content.is_empty() {
226 return String::new();
227 }
228
229 let tail_start = ceil_char_boundary(content, content.len().saturating_sub(tail_bytes));
230 let tail_slice = &content[tail_start..];
231
232 let mut line_start = 0usize;
233 if max_lines > 0 {
234 let mut seen = 0usize;
235 for (idx, b) in tail_slice.as_bytes().iter().enumerate().rev() {
236 if *b == b'\n' {
237 seen += 1;
238 if seen >= max_lines {
239 line_start = idx.saturating_add(1);
240 break;
241 }
242 }
243 }
244 }
245
246 let preview = &tail_slice[line_start..];
247 let omitted = tail_start.saturating_add(line_start);
248 if omitted == 0 {
249 return preview.to_string();
250 }
251
252 format!("{}\n{}", format_hidden_bytes_summary(omitted), preview)
253}
254
255fn floor_char_boundary(value: &str, index: usize) -> usize {
256 if index >= value.len() {
257 return value.len();
258 }
259
260 let mut i = index;
261 while i > 0 && !value.is_char_boundary(i) {
262 i -= 1;
263 }
264 i
265}
266
267fn ceil_char_boundary(value: &str, index: usize) -> usize {
268 if index >= value.len() {
269 return value.len();
270 }
271
272 let mut i = index;
273 while i < value.len() && !value.is_char_boundary(i) {
274 i += 1;
275 }
276 i
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn truncate_to_display_width_respects_wide_chars() {
285 let value = "表表表";
286 assert_eq!(truncate_to_display_width(value, 5), "表表");
287 }
288
289 #[test]
290 fn truncate_with_ellipsis_respects_width_budget() {
291 assert_eq!(truncate_with_ellipsis("abcdef", 4, "…"), "abc…");
292 }
293
294 #[test]
295 fn pad_to_display_width_handles_wide_chars() {
296 let padded = pad_to_display_width("表", 4, ' ');
297 assert_eq!(display_width(padded.as_str()), 4);
298 }
299
300 #[test]
301 fn suffix_for_display_width_preserves_tail() {
302 assert_eq!(suffix_for_display_width("hello/world.rs", 8), "world.rs");
303 }
304
305 #[test]
306 fn split_head_tail_preview_preserves_hidden_count() {
307 let items = [1, 2, 3, 4, 5, 6, 7];
308 let preview = split_head_tail_preview(&items, 2, 2);
309 assert_eq!(preview.head, &[1, 2]);
310 assert_eq!(preview.tail, &[6, 7]);
311 assert_eq!(preview.hidden_count, 3);
312 assert_eq!(preview.total, 7);
313 }
314
315 #[test]
316 fn split_head_tail_preview_keeps_short_input_intact() {
317 let items = [1, 2, 3];
318 let preview = split_head_tail_preview(&items, 2, 2);
319 assert_eq!(preview.head, &[1, 2, 3]);
320 assert!(preview.tail.is_empty());
321 assert_eq!(preview.hidden_count, 0);
322 }
323
324 #[test]
325 fn split_head_tail_preview_with_limit_preserves_total_and_gap() {
326 let items = [1, 2, 3, 4, 5, 6, 7];
327 let preview = split_head_tail_preview_with_limit(&items, 6, 3);
328 assert_eq!(preview.head, &[1, 2]);
329 assert_eq!(preview.tail, &[5, 6, 7]);
330 assert_eq!(preview.hidden_count, 2);
331 assert_eq!(preview.total, 7);
332 }
333
334 #[test]
335 fn summary_window_reserves_gap_row() {
336 assert_eq!(summary_window(6, 3), (2, 3));
337 assert_eq!(summary_window(2, 3), (0, 2));
338 }
339
340 #[test]
341 fn hidden_lines_summary_matches_existing_copy() {
342 assert_eq!(format_hidden_lines_summary(1), "… +1 line");
343 assert_eq!(format_hidden_lines_summary(4), "… +4 lines");
344 }
345
346 #[test]
347 fn excerpt_text_lines_builds_head_tail_vectors() {
348 let preview = excerpt_text_lines("l1\nl2\nl3\nl4\nl5\nl6", 2, 2);
349 assert_eq!(preview.head, vec!["l1", "l2"]);
350 assert_eq!(preview.tail, vec!["l5", "l6"]);
351 assert_eq!(preview.hidden_count, 2);
352 assert_eq!(preview.total, 6);
353 }
354
355 #[test]
356 fn condense_text_bytes_respects_utf8_boundaries() {
357 let mut content = "a".repeat(7);
358 content.push('é');
359 content.push_str("bbbbbbbb");
360
361 let preview = condense_text_bytes(&content, 8, 4);
362 assert!(preview.contains("bytes omitted"));
363 assert!(preview.is_char_boundary(0));
364 }
365
366 #[test]
367 fn tail_preview_text_keeps_last_lines_only() {
368 let input = (0..20).map(|index| format!("line-{index}")).collect::<Vec<_>>().join("\n");
369
370 let preview = tail_preview_text(&input, 40, 3);
371 assert!(preview.contains("bytes omitted"));
372 assert!(preview.contains("line-19"));
373 assert!(!preview.contains("line-1\n"));
374 }
375}