1#![forbid(unsafe_code)]
2
3use unicode_segmentation::UnicodeSegmentation;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum WrapMode {
28 None,
30 #[default]
32 Word,
33 Char,
35 WordChar,
37}
38
39#[derive(Debug, Clone)]
41pub struct WrapOptions {
42 pub width: usize,
44 pub mode: WrapMode,
46 pub preserve_indent: bool,
48 pub trim_trailing: bool,
50}
51
52impl WrapOptions {
53 #[must_use]
55 pub fn new(width: usize) -> Self {
56 Self {
57 width,
58 mode: WrapMode::Word,
59 preserve_indent: false,
60 trim_trailing: true,
61 }
62 }
63
64 #[must_use]
66 pub fn mode(mut self, mode: WrapMode) -> Self {
67 self.mode = mode;
68 self
69 }
70
71 #[must_use]
73 pub fn preserve_indent(mut self, preserve: bool) -> Self {
74 self.preserve_indent = preserve;
75 self
76 }
77
78 #[must_use]
80 pub fn trim_trailing(mut self, trim: bool) -> Self {
81 self.trim_trailing = trim;
82 self
83 }
84}
85
86impl Default for WrapOptions {
87 fn default() -> Self {
88 Self::new(80)
89 }
90}
91
92#[must_use]
96pub fn wrap_text(text: &str, width: usize, mode: WrapMode) -> Vec<String> {
97 let preserve = mode == WrapMode::Char;
99 wrap_with_options(
100 text,
101 &WrapOptions::new(width).mode(mode).preserve_indent(preserve),
102 )
103}
104
105#[must_use]
107pub fn wrap_with_options(text: &str, options: &WrapOptions) -> Vec<String> {
108 if options.width == 0 {
109 return vec![text.to_string()];
110 }
111
112 match options.mode {
113 WrapMode::None => vec![text.to_string()],
114 WrapMode::Char => wrap_chars(text, options),
115 WrapMode::Word => wrap_words(text, options, false),
116 WrapMode::WordChar => wrap_words(text, options, true),
117 }
118}
119
120fn wrap_chars(text: &str, options: &WrapOptions) -> Vec<String> {
122 let mut lines = Vec::new();
123 let mut current_line = String::new();
124 let mut current_width = 0;
125
126 for grapheme in text.graphemes(true) {
127 if grapheme == "\n" || grapheme == "\r\n" {
129 lines.push(finalize_line(¤t_line, options));
130 current_line.clear();
131 current_width = 0;
132 continue;
133 }
134
135 let grapheme_width = crate::wrap::grapheme_width(grapheme);
136
137 if current_width + grapheme_width > options.width && !current_line.is_empty() {
139 lines.push(finalize_line(¤t_line, options));
140 current_line.clear();
141 current_width = 0;
142 }
143
144 current_line.push_str(grapheme);
146 current_width += grapheme_width;
147 }
148
149 lines.push(finalize_line(¤t_line, options));
152
153 lines
154}
155
156fn wrap_words(text: &str, options: &WrapOptions, char_fallback: bool) -> Vec<String> {
158 let mut lines = Vec::new();
159
160 for raw_paragraph in text.split('\n') {
162 let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
163 let mut current_line = String::new();
164 let mut current_width = 0;
165
166 let len_before = lines.len();
167
168 wrap_paragraph(
169 paragraph,
170 options,
171 char_fallback,
172 &mut lines,
173 &mut current_line,
174 &mut current_width,
175 );
176
177 if !current_line.is_empty() || lines.len() == len_before {
180 lines.push(finalize_line(¤t_line, options));
181 }
182 }
183
184 lines
185}
186
187fn wrap_paragraph(
189 text: &str,
190 options: &WrapOptions,
191 char_fallback: bool,
192 lines: &mut Vec<String>,
193 current_line: &mut String,
194 current_width: &mut usize,
195) {
196 for word in split_words(text) {
197 let word_width = display_width(&word);
198
199 if *current_width + word_width <= options.width {
201 current_line.push_str(&word);
202 *current_width += word_width;
203 continue;
204 }
205
206 if !current_line.is_empty() {
208 lines.push(finalize_line(current_line, options));
209 current_line.clear();
210 *current_width = 0;
211 }
212
213 if word_width > options.width {
215 if char_fallback {
216 wrap_long_word(&word, options, lines, current_line, current_width);
218 } else {
219 lines.push(finalize_line(&word, options));
221 }
222 } else {
223 let (fragment, fragment_width) = if options.preserve_indent {
225 (word.as_str(), word_width)
226 } else {
227 let trimmed = word.trim_start();
228 (trimmed, display_width(trimmed))
229 };
230 if !fragment.is_empty() {
231 current_line.push_str(fragment);
232 }
233 *current_width = fragment_width;
234 }
235 }
236}
237
238fn wrap_long_word(
240 word: &str,
241 options: &WrapOptions,
242 lines: &mut Vec<String>,
243 current_line: &mut String,
244 current_width: &mut usize,
245) {
246 for grapheme in word.graphemes(true) {
247 let grapheme_width = crate::wrap::grapheme_width(grapheme);
248
249 if *current_width == 0 && grapheme.trim().is_empty() && !options.preserve_indent {
251 continue;
252 }
253
254 if *current_width + grapheme_width > options.width && !current_line.is_empty() {
255 lines.push(finalize_line(current_line, options));
256 current_line.clear();
257 *current_width = 0;
258
259 if grapheme.trim().is_empty() && !options.preserve_indent {
261 continue;
262 }
263 }
264
265 current_line.push_str(grapheme);
266 *current_width += grapheme_width;
267 }
268}
269
270fn split_words(text: &str) -> Vec<String> {
275 let mut words = Vec::new();
276 let mut current = String::new();
277 let mut in_whitespace = false;
278
279 for grapheme in text.graphemes(true) {
280 let is_ws = grapheme.chars().all(|c| c.is_whitespace());
281
282 if is_ws != in_whitespace && !current.is_empty() {
283 words.push(std::mem::take(&mut current));
284 }
285
286 current.push_str(grapheme);
287 in_whitespace = is_ws;
288 }
289
290 if !current.is_empty() {
291 words.push(current);
292 }
293
294 words
295}
296
297fn finalize_line(line: &str, options: &WrapOptions) -> String {
299 let mut result = if options.trim_trailing {
300 line.trim_end().to_string()
301 } else {
302 line.to_string()
303 };
304
305 if !options.preserve_indent {
306 let trimmed = result.trim_start();
326 if trimmed.len() != result.len() {
327 result = trimmed.to_string();
328 }
329 }
330
331 result
332}
333
334#[must_use]
339pub fn truncate_with_ellipsis(text: &str, max_width: usize, ellipsis: &str) -> String {
340 let text_width = display_width(text);
341
342 if text_width <= max_width {
343 return text.to_string();
344 }
345
346 let ellipsis_width = display_width(ellipsis);
347
348 if ellipsis_width >= max_width {
350 return truncate_to_width(text, max_width);
351 }
352
353 let target_width = max_width - ellipsis_width;
354 let mut result = truncate_to_width(text, target_width);
355 result.push_str(ellipsis);
356 result
357}
358
359#[must_use]
363pub fn truncate_to_width(text: &str, max_width: usize) -> String {
364 let mut result = String::new();
365 let mut current_width = 0;
366
367 for grapheme in text.graphemes(true) {
368 let grapheme_width = crate::wrap::grapheme_width(grapheme);
369
370 if current_width + grapheme_width > max_width {
371 break;
372 }
373
374 result.push_str(grapheme);
375 current_width += grapheme_width;
376 }
377
378 result
379}
380
381#[inline]
400#[must_use]
401pub fn ascii_width(text: &str) -> Option<usize> {
402 ftui_core::text_width::ascii_width(text)
403}
404
405#[inline]
413#[must_use]
414pub fn grapheme_width(grapheme: &str) -> usize {
415 ftui_core::text_width::grapheme_width(grapheme)
416}
417
418#[inline]
429#[must_use]
430pub fn display_width(text: &str) -> usize {
431 ftui_core::text_width::display_width(text)
432}
433
434#[must_use]
436pub fn has_wide_chars(text: &str) -> bool {
437 text.graphemes(true)
438 .any(|g| crate::wrap::grapheme_width(g) > 1)
439}
440
441#[must_use]
443pub fn is_ascii_only(text: &str) -> bool {
444 text.is_ascii()
445}
446
447#[inline]
465#[must_use]
466pub fn grapheme_count(text: &str) -> usize {
467 text.graphemes(true).count()
468}
469
470#[inline]
483pub fn graphemes(text: &str) -> impl Iterator<Item = &str> {
484 text.graphemes(true)
485}
486
487#[must_use]
510pub fn truncate_to_width_with_info(text: &str, max_width: usize) -> (&str, usize) {
511 let mut byte_end = 0;
512 let mut current_width = 0;
513
514 for grapheme in text.graphemes(true) {
515 let grapheme_width = crate::wrap::grapheme_width(grapheme);
516
517 if current_width + grapheme_width > max_width {
518 break;
519 }
520
521 current_width += grapheme_width;
522 byte_end += grapheme.len();
523 }
524
525 (&text[..byte_end], current_width)
526}
527
528pub fn word_boundaries(text: &str) -> impl Iterator<Item = usize> + '_ {
543 text.split_word_bound_indices().filter_map(|(idx, word)| {
544 if word.chars().all(|c| c.is_whitespace()) {
546 Some(idx + word.len())
547 } else {
548 None
549 }
550 })
551}
552
553pub fn word_segments(text: &str) -> impl Iterator<Item = &str> {
566 text.split_word_bounds()
567}
568
569const BADNESS_SCALE: u64 = 10_000;
609
610const BADNESS_INF: u64 = u64::MAX / 2;
612
613const PENALTY_FORCE_BREAK: u64 = 5000;
615
616const KP_MAX_LOOKAHEAD: usize = 64;
620
621#[inline]
630fn knuth_plass_badness(slack: i64, width: usize, is_last_line: bool) -> u64 {
631 if slack < 0 {
632 return BADNESS_INF;
633 }
634 if is_last_line {
635 return 0;
636 }
637 if width == 0 {
638 return if slack == 0 { 0 } else { BADNESS_INF };
639 }
640 let s = slack as u64;
644 let w = width as u64;
645 let s3 = s.saturating_mul(s).saturating_mul(s);
647 let w3 = w.saturating_mul(w).saturating_mul(w);
648 if w3 == 0 {
649 return BADNESS_INF;
650 }
651 s3.saturating_mul(BADNESS_SCALE) / w3
652}
653
654#[derive(Debug, Clone)]
656struct KpWord {
657 text: String,
659 content_width: usize,
661 space_width: usize,
663}
664
665fn kp_tokenize(text: &str) -> Vec<KpWord> {
667 let mut words = Vec::new();
668 let raw_segments: Vec<&str> = text.split_word_bounds().collect();
669
670 let mut i = 0;
671 while i < raw_segments.len() {
672 let seg = raw_segments[i];
673 if seg.chars().all(|c| c.is_whitespace()) {
674 if let Some(last) = words.last_mut() {
676 let w: &mut KpWord = last;
677 w.text.push_str(seg);
678 w.space_width += display_width(seg);
679 } else {
680 words.push(KpWord {
682 text: seg.to_string(),
683 content_width: 0,
684 space_width: display_width(seg),
685 });
686 }
687 i += 1;
688 } else {
689 let content_width = display_width(seg);
690 words.push(KpWord {
691 text: seg.to_string(),
692 content_width,
693 space_width: 0,
694 });
695 i += 1;
696 }
697 }
698
699 words
700}
701
702#[derive(Debug, Clone)]
704pub struct KpBreakResult {
705 pub lines: Vec<String>,
707 pub total_cost: u64,
709 pub line_badness: Vec<u64>,
711}
712
713pub fn wrap_optimal(text: &str, width: usize) -> KpBreakResult {
728 if width == 0 || text.is_empty() {
729 return KpBreakResult {
730 lines: vec![text.to_string()],
731 total_cost: 0,
732 line_badness: vec![0],
733 };
734 }
735
736 let words = kp_tokenize(text);
737 if words.is_empty() {
738 return KpBreakResult {
739 lines: vec![text.to_string()],
740 total_cost: 0,
741 line_badness: vec![0],
742 };
743 }
744
745 let n = words.len();
746
747 let mut cost = vec![BADNESS_INF; n + 1];
750 let mut from = vec![0usize; n + 1];
751 cost[0] = 0;
752
753 for j in 1..=n {
754 let mut line_width: usize = 0;
755 let earliest = j.saturating_sub(KP_MAX_LOOKAHEAD);
758 for i in (earliest..j).rev() {
759 line_width += words[i].content_width;
761 if i < j - 1 {
762 line_width += words[i].space_width;
764 }
765
766 if line_width > width && i < j - 1 {
768 break;
770 }
771
772 let slack = width as i64 - line_width as i64;
773 let is_last = j == n;
774 let badness = if line_width > width {
775 PENALTY_FORCE_BREAK
777 } else {
778 knuth_plass_badness(slack, width, is_last)
779 };
780
781 let candidate = cost[i].saturating_add(badness);
782 if candidate < cost[j] || (candidate == cost[j] && i > from[j]) {
784 cost[j] = candidate;
785 from[j] = i;
786 }
787 }
788 }
789
790 let mut breaks = Vec::new();
792 let mut pos = n;
793 while pos > 0 {
794 breaks.push(from[pos]);
795 pos = from[pos];
796 }
797 breaks.reverse();
798
799 let mut lines = Vec::new();
801 let mut line_badness = Vec::new();
802 let break_count = breaks.len();
803
804 for (idx, &start) in breaks.iter().enumerate() {
805 let end = if idx + 1 < break_count {
806 breaks[idx + 1]
807 } else {
808 n
809 };
810
811 let mut line = String::new();
813 for word in words.iter().take(end).skip(start) {
814 line.push_str(&word.text);
815 }
816
817 let trimmed = line.trim_end().to_string();
819
820 let line_w = display_width(trimmed.as_str());
822 let slack = width as i64 - line_w as i64;
823 let is_last = idx == break_count - 1;
824 let bad = if slack < 0 {
825 PENALTY_FORCE_BREAK
826 } else {
827 knuth_plass_badness(slack, width, is_last)
828 };
829
830 lines.push(trimmed);
831 line_badness.push(bad);
832 }
833
834 KpBreakResult {
835 lines,
836 total_cost: cost[n],
837 line_badness,
838 }
839}
840
841#[must_use]
845pub fn wrap_text_optimal(text: &str, width: usize) -> Vec<String> {
846 let mut result = Vec::new();
847 for raw_paragraph in text.split('\n') {
848 let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
849 if paragraph.is_empty() {
850 result.push(String::new());
851 continue;
852 }
853 let kp = wrap_optimal(paragraph, width);
854 result.extend(kp.lines);
855 }
856 result
857}
858
859#[cfg(test)]
860trait TestWidth {
861 fn width(&self) -> usize;
862}
863
864#[cfg(test)]
865impl TestWidth for str {
866 fn width(&self) -> usize {
867 display_width(self)
868 }
869}
870
871#[cfg(test)]
872impl TestWidth for String {
873 fn width(&self) -> usize {
874 display_width(self)
875 }
876}
877
878#[cfg(test)]
879mod tests {
880 use super::TestWidth;
881 use super::*;
882
883 #[test]
888 fn wrap_text_no_wrap_needed() {
889 let lines = wrap_text("hello", 10, WrapMode::Word);
890 assert_eq!(lines, vec!["hello"]);
891 }
892
893 #[test]
894 fn wrap_text_single_word_wrap() {
895 let lines = wrap_text("hello world", 5, WrapMode::Word);
896 assert_eq!(lines, vec!["hello", "world"]);
897 }
898
899 #[test]
900 fn wrap_text_multiple_words() {
901 let lines = wrap_text("hello world foo bar", 11, WrapMode::Word);
902 assert_eq!(lines, vec!["hello world", "foo bar"]);
903 }
904
905 #[test]
906 fn wrap_text_preserves_newlines() {
907 let lines = wrap_text("line1\nline2", 20, WrapMode::Word);
908 assert_eq!(lines, vec!["line1", "line2"]);
909 }
910
911 #[test]
912 fn wrap_text_preserves_crlf_newlines() {
913 let lines = wrap_text("line1\r\nline2\r\n", 20, WrapMode::Word);
914 assert_eq!(lines, vec!["line1", "line2", ""]);
915 }
916
917 #[test]
918 fn wrap_text_trailing_newlines() {
919 let lines = wrap_text("line1\n", 20, WrapMode::Word);
921 assert_eq!(lines, vec!["line1", ""]);
922
923 let lines = wrap_text("\n", 20, WrapMode::Word);
925 assert_eq!(lines, vec!["", ""]);
926
927 let lines = wrap_text("line1\n", 20, WrapMode::Char);
929 assert_eq!(lines, vec!["line1", ""]);
930 }
931
932 #[test]
933 fn wrap_text_empty_string() {
934 let lines = wrap_text("", 10, WrapMode::Word);
935 assert_eq!(lines, vec![""]);
936 }
937
938 #[test]
939 fn wrap_text_long_word_no_fallback() {
940 let lines = wrap_text("supercalifragilistic", 10, WrapMode::Word);
941 assert_eq!(lines, vec!["supercalifragilistic"]);
943 }
944
945 #[test]
946 fn wrap_text_long_word_with_fallback() {
947 let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
948 assert!(lines.len() > 1);
950 for line in &lines {
951 assert!(line.width() <= 10);
952 }
953 }
954
955 #[test]
956 fn wrap_char_mode() {
957 let lines = wrap_text("hello world", 5, WrapMode::Char);
958 assert_eq!(lines, vec!["hello", " worl", "d"]);
959 }
960
961 #[test]
962 fn wrap_none_mode() {
963 let lines = wrap_text("hello world", 5, WrapMode::None);
964 assert_eq!(lines, vec!["hello world"]);
965 }
966
967 #[test]
972 fn wrap_cjk_respects_width() {
973 let lines = wrap_text("你好世界", 4, WrapMode::Char);
975 assert_eq!(lines, vec!["你好", "世界"]);
976 }
977
978 #[test]
979 fn wrap_cjk_odd_width() {
980 let lines = wrap_text("你好世", 5, WrapMode::Char);
982 assert_eq!(lines, vec!["你好", "世"]);
983 }
984
985 #[test]
986 fn wrap_mixed_ascii_cjk() {
987 let lines = wrap_text("hi你好", 4, WrapMode::Char);
988 assert_eq!(lines, vec!["hi你", "好"]);
989 }
990
991 #[test]
996 fn wrap_emoji_as_unit() {
997 let lines = wrap_text("😀😀😀", 4, WrapMode::Char);
999 assert_eq!(lines.len(), 2);
1001 for line in &lines {
1002 assert!(!line.contains("\\u"));
1004 }
1005 }
1006
1007 #[test]
1008 fn wrap_zwj_sequence_as_unit() {
1009 let text = "👨👩👧";
1011 let lines = wrap_text(text, 2, WrapMode::Char);
1012 assert!(lines.iter().any(|l| l.contains("👨👩👧")));
1015 }
1016
1017 #[test]
1018 fn wrap_mixed_ascii_and_emoji_respects_width() {
1019 let lines = wrap_text("a😀b", 3, WrapMode::Char);
1020 assert_eq!(lines, vec!["a😀", "b"]);
1021 }
1022
1023 #[test]
1028 fn truncate_no_change_if_fits() {
1029 let result = truncate_with_ellipsis("hello", 10, "...");
1030 assert_eq!(result, "hello");
1031 }
1032
1033 #[test]
1034 fn truncate_with_ellipsis_ascii() {
1035 let result = truncate_with_ellipsis("hello world", 8, "...");
1036 assert_eq!(result, "hello...");
1037 }
1038
1039 #[test]
1040 fn truncate_cjk() {
1041 let result = truncate_with_ellipsis("你好世界", 6, "...");
1042 assert_eq!(result, "你...");
1045 }
1046
1047 #[test]
1048 fn truncate_to_width_basic() {
1049 let result = truncate_to_width("hello world", 5);
1050 assert_eq!(result, "hello");
1051 }
1052
1053 #[test]
1054 fn truncate_to_width_cjk() {
1055 let result = truncate_to_width("你好世界", 4);
1056 assert_eq!(result, "你好");
1057 }
1058
1059 #[test]
1060 fn truncate_to_width_odd_boundary() {
1061 let result = truncate_to_width("你好", 3);
1063 assert_eq!(result, "你");
1064 }
1065
1066 #[test]
1067 fn truncate_combining_chars() {
1068 let text = "e\u{0301}test";
1070 let result = truncate_to_width(text, 2);
1071 assert_eq!(result.chars().count(), 3); }
1074
1075 #[test]
1080 fn display_width_ascii() {
1081 assert_eq!(display_width("hello"), 5);
1082 }
1083
1084 #[test]
1085 fn display_width_cjk() {
1086 assert_eq!(display_width("你好"), 4);
1087 }
1088
1089 #[test]
1090 fn display_width_emoji_sequences() {
1091 assert_eq!(display_width("👩🔬"), 2);
1092 assert_eq!(display_width("👨👩👧👦"), 2);
1093 assert_eq!(display_width("👩🚀x"), 3);
1094 }
1095
1096 #[test]
1097 fn display_width_misc_symbol_emoji() {
1098 assert_eq!(display_width("⏳"), 2);
1099 assert_eq!(display_width("⌛"), 2);
1100 }
1101
1102 #[test]
1103 fn display_width_emoji_presentation_selector() {
1104 assert_eq!(display_width("❤️"), 2);
1105 assert_eq!(display_width("⌨️"), 2);
1106 assert_eq!(display_width("⚠️"), 2);
1107 }
1108
1109 #[test]
1110 fn display_width_misc_symbol_ranges() {
1111 assert_eq!(display_width("⌚"), 2); assert_eq!(display_width("⭐"), 2); let airplane_width = display_width("✈"); let arrow_width = display_width("⬆"); assert!(
1119 [1, 2].contains(&airplane_width),
1120 "airplane should be 1 (non-CJK) or 2 (CJK), got {airplane_width}"
1121 );
1122 assert_eq!(
1123 airplane_width, arrow_width,
1124 "both Neutral-width chars should have same width in any mode"
1125 );
1126 }
1127
1128 #[test]
1129 fn display_width_flags() {
1130 assert_eq!(display_width("🇺🇸"), 2);
1131 assert_eq!(display_width("🇯🇵"), 2);
1132 assert_eq!(display_width("🇺🇸🇯🇵"), 4);
1133 }
1134
1135 #[test]
1136 fn display_width_skin_tone_modifiers() {
1137 assert_eq!(display_width("👍🏻"), 2);
1138 assert_eq!(display_width("👍🏽"), 2);
1139 }
1140
1141 #[test]
1142 fn display_width_zwj_sequences() {
1143 assert_eq!(display_width("👩💻"), 2);
1144 assert_eq!(display_width("👨👩👧👦"), 2);
1145 }
1146
1147 #[test]
1148 fn display_width_mixed_ascii_and_emoji() {
1149 assert_eq!(display_width("A😀B"), 4);
1150 assert_eq!(display_width("A👩💻B"), 4);
1151 assert_eq!(display_width("ok ✅"), 5);
1152 }
1153
1154 #[test]
1155 fn display_width_file_icons() {
1156 let icons = [
1157 "📁", "🔗", "🦀", "🐍", "📜", "📝", "⚙️", "🖼️", "🎵", "🎬", "⚡️", "📄",
1158 ];
1159 for icon in icons {
1160 assert_eq!(display_width(icon), 2, "icon width mismatch: {icon}");
1161 }
1162 }
1163
1164 #[test]
1165 fn grapheme_width_emoji_sequence() {
1166 assert_eq!(grapheme_width("👩🔬"), 2);
1167 }
1168
1169 #[test]
1170 fn grapheme_width_flags_and_modifiers() {
1171 assert_eq!(grapheme_width("🇺🇸"), 2);
1172 assert_eq!(grapheme_width("👍🏽"), 2);
1173 }
1174
1175 #[test]
1176 fn display_width_empty() {
1177 assert_eq!(display_width(""), 0);
1178 }
1179
1180 #[test]
1185 fn ascii_width_pure_ascii() {
1186 assert_eq!(ascii_width("hello"), Some(5));
1187 assert_eq!(ascii_width("hello world 123"), Some(15));
1188 }
1189
1190 #[test]
1191 fn ascii_width_empty() {
1192 assert_eq!(ascii_width(""), Some(0));
1193 }
1194
1195 #[test]
1196 fn ascii_width_non_ascii_returns_none() {
1197 assert_eq!(ascii_width("你好"), None);
1198 assert_eq!(ascii_width("héllo"), None);
1199 assert_eq!(ascii_width("hello😀"), None);
1200 }
1201
1202 #[test]
1203 fn ascii_width_mixed_returns_none() {
1204 assert_eq!(ascii_width("hi你好"), None);
1205 assert_eq!(ascii_width("caf\u{00e9}"), None); }
1207
1208 #[test]
1209 fn ascii_width_control_chars_returns_none() {
1210 assert_eq!(ascii_width("\t"), None); assert_eq!(ascii_width("\n"), None); assert_eq!(ascii_width("\r"), None); assert_eq!(ascii_width("\0"), None); assert_eq!(ascii_width("\x7F"), None); assert_eq!(ascii_width("hello\tworld"), None); assert_eq!(ascii_width("line1\nline2"), None); }
1219
1220 #[test]
1221 fn display_width_uses_ascii_fast_path() {
1222 assert_eq!(display_width("test"), 4);
1224 assert_eq!(display_width("你"), 2);
1226 }
1227
1228 #[test]
1229 fn has_wide_chars_true() {
1230 assert!(has_wide_chars("hi你好"));
1231 }
1232
1233 #[test]
1234 fn has_wide_chars_false() {
1235 assert!(!has_wide_chars("hello"));
1236 }
1237
1238 #[test]
1239 fn is_ascii_only_true() {
1240 assert!(is_ascii_only("hello world 123"));
1241 }
1242
1243 #[test]
1244 fn is_ascii_only_false() {
1245 assert!(!is_ascii_only("héllo"));
1246 }
1247
1248 #[test]
1253 fn grapheme_count_ascii() {
1254 assert_eq!(grapheme_count("hello"), 5);
1255 assert_eq!(grapheme_count(""), 0);
1256 }
1257
1258 #[test]
1259 fn grapheme_count_combining() {
1260 assert_eq!(grapheme_count("e\u{0301}"), 1);
1262 assert_eq!(grapheme_count("e\u{0301}\u{0308}"), 1);
1264 }
1265
1266 #[test]
1267 fn grapheme_count_cjk() {
1268 assert_eq!(grapheme_count("你好"), 2);
1269 }
1270
1271 #[test]
1272 fn grapheme_count_emoji() {
1273 assert_eq!(grapheme_count("😀"), 1);
1274 assert_eq!(grapheme_count("👍🏻"), 1);
1276 }
1277
1278 #[test]
1279 fn grapheme_count_zwj() {
1280 assert_eq!(grapheme_count("👨👩👧"), 1);
1282 }
1283
1284 #[test]
1285 fn graphemes_iteration() {
1286 let gs: Vec<&str> = graphemes("e\u{0301}bc").collect();
1287 assert_eq!(gs, vec!["e\u{0301}", "b", "c"]);
1288 }
1289
1290 #[test]
1291 fn graphemes_empty() {
1292 let gs: Vec<&str> = graphemes("").collect();
1293 assert!(gs.is_empty());
1294 }
1295
1296 #[test]
1297 fn graphemes_cjk() {
1298 let gs: Vec<&str> = graphemes("你好").collect();
1299 assert_eq!(gs, vec!["你", "好"]);
1300 }
1301
1302 #[test]
1303 fn truncate_to_width_with_info_basic() {
1304 let (text, width) = truncate_to_width_with_info("hello world", 5);
1305 assert_eq!(text, "hello");
1306 assert_eq!(width, 5);
1307 }
1308
1309 #[test]
1310 fn truncate_to_width_with_info_cjk() {
1311 let (text, width) = truncate_to_width_with_info("你好世界", 3);
1312 assert_eq!(text, "你");
1313 assert_eq!(width, 2);
1314 }
1315
1316 #[test]
1317 fn truncate_to_width_with_info_combining() {
1318 let (text, width) = truncate_to_width_with_info("e\u{0301}bc", 2);
1319 assert_eq!(text, "e\u{0301}b");
1320 assert_eq!(width, 2);
1321 }
1322
1323 #[test]
1324 fn truncate_to_width_with_info_fits() {
1325 let (text, width) = truncate_to_width_with_info("hi", 10);
1326 assert_eq!(text, "hi");
1327 assert_eq!(width, 2);
1328 }
1329
1330 #[test]
1331 fn word_boundaries_basic() {
1332 let breaks: Vec<usize> = word_boundaries("hello world").collect();
1333 assert!(breaks.contains(&6)); }
1335
1336 #[test]
1337 fn word_boundaries_multiple_spaces() {
1338 let breaks: Vec<usize> = word_boundaries("a b").collect();
1339 assert!(breaks.contains(&3)); }
1341
1342 #[test]
1343 fn word_segments_basic() {
1344 let segs: Vec<&str> = word_segments("hello world").collect();
1345 assert!(segs.contains(&"hello"));
1347 assert!(segs.contains(&"world"));
1348 }
1349
1350 #[test]
1355 fn wrap_options_builder() {
1356 let opts = WrapOptions::new(40)
1357 .mode(WrapMode::Char)
1358 .preserve_indent(true)
1359 .trim_trailing(false);
1360
1361 assert_eq!(opts.width, 40);
1362 assert_eq!(opts.mode, WrapMode::Char);
1363 assert!(opts.preserve_indent);
1364 assert!(!opts.trim_trailing);
1365 }
1366
1367 #[test]
1368 fn wrap_options_trim_trailing() {
1369 let opts = WrapOptions::new(10).trim_trailing(true);
1370 let lines = wrap_with_options("hello world", &opts);
1371 assert!(!lines.iter().any(|l| l.ends_with(' ')));
1373 }
1374
1375 #[test]
1376 fn wrap_preserve_indent_keeps_leading_ws_on_new_line() {
1377 let opts = WrapOptions::new(7)
1378 .mode(WrapMode::Word)
1379 .preserve_indent(true);
1380 let lines = wrap_with_options("word12 abcde", &opts);
1381 assert_eq!(lines, vec!["word12", " abcde"]);
1382 }
1383
1384 #[test]
1385 fn wrap_no_preserve_indent_trims_leading_ws_on_new_line() {
1386 let opts = WrapOptions::new(7)
1387 .mode(WrapMode::Word)
1388 .preserve_indent(false);
1389 let lines = wrap_with_options("word12 abcde", &opts);
1390 assert_eq!(lines, vec!["word12", "abcde"]);
1391 }
1392
1393 #[test]
1394 fn wrap_zero_width() {
1395 let lines = wrap_text("hello", 0, WrapMode::Word);
1396 assert_eq!(lines, vec!["hello"]);
1398 }
1399
1400 #[test]
1405 fn wrap_mode_default() {
1406 let mode = WrapMode::default();
1407 assert_eq!(mode, WrapMode::Word);
1408 }
1409
1410 #[test]
1411 fn wrap_options_default() {
1412 let opts = WrapOptions::default();
1413 assert_eq!(opts.width, 80);
1414 assert_eq!(opts.mode, WrapMode::Word);
1415 assert!(!opts.preserve_indent);
1416 assert!(opts.trim_trailing);
1417 }
1418
1419 #[test]
1420 fn display_width_emoji_skin_tone() {
1421 let width = display_width("👍🏻");
1422 assert_eq!(width, 2);
1423 }
1424
1425 #[test]
1426 fn display_width_flag_emoji() {
1427 let width = display_width("🇺🇸");
1428 assert_eq!(width, 2);
1429 }
1430
1431 #[test]
1432 fn display_width_zwj_family() {
1433 let width = display_width("👨👩👧");
1434 assert_eq!(width, 2);
1435 }
1436
1437 #[test]
1438 fn display_width_multiple_combining() {
1439 let width = display_width("e\u{0301}\u{0308}");
1441 assert_eq!(width, 1);
1442 }
1443
1444 #[test]
1445 fn ascii_width_printable_range() {
1446 let printable: String = (0x20u8..=0x7Eu8).map(|b| b as char).collect();
1448 assert_eq!(ascii_width(&printable), Some(printable.len()));
1449 }
1450
1451 #[test]
1452 fn ascii_width_newline_returns_none() {
1453 assert!(ascii_width("hello\nworld").is_none());
1455 }
1456
1457 #[test]
1458 fn ascii_width_tab_returns_none() {
1459 assert!(ascii_width("hello\tworld").is_none());
1461 }
1462
1463 #[test]
1464 fn ascii_width_del_returns_none() {
1465 assert!(ascii_width("hello\x7Fworld").is_none());
1467 }
1468
1469 #[test]
1470 fn has_wide_chars_cjk_mixed() {
1471 assert!(has_wide_chars("abc你def"));
1472 assert!(has_wide_chars("你"));
1473 assert!(!has_wide_chars("abc"));
1474 }
1475
1476 #[test]
1477 fn has_wide_chars_emoji() {
1478 assert!(has_wide_chars("😀"));
1479 assert!(has_wide_chars("hello😀"));
1480 }
1481
1482 #[test]
1483 fn grapheme_count_empty() {
1484 assert_eq!(grapheme_count(""), 0);
1485 }
1486
1487 #[test]
1488 fn grapheme_count_regional_indicators() {
1489 assert_eq!(grapheme_count("🇺🇸"), 1);
1491 }
1492
1493 #[test]
1494 fn word_boundaries_no_spaces() {
1495 let breaks: Vec<usize> = word_boundaries("helloworld").collect();
1496 assert!(breaks.is_empty());
1497 }
1498
1499 #[test]
1500 fn word_boundaries_only_spaces() {
1501 let breaks: Vec<usize> = word_boundaries(" ").collect();
1502 assert!(!breaks.is_empty());
1503 }
1504
1505 #[test]
1506 fn word_segments_empty() {
1507 let segs: Vec<&str> = word_segments("").collect();
1508 assert!(segs.is_empty());
1509 }
1510
1511 #[test]
1512 fn word_segments_single_word() {
1513 let segs: Vec<&str> = word_segments("hello").collect();
1514 assert_eq!(segs.len(), 1);
1515 assert_eq!(segs[0], "hello");
1516 }
1517
1518 #[test]
1519 fn truncate_to_width_empty() {
1520 let result = truncate_to_width("", 10);
1521 assert_eq!(result, "");
1522 }
1523
1524 #[test]
1525 fn truncate_to_width_zero_width() {
1526 let result = truncate_to_width("hello", 0);
1527 assert_eq!(result, "");
1528 }
1529
1530 #[test]
1531 fn truncate_with_ellipsis_exact_fit() {
1532 let result = truncate_with_ellipsis("hello", 5, "...");
1534 assert_eq!(result, "hello");
1535 }
1536
1537 #[test]
1538 fn truncate_with_ellipsis_empty_ellipsis() {
1539 let result = truncate_with_ellipsis("hello world", 5, "");
1540 assert_eq!(result, "hello");
1541 }
1542
1543 #[test]
1544 fn truncate_to_width_with_info_empty() {
1545 let (text, width) = truncate_to_width_with_info("", 10);
1546 assert_eq!(text, "");
1547 assert_eq!(width, 0);
1548 }
1549
1550 #[test]
1551 fn truncate_to_width_with_info_zero_width() {
1552 let (text, width) = truncate_to_width_with_info("hello", 0);
1553 assert_eq!(text, "");
1554 assert_eq!(width, 0);
1555 }
1556
1557 #[test]
1558 fn truncate_to_width_wide_char_boundary() {
1559 let (text, width) = truncate_to_width_with_info("a你好", 2);
1561 assert_eq!(text, "a");
1563 assert_eq!(width, 1);
1564 }
1565
1566 #[test]
1567 fn wrap_mode_none() {
1568 let lines = wrap_text("hello world", 5, WrapMode::None);
1569 assert_eq!(lines, vec!["hello world"]);
1570 }
1571
1572 #[test]
1573 fn wrap_long_word_no_char_fallback() {
1574 let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
1576 for line in &lines {
1578 assert!(line.width() <= 10);
1579 }
1580 }
1581
1582 #[test]
1587 fn unit_badness_monotone() {
1588 let width = 80;
1590 let mut prev = knuth_plass_badness(0, width, false);
1591 for slack in 1..=80i64 {
1592 let bad = knuth_plass_badness(slack, width, false);
1593 assert!(
1594 bad >= prev,
1595 "badness must be monotonically non-decreasing: \
1596 badness({slack}) = {bad} < badness({}) = {prev}",
1597 slack - 1
1598 );
1599 prev = bad;
1600 }
1601 }
1602
1603 #[test]
1604 fn unit_badness_zero_slack() {
1605 assert_eq!(knuth_plass_badness(0, 80, false), 0);
1607 assert_eq!(knuth_plass_badness(0, 80, true), 0);
1608 }
1609
1610 #[test]
1611 fn unit_badness_overflow_is_inf() {
1612 assert_eq!(knuth_plass_badness(-1, 80, false), BADNESS_INF);
1614 assert_eq!(knuth_plass_badness(-10, 80, false), BADNESS_INF);
1615 }
1616
1617 #[test]
1618 fn unit_badness_last_line_always_zero() {
1619 assert_eq!(knuth_plass_badness(0, 80, true), 0);
1621 assert_eq!(knuth_plass_badness(40, 80, true), 0);
1622 assert_eq!(knuth_plass_badness(79, 80, true), 0);
1623 }
1624
1625 #[test]
1626 fn unit_badness_cubic_growth() {
1627 let width = 100;
1628 let b10 = knuth_plass_badness(10, width, false);
1629 let b20 = knuth_plass_badness(20, width, false);
1630 let b40 = knuth_plass_badness(40, width, false);
1631
1632 assert!(
1635 b20 >= b10 * 6,
1636 "doubling slack 10→20: expected ~8× but got {}× (b10={b10}, b20={b20})",
1637 b20.checked_div(b10).unwrap_or(0)
1638 );
1639 assert!(
1640 b40 >= b20 * 6,
1641 "doubling slack 20→40: expected ~8× but got {}× (b20={b20}, b40={b40})",
1642 b40.checked_div(b20).unwrap_or(0)
1643 );
1644 }
1645
1646 #[test]
1647 fn unit_penalty_applied() {
1648 let result = wrap_optimal("superlongwordthatcannotfit", 10);
1650 assert!(
1652 result.total_cost >= PENALTY_FORCE_BREAK,
1653 "force-break penalty should be applied: cost={}",
1654 result.total_cost
1655 );
1656 }
1657
1658 #[test]
1659 fn kp_simple_wrap() {
1660 let result = wrap_optimal("Hello world foo bar", 10);
1661 for line in &result.lines {
1663 assert!(
1664 line.width() <= 10,
1665 "line '{line}' exceeds width 10 (width={})",
1666 line.width()
1667 );
1668 }
1669 assert!(result.lines.len() >= 2);
1671 }
1672
1673 #[test]
1674 fn kp_perfect_fit() {
1675 let result = wrap_optimal("aaaa bbbb", 9);
1677 assert_eq!(result.lines.len(), 1);
1679 assert_eq!(result.total_cost, 0);
1680 }
1681
1682 #[test]
1683 fn kp_optimal_vs_greedy() {
1684 let result = wrap_optimal("aaa bb cc ddddd", 6);
1689
1690 for line in &result.lines {
1692 assert!(line.width() <= 6, "line '{line}' exceeds width 6");
1693 }
1694
1695 assert!(result.lines.len() >= 2);
1699 }
1700
1701 #[test]
1702 fn kp_empty_text() {
1703 let result = wrap_optimal("", 80);
1704 assert_eq!(result.lines, vec![""]);
1705 assert_eq!(result.total_cost, 0);
1706 }
1707
1708 #[test]
1709 fn kp_single_word() {
1710 let result = wrap_optimal("hello", 80);
1711 assert_eq!(result.lines, vec!["hello"]);
1712 assert_eq!(result.total_cost, 0); }
1714
1715 #[test]
1716 fn kp_multiline_preserves_newlines() {
1717 let lines = wrap_text_optimal("hello world\nfoo bar baz", 10);
1718 assert!(lines.len() >= 2);
1720 assert!(lines[0].width() <= 10);
1722 }
1723
1724 #[test]
1725 fn kp_tokenize_basic() {
1726 let words = kp_tokenize("hello world foo");
1727 assert_eq!(words.len(), 3);
1728 assert_eq!(words[0].content_width, 5);
1729 assert_eq!(words[0].space_width, 1);
1730 assert_eq!(words[1].content_width, 5);
1731 assert_eq!(words[1].space_width, 1);
1732 assert_eq!(words[2].content_width, 3);
1733 assert_eq!(words[2].space_width, 0);
1734 }
1735
1736 #[test]
1737 fn kp_diagnostics_line_badness() {
1738 let result = wrap_optimal("short text here for testing the dp", 15);
1739 assert_eq!(result.line_badness.len(), result.lines.len());
1741 assert_eq!(
1743 *result.line_badness.last().unwrap(),
1744 0,
1745 "last line should have zero badness"
1746 );
1747 }
1748
1749 #[test]
1750 fn kp_deterministic() {
1751 let text = "The quick brown fox jumps over the lazy dog near a riverbank";
1752 let r1 = wrap_optimal(text, 20);
1753 let r2 = wrap_optimal(text, 20);
1754 assert_eq!(r1.lines, r2.lines);
1755 assert_eq!(r1.total_cost, r2.total_cost);
1756 }
1757
1758 #[test]
1763 fn unit_dp_matches_known() {
1764 let result = wrap_optimal("aaa bb cc ddddd", 6);
1769
1770 for line in &result.lines {
1772 assert!(line.width() <= 6, "line '{line}' exceeds width 6");
1773 }
1774
1775 assert_eq!(
1777 result.lines.len(),
1778 3,
1779 "expected 3 lines, got {:?}",
1780 result.lines
1781 );
1782 assert_eq!(result.lines[0], "aaa");
1783 assert_eq!(result.lines[1], "bb cc");
1784 assert_eq!(result.lines[2], "ddddd");
1785
1786 assert_eq!(*result.line_badness.last().unwrap(), 0);
1788 }
1789
1790 #[test]
1791 fn unit_dp_known_two_line() {
1792 let r1 = wrap_optimal("hello world", 11);
1794 assert_eq!(r1.lines, vec!["hello world"]);
1795 assert_eq!(r1.total_cost, 0);
1796
1797 let r2 = wrap_optimal("hello world", 7);
1799 assert_eq!(r2.lines.len(), 2);
1800 assert_eq!(r2.lines[0], "hello");
1801 assert_eq!(r2.lines[1], "world");
1802 assert!(
1805 r2.total_cost > 0 && r2.total_cost < 300,
1806 "expected cost ~233, got {}",
1807 r2.total_cost
1808 );
1809 }
1810
1811 #[test]
1812 fn unit_dp_optimal_beats_greedy() {
1813 let greedy = wrap_text("the quick brown fox", 10, WrapMode::Word);
1834 let optimal = wrap_optimal("the quick brown fox", 10);
1835
1836 for line in &greedy {
1838 assert!(line.width() <= 10);
1839 }
1840 for line in &optimal.lines {
1841 assert!(line.width() <= 10);
1842 }
1843
1844 let mut greedy_cost: u64 = 0;
1847 for (i, line) in greedy.iter().enumerate() {
1848 let slack = 10i64 - line.width() as i64;
1849 let is_last = i == greedy.len() - 1;
1850 greedy_cost += knuth_plass_badness(slack, 10, is_last);
1851 }
1852 assert!(
1853 optimal.total_cost <= greedy_cost,
1854 "optimal ({}) should be <= greedy ({}) for 'the quick brown fox' at width 10",
1855 optimal.total_cost,
1856 greedy_cost
1857 );
1858 }
1859
1860 #[test]
1861 fn perf_wrap_large() {
1862 use std::time::Instant;
1863
1864 let words: Vec<&str> = [
1866 "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
1867 "back", "to", "its", "den", "in",
1868 ]
1869 .to_vec();
1870
1871 let mut paragraph = String::new();
1872 for i in 0..1000 {
1873 if i > 0 {
1874 paragraph.push(' ');
1875 }
1876 paragraph.push_str(words[i % words.len()]);
1877 }
1878
1879 let iterations = 20;
1880 let start = Instant::now();
1881 for _ in 0..iterations {
1882 let result = wrap_optimal(¶graph, 80);
1883 assert!(!result.lines.is_empty());
1884 }
1885 let elapsed = start.elapsed();
1886
1887 eprintln!(
1888 "{{\"test\":\"perf_wrap_large\",\"words\":1000,\"width\":80,\"iterations\":{},\"total_ms\":{},\"per_iter_us\":{}}}",
1889 iterations,
1890 elapsed.as_millis(),
1891 elapsed.as_micros() / iterations as u128
1892 );
1893
1894 assert!(
1896 elapsed.as_secs() < 2,
1897 "Knuth-Plass DP too slow: {elapsed:?} for {iterations} iterations of 1000 words"
1898 );
1899 }
1900
1901 #[test]
1902 fn kp_pruning_lookahead_bound() {
1903 let text = "a b c d e f g h i j k l m n o p q r s t u v w x y z";
1905 let result = wrap_optimal(text, 10);
1906 for line in &result.lines {
1907 assert!(line.width() <= 10, "line '{line}' exceeds width");
1908 }
1909 let joined: String = result.lines.join(" ");
1911 for ch in 'a'..='z' {
1912 assert!(joined.contains(ch), "missing letter '{ch}' in output");
1913 }
1914 }
1915
1916 #[test]
1917 fn kp_very_narrow_width() {
1918 let result = wrap_optimal("ab cd ef", 2);
1920 assert_eq!(result.lines, vec!["ab", "cd", "ef"]);
1921 }
1922
1923 #[test]
1924 fn kp_wide_width_single_line() {
1925 let result = wrap_optimal("hello world", 1000);
1927 assert_eq!(result.lines, vec!["hello world"]);
1928 assert_eq!(result.total_cost, 0);
1929 }
1930
1931 fn fnv1a_lines(lines: &[String]) -> u64 {
1937 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1938 for (i, line) in lines.iter().enumerate() {
1939 for byte in (i as u32)
1940 .to_le_bytes()
1941 .iter()
1942 .chain(line.as_bytes().iter())
1943 {
1944 hash ^= *byte as u64;
1945 hash = hash.wrapping_mul(0x0100_0000_01b3);
1946 }
1947 }
1948 hash
1949 }
1950
1951 #[test]
1952 fn snapshot_wrap_quality() {
1953 let paragraphs = [
1955 "The quick brown fox jumps over the lazy dog near a riverbank while the sun sets behind the mountains in the distance",
1956 "To be or not to be that is the question whether tis nobler in the mind to suffer the slings and arrows of outrageous fortune",
1957 "aaa bb cc ddddd ee fff gg hhhh ii jjj kk llll mm nnn oo pppp qq rrr ss tttt",
1958 ];
1959
1960 let widths = [20, 40, 60, 80];
1961
1962 for paragraph in ¶graphs {
1963 for &width in &widths {
1964 let result = wrap_optimal(paragraph, width);
1965
1966 let result2 = wrap_optimal(paragraph, width);
1968 assert_eq!(
1969 fnv1a_lines(&result.lines),
1970 fnv1a_lines(&result2.lines),
1971 "non-deterministic wrap at width {width}"
1972 );
1973
1974 for line in &result.lines {
1976 assert!(line.width() <= width, "line '{line}' exceeds width {width}");
1977 }
1978
1979 if !paragraph.is_empty() {
1981 for line in &result.lines {
1982 assert!(!line.is_empty(), "empty line in output at width {width}");
1983 }
1984 }
1985
1986 let original_words: Vec<&str> = paragraph.split_whitespace().collect();
1988 let result_words: Vec<&str> = result
1989 .lines
1990 .iter()
1991 .flat_map(|l| l.split_whitespace())
1992 .collect();
1993 assert_eq!(
1994 original_words, result_words,
1995 "content lost at width {width}"
1996 );
1997
1998 assert_eq!(
2000 *result.line_badness.last().unwrap(),
2001 0,
2002 "last line should have zero badness at width {width}"
2003 );
2004 }
2005 }
2006 }
2007
2008 #[test]
2013 fn perf_wrap_bench() {
2014 use std::time::Instant;
2015
2016 let sample_words = [
2017 "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
2018 "back", "to", "its", "den", "in", "forest", "while", "birds", "sing", "above", "trees",
2019 "near",
2020 ];
2021
2022 let scenarios: &[(usize, usize, &str)] = &[
2023 (50, 40, "short_40"),
2024 (50, 80, "short_80"),
2025 (200, 40, "medium_40"),
2026 (200, 80, "medium_80"),
2027 (500, 40, "long_40"),
2028 (500, 80, "long_80"),
2029 ];
2030
2031 for &(word_count, width, label) in scenarios {
2032 let mut paragraph = String::new();
2034 for i in 0..word_count {
2035 if i > 0 {
2036 paragraph.push(' ');
2037 }
2038 paragraph.push_str(sample_words[i % sample_words.len()]);
2039 }
2040
2041 let iterations = 30u32;
2042 let mut times_us = Vec::with_capacity(iterations as usize);
2043 let mut last_lines = 0usize;
2044 let mut last_cost = 0u64;
2045 let mut last_checksum = 0u64;
2046
2047 for _ in 0..iterations {
2048 let start = Instant::now();
2049 let result = wrap_optimal(¶graph, width);
2050 let elapsed = start.elapsed();
2051
2052 last_lines = result.lines.len();
2053 last_cost = result.total_cost;
2054 last_checksum = fnv1a_lines(&result.lines);
2055 times_us.push(elapsed.as_micros() as u64);
2056 }
2057
2058 times_us.sort();
2059 let len = times_us.len();
2060 let p50 = times_us[len / 2];
2061 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
2062
2063 eprintln!(
2065 "{{\"ts\":\"2026-02-03T00:00:00Z\",\"test\":\"perf_wrap_bench\",\"scenario\":\"{label}\",\"words\":{word_count},\"width\":{width},\"lines\":{last_lines},\"badness_total\":{last_cost},\"algorithm\":\"dp\",\"p50_us\":{p50},\"p95_us\":{p95},\"breaks_checksum\":\"0x{last_checksum:016x}\"}}"
2066 );
2067
2068 let verify = wrap_optimal(¶graph, width);
2070 assert_eq!(
2071 fnv1a_lines(&verify.lines),
2072 last_checksum,
2073 "non-deterministic: {label}"
2074 );
2075
2076 if word_count >= 500 && p95 > 5000 {
2078 eprintln!("WARN: {label} p95={p95}µs exceeds 5ms budget");
2079 }
2080 }
2081 }
2082}
2083
2084#[cfg(test)]
2085mod proptests {
2086 use super::TestWidth;
2087 use super::*;
2088 use proptest::prelude::*;
2089
2090 proptest! {
2091 #[test]
2092 fn wrapped_lines_never_exceed_width(s in "[a-zA-Z ]{1,100}", width in 5usize..50) {
2093 let lines = wrap_text(&s, width, WrapMode::Char);
2094 for line in &lines {
2095 prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
2096 }
2097 }
2098
2099 #[test]
2100 fn wrapped_content_preserved(s in "[a-zA-Z]{1,50}", width in 5usize..20) {
2101 let lines = wrap_text(&s, width, WrapMode::Char);
2102 let rejoined: String = lines.join("");
2103 prop_assert_eq!(s.replace(" ", ""), rejoined.replace(" ", ""));
2105 }
2106
2107 #[test]
2108 fn truncate_never_exceeds_width(s in "[a-zA-Z0-9]{1,50}", width in 5usize..30) {
2109 let result = truncate_with_ellipsis(&s, width, "...");
2110 prop_assert!(result.width() <= width, "Result '{}' exceeds width {}", result, width);
2111 }
2112
2113 #[test]
2114 fn truncate_to_width_exact(s in "[a-zA-Z]{1,50}", width in 1usize..30) {
2115 let result = truncate_to_width(&s, width);
2116 prop_assert!(result.width() <= width);
2117 if s.width() > width {
2119 prop_assert!(result.width() >= width.saturating_sub(1) || s.width() <= width);
2121 }
2122 }
2123
2124 #[test]
2125 fn wordchar_mode_respects_width(s in "[a-zA-Z ]{1,100}", width in 5usize..30) {
2126 let lines = wrap_text(&s, width, WrapMode::WordChar);
2127 for line in &lines {
2128 prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
2129 }
2130 }
2131
2132 #[test]
2138 fn property_dp_vs_greedy(
2139 text in "[a-zA-Z]{1,6}( [a-zA-Z]{1,6}){2,20}",
2140 width in 8usize..40,
2141 ) {
2142 let greedy = wrap_text(&text, width, WrapMode::Word);
2143 let optimal = wrap_optimal(&text, width);
2144
2145 let mut greedy_cost: u64 = 0;
2147 for (i, line) in greedy.iter().enumerate() {
2148 let lw = line.width();
2149 let slack = width as i64 - lw as i64;
2150 let is_last = i == greedy.len() - 1;
2151 if slack >= 0 {
2152 greedy_cost = greedy_cost.saturating_add(
2153 knuth_plass_badness(slack, width, is_last)
2154 );
2155 } else {
2156 greedy_cost = greedy_cost.saturating_add(PENALTY_FORCE_BREAK);
2157 }
2158 }
2159
2160 prop_assert!(
2161 optimal.total_cost <= greedy_cost,
2162 "DP ({}) should be <= greedy ({}) for width={}: {:?} vs {:?}",
2163 optimal.total_cost, greedy_cost, width, optimal.lines, greedy
2164 );
2165 }
2166
2167 #[test]
2169 fn property_dp_respects_width(
2170 text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,15}",
2171 width in 6usize..30,
2172 ) {
2173 let result = wrap_optimal(&text, width);
2174 for line in &result.lines {
2175 prop_assert!(
2176 line.width() <= width,
2177 "DP line '{}' (width {}) exceeds target {}",
2178 line, line.width(), width
2179 );
2180 }
2181 }
2182
2183 #[test]
2185 fn property_dp_preserves_content(
2186 text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,10}",
2187 width in 8usize..30,
2188 ) {
2189 let result = wrap_optimal(&text, width);
2190 let original_words: Vec<&str> = text.split_whitespace().collect();
2191 let result_words: Vec<&str> = result.lines.iter()
2192 .flat_map(|l| l.split_whitespace())
2193 .collect();
2194 prop_assert_eq!(
2195 original_words, result_words,
2196 "DP should preserve all words"
2197 );
2198 }
2199 }
2200}