1#![expect(
2 clippy::string_slice,
3 unused_results,
4 reason = "Formatting uses ASCII delimiters and intentionally ignores infallible String mutation results."
5)]
6
7#[inline]
20pub fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
21 let needle = needle.as_bytes();
22 if needle.is_empty() {
23 return true;
24 }
25 haystack
28 .as_bytes()
29 .windows(needle.len())
30 .any(|window| window.eq_ignore_ascii_case(needle))
31}
32
33pub fn format_size(size: u64) -> String {
35 const KB: u64 = 1024;
36 const MB: u64 = KB * 1024;
37 const GB: u64 = MB * 1024;
38
39 if size >= GB {
40 format!("{:.1}GB", size as f64 / GB as f64)
41 } else if size >= MB {
42 format!("{:.1}MB", size as f64 / MB as f64)
43 } else if size >= KB {
44 format!("{:.1}KB", size as f64 / KB as f64)
45 } else {
46 format!("{size}B")
47 }
48}
49
50#[must_use]
56pub fn humanize_duration_compact(seconds: u64) -> String {
57 if seconds < 60 {
58 format!("{seconds}s")
59 } else if seconds < 3600 {
60 format!("{}m", seconds / 60)
61 } else if seconds < 86400 {
62 format!("{}h", seconds / 3600)
63 } else {
64 format!("{}d", seconds / 86400)
65 }
66}
67
68pub fn indent_block(text: &str, indent: &str) -> String {
70 if indent.is_empty() || text.is_empty() {
71 return text.to_string();
72 }
73 let mut indented = String::with_capacity(text.len() + indent.len() * text.lines().count());
74 for (idx, line) in text.split('\n').enumerate() {
75 if idx > 0 {
76 indented.push('\n');
77 }
78 if !line.is_empty() {
79 indented.push_str(indent);
80 }
81 indented.push_str(line);
82 }
83 indented
84}
85
86pub fn truncate_text(text: &str, max_len: usize, ellipsis: &str) -> String {
88 if text.chars().count() <= max_len {
89 return text.to_string();
90 }
91
92 let mut truncated = text.chars().take(max_len).collect::<String>();
93 truncated.push_str(ellipsis);
94 truncated
95}
96
97pub fn truncate_within(text: &str, max_len: usize, ellipsis: &str) -> String {
111 if text.chars().count() <= max_len {
112 return text.to_string();
113 }
114 let keep = max_len.saturating_sub(ellipsis.chars().count());
115 let mut truncated = text.chars().take(keep).collect::<String>();
116 truncated.push_str(ellipsis);
117 truncated
118}
119
120pub fn truncate_middle(text: &str, max_len: usize) -> String {
130 if max_len == 0 {
131 return String::new();
132 }
133 let sanitized: String = text
134 .chars()
135 .map(|c| if matches!(c, '\n' | '\r' | '\t') { ' ' } else { c })
136 .collect();
137 let char_count = sanitized.chars().count();
138 if char_count <= max_len {
139 return sanitized;
140 }
141 if max_len <= 1 {
142 return "…".to_string();
143 }
144 let head_len = max_len / 2;
145 let tail_len = max_len.saturating_sub(head_len + 1);
146
147 let head: String = sanitized.chars().take(head_len).collect();
148 let mut result = String::with_capacity(head.len() + tail_len + 1);
149 result.push_str(&head);
150 result.push('…');
151 if tail_len > 0 {
152 let mut tail_rev: Vec<char> = sanitized.chars().rev().take(tail_len).collect();
153 tail_rev.reverse();
154 let tail: String = tail_rev.into_iter().collect();
155 result.push_str(&tail);
156 }
157 result
158}
159
160pub fn truncate_path_middle(path: &str, max_len: usize) -> String {
167 if max_len == 0 {
168 return String::new();
169 }
170 let char_count = path.chars().count();
171 if char_count <= max_len {
172 return path.to_string();
173 }
174 if max_len <= 1 {
175 return "…".to_string();
176 }
177
178 let head_budget = max_len / 2;
180 let tail_budget = max_len.saturating_sub(head_budget + 1);
181
182 let head_str: String = path.chars().take(head_budget).collect();
186 let head_break = head_str.rfind('/').unwrap_or(head_budget);
187
188 let tail_chars: Vec<char> = path.chars().rev().take(tail_budget).collect();
190 let tail_str: String = tail_chars.iter().rev().collect();
191 let tail_break_from_end = tail_str.find('/').map(|pos| tail_str.len() - pos).unwrap_or(tail_budget);
192
193 let head: String = path.chars().take(head_break).collect();
194 let tail: String = path
195 .chars()
196 .rev()
197 .take(tail_break_from_end)
198 .collect::<Vec<_>>()
199 .into_iter()
200 .rev()
201 .collect();
202
203 format!("{head}…{tail}")
204}
205
206pub fn head_tail_truncate(value: &str, max_chars: usize, marker: &str) -> (String, bool) {
220 const SUFFIX: &str = " [truncated]";
221
222 let total_chars = value.chars().count();
223 if total_chars <= max_chars {
224 return (value.to_string(), false);
225 }
226
227 let marker_chars = marker.chars().count();
228 if max_chars <= marker_chars + 16 {
229 let suffix_len = SUFFIX.chars().count();
230 let truncated = if max_chars > suffix_len {
231 let available = max_chars - suffix_len;
232 let mut result = value.chars().take(available).collect::<String>();
233 result.push_str(SUFFIX);
234 result
235 } else {
236 value.chars().take(max_chars).collect::<String>()
237 };
238 return (truncated, true);
239 }
240
241 let available = max_chars.saturating_sub(marker_chars);
242 let head_chars = (available * 2) / 3;
243 let tail_chars = available.saturating_sub(head_chars);
244 let head = value.chars().take(head_chars).collect::<String>();
245 let tail = value.chars().skip(total_chars.saturating_sub(tail_chars)).collect::<String>();
246 let mut truncated = String::with_capacity(max_chars + 20);
247 truncated.push_str(&head);
248 truncated.push_str(marker);
249 truncated.push_str(&tail);
250 (truncated, true)
251}
252
253pub fn wrap_text_words(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
267 let trimmed = text.trim();
268 if trimmed.is_empty() {
269 return Vec::new();
270 }
271
272 let mut result = Vec::new();
273 let mut remaining = trimmed;
274 let mut width = first_width.max(1);
275
276 while remaining.chars().take(width + 1).count() > width {
277 let split = split_at_word_boundary(remaining, width);
278 let (head, tail) = remaining.split_at(split);
279 let head = head.trim();
280 if head.is_empty() {
281 break;
282 }
283 result.push(head.to_string());
284 remaining = tail.trim_start();
285 if remaining.is_empty() {
286 break;
287 }
288 width = continuation_width.max(1);
289 }
290
291 if !remaining.is_empty() {
292 result.push(remaining.to_string());
293 }
294 result
295}
296
297fn split_at_word_boundary(input: &str, width: usize) -> usize {
298 let mut last_space: Option<usize> = None;
299 for (seen, (idx, ch)) in input.char_indices().enumerate() {
300 if seen > width {
301 break;
302 }
303 if ch.is_whitespace() {
304 last_space = Some(idx);
305 }
306 }
307 match last_space {
308 Some(pos) => pos,
309 None => byte_index_for_char_count(input, width),
310 }
311}
312
313fn byte_index_for_char_count(input: &str, chars: usize) -> usize {
314 if chars == 0 {
315 return 0;
316 }
317 let mut seen = 0usize;
318 for (idx, ch) in input.char_indices() {
319 seen += 1;
320 if seen == chars {
321 return idx + ch.len_utf8();
322 }
323 }
324 input.len()
325}
326
327fn split_shell_words(text: &str) -> Vec<String> {
336 let mut words = Vec::new();
337 let mut current = String::new();
338 let mut in_single = false;
339 let mut in_double = false;
340 let mut escaped = false;
341 for ch in text.chars() {
342 if escaped {
343 current.push(ch);
344 escaped = false;
345 continue;
346 }
347 if ch == '\\' && !in_single {
348 current.push(ch);
349 escaped = true;
350 continue;
351 }
352 if ch == '\'' && !in_double {
353 in_single = !in_single;
354 current.push(ch);
355 continue;
356 }
357 if ch == '"' && !in_single {
358 in_double = !in_double;
359 current.push(ch);
360 continue;
361 }
362 if ch.is_whitespace() && !in_single && !in_double {
363 if !current.is_empty() {
364 words.push(std::mem::take(&mut current));
365 }
366 continue;
367 }
368 current.push(ch);
369 }
370 if !current.is_empty() {
371 words.push(current);
372 }
373 words
374}
375
376pub const RAN_COMMAND_FIRST_WIDTH: usize = 62;
382pub const RAN_COMMAND_CONTINUATION_WIDTH: usize = 58;
384
385pub fn wrap_shell_command(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
407 let trimmed = text.trim();
408 if trimmed.is_empty() {
409 return Vec::new();
410 }
411 let words = split_shell_words(trimmed);
412 if words.is_empty() {
413 return Vec::new();
414 }
415
416 let mut lines = Vec::with_capacity(words.len());
417 let mut current = String::new();
418 let mut width = first_width.max(1);
419 for word in words {
420 let word_len = word.chars().count();
421 if word_len > width {
422 if !current.is_empty() {
423 lines.push(std::mem::take(&mut current));
424 width = continuation_width.max(1);
425 }
426 current = push_split_word(&mut lines, &word, width, continuation_width);
427 width = continuation_width.max(1);
428 continue;
429 }
430 let current_len = current.chars().count();
431 let need = if current_len == 0 {
432 word_len
433 } else {
434 current_len + 1 + word_len
435 };
436 if need <= width {
437 if current_len > 0 {
438 current.push(' ');
439 }
440 current.push_str(&word);
441 } else {
442 lines.push(std::mem::take(&mut current));
443 width = continuation_width.max(1);
444 if word_len > width {
445 current = push_split_word(&mut lines, &word, width, continuation_width);
449 } else {
450 current = word;
451 }
452 }
453 }
454 if !current.is_empty() {
455 lines.push(current);
456 }
457 lines
458}
459
460pub fn wrap_shell_command_with_continuations(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
493 let lines = wrap_shell_command_lines(text, first_width, continuation_width);
494 let total = lines.len();
495 if total <= 1 {
496 return lines;
497 }
498 lines
499 .into_iter()
500 .enumerate()
501 .map(|(index, mut line)| {
502 if index + 1 < total {
504 line.push_str(" \\");
505 }
506 line
507 })
508 .collect()
509}
510
511pub fn wrap_shell_command_lines(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
520 let trimmed = text.trim();
521 if trimmed.is_empty() {
522 return Vec::new();
523 }
524 let single = wrap_shell_command(trimmed, first_width.max(1), continuation_width.max(1));
527 if single.len() <= 1 {
528 return single;
529 }
530 let chunks = split_shell_operator_chunks(trimmed);
534 let mut lines: Vec<String> = Vec::new();
535 for (chunk_idx, chunk) in chunks.iter().enumerate() {
536 let width = if lines.is_empty() {
537 first_width.max(1)
538 } else {
539 continuation_width.max(1)
540 };
541 let wrapped = if chunk_idx == 0 {
544 wrap_shell_command(chunk, first_width.max(1), continuation_width.max(1))
545 } else {
546 wrap_shell_command(chunk, width, continuation_width.max(1))
547 };
548 if wrapped.is_empty() {
549 continue;
550 }
551 lines.extend(wrapped);
552 }
553 lines
554}
555
556fn is_shell_list_separator(word: &str) -> bool {
566 matches!(word, "&&" | "||" | "|" | "|&" | ";" | ";;" | ";&" | ";;&" | "&")
567 || (word.len() > 1 && word.ends_with(';') && !word.ends_with(";;"))
568}
569
570fn split_shell_operator_chunks(command: &str) -> Vec<String> {
578 let words = split_shell_words(command);
579 if words.is_empty() {
580 return Vec::new();
581 }
582 let mut chunks: Vec<String> = Vec::new();
583 let mut current = String::new();
584 for word in words {
585 if !current.is_empty() {
586 current.push(' ');
587 }
588 current.push_str(&word);
589 if is_shell_list_separator(&word) {
590 chunks.push(std::mem::take(&mut current));
591 }
592 }
593 if !current.is_empty() {
594 chunks.push(current);
595 }
596 chunks
597}
598
599fn push_split_word(lines: &mut Vec<String>, word: &str, width: usize, continuation_width: usize) -> String {
603 let mut width = width.max(1);
604 let mut rest = word;
605 while rest.chars().count() > width {
606 let idx = byte_index_for_char_count(rest, width);
607 let (head, tail) = rest.split_at(idx);
608 lines.push(head.to_string());
609 rest = tail;
610 width = continuation_width.max(1);
611 }
612 rest.to_string()
613}
614
615#[allow(
633 clippy::cast_possible_truncation,
634 clippy::float_cmp,
635 reason = "Narrowing `f64` to `f32` and comparing exactly is the detection mechanism: only exact round-trips collapse."
636)]
637pub fn format_float_display(value: f64) -> String {
638 if !value.is_finite() {
639 return value.to_string();
640 }
641 let narrowed = value as f32;
642 if (narrowed as f64) == value {
643 return narrowed.to_string();
644 }
645 const GRID_EPSILON: f64 = 1e-7;
648 for precision in [1i32, 2i32] {
649 let factor = 10f64.powi(precision);
650 let snapped = (value * factor).round() / factor;
651 if (snapped - value).abs() < GRID_EPSILON {
652 return snapped.to_string();
653 }
654 }
655 value.to_string()
656}
657
658#[inline]
672pub fn truncate_utf8_prefix(text: &str, max_bytes: usize) -> &str {
673 let mut end = max_bytes.min(text.len());
674 while end > 0 && !text.is_char_boundary(end) {
675 end -= 1;
676 }
677 &text[..end]
678}
679
680pub fn truncate_byte_budget(text: &str, max_bytes: usize, suffix: &str) -> String {
684 if text.len() <= max_bytes {
685 return text.to_string();
686 }
687 format!("{}{suffix}", truncate_utf8_prefix(text, max_bytes))
688}
689
690#[inline]
704pub fn is_markdown_fence_delimiter(line: &str) -> bool {
705 let indent = line.len() - line.trim_start().len();
706 if indent > 3 {
707 return false;
708 }
709 let trimmed = line.trim_start();
710 trimmed.starts_with("```") || trimmed.starts_with("~~~")
711}
712
713pub fn lowercase_leading_word(text: &str) -> String {
726 let mut chars = text.chars();
727 let Some(first) = chars.next() else {
728 return String::new();
729 };
730 let second = chars.clone().next();
731 let plain_word =
732 first.is_uppercase() && first != 'I' && second.is_none_or(|c| c.is_lowercase() || c.is_whitespace());
733 if plain_word {
734 let mut lowered: String = first.to_lowercase().collect();
735 lowered.push_str(chars.as_str());
736 lowered
737 } else {
738 text.to_string()
739 }
740}
741
742#[inline]
750pub fn collapse_whitespace(text: &str) -> String {
751 let mut result = String::with_capacity(text.len());
752 let mut pending_space = false;
753 for ch in text.chars() {
754 if ch.is_whitespace() {
755 pending_space = true;
756 } else {
757 if pending_space && !result.is_empty() {
758 result.push(' ');
759 }
760 result.push(ch);
761 pending_space = false;
762 }
763 }
764 result
765}
766
767#[inline]
781pub fn strip_optional_word_prefix<'a>(input: &'a str, word: &str) -> &'a str {
782 let Some(prefix) = input.get(..word.len()) else {
783 return input;
784 };
785 if !prefix.eq_ignore_ascii_case(word) {
786 return input;
787 }
788 let remainder = &input[word.len()..];
789 if remainder.chars().next().is_some_and(char::is_whitespace) {
790 remainder.trim_start()
791 } else {
792 input
793 }
794}
795
796#[inline]
805pub fn trim_wrapping_quotes_and_punctuation(target: &str) -> &str {
806 let mut normalized = target.trim();
807 loop {
808 let previous = normalized;
809 normalized = normalized.trim();
810 normalized = normalized.strip_prefix('"').unwrap_or(normalized);
811 normalized = normalized.strip_suffix('"').unwrap_or(normalized);
812 normalized = normalized.strip_prefix('\'').unwrap_or(normalized);
813 normalized = normalized.strip_suffix('\'').unwrap_or(normalized);
814 normalized = normalized.trim_end_matches(['.', ',', ';', '!', '?']).trim();
815 if normalized == previous {
816 return normalized;
817 }
818 }
819}
820
821pub fn clean_reasoning_text(text: &str) -> String {
830 text.lines()
831 .map(str::trim_end)
832 .filter(|line| !line.trim().is_empty())
833 .collect::<Vec<_>>()
834 .join("\n")
835}
836
837pub fn compact_reasoning_text(text: &str) -> String {
853 let mut out: Vec<&str> = Vec::with_capacity(text.lines().count());
854 let mut prev_blank = false;
855 for line in text.lines() {
856 let trimmed = line.trim();
857 let is_blank = trimmed.is_empty();
858 if is_blank {
859 if prev_blank {
860 continue;
861 }
862 out.push("");
863 prev_blank = true;
864 } else {
865 out.push(trimmed);
866 prev_blank = false;
867 }
868 }
869 while out.first().is_some_and(|l| l.trim().is_empty()) {
870 out.remove(0);
871 }
872 while out.last().is_some_and(|l| l.trim().is_empty()) {
873 out.pop();
874 }
875 out.join("\n")
876}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881
882 #[test]
883 fn humanize_duration_compact_ladder() {
884 assert_eq!(humanize_duration_compact(0), "0s");
885 assert_eq!(humanize_duration_compact(42), "42s");
886 assert_eq!(humanize_duration_compact(59), "59s");
887 assert_eq!(humanize_duration_compact(60), "1m");
888 assert_eq!(humanize_duration_compact(300), "5m");
889 assert_eq!(humanize_duration_compact(3599), "59m");
890 assert_eq!(humanize_duration_compact(3600), "1h");
891 assert_eq!(humanize_duration_compact(3 * 3600), "3h");
892 assert_eq!(humanize_duration_compact(86399), "23h");
893 assert_eq!(humanize_duration_compact(86400), "1d");
894 assert_eq!(humanize_duration_compact(2 * 86400), "2d");
895 }
896
897 #[test]
898 fn format_float_display_collapses_f32_widening() {
899 assert_eq!(format_float_display(f64::from(0.7f32)), "0.7");
902 assert_eq!(format_float_display(f64::from(0.3f32)), "0.3");
903 assert_eq!(format_float_display(f64::from(-0.7f32)), "-0.7");
904 assert_eq!(format_float_display(0.3333333333333333), "0.3333333333333333");
905 }
906
907 #[test]
908 fn format_float_display_snaps_stepper_artifacts_to_grid() {
909 assert_eq!(format_float_display(f64::from(0.7f32) + 0.1), "0.8");
913 assert_eq!(format_float_display(0.799999988079071), "0.8");
914 assert_eq!(format_float_display(0.1 + 0.2), "0.3");
915 assert_eq!(format_float_display(-0.799999988079071), "-0.8");
916 assert_eq!(format_float_display(0.123456789), "0.123456789");
917 }
918
919 #[test]
920 fn format_float_display_keeps_plain_floats() {
921 assert_eq!(format_float_display(1.0), "1");
922 assert_eq!(format_float_display(0.0), "0");
923 assert_eq!(format_float_display(0.05), "0.05");
924 assert_eq!(format_float_display(0.75), "0.75");
925 }
926
927 #[test]
928 fn truncate_byte_budget_ascii() {
929 assert_eq!(truncate_byte_budget("hello world", 5, "..."), "hello...");
930 assert_eq!(truncate_byte_budget("hi", 10, "..."), "hi");
931 }
932
933 #[test]
934 fn contains_ignore_ascii_case_matches_str_contains_semantics() {
935 assert!(contains_ignore_ascii_case("MiniMax-M2.5", "minimax-m2.5"));
937 assert!(contains_ignore_ascii_case("minimax-m2.5", "MINIMAX"));
938 assert!(contains_ignore_ascii_case("openai/gpt-5", "GPT-5"));
940 assert!(contains_ignore_ascii_case("anything", ""));
942 assert!(!contains_ignore_ascii_case("glm", "glm-4.5"));
944 assert!(!contains_ignore_ascii_case("openai/gpt-5", "minimax"));
946 }
947
948 #[test]
949 fn contains_ignore_ascii_case_matches_lowercased_contains_for_unicode() {
950 for (haystack, needle) in [
953 ("café GLM-5", "glm-5"),
954 ("日本語 <THINK", "<think"),
955 ("straße", "STRASSE"),
956 ("naïve", "naïve"),
957 ] {
958 assert_eq!(
959 contains_ignore_ascii_case(haystack, needle),
960 haystack.to_ascii_lowercase().contains(&needle.to_ascii_lowercase()),
961 "mismatch for haystack={haystack:?} needle={needle:?}"
962 );
963 }
964 }
965
966 #[test]
967 fn truncate_byte_budget_cjk_no_panic() {
968 let jp = "こんにちは";
970 assert_eq!(truncate_byte_budget(jp, 5, "…"), "こ…");
972 assert_eq!(truncate_byte_budget(jp, 6, "…"), "こん…");
974 }
975
976 #[test]
977 fn truncate_byte_budget_mixed_ascii_cjk() {
978 let mixed = "AB日本語CD";
979 assert_eq!(truncate_byte_budget(mixed, 4, ".."), "AB.."); assert_eq!(truncate_byte_budget(mixed, 5, ".."), "AB日.."); }
983
984 #[test]
985 fn truncate_byte_budget_emoji() {
986 let emoji = "👋🌍"; assert_eq!(truncate_byte_budget(emoji, 5, "!"), "👋!");
988 }
989
990 #[test]
991 fn truncate_byte_budget_zero() {
992 assert_eq!(truncate_byte_budget("abc", 0, "..."), "...");
993 }
994
995 #[test]
996 fn compact_reasoning_text_collapses_blank_runs() {
997 assert_eq!(compact_reasoning_text("line1\n\n\n\nline2\n"), "line1\n\nline2");
998 assert_eq!(compact_reasoning_text("a\n\n\n\n\n\nb"), "a\n\nb");
999 }
1000
1001 #[test]
1002 fn compact_reasoning_text_preserves_single_paragraph_breaks() {
1003 assert_eq!(compact_reasoning_text("para one\n\npara two\n"), "para one\n\npara two");
1004 }
1005
1006 #[test]
1007 fn compact_reasoning_text_trims_trailing_whitespace() {
1008 assert_eq!(compact_reasoning_text(" a \n\n\n b \n"), "a\n\nb");
1009 }
1010
1011 #[test]
1012 fn compact_reasoning_text_strips_leading_trailing_blanks() {
1013 assert_eq!(compact_reasoning_text("\n\n\nmid\n\n\n"), "mid");
1014 assert_eq!(compact_reasoning_text("\n\n\n"), "");
1015 assert_eq!(compact_reasoning_text(""), "");
1016 }
1017
1018 #[test]
1019 fn wrap_text_words_basic_and_continuation_width() {
1020 assert_eq!(wrap_text_words("the quick brown fox", 9, 9), vec!["the quick", "brown fox"]);
1021 assert_eq!(wrap_text_words("alpha beta gamma delta", 11, 5), vec!["alpha beta", "gamma", "delta"]);
1023 }
1024
1025 #[test]
1026 fn wrap_text_words_blank_and_unicode() {
1027 assert!(wrap_text_words(" ", 5, 5).is_empty());
1028 let wrapped = wrap_text_words("あいう えお かきく", 3, 3);
1030 assert_eq!(wrapped, vec!["あいう", "えお", "かきく"]);
1031 }
1032
1033 #[test]
1034 fn wrap_shell_command_keeps_screenshot_pipeline_in_full() {
1035 let command = "grep -rn \"@vinhnx/vtcode|npm install -g||npx @vinhnx\" docs | grep -v node_modules | grep -v package-lock | grep -v \"\\.backup\"";
1039 let wrapped = wrap_shell_command(command, 62, 58);
1040 assert_eq!(
1041 wrapped,
1042 vec![
1043 "grep -rn \"@vinhnx/vtcode|npm install -g||npx @vinhnx\" docs |",
1044 "grep -v node_modules | grep -v package-lock | grep -v",
1045 "\"\\.backup\"",
1046 ]
1047 );
1048 assert!(wrapped.iter().all(|line| !line.contains('…')));
1049 assert_eq!(wrapped.join(" "), command);
1050 assert_eq!(wrapped.join("\n").matches('|').count(), 6);
1051 }
1052
1053 #[test]
1054 fn wrap_shell_command_breaks_only_at_unquoted_spaces() {
1055 assert_eq!(wrap_shell_command("echo \"a b c\" d", 10, 10), vec!["echo", "\"a b c\" d"]);
1058 assert_eq!(wrap_shell_command(" ", 5, 5), Vec::<String>::new());
1059 }
1060
1061 #[test]
1062 fn wrap_shell_command_splits_overlong_token_at_width() {
1063 assert_eq!(wrap_shell_command("abcdefghij", 4, 4), vec!["abcd", "efgh", "ij"]);
1064 }
1065
1066 #[test]
1067 fn wrap_shell_command_with_continuations_marks_wrapped_lines() {
1068 assert_eq!(wrap_shell_command_with_continuations("echo a b c d", 7, 7), vec!["echo a \\", "b c d"]);
1071 assert_eq!(wrap_shell_command_with_continuations("git status --short", 62, 58), vec!["git status --short"]);
1072 assert!(wrap_shell_command_with_continuations(" ", 5, 5).is_empty());
1073 let command = "git add a b && git commit -m \"msg\" && git status --short";
1076 let wrapped = wrap_shell_command_with_continuations(command, 20, 20);
1077 assert!(wrapped.len() > 1, "expected wrapping: {wrapped:?}");
1078 assert!(wrapped[..wrapped.len() - 1].iter().all(|line| line.ends_with(" \\")));
1079 assert!(!wrapped.last().unwrap().ends_with(" \\"));
1080 assert!(wrapped.iter().all(|line| !line.contains('…')));
1081 assert_eq!(wrap_shell_command_with_continuations("echo a && echo b", 10, 10), vec!["echo a && \\", "echo b"]);
1084 assert_eq!(wrap_shell_command_with_continuations("a | b | c", 7, 5), vec!["a | \\", "b | \\", "c"]);
1085 assert_eq!(
1086 wrap_shell_command_with_continuations("grep -rn \"a||b\" docs", 62, 58),
1087 vec!["grep -rn \"a||b\" docs"]
1088 );
1089 assert_eq!(wrap_shell_command_with_continuations("echo a && echo b", 62, 58), vec!["echo a && echo b"]);
1091 assert_eq!(wrap_shell_command_with_continuations("a | b | c", 62, 58), vec!["a | b | c"]);
1092 assert_eq!(wrap_shell_command_lines("echo a && echo b", 10, 10), vec!["echo a &&", "echo b"]);
1094 assert_eq!(wrap_shell_command_lines("git status", 62, 58), vec!["git status"]);
1095 }
1096
1097 #[test]
1098 fn wrap_shell_command_narrow_continuation_never_overflows() {
1099 let token = "b".repeat(60);
1103 let command = format!("aa {token} cc");
1104 let wrapped = wrap_shell_command(&command, 62, 58);
1105 assert!(wrapped.len() >= 3, "expected a split continuation: {wrapped:?}");
1106 assert!(wrapped[0].chars().count() <= 62, "first line budget: {wrapped:?}");
1107 for line in wrapped.iter().skip(1) {
1108 assert!(line.chars().count() <= 58, "continuation budget: {wrapped:?}");
1109 }
1110 let wrapped = wrap_shell_command("aa bbbbbbbb cc", 10, 5);
1112 assert_eq!(wrapped, vec!["aa", "bbbbb", "bbb", "cc"]);
1113 }
1114
1115 #[test]
1116 fn truncate_within_reserves_ellipsis_budget() {
1117 assert_eq!(truncate_within("hello world", 8, "..."), "hello...");
1119 assert_eq!(truncate_within("hi", 8, "..."), "hi");
1120 assert_eq!(truncate_within("abcdef", 4, "…"), "abc…");
1123 }
1124
1125 #[test]
1126 fn truncate_within_counts_chars() {
1127 let jp = "あいうえお"; assert_eq!(truncate_within(jp, 5, "…"), jp);
1129 assert_eq!(truncate_within(jp, 3, "…"), "あい…");
1130 }
1131
1132 #[test]
1133 fn head_tail_truncate_keeps_both_ends() {
1134 let value = "0123456789".repeat(10); let (out, truncated) = head_tail_truncate(&value, 40, " ... [truncated] ... ");
1136 assert!(truncated);
1137 assert!(out.chars().count() <= 40);
1138 assert!(out.starts_with("012"));
1139 assert!(out.contains("[truncated]"));
1140 assert!(out.ends_with('9'));
1141 }
1142
1143 #[test]
1144 fn head_tail_truncate_passes_through_when_short() {
1145 let (out, truncated) = head_tail_truncate("short", 64, " ... ");
1146 assert_eq!(out, "short");
1147 assert!(!truncated);
1148 }
1149
1150 #[test]
1151 fn head_tail_truncate_small_budget_falls_back_to_prefix() {
1152 let marker = " ... [truncated] ... ";
1153 let (out, truncated) = head_tail_truncate("abcdefghij", 5, marker);
1156 assert!(truncated);
1157 assert_eq!(out, "abcde");
1158
1159 let long_text = "abcdefghijklmnopqrstuvwxyz";
1162 let (out2, truncated2) = head_tail_truncate(long_text, 17, marker);
1163 assert!(truncated2);
1164 assert_eq!(out2, "abcde [truncated]");
1165 assert_eq!(out2.chars().count(), 17);
1166 }
1167
1168 #[test]
1169 fn truncate_text_counts_chars_not_bytes() {
1170 let jp = "あいうえお"; assert_eq!(truncate_text(jp, 3, "…"), "あいう…");
1172 assert_eq!(truncate_text(jp, 5, "…"), "あいうえお");
1173 }
1174
1175 #[test]
1176 fn truncate_middle_keeps_both_ends() {
1177 assert_eq!(truncate_middle("short", 80), "short");
1178 assert_eq!(truncate_middle("abcdefghij", 5), "ab…ij");
1179 assert_eq!(truncate_middle("a b c", 80), "a b c");
1180 assert_eq!(truncate_middle("abc", 0), "");
1182 assert_eq!(truncate_middle("abc", 1), "…");
1183 assert_eq!(truncate_middle("a\nb\tc", 80), "a b c");
1185 }
1186
1187 #[test]
1188 fn truncate_path_middle_breaks_at_separator() {
1189 assert_eq!(truncate_path_middle("src/lib.rs", 80), "src/lib.rs");
1190 assert_eq!(truncate_path_middle("foo/bar/baz/qux", 12), "foo…/qux");
1191 assert_eq!(truncate_path_middle("abc", 0), "");
1192 }
1193}