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 chars: Vec<char> = path.chars().collect();
181 let head_budget = max_len / 2;
182 let tail_budget = max_len.saturating_sub(head_budget + 1);
183
184 let head_break = chars.iter().take(head_budget).rposition(|c| *c == '/').unwrap_or(head_budget);
185
186 let tail: Vec<char> = chars.iter().rev().take(tail_budget).rev().copied().collect();
187 let tail_len = tail.iter().position(|c| *c == '/').map_or(tail_budget, |pos| tail_budget - pos);
188
189 let head: String = chars.iter().take(head_break).collect();
190 let tail: String = tail.iter().skip(tail_budget - tail_len).collect();
191
192 format!("{head}…{tail}")
193}
194
195pub fn head_tail_truncate(value: &str, max_chars: usize, marker: &str) -> (String, bool) {
209 const SUFFIX: &str = " [truncated]";
210
211 let total_chars = value.chars().count();
212 if total_chars <= max_chars {
213 return (value.to_string(), false);
214 }
215
216 let marker_chars = marker.chars().count();
217 if max_chars <= marker_chars + 16 {
218 let suffix_len = SUFFIX.chars().count();
219 let truncated = if max_chars > suffix_len {
220 let available = max_chars - suffix_len;
221 let mut result = value.chars().take(available).collect::<String>();
222 result.push_str(SUFFIX);
223 result
224 } else {
225 value.chars().take(max_chars).collect::<String>()
226 };
227 return (truncated, true);
228 }
229
230 let available = max_chars.saturating_sub(marker_chars);
231 let head_chars = (available * 2) / 3;
232 let tail_chars = available.saturating_sub(head_chars);
233 let head = value.chars().take(head_chars).collect::<String>();
234 let tail = value.chars().skip(total_chars.saturating_sub(tail_chars)).collect::<String>();
235 let mut truncated = String::with_capacity(max_chars + 20);
236 truncated.push_str(&head);
237 truncated.push_str(marker);
238 truncated.push_str(&tail);
239 (truncated, true)
240}
241
242pub fn wrap_text_words(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
256 let trimmed = text.trim();
257 if trimmed.is_empty() {
258 return Vec::new();
259 }
260
261 let mut result = Vec::new();
262 let mut remaining = trimmed;
263 let mut width = first_width.max(1);
264
265 while remaining.chars().take(width + 1).count() > width {
266 let split = split_at_word_boundary(remaining, width);
267 let (head, tail) = remaining.split_at(split);
268 let head = head.trim();
269 if head.is_empty() {
270 break;
271 }
272 result.push(head.to_string());
273 remaining = tail.trim_start();
274 if remaining.is_empty() {
275 break;
276 }
277 width = continuation_width.max(1);
278 }
279
280 if !remaining.is_empty() {
281 result.push(remaining.to_string());
282 }
283 result
284}
285
286fn split_at_word_boundary(input: &str, width: usize) -> usize {
287 let mut last_space: Option<usize> = None;
288 for (seen, (idx, ch)) in input.char_indices().enumerate() {
289 if seen > width {
290 break;
291 }
292 if ch.is_whitespace() {
293 last_space = Some(idx);
294 }
295 }
296 match last_space {
297 Some(pos) => pos,
298 None => byte_index_for_char_count(input, width),
299 }
300}
301
302fn byte_index_for_char_count(input: &str, chars: usize) -> usize {
303 if chars == 0 {
304 return 0;
305 }
306 let mut seen = 0usize;
307 for (idx, ch) in input.char_indices() {
308 seen += 1;
309 if seen == chars {
310 return idx + ch.len_utf8();
311 }
312 }
313 input.len()
314}
315
316fn split_shell_words(text: &str) -> Vec<String> {
325 let mut words = Vec::new();
326 let mut current = String::new();
327 let mut in_single = false;
328 let mut in_double = false;
329 let mut escaped = false;
330 for ch in text.chars() {
331 if escaped {
332 current.push(ch);
333 escaped = false;
334 continue;
335 }
336 if ch == '\\' && !in_single {
337 current.push(ch);
338 escaped = true;
339 continue;
340 }
341 if ch == '\'' && !in_double {
342 in_single = !in_single;
343 current.push(ch);
344 continue;
345 }
346 if ch == '"' && !in_single {
347 in_double = !in_double;
348 current.push(ch);
349 continue;
350 }
351 if ch.is_whitespace() && !in_single && !in_double {
352 if !current.is_empty() {
353 words.push(std::mem::take(&mut current));
354 }
355 continue;
356 }
357 current.push(ch);
358 }
359 if !current.is_empty() {
360 words.push(current);
361 }
362 words
363}
364
365pub const RAN_COMMAND_FIRST_WIDTH: usize = 62;
371pub const RAN_COMMAND_CONTINUATION_WIDTH: usize = 58;
373
374pub fn wrap_shell_command(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
396 let trimmed = text.trim();
397 if trimmed.is_empty() {
398 return Vec::new();
399 }
400 let words = split_shell_words(trimmed);
401 if words.is_empty() {
402 return Vec::new();
403 }
404
405 let mut lines = Vec::with_capacity(words.len());
406 let mut current = String::new();
407 let mut width = first_width.max(1);
408 for word in words {
409 let word_len = word.chars().count();
410 if word_len > width {
411 if !current.is_empty() {
412 lines.push(std::mem::take(&mut current));
413 width = continuation_width.max(1);
414 }
415 current = push_split_word(&mut lines, &word, width, continuation_width);
416 width = continuation_width.max(1);
417 continue;
418 }
419 let current_len = current.chars().count();
420 let need = if current_len == 0 {
421 word_len
422 } else {
423 current_len + 1 + word_len
424 };
425 if need <= width {
426 if current_len > 0 {
427 current.push(' ');
428 }
429 current.push_str(&word);
430 } else {
431 lines.push(std::mem::take(&mut current));
432 width = continuation_width.max(1);
433 if word_len > width {
434 current = push_split_word(&mut lines, &word, width, continuation_width);
438 } else {
439 current = word;
440 }
441 }
442 }
443 if !current.is_empty() {
444 lines.push(current);
445 }
446 lines
447}
448
449pub fn wrap_shell_command_with_continuations(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
482 let lines = wrap_shell_command_lines(text, first_width, continuation_width);
483 let total = lines.len();
484 if total <= 1 {
485 return lines;
486 }
487 lines
488 .into_iter()
489 .enumerate()
490 .map(|(index, mut line)| {
491 if index + 1 < total {
493 line.push_str(" \\");
494 }
495 line
496 })
497 .collect()
498}
499
500pub fn wrap_shell_command_lines(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
509 let trimmed = text.trim();
510 if trimmed.is_empty() {
511 return Vec::new();
512 }
513 let single = wrap_shell_command(trimmed, first_width.max(1), continuation_width.max(1));
516 if single.len() <= 1 {
517 return single;
518 }
519 let chunks = split_shell_operator_chunks(trimmed);
523 let mut lines: Vec<String> = Vec::new();
524 for (chunk_idx, chunk) in chunks.iter().enumerate() {
525 let width = if lines.is_empty() {
526 first_width.max(1)
527 } else {
528 continuation_width.max(1)
529 };
530 let wrapped = if chunk_idx == 0 {
533 wrap_shell_command(chunk, first_width.max(1), continuation_width.max(1))
534 } else {
535 wrap_shell_command(chunk, width, continuation_width.max(1))
536 };
537 if wrapped.is_empty() {
538 continue;
539 }
540 lines.extend(wrapped);
541 }
542 lines
543}
544
545fn is_shell_list_separator(word: &str) -> bool {
555 matches!(word, "&&" | "||" | "|" | "|&" | ";" | ";;" | ";&" | ";;&" | "&")
556 || (word.len() > 1 && word.ends_with(';') && !word.ends_with(";;"))
557}
558
559fn split_shell_operator_chunks(command: &str) -> Vec<String> {
567 let words = split_shell_words(command);
568 if words.is_empty() {
569 return Vec::new();
570 }
571 let mut chunks: Vec<String> = Vec::new();
572 let mut current = String::new();
573 for word in words {
574 if !current.is_empty() {
575 current.push(' ');
576 }
577 current.push_str(&word);
578 if is_shell_list_separator(&word) {
579 chunks.push(std::mem::take(&mut current));
580 }
581 }
582 if !current.is_empty() {
583 chunks.push(current);
584 }
585 chunks
586}
587
588fn push_split_word(lines: &mut Vec<String>, word: &str, width: usize, continuation_width: usize) -> String {
592 let mut width = width.max(1);
593 let mut rest = word;
594 while rest.chars().count() > width {
595 let idx = byte_index_for_char_count(rest, width);
596 let (head, tail) = rest.split_at(idx);
597 lines.push(head.to_string());
598 rest = tail;
599 width = continuation_width.max(1);
600 }
601 rest.to_string()
602}
603
604#[allow(
622 clippy::cast_possible_truncation,
623 clippy::float_cmp,
624 reason = "Narrowing `f64` to `f32` and comparing exactly is the detection mechanism: only exact round-trips collapse."
625)]
626pub fn format_float_display(value: f64) -> String {
627 if !value.is_finite() {
628 return value.to_string();
629 }
630 let narrowed = value as f32;
631 if (narrowed as f64) == value {
632 return narrowed.to_string();
633 }
634 const GRID_EPSILON: f64 = 1e-7;
637 for precision in [1i32, 2i32] {
638 let factor = 10f64.powi(precision);
639 let snapped = (value * factor).round() / factor;
640 if (snapped - value).abs() < GRID_EPSILON {
641 return snapped.to_string();
642 }
643 }
644 value.to_string()
645}
646
647#[inline]
661pub fn truncate_utf8_prefix(text: &str, max_bytes: usize) -> &str {
662 let mut end = max_bytes.min(text.len());
663 while end > 0 && !text.is_char_boundary(end) {
664 end -= 1;
665 }
666 &text[..end]
667}
668
669pub fn truncate_byte_budget(text: &str, max_bytes: usize, suffix: &str) -> String {
673 if text.len() <= max_bytes {
674 return text.to_string();
675 }
676 format!("{}{suffix}", truncate_utf8_prefix(text, max_bytes))
677}
678
679#[inline]
693pub fn is_markdown_fence_delimiter(line: &str) -> bool {
694 let indent = line.len() - line.trim_start().len();
695 if indent > 3 {
696 return false;
697 }
698 let trimmed = line.trim_start();
699 trimmed.starts_with("```") || trimmed.starts_with("~~~")
700}
701
702pub fn lowercase_leading_word(text: &str) -> String {
715 let mut chars = text.chars();
716 let Some(first) = chars.next() else {
717 return String::new();
718 };
719 let second = chars.clone().next();
720 let plain_word =
721 first.is_uppercase() && first != 'I' && second.is_none_or(|c| c.is_lowercase() || c.is_whitespace());
722 if plain_word {
723 let mut lowered: String = first.to_lowercase().collect();
724 lowered.push_str(chars.as_str());
725 lowered
726 } else {
727 text.to_string()
728 }
729}
730
731#[inline]
739pub fn collapse_whitespace(text: &str) -> String {
740 let mut result = String::with_capacity(text.len());
741 let mut pending_space = false;
742 for ch in text.chars() {
743 if ch.is_whitespace() {
744 pending_space = true;
745 } else {
746 if pending_space && !result.is_empty() {
747 result.push(' ');
748 }
749 result.push(ch);
750 pending_space = false;
751 }
752 }
753 result
754}
755
756#[inline]
770pub fn strip_optional_word_prefix<'a>(input: &'a str, word: &str) -> &'a str {
771 let Some(prefix) = input.get(..word.len()) else {
772 return input;
773 };
774 if !prefix.eq_ignore_ascii_case(word) {
775 return input;
776 }
777 let remainder = &input[word.len()..];
778 if remainder.chars().next().is_some_and(char::is_whitespace) {
779 remainder.trim_start()
780 } else {
781 input
782 }
783}
784
785#[inline]
794pub fn trim_wrapping_quotes_and_punctuation(target: &str) -> &str {
795 let mut normalized = target.trim();
796 loop {
797 let previous = normalized;
798 normalized = normalized.trim();
799 normalized = normalized.strip_prefix('"').unwrap_or(normalized);
800 normalized = normalized.strip_suffix('"').unwrap_or(normalized);
801 normalized = normalized.strip_prefix('\'').unwrap_or(normalized);
802 normalized = normalized.strip_suffix('\'').unwrap_or(normalized);
803 normalized = normalized.trim_end_matches(['.', ',', ';', '!', '?']).trim();
804 if normalized == previous {
805 return normalized;
806 }
807 }
808}
809
810pub fn clean_reasoning_text(text: &str) -> String {
819 text.lines()
820 .map(str::trim_end)
821 .filter(|line| !line.trim().is_empty())
822 .collect::<Vec<_>>()
823 .join("\n")
824}
825
826pub fn compact_reasoning_text(text: &str) -> String {
842 let mut out: Vec<&str> = Vec::with_capacity(text.lines().count());
843 let mut prev_blank = false;
844 for line in text.lines() {
845 let trimmed = line.trim();
846 let is_blank = trimmed.is_empty();
847 if is_blank {
848 if prev_blank {
849 continue;
850 }
851 out.push("");
852 prev_blank = true;
853 } else {
854 out.push(trimmed);
855 prev_blank = false;
856 }
857 }
858 while out.first().is_some_and(|l| l.trim().is_empty()) {
859 out.remove(0);
860 }
861 while out.last().is_some_and(|l| l.trim().is_empty()) {
862 out.pop();
863 }
864 out.join("\n")
865}
866
867#[cfg(test)]
868mod tests {
869 use super::*;
870
871 #[test]
872 fn humanize_duration_compact_ladder() {
873 assert_eq!(humanize_duration_compact(0), "0s");
874 assert_eq!(humanize_duration_compact(42), "42s");
875 assert_eq!(humanize_duration_compact(59), "59s");
876 assert_eq!(humanize_duration_compact(60), "1m");
877 assert_eq!(humanize_duration_compact(300), "5m");
878 assert_eq!(humanize_duration_compact(3599), "59m");
879 assert_eq!(humanize_duration_compact(3600), "1h");
880 assert_eq!(humanize_duration_compact(3 * 3600), "3h");
881 assert_eq!(humanize_duration_compact(86399), "23h");
882 assert_eq!(humanize_duration_compact(86400), "1d");
883 assert_eq!(humanize_duration_compact(2 * 86400), "2d");
884 }
885
886 #[test]
887 fn format_float_display_collapses_f32_widening() {
888 assert_eq!(format_float_display(f64::from(0.7f32)), "0.7");
891 assert_eq!(format_float_display(f64::from(0.3f32)), "0.3");
892 assert_eq!(format_float_display(f64::from(-0.7f32)), "-0.7");
893 assert_eq!(format_float_display(0.3333333333333333), "0.3333333333333333");
894 }
895
896 #[test]
897 fn format_float_display_snaps_stepper_artifacts_to_grid() {
898 assert_eq!(format_float_display(f64::from(0.7f32) + 0.1), "0.8");
902 assert_eq!(format_float_display(0.799999988079071), "0.8");
903 assert_eq!(format_float_display(0.1 + 0.2), "0.3");
904 assert_eq!(format_float_display(-0.799999988079071), "-0.8");
905 assert_eq!(format_float_display(0.123456789), "0.123456789");
906 }
907
908 #[test]
909 fn format_float_display_keeps_plain_floats() {
910 assert_eq!(format_float_display(1.0), "1");
911 assert_eq!(format_float_display(0.0), "0");
912 assert_eq!(format_float_display(0.05), "0.05");
913 assert_eq!(format_float_display(0.75), "0.75");
914 }
915
916 #[test]
917 fn truncate_byte_budget_ascii() {
918 assert_eq!(truncate_byte_budget("hello world", 5, "..."), "hello...");
919 assert_eq!(truncate_byte_budget("hi", 10, "..."), "hi");
920 }
921
922 #[test]
923 fn contains_ignore_ascii_case_matches_str_contains_semantics() {
924 assert!(contains_ignore_ascii_case("MiniMax-M2.5", "minimax-m2.5"));
926 assert!(contains_ignore_ascii_case("minimax-m2.5", "MINIMAX"));
927 assert!(contains_ignore_ascii_case("openai/gpt-5", "GPT-5"));
929 assert!(contains_ignore_ascii_case("anything", ""));
931 assert!(!contains_ignore_ascii_case("glm", "glm-4.5"));
933 assert!(!contains_ignore_ascii_case("openai/gpt-5", "minimax"));
935 }
936
937 #[test]
938 fn contains_ignore_ascii_case_matches_lowercased_contains_for_unicode() {
939 for (haystack, needle) in [
942 ("café GLM-5", "glm-5"),
943 ("日本語 <THINK", "<think"),
944 ("straße", "STRASSE"),
945 ("naïve", "naïve"),
946 ] {
947 assert_eq!(
948 contains_ignore_ascii_case(haystack, needle),
949 haystack.to_ascii_lowercase().contains(&needle.to_ascii_lowercase()),
950 "mismatch for haystack={haystack:?} needle={needle:?}"
951 );
952 }
953 }
954
955 #[test]
956 fn truncate_byte_budget_cjk_no_panic() {
957 let jp = "こんにちは";
959 assert_eq!(truncate_byte_budget(jp, 5, "…"), "こ…");
961 assert_eq!(truncate_byte_budget(jp, 6, "…"), "こん…");
963 }
964
965 #[test]
966 fn truncate_byte_budget_mixed_ascii_cjk() {
967 let mixed = "AB日本語CD";
968 assert_eq!(truncate_byte_budget(mixed, 4, ".."), "AB.."); assert_eq!(truncate_byte_budget(mixed, 5, ".."), "AB日.."); }
972
973 #[test]
974 fn truncate_byte_budget_emoji() {
975 let emoji = "👋🌍"; assert_eq!(truncate_byte_budget(emoji, 5, "!"), "👋!");
977 }
978
979 #[test]
980 fn truncate_byte_budget_zero() {
981 assert_eq!(truncate_byte_budget("abc", 0, "..."), "...");
982 }
983
984 #[test]
985 fn compact_reasoning_text_collapses_blank_runs() {
986 assert_eq!(compact_reasoning_text("line1\n\n\n\nline2\n"), "line1\n\nline2");
987 assert_eq!(compact_reasoning_text("a\n\n\n\n\n\nb"), "a\n\nb");
988 }
989
990 #[test]
991 fn compact_reasoning_text_preserves_single_paragraph_breaks() {
992 assert_eq!(compact_reasoning_text("para one\n\npara two\n"), "para one\n\npara two");
993 }
994
995 #[test]
996 fn compact_reasoning_text_trims_trailing_whitespace() {
997 assert_eq!(compact_reasoning_text(" a \n\n\n b \n"), "a\n\nb");
998 }
999
1000 #[test]
1001 fn compact_reasoning_text_strips_leading_trailing_blanks() {
1002 assert_eq!(compact_reasoning_text("\n\n\nmid\n\n\n"), "mid");
1003 assert_eq!(compact_reasoning_text("\n\n\n"), "");
1004 assert_eq!(compact_reasoning_text(""), "");
1005 }
1006
1007 #[test]
1008 fn wrap_text_words_basic_and_continuation_width() {
1009 assert_eq!(wrap_text_words("the quick brown fox", 9, 9), vec!["the quick", "brown fox"]);
1010 assert_eq!(wrap_text_words("alpha beta gamma delta", 11, 5), vec!["alpha beta", "gamma", "delta"]);
1012 }
1013
1014 #[test]
1015 fn wrap_text_words_blank_and_unicode() {
1016 assert!(wrap_text_words(" ", 5, 5).is_empty());
1017 let wrapped = wrap_text_words("あいう えお かきく", 3, 3);
1019 assert_eq!(wrapped, vec!["あいう", "えお", "かきく"]);
1020 }
1021
1022 #[test]
1023 fn wrap_shell_command_keeps_screenshot_pipeline_in_full() {
1024 let command = "grep -rn \"@vinhnx/vtcode|npm install -g||npx @vinhnx\" docs | grep -v node_modules | grep -v package-lock | grep -v \"\\.backup\"";
1028 let wrapped = wrap_shell_command(command, 62, 58);
1029 assert_eq!(
1030 wrapped,
1031 vec![
1032 "grep -rn \"@vinhnx/vtcode|npm install -g||npx @vinhnx\" docs |",
1033 "grep -v node_modules | grep -v package-lock | grep -v",
1034 "\"\\.backup\"",
1035 ]
1036 );
1037 assert!(wrapped.iter().all(|line| !line.contains('…')));
1038 assert_eq!(wrapped.join(" "), command);
1039 assert_eq!(wrapped.join("\n").matches('|').count(), 6);
1040 }
1041
1042 #[test]
1043 fn wrap_shell_command_breaks_only_at_unquoted_spaces() {
1044 assert_eq!(wrap_shell_command("echo \"a b c\" d", 10, 10), vec!["echo", "\"a b c\" d"]);
1047 assert_eq!(wrap_shell_command(" ", 5, 5), Vec::<String>::new());
1048 }
1049
1050 #[test]
1051 fn wrap_shell_command_splits_overlong_token_at_width() {
1052 assert_eq!(wrap_shell_command("abcdefghij", 4, 4), vec!["abcd", "efgh", "ij"]);
1053 }
1054
1055 #[test]
1056 fn wrap_shell_command_with_continuations_marks_wrapped_lines() {
1057 assert_eq!(wrap_shell_command_with_continuations("echo a b c d", 7, 7), vec!["echo a \\", "b c d"]);
1060 assert_eq!(wrap_shell_command_with_continuations("git status --short", 62, 58), vec!["git status --short"]);
1061 assert!(wrap_shell_command_with_continuations(" ", 5, 5).is_empty());
1062 let command = "git add a b && git commit -m \"msg\" && git status --short";
1065 let wrapped = wrap_shell_command_with_continuations(command, 20, 20);
1066 assert!(wrapped.len() > 1, "expected wrapping: {wrapped:?}");
1067 assert!(wrapped[..wrapped.len() - 1].iter().all(|line| line.ends_with(" \\")));
1068 assert!(!wrapped.last().unwrap().ends_with(" \\"));
1069 assert!(wrapped.iter().all(|line| !line.contains('…')));
1070 assert_eq!(wrap_shell_command_with_continuations("echo a && echo b", 10, 10), vec!["echo a && \\", "echo b"]);
1073 assert_eq!(wrap_shell_command_with_continuations("a | b | c", 7, 5), vec!["a | \\", "b | \\", "c"]);
1074 assert_eq!(
1075 wrap_shell_command_with_continuations("grep -rn \"a||b\" docs", 62, 58),
1076 vec!["grep -rn \"a||b\" docs"]
1077 );
1078 assert_eq!(wrap_shell_command_with_continuations("echo a && echo b", 62, 58), vec!["echo a && echo b"]);
1080 assert_eq!(wrap_shell_command_with_continuations("a | b | c", 62, 58), vec!["a | b | c"]);
1081 assert_eq!(wrap_shell_command_lines("echo a && echo b", 10, 10), vec!["echo a &&", "echo b"]);
1083 assert_eq!(wrap_shell_command_lines("git status", 62, 58), vec!["git status"]);
1084 }
1085
1086 #[test]
1087 fn wrap_shell_command_narrow_continuation_never_overflows() {
1088 let token = "b".repeat(60);
1092 let command = format!("aa {token} cc");
1093 let wrapped = wrap_shell_command(&command, 62, 58);
1094 assert!(wrapped.len() >= 3, "expected a split continuation: {wrapped:?}");
1095 assert!(wrapped[0].chars().count() <= 62, "first line budget: {wrapped:?}");
1096 for line in wrapped.iter().skip(1) {
1097 assert!(line.chars().count() <= 58, "continuation budget: {wrapped:?}");
1098 }
1099 let wrapped = wrap_shell_command("aa bbbbbbbb cc", 10, 5);
1101 assert_eq!(wrapped, vec!["aa", "bbbbb", "bbb", "cc"]);
1102 }
1103
1104 #[test]
1105 fn truncate_within_reserves_ellipsis_budget() {
1106 assert_eq!(truncate_within("hello world", 8, "..."), "hello...");
1108 assert_eq!(truncate_within("hi", 8, "..."), "hi");
1109 assert_eq!(truncate_within("abcdef", 4, "…"), "abc…");
1112 }
1113
1114 #[test]
1115 fn truncate_within_counts_chars() {
1116 let jp = "あいうえお"; assert_eq!(truncate_within(jp, 5, "…"), jp);
1118 assert_eq!(truncate_within(jp, 3, "…"), "あい…");
1119 }
1120
1121 #[test]
1122 fn head_tail_truncate_keeps_both_ends() {
1123 let value = "0123456789".repeat(10); let (out, truncated) = head_tail_truncate(&value, 40, " ... [truncated] ... ");
1125 assert!(truncated);
1126 assert!(out.chars().count() <= 40);
1127 assert!(out.starts_with("012"));
1128 assert!(out.contains("[truncated]"));
1129 assert!(out.ends_with('9'));
1130 }
1131
1132 #[test]
1133 fn head_tail_truncate_passes_through_when_short() {
1134 let (out, truncated) = head_tail_truncate("short", 64, " ... ");
1135 assert_eq!(out, "short");
1136 assert!(!truncated);
1137 }
1138
1139 #[test]
1140 fn head_tail_truncate_small_budget_falls_back_to_prefix() {
1141 let marker = " ... [truncated] ... ";
1142 let (out, truncated) = head_tail_truncate("abcdefghij", 5, marker);
1145 assert!(truncated);
1146 assert_eq!(out, "abcde");
1147
1148 let long_text = "abcdefghijklmnopqrstuvwxyz";
1151 let (out2, truncated2) = head_tail_truncate(long_text, 17, marker);
1152 assert!(truncated2);
1153 assert_eq!(out2, "abcde [truncated]");
1154 assert_eq!(out2.chars().count(), 17);
1155 }
1156
1157 #[test]
1158 fn truncate_text_counts_chars_not_bytes() {
1159 let jp = "あいうえお"; assert_eq!(truncate_text(jp, 3, "…"), "あいう…");
1161 assert_eq!(truncate_text(jp, 5, "…"), "あいうえお");
1162 }
1163
1164 #[test]
1165 fn truncate_middle_keeps_both_ends() {
1166 assert_eq!(truncate_middle("short", 80), "short");
1167 assert_eq!(truncate_middle("abcdefghij", 5), "ab…ij");
1168 assert_eq!(truncate_middle("a b c", 80), "a b c");
1169 assert_eq!(truncate_middle("abc", 0), "");
1171 assert_eq!(truncate_middle("abc", 1), "…");
1172 assert_eq!(truncate_middle("a\nb\tc", 80), "a b c");
1174 }
1175
1176 #[test]
1177 fn truncate_path_middle_breaks_at_separator() {
1178 assert_eq!(truncate_path_middle("src/lib.rs", 80), "src/lib.rs");
1179 assert_eq!(truncate_path_middle("foo/bar/baz/qux", 12), "foo…/qux");
1180 assert_eq!(truncate_path_middle("abc", 0), "");
1181 }
1182
1183 #[test]
1184 fn truncate_path_middle_counts_chars_not_bytes() {
1185 let out = truncate_path_middle("日本/日本語/日本語日本語/xyz", 6);
1187 assert!(out.chars().count() <= 6, "{out:?}");
1188 assert_eq!(truncate_path_middle("日本/日本語/xyz", 8), "日本…xyz");
1189 }
1190}
1191
1192#[cfg(test)]
1193mod generative_tests;