1use console::{measure_text_width, pad_str, Alignment};
7
8pub fn display_width(s: &str) -> usize {
26 measure_text_width(s)
27}
28
29pub fn truncate_end(s: &str, max_width: usize, ellipsis: &str) -> String {
51 let width = measure_text_width(s);
52 if width <= max_width {
53 return s.to_string();
54 }
55
56 let ellipsis_width = measure_text_width(ellipsis);
57 if max_width < ellipsis_width {
58 return truncate_to_display_width(ellipsis, max_width);
60 }
61 if max_width == ellipsis_width {
62 return ellipsis.to_string();
64 }
65
66 let target_width = max_width - ellipsis_width;
67 let mut result = truncate_to_display_width(s, target_width);
68 result.push_str(ellipsis);
69 result
70}
71
72pub fn truncate_start(s: &str, max_width: usize, ellipsis: &str) -> String {
89 let width = measure_text_width(s);
90 if width <= max_width {
91 return s.to_string();
92 }
93
94 let ellipsis_width = measure_text_width(ellipsis);
95 if max_width < ellipsis_width {
96 return truncate_to_display_width(ellipsis, max_width);
98 }
99 if max_width == ellipsis_width {
100 return ellipsis.to_string();
102 }
103
104 let target_width = max_width - ellipsis_width;
105 let truncated = find_suffix_with_width(s, target_width);
106 format!("{}{}", ellipsis, truncated)
107}
108
109pub fn truncate_middle(s: &str, max_width: usize, ellipsis: &str) -> String {
126 let width = measure_text_width(s);
127 if width <= max_width {
128 return s.to_string();
129 }
130
131 let ellipsis_width = measure_text_width(ellipsis);
132 if max_width < ellipsis_width {
133 return truncate_to_display_width(ellipsis, max_width);
135 }
136 if max_width == ellipsis_width {
137 return ellipsis.to_string();
139 }
140
141 let available = max_width - ellipsis_width;
142 let right_width = available.div_ceil(2); let left_width = available - right_width;
144
145 let left = truncate_to_display_width(s, left_width);
146 let right = find_suffix_with_width(s, right_width);
147
148 format!("{}{}{}", left, ellipsis, right)
149}
150
151pub fn pad_left(s: &str, width: usize) -> String {
164 pad_str(s, width, Alignment::Right, None).into_owned()
165}
166
167pub fn pad_right(s: &str, width: usize) -> String {
180 pad_str(s, width, Alignment::Left, None).into_owned()
181}
182
183pub fn pad_center(s: &str, width: usize) -> String {
197 pad_str(s, width, Alignment::Center, None).into_owned()
198}
199
200pub fn wrap(s: &str, width: usize) -> Vec<String> {
231 wrap_indent(s, width, 0)
232}
233
234pub fn wrap_indent(s: &str, width: usize, indent: usize) -> Vec<String> {
256 if width == 0 {
257 return vec![];
258 }
259
260 let s = s.trim();
261 if s.is_empty() {
262 return vec![];
263 }
264
265 if measure_text_width(s) <= width {
267 return vec![s.to_string()];
268 }
269
270 let mut lines = Vec::new();
271 let mut current_line = String::new();
272 let mut current_width = 0;
273 let mut is_first_line = true;
274
275 for word in s.split_whitespace() {
277 let word_width = measure_text_width(word);
278 let effective_width = if is_first_line {
279 width
280 } else {
281 width.saturating_sub(indent)
282 };
283
284 if word_width > effective_width {
286 if !current_line.is_empty() {
288 lines.push(current_line);
289 current_line = String::new();
290 current_width = 0;
291 is_first_line = false;
292 }
293
294 let broken = break_long_word(word, effective_width, indent, is_first_line);
296 let broken_len = broken.len();
297 for (i, part) in broken.into_iter().enumerate() {
298 if i == 0 && is_first_line {
299 lines.push(part);
300 is_first_line = false;
301 } else if i < broken_len - 1 {
302 lines.push(part);
304 } else {
305 current_line = part;
307 current_width = measure_text_width(¤t_line);
308 }
309 }
310 continue;
311 }
312
313 let needed_width = if current_line.is_empty() {
315 word_width
316 } else {
317 current_width + 1 + word_width };
319
320 if needed_width <= effective_width {
321 if !current_line.is_empty() {
323 current_line.push(' ');
324 current_width += 1;
325 }
326 current_line.push_str(word);
327 current_width += word_width;
328 } else {
329 if !current_line.is_empty() {
331 lines.push(current_line);
332 }
333 is_first_line = false;
334
335 let indent_str: String = " ".repeat(indent);
337 current_line = format!("{}{}", indent_str, word);
338 current_width = indent + word_width;
339 }
340 }
341
342 if !current_line.is_empty() {
344 lines.push(current_line);
345 }
346
347 if lines.is_empty() && !s.is_empty() {
349 lines.push(truncate_to_display_width(s, width));
350 }
351
352 lines
353}
354
355fn break_long_word(word: &str, width: usize, indent: usize, is_first: bool) -> Vec<String> {
357 let mut parts = Vec::new();
358 let mut remaining = word;
359 let mut first_part = is_first;
360
361 while !remaining.is_empty() {
362 let effective_width = if first_part {
363 width
364 } else {
365 width.saturating_sub(indent)
366 };
367
368 if effective_width == 0 {
369 break;
371 }
372
373 let remaining_width = measure_text_width(remaining);
374 if remaining_width <= effective_width {
375 let prefix = if first_part {
377 String::new()
378 } else {
379 " ".repeat(indent)
380 };
381 parts.push(format!("{}{}", prefix, remaining));
382 break;
383 }
384
385 let break_width = effective_width.saturating_sub(1); if break_width == 0 {
388 let prefix = if first_part {
390 String::new()
391 } else {
392 " ".repeat(indent)
393 };
394 parts.push(format!("{}…", prefix));
395 break;
396 }
397
398 let prefix = if first_part {
399 String::new()
400 } else {
401 " ".repeat(indent)
402 };
403 let truncated = truncate_to_display_width(remaining, break_width);
404 parts.push(format!("{}{}…", prefix, truncated));
405
406 let truncated_len = truncated.chars().count();
408 remaining = &remaining[remaining
409 .char_indices()
410 .nth(truncated_len)
411 .map(|(i, _)| i)
412 .unwrap_or(remaining.len())..];
413 first_part = false;
414 }
415
416 parts
417}
418
419fn truncate_to_display_width(s: &str, max_width: usize) -> String {
424 if max_width == 0 {
425 return String::new();
426 }
427
428 if measure_text_width(s) <= max_width {
430 return s.to_string();
431 }
432
433 let mut result = String::new();
436 let mut current_width = 0;
437 let chars = s.chars().peekable();
438 let mut in_escape = false;
439
440 for c in chars {
441 if c == '\x1b' {
442 result.push(c);
444 in_escape = true;
445 continue;
446 }
447
448 if in_escape {
449 result.push(c);
450 if c.is_ascii_alphabetic() || c == '~' {
452 in_escape = false;
453 }
454 continue;
455 }
456
457 let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
459 if current_width + char_width > max_width {
460 break;
461 }
462 result.push(c);
463 current_width += char_width;
464 }
465
466 result
467}
468
469fn find_suffix_with_width(s: &str, max_width: usize) -> String {
471 if max_width == 0 {
472 return String::new();
473 }
474
475 let total_width = measure_text_width(s);
476 if total_width <= max_width {
477 return s.to_string();
478 }
479
480 let skip_width = total_width - max_width;
483
484 let mut current_width = 0;
485 let mut byte_offset = 0;
486 let mut in_escape = false;
487
488 for (i, c) in s.char_indices() {
489 if c == '\x1b' {
490 in_escape = true;
491 byte_offset = i + c.len_utf8();
492 continue;
493 }
494
495 if in_escape {
496 byte_offset = i + c.len_utf8();
497 if c.is_ascii_alphabetic() || c == '~' {
498 in_escape = false;
499 }
500 continue;
501 }
502
503 let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
504 current_width += char_width;
505 byte_offset = i + c.len_utf8();
506
507 if current_width >= skip_width {
508 break;
509 }
510 }
511
512 s[byte_offset..].to_string()
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 #[test]
522 fn display_width_ascii() {
523 assert_eq!(display_width("hello"), 5);
524 assert_eq!(display_width(""), 0);
525 assert_eq!(display_width(" "), 1);
526 }
527
528 #[test]
529 fn display_width_ansi() {
530 assert_eq!(display_width("\x1b[31mred\x1b[0m"), 3);
531 assert_eq!(display_width("\x1b[1;32mbold green\x1b[0m"), 10);
532 assert_eq!(display_width("\x1b[38;5;196mcolor\x1b[0m"), 5);
533 }
534
535 #[test]
536 fn display_width_unicode() {
537 assert_eq!(display_width("日本語"), 6); assert_eq!(display_width("café"), 4);
539 assert_eq!(display_width("🎉"), 2); }
541
542 #[test]
545 fn truncate_end_no_truncation() {
546 assert_eq!(truncate_end("hello", 10, "…"), "hello");
547 assert_eq!(truncate_end("hello", 5, "…"), "hello");
548 }
549
550 #[test]
551 fn truncate_end_basic() {
552 assert_eq!(truncate_end("hello world", 8, "…"), "hello w…");
553 assert_eq!(truncate_end("hello world", 6, "…"), "hello…");
554 }
555
556 #[test]
557 fn truncate_end_multi_char_ellipsis() {
558 assert_eq!(truncate_end("hello world", 8, "..."), "hello...");
559 }
560
561 #[test]
562 fn truncate_end_exact_fit() {
563 assert_eq!(truncate_end("hello", 5, "…"), "hello");
564 }
565
566 #[test]
567 fn truncate_end_tiny_width() {
568 assert_eq!(truncate_end("hello", 1, "…"), "…");
569 assert_eq!(truncate_end("hello", 0, "…"), "");
570 }
571
572 #[test]
573 fn truncate_end_ansi() {
574 let styled = "\x1b[31mhello world\x1b[0m";
575 let result = truncate_end(styled, 8, "…");
576 assert_eq!(display_width(&result), 8);
577 assert!(result.contains("\x1b[31m")); }
579
580 #[test]
581 fn truncate_end_cjk() {
582 assert_eq!(truncate_end("日本語テスト", 7, "…"), "日本語…"); }
584
585 #[test]
588 fn truncate_start_no_truncation() {
589 assert_eq!(truncate_start("hello", 10, "…"), "hello");
590 }
591
592 #[test]
593 fn truncate_start_basic() {
594 assert_eq!(truncate_start("hello world", 8, "…"), "…o world");
595 }
596
597 #[test]
598 fn truncate_start_path() {
599 assert_eq!(truncate_start("/path/to/file.rs", 12, "…"), "…/to/file.rs");
600 }
601
602 #[test]
603 fn truncate_start_tiny_width() {
604 assert_eq!(truncate_start("hello", 1, "…"), "…");
605 assert_eq!(truncate_start("hello", 0, "…"), "");
606 }
607
608 #[test]
611 fn truncate_middle_no_truncation() {
612 assert_eq!(truncate_middle("hello", 10, "…"), "hello");
613 }
614
615 #[test]
616 fn truncate_middle_basic() {
617 assert_eq!(truncate_middle("hello world", 8, "…"), "hel…orld");
618 }
619
620 #[test]
621 fn truncate_middle_multi_char_ellipsis() {
622 assert_eq!(truncate_middle("abcdefghij", 7, "..."), "ab...ij");
623 }
624
625 #[test]
626 fn truncate_middle_tiny_width() {
627 assert_eq!(truncate_middle("hello", 1, "…"), "…");
628 assert_eq!(truncate_middle("hello", 0, "…"), "");
629 }
630
631 #[test]
632 fn truncate_middle_even_split() {
633 assert_eq!(truncate_middle("abcdefghij", 6, "…"), "ab…hij");
635 }
636
637 #[test]
640 fn pad_left_basic() {
641 assert_eq!(pad_left("42", 5), " 42");
642 assert_eq!(pad_left("hello", 10), " hello");
643 }
644
645 #[test]
646 fn pad_left_no_padding_needed() {
647 assert_eq!(pad_left("hello", 5), "hello");
648 assert_eq!(pad_left("hello", 3), "hello"); }
650
651 #[test]
652 fn pad_left_empty() {
653 assert_eq!(pad_left("", 5), " ");
654 }
655
656 #[test]
657 fn pad_left_ansi() {
658 let styled = "\x1b[31mhi\x1b[0m";
659 let result = pad_left(styled, 5);
660 assert!(result.ends_with("\x1b[0m"));
661 assert_eq!(display_width(&result), 5);
662 }
663
664 #[test]
667 fn pad_right_basic() {
668 assert_eq!(pad_right("42", 5), "42 ");
669 assert_eq!(pad_right("hello", 10), "hello ");
670 }
671
672 #[test]
673 fn pad_right_no_padding_needed() {
674 assert_eq!(pad_right("hello", 5), "hello");
675 assert_eq!(pad_right("hello", 3), "hello");
676 }
677
678 #[test]
679 fn pad_right_empty() {
680 assert_eq!(pad_right("", 5), " ");
681 }
682
683 #[test]
686 fn pad_center_basic() {
687 assert_eq!(pad_center("hi", 6), " hi ");
688 }
689
690 #[test]
691 fn pad_center_odd_space() {
692 assert_eq!(pad_center("hi", 5), " hi "); }
694
695 #[test]
696 fn pad_center_no_padding() {
697 assert_eq!(pad_center("hello", 5), "hello");
698 assert_eq!(pad_center("hello", 3), "hello");
699 }
700
701 #[test]
702 fn pad_center_empty() {
703 assert_eq!(pad_center("", 4), " ");
704 }
705
706 #[test]
709 fn empty_string_operations() {
710 assert_eq!(display_width(""), 0);
711 assert_eq!(truncate_end("", 5, "…"), "");
712 assert_eq!(truncate_start("", 5, "…"), "");
713 assert_eq!(truncate_middle("", 5, "…"), "");
714 assert_eq!(pad_left("", 0), "");
715 assert_eq!(pad_right("", 0), "");
716 }
717
718 #[test]
719 fn zero_width_target() {
720 assert_eq!(truncate_end("hello", 0, "…"), "");
721 assert_eq!(truncate_start("hello", 0, "…"), "");
722 assert_eq!(truncate_middle("hello", 0, "…"), "");
723 }
724
725 #[test]
728 fn wrap_single_line_fits() {
729 assert_eq!(wrap("hello world", 20), vec!["hello world"]);
730 assert_eq!(wrap("short", 10), vec!["short"]);
731 }
732
733 #[test]
734 fn wrap_basic_multiline() {
735 assert_eq!(wrap("hello world foo", 11), vec!["hello world", "foo"]);
736 assert_eq!(
737 wrap("one two three four", 10),
738 vec!["one two", "three four"]
739 );
740 }
741
742 #[test]
743 fn wrap_exact_fit() {
744 assert_eq!(wrap("hello", 5), vec!["hello"]);
745 assert_eq!(wrap("hello world", 11), vec!["hello world"]);
746 }
747
748 #[test]
749 fn wrap_empty_string() {
750 let result: Vec<String> = wrap("", 10);
751 assert!(result.is_empty());
752 }
753
754 #[test]
755 fn wrap_whitespace_only() {
756 let result: Vec<String> = wrap(" ", 10);
757 assert!(result.is_empty());
758 }
759
760 #[test]
761 fn wrap_zero_width() {
762 let result: Vec<String> = wrap("hello", 0);
763 assert!(result.is_empty());
764 }
765
766 #[test]
767 fn wrap_single_word_per_line() {
768 assert_eq!(wrap("a b c d", 1), vec!["a", "b", "c", "d"]);
769 }
770
771 #[test]
772 fn wrap_long_word_force_break() {
773 let result = wrap("supercalifragilistic", 10);
776 assert!(result.len() >= 2, "should produce multiple lines");
777 for line in &result {
778 assert!(display_width(line) <= 10, "line '{}' exceeds width", line);
779 }
780 }
781
782 #[test]
783 fn wrap_preserves_word_boundaries() {
784 let result = wrap("hello world test", 10);
785 assert_eq!(result[0], "hello");
787 assert_eq!(result[1], "world test");
788 }
789
790 #[test]
791 fn wrap_multiple_spaces_normalized_when_wrapping() {
792 let result = wrap("hello world foo", 12);
795 assert_eq!(result, vec!["hello world", "foo"]);
797 }
798
799 #[test]
802 fn wrap_indent_basic() {
803 let result = wrap_indent("hello world foo bar", 12, 2);
804 assert_eq!(result.len(), 2);
805 assert_eq!(result[0], "hello world");
806 assert!(result[1].starts_with(" ")); }
808
809 #[test]
810 fn wrap_indent_no_wrap_needed() {
811 assert_eq!(wrap_indent("short", 20, 4), vec!["short"]);
812 }
813
814 #[test]
815 fn wrap_indent_multiple_lines() {
816 let result = wrap_indent("one two three four five six", 10, 2);
817 assert!(!result[0].starts_with(' '));
820 for line in result.iter().skip(1) {
821 assert!(line.starts_with(" "), "continuation should be indented");
822 }
823 }
824
825 #[test]
826 fn wrap_indent_zero_indent() {
827 let result = wrap_indent("hello world foo", 11, 0);
829 assert_eq!(result, vec!["hello world", "foo"]);
830 }
831
832 #[test]
833 fn wrap_cjk_characters() {
834 let result = wrap("日本語 テスト", 8);
837 assert_eq!(result.len(), 2);
838 for line in &result {
839 assert!(display_width(line) <= 8);
840 }
841 }
842}
843
844#[cfg(test)]
845mod proptests {
846 use super::*;
847 use proptest::prelude::*;
848
849 proptest! {
850 #[test]
851 fn truncate_end_respects_max_width(
852 s in "[a-zA-Z0-9 ]{0,100}",
853 max_width in 0usize..50,
854 ) {
855 let result = truncate_end(&s, max_width, "…");
856 let result_width = display_width(&result);
857 prop_assert!(
858 result_width <= max_width,
859 "truncate_end exceeded max_width: result '{}' has width {}, max was {}",
860 result, result_width, max_width
861 );
862 }
863
864 #[test]
865 fn truncate_start_respects_max_width(
866 s in "[a-zA-Z0-9 ]{0,100}",
867 max_width in 0usize..50,
868 ) {
869 let result = truncate_start(&s, max_width, "…");
870 let result_width = display_width(&result);
871 prop_assert!(
872 result_width <= max_width,
873 "truncate_start exceeded max_width: result '{}' has width {}, max was {}",
874 result, result_width, max_width
875 );
876 }
877
878 #[test]
879 fn truncate_middle_respects_max_width(
880 s in "[a-zA-Z0-9 ]{0,100}",
881 max_width in 0usize..50,
882 ) {
883 let result = truncate_middle(&s, max_width, "…");
884 let result_width = display_width(&result);
885 prop_assert!(
886 result_width <= max_width,
887 "truncate_middle exceeded max_width: result '{}' has width {}, max was {}",
888 result, result_width, max_width
889 );
890 }
891
892 #[test]
893 fn truncate_preserves_short_strings(
894 s in "[a-zA-Z0-9]{0,20}",
895 extra_width in 0usize..30,
896 ) {
897 let width = display_width(&s);
898 let max_width = width + extra_width;
899
900 prop_assert_eq!(truncate_end(&s, max_width, "…"), s.clone());
902 prop_assert_eq!(truncate_start(&s, max_width, "…"), s.clone());
903 prop_assert_eq!(truncate_middle(&s, max_width, "…"), s);
904 }
905
906 #[test]
907 fn pad_produces_exact_width_when_larger(
908 s in "[a-zA-Z0-9]{0,20}",
909 extra in 1usize..30,
910 ) {
911 let original_width = display_width(&s);
912 let target_width = original_width + extra;
913
914 prop_assert_eq!(display_width(&pad_left(&s, target_width)), target_width);
915 prop_assert_eq!(display_width(&pad_right(&s, target_width)), target_width);
916 prop_assert_eq!(display_width(&pad_center(&s, target_width)), target_width);
917 }
918
919 #[test]
920 fn pad_preserves_content_when_smaller(
921 s in "[a-zA-Z0-9]{1,30}",
922 ) {
923 let original_width = display_width(&s);
924 let target_width = original_width.saturating_sub(5);
925
926 prop_assert_eq!(pad_left(&s, target_width), s.clone());
928 prop_assert_eq!(pad_right(&s, target_width), s.clone());
929 prop_assert_eq!(pad_center(&s, target_width), s);
930 }
931
932 #[test]
933 fn truncate_end_contains_ellipsis_when_truncated(
934 s in "[a-zA-Z0-9]{10,50}",
935 max_width in 3usize..9,
936 ) {
937 let result = truncate_end(&s, max_width, "…");
938 if display_width(&s) > max_width {
939 prop_assert!(
940 result.contains("…"),
941 "truncated string should contain ellipsis"
942 );
943 }
944 }
945
946 #[test]
947 fn truncate_start_contains_ellipsis_when_truncated(
948 s in "[a-zA-Z0-9]{10,50}",
949 max_width in 3usize..9,
950 ) {
951 let result = truncate_start(&s, max_width, "…");
952 if display_width(&s) > max_width {
953 prop_assert!(
954 result.contains("…"),
955 "truncated string should contain ellipsis"
956 );
957 }
958 }
959
960 #[test]
961 fn truncate_middle_contains_ellipsis_when_truncated(
962 s in "[a-zA-Z0-9]{10,50}",
963 max_width in 3usize..9,
964 ) {
965 let result = truncate_middle(&s, max_width, "…");
966 if display_width(&s) > max_width {
967 prop_assert!(
968 result.contains("…"),
969 "truncated string should contain ellipsis"
970 );
971 }
972 }
973
974 #[test]
975 fn wrap_all_lines_respect_width(
976 s in "[a-zA-Z]{1,10}( [a-zA-Z]{1,10}){0,10}",
977 width in 5usize..30,
978 ) {
979 let lines = wrap(&s, width);
980 for line in &lines {
981 let line_width = display_width(line);
982 prop_assert!(
983 line_width <= width,
984 "wrap produced line '{}' with width {}, max was {}",
985 line, line_width, width
986 );
987 }
988 }
989
990 #[test]
991 fn wrap_preserves_all_words(
992 words in prop::collection::vec("[a-zA-Z]{1,8}", 1..10),
993 width in 10usize..40,
994 ) {
995 let input = words.join(" ");
996 let lines = wrap(&input, width);
997 let rejoined = lines.join(" ");
998
999 for word in &words {
1001 prop_assert!(
1002 rejoined.contains(word),
1003 "word '{}' missing from wrapped output",
1004 word
1005 );
1006 }
1007 }
1008
1009 #[test]
1010 fn wrap_indent_continuation_lines_are_indented(
1011 s in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){3,8}",
1012 width in 10usize..20,
1013 indent in 1usize..4,
1014 ) {
1015 let lines = wrap_indent(&s, width, indent);
1016 if lines.len() > 1 {
1017 let indent_str: String = " ".repeat(indent);
1018 for line in lines.iter().skip(1) {
1019 prop_assert!(
1020 line.starts_with(&indent_str),
1021 "continuation line '{}' should start with {} spaces",
1022 line, indent
1023 );
1024 }
1025 }
1026 }
1027
1028 #[test]
1029 fn wrap_nonempty_input_produces_nonempty_output(
1030 s in "[a-zA-Z]{1,20}",
1031 width in 1usize..30,
1032 ) {
1033 let lines = wrap(&s, width);
1034 prop_assert!(
1035 !lines.is_empty(),
1036 "non-empty input '{}' should produce non-empty output",
1037 s
1038 );
1039 }
1040 }
1041}