Skip to main content

ftui_text/
wrap.rs

1#![forbid(unsafe_code)]
2
3//! Text wrapping with Unicode correctness.
4//!
5//! This module provides width-correct text wrapping that respects:
6//! - Grapheme cluster boundaries (never break emoji, ZWJ sequences, etc.)
7//! - Cell widths (CJK characters are 2 cells wide)
8//! - Word boundaries when possible
9//!
10//! # Example
11//! ```
12//! use ftui_text::wrap::{wrap_text, WrapMode};
13//!
14//! // Word wrap
15//! let lines = wrap_text("Hello world foo bar", 10, WrapMode::Word);
16//! assert_eq!(lines, vec!["Hello", "world foo", "bar"]);
17//!
18//! // Character wrap (for long words)
19//! let lines = wrap_text("Supercalifragilistic", 10, WrapMode::Char);
20//! assert_eq!(lines.len(), 2);
21//! ```
22
23use unicode_segmentation::UnicodeSegmentation;
24
25/// Text wrapping mode.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum WrapMode {
28    /// No wrapping - lines may exceed width.
29    None,
30    /// Wrap at word boundaries when possible.
31    #[default]
32    Word,
33    /// Wrap at character (grapheme) boundaries.
34    Char,
35    /// Word wrap with character fallback for long words.
36    WordChar,
37}
38
39/// Options for text wrapping.
40#[derive(Debug, Clone)]
41pub struct WrapOptions {
42    /// Maximum width in cells.
43    pub width: usize,
44    /// Wrapping mode.
45    pub mode: WrapMode,
46    /// Preserve leading whitespace on continued lines.
47    pub preserve_indent: bool,
48    /// Trim trailing whitespace from wrapped lines.
49    pub trim_trailing: bool,
50}
51
52impl WrapOptions {
53    /// Create new wrap options with the given width.
54    #[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    /// Set the wrap mode.
65    #[must_use]
66    pub fn mode(mut self, mode: WrapMode) -> Self {
67        self.mode = mode;
68        self
69    }
70
71    /// Set whether to preserve indentation.
72    #[must_use]
73    pub fn preserve_indent(mut self, preserve: bool) -> Self {
74        self.preserve_indent = preserve;
75        self
76    }
77
78    /// Set whether to trim trailing whitespace.
79    #[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/// Wrap text to the specified width.
93///
94/// This is a convenience function using default word-wrap mode.
95#[must_use]
96pub fn wrap_text(text: &str, width: usize, mode: WrapMode) -> Vec<String> {
97    // Char mode should preserve leading whitespace since it's raw character-boundary wrapping
98    let preserve = mode == WrapMode::Char;
99    wrap_with_options(
100        text,
101        &WrapOptions::new(width).mode(mode).preserve_indent(preserve),
102    )
103}
104
105/// Wrap text with full options.
106#[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
120/// Wrap at grapheme boundaries (character wrap).
121fn 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        // Handle newlines
128        if grapheme == "\n" || grapheme == "\r\n" {
129            lines.push(finalize_line(&current_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        // Check if this grapheme fits
138        if current_width + grapheme_width > options.width && !current_line.is_empty() {
139            lines.push(finalize_line(&current_line, options));
140            current_line.clear();
141            current_width = 0;
142        }
143
144        // Add grapheme to current line
145        current_line.push_str(grapheme);
146        current_width += grapheme_width;
147    }
148
149    // Always push the pending line at the end.
150    // This handles the last segment of text, or the empty line after a trailing newline.
151    lines.push(finalize_line(&current_line, options));
152
153    lines
154}
155
156/// Wrap at word boundaries.
157fn wrap_words(text: &str, options: &WrapOptions, char_fallback: bool) -> Vec<String> {
158    let mut lines = Vec::new();
159
160    // Split by existing newlines first
161    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        // Push the last line of the paragraph if non-empty, or if wrap_paragraph
178        // added no lines (empty paragraph from explicit newline).
179        if !current_line.is_empty() || lines.len() == len_before {
180            lines.push(finalize_line(&current_line, options));
181        }
182    }
183
184    lines
185}
186
187/// Wrap a single paragraph (no embedded newlines).
188fn 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 word fits on current line
200        if *current_width + word_width <= options.width {
201            current_line.push_str(&word);
202            *current_width += word_width;
203            continue;
204        }
205
206        // Word doesn't fit - need to wrap
207        if !current_line.is_empty() {
208            lines.push(finalize_line(current_line, options));
209            current_line.clear();
210            *current_width = 0;
211        }
212
213        // Check if word itself exceeds width
214        if word_width > options.width {
215            if char_fallback {
216                // Break the long word into pieces
217                wrap_long_word(&word, options, lines, current_line, current_width);
218            } else {
219                // Just put the long word on its own line
220                lines.push(finalize_line(&word, options));
221            }
222        } else {
223            // Word fits on a fresh line
224            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
238/// Break a long word that exceeds the width limit.
239fn 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        // Skip leading whitespace on new lines
250        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            // Skip leading whitespace after wrap
260            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
270/// Split text into words (preserving whitespace with words).
271///
272/// Splits on whitespace boundaries, keeping whitespace-only segments
273/// separate from non-whitespace segments.
274fn 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
297/// Finalize a line (apply trimming, etc.).
298fn 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        // We only trim start if the user explicitly opted out of preserving indent.
307        // However, standard wrapping usually preserves start indent of the first line
308        // and only indents continuations.
309        // The `preserve_indent` option in `WrapOptions` usually refers to *hanging* indent
310        // or preserving leading whitespace on new lines.
311        //
312        // In this implementation, `wrap_paragraph` logic trims start of *continuation* lines
313        // if they fit.
314        //
315        // But for `finalize_line`, which handles the *completed* line string,
316        // we generally don't want to aggressively strip leading whitespace unless
317        // it was a blank line.
318        //
319        // Let's stick to the requested change: trim start if not preserving indent.
320        // But wait, `line.trim_start()` would kill paragraph indentation.
321        //
322        // Re-reading intent: "trim leading indentation if preserve_indent is false".
323        // This implies that if `preserve_indent` is false, we want flush-left text.
324
325        let trimmed = result.trim_start();
326        if trimmed.len() != result.len() {
327            result = trimmed.to_string();
328        }
329    }
330
331    result
332}
333
334/// Truncate text to fit within a width, adding ellipsis if needed.
335///
336/// This function respects grapheme boundaries - it will never break
337/// an emoji, ZWJ sequence, or combining character sequence.
338#[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 alone exceeds width, just truncate without ellipsis
349    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/// Truncate text to exactly fit within a width (no ellipsis).
360///
361/// Respects grapheme boundaries.
362#[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/// Returns `Some(width)` if text is printable ASCII only, `None` otherwise.
382///
383/// This is a fast-path optimization. For printable ASCII (0x20-0x7E), display width
384/// equals byte length, so we can avoid the full Unicode width calculation.
385///
386/// Returns `None` for:
387/// - Non-ASCII characters (multi-byte UTF-8)
388/// - ASCII control characters (0x00-0x1F, 0x7F) which have display width 0
389///
390/// # Example
391/// ```
392/// use ftui_text::wrap::ascii_width;
393///
394/// assert_eq!(ascii_width("hello"), Some(5));
395/// assert_eq!(ascii_width("你好"), None);  // Contains CJK
396/// assert_eq!(ascii_width(""), Some(0));
397/// assert_eq!(ascii_width("hello\tworld"), None);  // Contains tab (control char)
398/// ```
399#[inline]
400#[must_use]
401pub fn ascii_width(text: &str) -> Option<usize> {
402    ftui_core::text_width::ascii_width(text)
403}
404
405/// Calculate the display width of a single grapheme cluster.
406///
407/// Uses `unicode-display-width` so grapheme clusters (ZWJ emoji, flags, combining
408/// marks) are treated as a single glyph with correct terminal width.
409///
410/// If `FTUI_TEXT_CJK_WIDTH=1` (or `FTUI_CJK_WIDTH=1`) or a CJK locale is detected,
411/// ambiguous-width characters are treated as double-width.
412#[inline]
413#[must_use]
414pub fn grapheme_width(grapheme: &str) -> usize {
415    ftui_core::text_width::grapheme_width(grapheme)
416}
417
418/// Calculate the display width of text in cells.
419///
420/// Uses ASCII fast-path when possible, falling back to Unicode width calculation.
421///
422/// If `FTUI_TEXT_CJK_WIDTH=1` (or `FTUI_CJK_WIDTH=1`) or a CJK locale is detected,
423/// ambiguous-width characters are treated as double-width.
424///
425/// # Performance
426/// - ASCII text: O(n) byte scan, no allocations
427/// - Non-ASCII: Grapheme segmentation + per-grapheme width
428#[inline]
429#[must_use]
430pub fn display_width(text: &str) -> usize {
431    ftui_core::text_width::display_width(text)
432}
433
434/// Check if a string contains any wide characters (width > 1).
435#[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/// Check if a string is ASCII-only (fast path possible).
442#[must_use]
443pub fn is_ascii_only(text: &str) -> bool {
444    text.is_ascii()
445}
446
447// =============================================================================
448// Grapheme Segmentation Helpers (bd-6e9.8)
449// =============================================================================
450
451/// Count the number of grapheme clusters in a string.
452///
453/// A grapheme cluster is a user-perceived character, which may consist of
454/// multiple Unicode code points (e.g., emoji with modifiers, combining marks).
455///
456/// # Example
457/// ```
458/// use ftui_text::wrap::grapheme_count;
459///
460/// assert_eq!(grapheme_count("hello"), 5);
461/// assert_eq!(grapheme_count("e\u{0301}"), 1);  // e + combining acute = 1 grapheme
462/// assert_eq!(grapheme_count("\u{1F468}\u{200D}\u{1F469}"), 1);  // ZWJ sequence = 1 grapheme
463/// ```
464#[inline]
465#[must_use]
466pub fn grapheme_count(text: &str) -> usize {
467    text.graphemes(true).count()
468}
469
470/// Iterate over grapheme clusters in a string.
471///
472/// Returns an iterator yielding `&str` slices for each grapheme cluster.
473/// Uses extended grapheme clusters (UAX #29).
474///
475/// # Example
476/// ```
477/// use ftui_text::wrap::graphemes;
478///
479/// let chars: Vec<&str> = graphemes("e\u{0301}bc").collect();
480/// assert_eq!(chars, vec!["e\u{0301}", "b", "c"]);
481/// ```
482#[inline]
483pub fn graphemes(text: &str) -> impl Iterator<Item = &str> {
484    text.graphemes(true)
485}
486
487/// Truncate text to fit within a maximum display width.
488///
489/// Returns a tuple of (truncated_text, actual_width) where:
490/// - `truncated_text` is the prefix that fits within `max_width`
491/// - `actual_width` is the display width of the truncated text
492///
493/// Respects grapheme boundaries - will never split an emoji, ZWJ sequence,
494/// or combining character sequence.
495///
496/// # Example
497/// ```
498/// use ftui_text::wrap::truncate_to_width_with_info;
499///
500/// let (text, width) = truncate_to_width_with_info("hello world", 5);
501/// assert_eq!(text, "hello");
502/// assert_eq!(width, 5);
503///
504/// // CJK characters are 2 cells wide
505/// let (text, width) = truncate_to_width_with_info("\u{4F60}\u{597D}", 3);
506/// assert_eq!(text, "\u{4F60}");  // Only first char fits
507/// assert_eq!(width, 2);
508/// ```
509#[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
528/// Find word boundary positions suitable for line breaking.
529///
530/// Returns byte indices where word breaks can occur. This is useful for
531/// implementing soft-wrap at word boundaries.
532///
533/// # Example
534/// ```
535/// use ftui_text::wrap::word_boundaries;
536///
537/// let breaks: Vec<usize> = word_boundaries("hello world foo").collect();
538/// // Breaks occur after spaces
539/// assert!(breaks.contains(&6));   // After "hello "
540/// assert!(breaks.contains(&12));  // After "world "
541/// ```
542pub fn word_boundaries(text: &str) -> impl Iterator<Item = usize> + '_ {
543    text.split_word_bound_indices().filter_map(|(idx, word)| {
544        // Return index at end of whitespace sequences (good break points)
545        if word.chars().all(|c| c.is_whitespace()) {
546            Some(idx + word.len())
547        } else {
548            None
549        }
550    })
551}
552
553/// Split text into word segments preserving boundaries.
554///
555/// Each segment is either a word or a whitespace sequence.
556/// Useful for word-based text processing.
557///
558/// # Example
559/// ```
560/// use ftui_text::wrap::word_segments;
561///
562/// let segments: Vec<&str> = word_segments("hello  world").collect();
563/// assert_eq!(segments, vec!["hello", "  ", "world"]);
564/// ```
565pub fn word_segments(text: &str) -> impl Iterator<Item = &str> {
566    text.split_word_bounds()
567}
568
569// =============================================================================
570// Knuth-Plass Optimal Line Breaking (bd-4kq0.5.1)
571// =============================================================================
572//
573// # Algorithm
574//
575// Classic Knuth-Plass DP for optimal paragraph line-breaking.
576// Given text split into words with measured widths, find line breaks
577// that minimize total "badness" across all lines.
578//
579// ## Badness Function
580//
581// For a line with slack `s = width - line_content_width`:
582//   badness(s, width) = (s / width)^3 * BADNESS_SCALE
583//
584// Badness is infinite (BADNESS_INF) for lines that overflow (s < 0).
585// The last line has badness 0 (TeX convention: last line is never penalized
586// for being short).
587//
588// ## Penalties
589//
590// - PENALTY_HYPHEN: cost for breaking at a hyphen (not yet used, reserved)
591// - PENALTY_FLAGGED: cost for consecutive flagged breaks
592// - PENALTY_FORCE_BREAK: large penalty for forcing a break mid-word
593//
594// ## DP Recurrence
595//
596// cost[j] = min over all valid i < j of:
597//   cost[i] + badness(line from word i to word j-1) + penalty(break at j)
598//
599// Backtrack via `from[j]` to recover the optimal break sequence.
600//
601// ## Tie-Breaking
602//
603// When two break sequences have equal cost, prefer:
604// 1. Fewer lines (later break)
605// 2. More balanced distribution (lower max badness)
606
607/// Scale factor for badness computation. Matches TeX convention.
608const BADNESS_SCALE: u64 = 10_000;
609
610/// Badness value for infeasible lines (overflow).
611const BADNESS_INF: u64 = u64::MAX / 2;
612
613/// Penalty for forcing a mid-word character break.
614const PENALTY_FORCE_BREAK: u64 = 5000;
615
616/// Maximum lookahead (words per line) for DP pruning.
617/// Limits worst-case to O(n × MAX_LOOKAHEAD) instead of O(n²).
618/// Any line with more than this many words will use the greedy breakpoint.
619const KP_MAX_LOOKAHEAD: usize = 64;
620
621/// Compute the badness of a line with the given slack.
622///
623/// Badness grows as the cube of the ratio `slack / width`, scaled by
624/// `BADNESS_SCALE`. This heavily penalizes very loose lines while being
625/// lenient on small amounts of slack.
626///
627/// Returns `BADNESS_INF` if the line overflows (`slack < 0`).
628/// Returns 0 for the last line (TeX convention).
629#[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    // badness = (slack/width)^3 * BADNESS_SCALE
641    // Use integer arithmetic to avoid floating point:
642    // (slack^3 * BADNESS_SCALE) / width^3
643    let s = slack as u64;
644    let w = width as u64;
645    // Prevent overflow: compute in stages
646    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/// A word token with its measured cell width.
655#[derive(Debug, Clone)]
656struct KpWord {
657    /// The word text (including any trailing space).
658    text: String,
659    /// Cell width of the content (excluding trailing space for break purposes).
660    content_width: usize,
661    /// Cell width of the trailing space (0 if none).
662    space_width: usize,
663}
664
665/// Split text into KpWord tokens for Knuth-Plass processing.
666fn 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            // Standalone whitespace — attach to previous word as trailing space
675            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                // Handle leading whitespace as a word with 0 content width
681                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/// Result of optimal line breaking.
703#[derive(Debug, Clone)]
704pub struct KpBreakResult {
705    /// The wrapped lines.
706    pub lines: Vec<String>,
707    /// Total cost (sum of badness + penalties).
708    pub total_cost: u64,
709    /// Per-line badness values (for diagnostics).
710    pub line_badness: Vec<u64>,
711}
712
713/// Compute optimal line breaks using Knuth-Plass DP.
714///
715/// Given a paragraph of text and a target width, finds the set of line
716/// breaks that minimizes total badness (cubic slack penalty).
717///
718/// Falls back to greedy word-wrap if the DP cost is prohibitive (very
719/// long paragraphs), controlled by `max_words`.
720///
721/// # Arguments
722/// * `text` - The paragraph to wrap (no embedded newlines expected).
723/// * `width` - Target line width in cells.
724///
725/// # Returns
726/// `KpBreakResult` with optimal lines, total cost, and per-line badness.
727pub 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    // cost[j] = minimum cost to set words 0..j
748    // from[j] = index i such that line starts at word i for the break ending at j
749    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        // Try all possible line starts i (going backwards from j).
756        // Bounded by KP_MAX_LOOKAHEAD to keep runtime O(n × lookahead).
757        let earliest = j.saturating_sub(KP_MAX_LOOKAHEAD);
758        for i in (earliest..j).rev() {
759            // Add word i's width
760            line_width += words[i].content_width;
761            if i < j - 1 {
762                // Add space between words (from word i's trailing space)
763                line_width += words[i].space_width;
764            }
765
766            // Check if line overflows
767            if line_width > width && i < j - 1 {
768                // Can't fit — and we've already tried adding more words
769                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                // Single word too wide — must force-break
776                PENALTY_FORCE_BREAK
777            } else {
778                knuth_plass_badness(slack, width, is_last)
779            };
780
781            let candidate = cost[i].saturating_add(badness);
782            // Tie-breaking: prefer later break (fewer lines)
783            if candidate < cost[j] || (candidate == cost[j] && i > from[j]) {
784                cost[j] = candidate;
785                from[j] = i;
786            }
787        }
788    }
789
790    // Backtrack to recover break positions
791    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    // Build output lines
800    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        // Reconstruct line text
812        let mut line = String::new();
813        for word in words.iter().take(end).skip(start) {
814            line.push_str(&word.text);
815        }
816
817        // Trim trailing whitespace from each line
818        let trimmed = line.trim_end().to_string();
819
820        // Compute this line's badness for diagnostics
821        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/// Wrap text optimally, returning just the lines (convenience wrapper).
842///
843/// Handles multiple paragraphs separated by `\n`.
844#[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    // ==========================================================================
884    // wrap_text tests
885    // ==========================================================================
886
887    #[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        // "line1\n" -> ["line1", ""]
920        let lines = wrap_text("line1\n", 20, WrapMode::Word);
921        assert_eq!(lines, vec!["line1", ""]);
922
923        // "\n" -> ["", ""]
924        let lines = wrap_text("\n", 20, WrapMode::Word);
925        assert_eq!(lines, vec!["", ""]);
926
927        // Same for Char mode
928        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        // Without fallback, long word stays on its own line
942        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        // With fallback, long word is broken
949        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    // ==========================================================================
968    // CJK wrapping tests
969    // ==========================================================================
970
971    #[test]
972    fn wrap_cjk_respects_width() {
973        // Each CJK char is 2 cells
974        let lines = wrap_text("你好世界", 4, WrapMode::Char);
975        assert_eq!(lines, vec!["你好", "世界"]);
976    }
977
978    #[test]
979    fn wrap_cjk_odd_width() {
980        // Width 5 can fit 2 CJK chars (4 cells)
981        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    // ==========================================================================
992    // Emoji/ZWJ tests
993    // ==========================================================================
994
995    #[test]
996    fn wrap_emoji_as_unit() {
997        // Emoji should not be broken
998        let lines = wrap_text("😀😀😀", 4, WrapMode::Char);
999        // Each emoji is typically 2 cells, so 2 per line
1000        assert_eq!(lines.len(), 2);
1001        for line in &lines {
1002            // No partial emoji
1003            assert!(!line.contains("\\u"));
1004        }
1005    }
1006
1007    #[test]
1008    fn wrap_zwj_sequence_as_unit() {
1009        // Family emoji (ZWJ sequence) - should stay together
1010        let text = "👨‍👩‍👧";
1011        let lines = wrap_text(text, 2, WrapMode::Char);
1012        // The ZWJ sequence should not be broken
1013        // It will exceed width but stay as one unit
1014        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    // ==========================================================================
1024    // Truncation tests
1025    // ==========================================================================
1026
1027    #[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        // 6 - 3 (ellipsis) = 3 cells for content
1043        // 你 = 2 cells fits, 好 = 2 cells doesn't fit
1044        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        // Can't fit half a CJK char
1062        let result = truncate_to_width("你好", 3);
1063        assert_eq!(result, "你");
1064    }
1065
1066    #[test]
1067    fn truncate_combining_chars() {
1068        // e + combining acute accent
1069        let text = "e\u{0301}test";
1070        let result = truncate_to_width(text, 2);
1071        // Should keep é together and add 't'
1072        assert_eq!(result.chars().count(), 3); // e + combining + t
1073    }
1074
1075    // ==========================================================================
1076    // Helper function tests
1077    // ==========================================================================
1078
1079    #[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        // Wide characters (east_asian_width=W) are always width 2
1112        assert_eq!(display_width("⌚"), 2); // U+231A WATCH, Wide
1113        assert_eq!(display_width("⭐"), 2); // U+2B50 WHITE MEDIUM STAR, Wide
1114
1115        // Neutral characters (east_asian_width=N): width depends on CJK mode
1116        let airplane_width = display_width("✈"); // U+2708 AIRPLANE, Neutral
1117        let arrow_width = display_width("⬆"); // U+2B06 UPWARDS BLACK ARROW, Neutral
1118        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    // ==========================================================================
1181    // ASCII width fast-path tests
1182    // ==========================================================================
1183
1184    #[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); // café
1206    }
1207
1208    #[test]
1209    fn ascii_width_control_chars_returns_none() {
1210        // Control characters are ASCII but have display width 0, not byte length
1211        assert_eq!(ascii_width("\t"), None); // tab
1212        assert_eq!(ascii_width("\n"), None); // newline
1213        assert_eq!(ascii_width("\r"), None); // carriage return
1214        assert_eq!(ascii_width("\0"), None); // NUL
1215        assert_eq!(ascii_width("\x7F"), None); // DEL
1216        assert_eq!(ascii_width("hello\tworld"), None); // mixed with tab
1217        assert_eq!(ascii_width("line1\nline2"), None); // mixed with newline
1218    }
1219
1220    #[test]
1221    fn display_width_uses_ascii_fast_path() {
1222        // ASCII should work (implicitly tests fast path)
1223        assert_eq!(display_width("test"), 4);
1224        // Non-ASCII should also work (tests fallback)
1225        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    // ==========================================================================
1249    // Grapheme helper tests (bd-6e9.8)
1250    // ==========================================================================
1251
1252    #[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        // e + combining acute = 1 grapheme
1261        assert_eq!(grapheme_count("e\u{0301}"), 1);
1262        // Multiple combining marks
1263        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        // Emoji with skin tone modifier = 1 grapheme
1275        assert_eq!(grapheme_count("👍🏻"), 1);
1276    }
1277
1278    #[test]
1279    fn grapheme_count_zwj() {
1280        // Family emoji (ZWJ sequence) = 1 grapheme
1281        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)); // After "hello "
1334    }
1335
1336    #[test]
1337    fn word_boundaries_multiple_spaces() {
1338        let breaks: Vec<usize> = word_boundaries("a  b").collect();
1339        assert!(breaks.contains(&3)); // After "a  "
1340    }
1341
1342    #[test]
1343    fn word_segments_basic() {
1344        let segs: Vec<&str> = word_segments("hello  world").collect();
1345        // split_word_bounds gives individual segments
1346        assert!(segs.contains(&"hello"));
1347        assert!(segs.contains(&"world"));
1348    }
1349
1350    // ==========================================================================
1351    // WrapOptions tests
1352    // ==========================================================================
1353
1354    #[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        // Trailing spaces should be trimmed
1372        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        // Zero width returns original text
1397        assert_eq!(lines, vec!["hello"]);
1398    }
1399
1400    // ==========================================================================
1401    // Additional coverage tests for width measurement
1402    // ==========================================================================
1403
1404    #[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        // e + combining acute + combining diaeresis = still 1 cell
1440        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        // Test entire printable ASCII range (0x20-0x7E)
1447        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        // Newline is a control character
1454        assert!(ascii_width("hello\nworld").is_none());
1455    }
1456
1457    #[test]
1458    fn ascii_width_tab_returns_none() {
1459        // Tab is a control character
1460        assert!(ascii_width("hello\tworld").is_none());
1461    }
1462
1463    #[test]
1464    fn ascii_width_del_returns_none() {
1465        // DEL (0x7F) is a control character
1466        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        // US flag = 2 regional indicators = 1 grapheme
1490        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        // String exactly fits without needing truncation
1533        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        // Try to truncate at width 3 where a CJK char (width 2) would split
1560        let (text, width) = truncate_to_width_with_info("a你好", 2);
1561        // "a" is 1 cell, "你" is 2 cells, so only "a" fits in width 2
1562        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        // WordChar mode handles long words by falling back to char wrap
1575        let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
1576        // Should wrap even the long word
1577        for line in &lines {
1578            assert!(line.width() <= 10);
1579        }
1580    }
1581
1582    // =========================================================================
1583    // Knuth-Plass Optimal Line Breaking Tests (bd-4kq0.5.1)
1584    // =========================================================================
1585
1586    #[test]
1587    fn unit_badness_monotone() {
1588        // Larger slack => higher badness (for non-last lines)
1589        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        // Perfect fit: badness should be 0
1606        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        // Negative slack (overflow) => BADNESS_INF
1613        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        // Last line: badness is always 0 regardless of slack
1620        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        // Doubling slack should ~8× badness (cubic)
1633        // Allow some tolerance for integer arithmetic
1634        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        // A single word that's too wide incurs PENALTY_FORCE_BREAK
1649        let result = wrap_optimal("superlongwordthatcannotfit", 10);
1650        // The word can't fit in width=10, so it must force-break
1651        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        // All lines should fit within width
1662        for line in &result.lines {
1663            assert!(
1664                line.width() <= 10,
1665                "line '{line}' exceeds width 10 (width={})",
1666                line.width()
1667            );
1668        }
1669        // Should produce at least 2 lines
1670        assert!(result.lines.len() >= 2);
1671    }
1672
1673    #[test]
1674    fn kp_perfect_fit() {
1675        // Words that perfectly fill each line should have zero badness
1676        let result = wrap_optimal("aaaa bbbb", 9);
1677        // "aaaa bbbb" is 9 chars, fits in one line
1678        assert_eq!(result.lines.len(), 1);
1679        assert_eq!(result.total_cost, 0);
1680    }
1681
1682    #[test]
1683    fn kp_optimal_vs_greedy() {
1684        // Classic example where greedy is suboptimal:
1685        // "aaa bb cc ddddd" with width 6
1686        // Greedy: "aaa bb" / "cc" / "ddddd" → unbalanced (cc line has 4 slack)
1687        // Optimal: "aaa" / "bb cc" / "ddddd" → more balanced
1688        let result = wrap_optimal("aaa bb cc ddddd", 6);
1689
1690        // Verify all lines fit
1691        for line in &result.lines {
1692            assert!(line.width() <= 6, "line '{line}' exceeds width 6");
1693        }
1694
1695        // The greedy solution would put "aaa bb" on line 1.
1696        // The optimal solution should find a lower-cost arrangement.
1697        // Just verify it produces reasonable output.
1698        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); // last line, zero badness
1713    }
1714
1715    #[test]
1716    fn kp_multiline_preserves_newlines() {
1717        let lines = wrap_text_optimal("hello world\nfoo bar baz", 10);
1718        // Each paragraph wrapped independently
1719        assert!(lines.len() >= 2);
1720        // First paragraph lines
1721        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        // Each line should have a badness value
1740        assert_eq!(result.line_badness.len(), result.lines.len());
1741        // Last line should have badness 0
1742        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    // =========================================================================
1759    // Knuth-Plass Implementation + Pruning Tests (bd-4kq0.5.2)
1760    // =========================================================================
1761
1762    #[test]
1763    fn unit_dp_matches_known() {
1764        // Known optimal break for "aaa bb cc ddddd" at width 6:
1765        // Greedy: "aaa bb" / "cc" / "ddddd" — line "cc" has 4 slack → badness = (4/6)^3*10000 = 2962
1766        // Optimal: "aaa" / "bb cc" / "ddddd" — line "aaa" has 3 slack → 1250, "bb cc" has 1 slack → 4
1767        // So optimal total < greedy total.
1768        let result = wrap_optimal("aaa bb cc ddddd", 6);
1769
1770        // Verify all lines fit
1771        for line in &result.lines {
1772            assert!(line.width() <= 6, "line '{line}' exceeds width 6");
1773        }
1774
1775        // The optimal should produce: "aaa" / "bb cc" / "ddddd"
1776        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        // Verify last line has zero badness
1787        assert_eq!(*result.line_badness.last().unwrap(), 0);
1788    }
1789
1790    #[test]
1791    fn unit_dp_known_two_line() {
1792        // "hello world" at width 11 → fits in one line
1793        let r1 = wrap_optimal("hello world", 11);
1794        assert_eq!(r1.lines, vec!["hello world"]);
1795        assert_eq!(r1.total_cost, 0);
1796
1797        // "hello world" at width 7 → must split
1798        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        // "hello" has 2 slack on width 7, badness = (2^3 * 10000) / 7^3 = 80000/343 = 233
1803        // "world" is last line, badness = 0
1804        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        // Construct a case where greedy produces worse results
1814        // "aa bb cc dd ee" at width 6
1815        // Greedy: "aa bb" / "cc dd" / "ee" → slacks: 1, 1, 4 → badness ~0 + 0 + 0(last)
1816        // vs: "aa bb" / "cc dd" / "ee" — actually greedy might be optimal here
1817        //
1818        // Better example: "xx yy zzz aa bbb" at width 7
1819        // Greedy: "xx yy" / "zzz aa" / "bbb" → slacks: 2, 1, 4(last=0)
1820        // Optimal might produce: "xx yy" / "zzz aa" / "bbb" (same)
1821        //
1822        // Use a real suboptimal greedy case:
1823        // "a bb ccc dddd" width 6
1824        // Greedy: "a bb" (slack 2) / "ccc" (slack 3) / "dddd" (slack 2, last=0)
1825        //   → badness: (2/6)^3*10000=370 + (3/6)^3*10000=1250 = 1620
1826        // Optimal: "a" (slack 5) / "bb ccc" (slack 0) / "dddd" (last=0)
1827        //   → badness: (5/6)^3*10000=5787 + 0 = 5787
1828        // Or: "a bb" (slack 2) / "ccc" (slack 3) / "dddd" (last=0)
1829        //   → 370 + 1250 + 0 = 1620 — actually greedy is better here!
1830        //
1831        // The classic example is when greedy makes a very short line mid-paragraph.
1832        // "the quick brown fox" width 10
1833        let greedy = wrap_text("the quick brown fox", 10, WrapMode::Word);
1834        let optimal = wrap_optimal("the quick brown fox", 10);
1835
1836        // Both should produce valid output
1837        for line in &greedy {
1838            assert!(line.width() <= 10);
1839        }
1840        for line in &optimal.lines {
1841            assert!(line.width() <= 10);
1842        }
1843
1844        // Optimal cost should be <= greedy cost (by definition)
1845        // Compute greedy cost for comparison
1846        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        // Generate a large paragraph (~1000 words)
1865        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(&paragraph, 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        // Budget: 1000 words × 20 iterations should complete in < 2s
1895        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        // Verify MAX_LOOKAHEAD doesn't break correctness for normal text
1904        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        // All 26 letters should appear in output
1910        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        // Width 1: every word must be on its own line (or force-broken)
1919        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        // Width much larger than text: single line, zero cost
1926        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    // =========================================================================
1932    // Snapshot Wrap Quality (bd-4kq0.5.3)
1933    // =========================================================================
1934
1935    /// FNV-1a hash for deterministic checksums of line break positions.
1936    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        // Known paragraphs at multiple widths — verify deterministic and sensible output.
1954        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 &paragraphs {
1963            for &width in &widths {
1964                let result = wrap_optimal(paragraph, width);
1965
1966                // Determinism: same input → same output
1967                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                // All lines fit within width
1975                for line in &result.lines {
1976                    assert!(line.width() <= width, "line '{line}' exceeds width {width}");
1977                }
1978
1979                // No empty lines (except if paragraph is empty)
1980                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                // All content preserved
1987                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                // Last line has zero badness
1999                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    // =========================================================================
2009    // Perf Wrap Bench with JSONL (bd-4kq0.5.3)
2010    // =========================================================================
2011
2012    #[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            // Build paragraph
2033            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(&paragraph, 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            // JSONL log
2064            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            // Determinism across iterations
2069            let verify = wrap_optimal(&paragraph, width);
2070            assert_eq!(
2071                fnv1a_lines(&verify.lines),
2072                last_checksum,
2073                "non-deterministic: {label}"
2074            );
2075
2076            // Budget: 500 words at p95 should be < 5ms
2077            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            // Content should be preserved (though whitespace may change)
2104            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 original was longer, result should be at max width or close
2118            if s.width() > width {
2119                // Should be close to width (may be less due to wide char at boundary)
2120                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        // =====================================================================
2133        // Knuth-Plass Property Tests (bd-4kq0.5.3)
2134        // =====================================================================
2135
2136        /// Property: DP optimal cost is never worse than greedy cost.
2137        #[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            // Compute greedy cost using same badness function
2146            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        /// Property: DP output lines never exceed width.
2168        #[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        /// Property: DP preserves all non-whitespace content.
2184        #[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}