Skip to main content

standout_render/tabular/
util.rs

1//! Utility functions for ANSI-aware text measurement, truncation, and padding.
2//!
3//! All functions in this module correctly handle ANSI escape codes: they are
4//! preserved in output but don't count toward display width calculations.
5
6use console::{measure_text_width, pad_str, strip_ansi_codes, Alignment};
7use standout_bbparser::strip_tags;
8
9/// Returns the display width of a string, ignoring ANSI escape codes.
10///
11/// **Warning:** This only strips ANSI escape codes, not BBCode tags like
12/// `[bold]...[/bold]`. For user-facing content that may contain BBCode markup,
13/// use [`visible_width`] instead.
14///
15/// # Example
16///
17/// ```rust
18/// use standout_render::tabular::display_width;
19///
20/// assert_eq!(display_width("hello"), 5);
21/// assert_eq!(display_width("\x1b[31mred\x1b[0m"), 3);  // ANSI codes ignored
22/// assert_eq!(display_width("日本"), 4);  // CJK characters are 2 columns each
23/// ```
24pub fn display_width(s: &str) -> usize {
25    measure_text_width(s)
26}
27
28/// Returns the visible display width of a string, stripping both BBCode tags
29/// and ANSI escape codes before measurement.
30///
31/// Use this for any text that may contain markup. For strings known to be
32/// tag-free (e.g., separator literals), [`display_width`] avoids the overhead.
33///
34/// ANSI is stripped first so that the `[` and `]` bytes inside CSI sequences
35/// don't get mistaken for malformed BBCode tags by the tag stripper.
36///
37/// # Example
38///
39/// ```rust
40/// use standout_render::tabular::visible_width;
41///
42/// assert_eq!(visible_width("hello"), 5);
43/// assert_eq!(visible_width("[bold]hello[/bold]"), 5);
44/// assert_eq!(visible_width("\x1b[31m[red]hi[/red]\x1b[0m"), 2);
45/// ```
46pub fn visible_width(s: &str) -> usize {
47    let no_ansi = strip_ansi_codes(s);
48    measure_text_width(&strip_tags(&no_ansi))
49}
50
51/// Truncates a string from the end to fit within a maximum display width.
52///
53/// If the string already fits, it is returned unchanged. Otherwise, characters
54/// are removed from the end and the ellipsis is appended.
55///
56/// ANSI escape codes are preserved but don't count toward display width.
57///
58/// # Arguments
59///
60/// * `s` - The string to truncate
61/// * `max_width` - Maximum display width in terminal columns
62/// * `ellipsis` - String to append when truncation occurs (e.g., "…" or "...")
63///
64/// # Example
65///
66/// ```rust
67/// use standout_render::tabular::truncate_end;
68///
69/// assert_eq!(truncate_end("Hello World", 8, "…"), "Hello W…");
70/// assert_eq!(truncate_end("Short", 10, "…"), "Short");
71/// ```
72pub fn truncate_end(s: &str, max_width: usize, ellipsis: &str) -> String {
73    let width = measure_text_width(s);
74    if width <= max_width {
75        return s.to_string();
76    }
77
78    let ellipsis_width = measure_text_width(ellipsis);
79    if max_width < ellipsis_width {
80        // Not enough room even for ellipsis - truncate ellipsis itself
81        return truncate_to_display_width(ellipsis, max_width);
82    }
83    if max_width == ellipsis_width {
84        // Exactly enough room for ellipsis only
85        return ellipsis.to_string();
86    }
87
88    let target_width = max_width - ellipsis_width;
89    let mut result = truncate_to_display_width(s, target_width);
90    result.push_str(ellipsis);
91    result
92}
93
94/// Truncates a string from the start to fit within a maximum display width.
95///
96/// Characters are removed from the beginning, and the ellipsis is prepended.
97/// Useful for paths where the filename at the end is more important than
98/// the directory prefix.
99///
100/// ANSI escape codes are preserved but don't count toward display width.
101///
102/// # Example
103///
104/// ```rust
105/// use standout_render::tabular::truncate_start;
106///
107/// assert_eq!(truncate_start("Hello World", 8, "…"), "…o World");
108/// assert_eq!(truncate_start("/path/to/file.rs", 12, "…"), "…/to/file.rs");
109/// ```
110pub fn truncate_start(s: &str, max_width: usize, ellipsis: &str) -> String {
111    let width = measure_text_width(s);
112    if width <= max_width {
113        return s.to_string();
114    }
115
116    let ellipsis_width = measure_text_width(ellipsis);
117    if max_width < ellipsis_width {
118        // Not enough room even for ellipsis - truncate ellipsis itself
119        return truncate_to_display_width(ellipsis, max_width);
120    }
121    if max_width == ellipsis_width {
122        // Exactly enough room for ellipsis only
123        return ellipsis.to_string();
124    }
125
126    let target_width = max_width - ellipsis_width;
127    let truncated = find_suffix_with_width(s, target_width);
128    format!("{}{}", ellipsis, truncated)
129}
130
131/// Truncates a string from the middle to fit within a maximum display width.
132///
133/// Characters are removed from the middle, preserving both start and end.
134/// The ellipsis is placed in the middle. Useful for identifiers or filenames
135/// where both prefix and suffix are meaningful.
136///
137/// ANSI escape codes are preserved but don't count toward display width.
138///
139/// # Example
140///
141/// ```rust
142/// use standout_render::tabular::truncate_middle;
143///
144/// assert_eq!(truncate_middle("Hello World", 8, "…"), "Hel…orld");
145/// assert_eq!(truncate_middle("abcdefghij", 7, "..."), "ab...ij");
146/// ```
147pub fn truncate_middle(s: &str, max_width: usize, ellipsis: &str) -> String {
148    let width = measure_text_width(s);
149    if width <= max_width {
150        return s.to_string();
151    }
152
153    let ellipsis_width = measure_text_width(ellipsis);
154    if max_width < ellipsis_width {
155        // Not enough room even for ellipsis - truncate ellipsis itself
156        return truncate_to_display_width(ellipsis, max_width);
157    }
158    if max_width == ellipsis_width {
159        // Exactly enough room for ellipsis only
160        return ellipsis.to_string();
161    }
162
163    let available = max_width - ellipsis_width;
164    let right_width = available.div_ceil(2); // Bias toward end (more useful info usually)
165    let left_width = available - right_width;
166
167    let left = truncate_to_display_width(s, left_width);
168    let right = find_suffix_with_width(s, right_width);
169
170    format!("{}{}{}", left, ellipsis, right)
171}
172
173/// Pads a string on the left (right-aligns) to reach the target width.
174///
175/// ANSI escape codes are preserved and don't count toward width calculations.
176///
177/// **Warning:** This does not strip BBCode tags—they will be counted toward
178/// the display width. For tagged content, compute padding manually using
179/// [`visible_width`] and `" ".repeat(padding)`.
180///
181/// # Example
182///
183/// ```rust
184/// use standout_render::tabular::pad_left;
185///
186/// assert_eq!(pad_left("42", 5), "   42");
187/// assert_eq!(pad_left("hello", 3), "hello");  // No truncation
188/// ```
189pub fn pad_left(s: &str, width: usize) -> String {
190    pad_str(s, width, Alignment::Right, None).into_owned()
191}
192
193/// Pads a string on the right (left-aligns) to reach the target width.
194///
195/// ANSI escape codes are preserved and don't count toward width calculations.
196///
197/// **Warning:** This does not strip BBCode tags—they will be counted toward
198/// the display width. For tagged content, compute padding manually using
199/// [`visible_width`] and `" ".repeat(padding)`.
200///
201/// # Example
202///
203/// ```rust
204/// use standout_render::tabular::pad_right;
205///
206/// assert_eq!(pad_right("42", 5), "42   ");
207/// assert_eq!(pad_right("hello", 3), "hello");  // No truncation
208/// ```
209pub fn pad_right(s: &str, width: usize) -> String {
210    pad_str(s, width, Alignment::Left, None).into_owned()
211}
212
213/// Pads a string on both sides (centers) to reach the target width.
214///
215/// When the remaining space is odd, the extra space goes on the right.
216/// ANSI escape codes are preserved and don't count toward width calculations.
217///
218/// **Warning:** This does not strip BBCode tags—they will be counted toward
219/// the display width. For tagged content, compute padding manually using
220/// [`visible_width`] and `" ".repeat(padding)`.
221///
222/// # Example
223///
224/// ```rust
225/// use standout_render::tabular::pad_center;
226///
227/// assert_eq!(pad_center("hi", 6), "  hi  ");
228/// assert_eq!(pad_center("hi", 5), " hi  ");  // Extra space on right
229/// ```
230pub fn pad_center(s: &str, width: usize) -> String {
231    pad_str(s, width, Alignment::Center, None).into_owned()
232}
233
234/// Wraps text to fit within a maximum display width, breaking at word boundaries.
235///
236/// Returns a vector of lines, each fitting within the specified width.
237/// Words longer than the width are force-broken to fit.
238///
239/// ANSI escape codes are preserved and don't count toward width calculations.
240///
241/// # Arguments
242///
243/// * `s` - The string to wrap
244/// * `width` - Maximum display width for each line
245///
246/// # Example
247///
248/// ```rust
249/// use standout_render::tabular::wrap;
250///
251/// let lines = wrap("hello world foo", 11);
252/// assert_eq!(lines, vec!["hello world", "foo"]);
253///
254/// let lines = wrap("short", 20);
255/// assert_eq!(lines, vec!["short"]);
256///
257/// // Long words are force-broken with ellipsis markers
258/// let lines = wrap("supercalifragilistic", 10);
259/// assert!(lines.len() >= 2);
260/// for line in &lines {
261///     assert!(standout_render::tabular::display_width(line) <= 10);
262/// }
263/// ```
264pub fn wrap(s: &str, width: usize) -> Vec<String> {
265    wrap_indent(s, width, 0)
266}
267
268/// Wraps text with a continuation indent on subsequent lines.
269///
270/// The first line uses the full width. Subsequent lines are indented by the
271/// specified amount, reducing their effective width.
272///
273/// ANSI escape codes are preserved and don't count toward width calculations.
274///
275/// # Arguments
276///
277/// * `s` - The string to wrap
278/// * `width` - Maximum display width for each line
279/// * `indent` - Number of spaces to indent continuation lines
280///
281/// # Example
282///
283/// ```rust
284/// use standout_render::tabular::wrap_indent;
285///
286/// let lines = wrap_indent("hello world foo bar", 12, 2);
287/// assert_eq!(lines, vec!["hello world", "  foo bar"]);
288/// ```
289pub fn wrap_indent(s: &str, width: usize, indent: usize) -> Vec<String> {
290    if width == 0 {
291        return vec![];
292    }
293
294    let s = s.trim();
295    if s.is_empty() {
296        return vec![];
297    }
298
299    // If the whole string fits, return it directly
300    if measure_text_width(s) <= width {
301        return vec![s.to_string()];
302    }
303
304    let mut lines = Vec::new();
305    let mut current_line = String::new();
306    let mut current_width = 0;
307    let mut is_first_line = true;
308
309    // Split on whitespace, preserving the structure
310    for word in s.split_whitespace() {
311        let word_width = measure_text_width(word);
312        let effective_width = if is_first_line {
313            width
314        } else {
315            width.saturating_sub(indent)
316        };
317
318        // Handle words longer than available width
319        if word_width > effective_width {
320            // Finish current line if it has content
321            if !current_line.is_empty() {
322                lines.push(current_line);
323                current_line = String::new();
324                current_width = 0;
325                is_first_line = false;
326            }
327
328            // Force-break the long word
329            let broken = break_long_word(word, effective_width, indent, is_first_line);
330            let broken_len = broken.len();
331            for (i, part) in broken.into_iter().enumerate() {
332                if i == 0 && is_first_line {
333                    lines.push(part);
334                    is_first_line = false;
335                } else if i < broken_len - 1 {
336                    // Not the last part - push as complete line
337                    lines.push(part);
338                } else {
339                    // Last part - becomes the start of the next line
340                    current_line = part;
341                    current_width = measure_text_width(&current_line);
342                }
343            }
344            continue;
345        }
346
347        // Check if word fits on current line
348        let needed_width = if current_line.is_empty() {
349            word_width
350        } else {
351            current_width + 1 + word_width // +1 for space
352        };
353
354        if needed_width <= effective_width {
355            // Word fits - add to current line
356            if !current_line.is_empty() {
357                current_line.push(' ');
358                current_width += 1;
359            }
360            current_line.push_str(word);
361            current_width += word_width;
362        } else {
363            // Word doesn't fit - start new line
364            if !current_line.is_empty() {
365                lines.push(current_line);
366            }
367            is_first_line = false;
368
369            // Start new line with indent
370            let indent_str: String = " ".repeat(indent);
371            current_line = format!("{}{}", indent_str, word);
372            current_width = indent + word_width;
373        }
374    }
375
376    // Don't forget the last line
377    if !current_line.is_empty() {
378        lines.push(current_line);
379    }
380
381    // Handle edge case where we produced no lines (shouldn't happen with non-empty input)
382    if lines.is_empty() && !s.is_empty() {
383        lines.push(truncate_to_display_width(s, width));
384    }
385
386    lines
387}
388
389/// Break a word that's longer than the available width into multiple parts.
390fn break_long_word(word: &str, width: usize, indent: usize, is_first: bool) -> Vec<String> {
391    let mut parts = Vec::new();
392    let mut remaining = word;
393    let mut first_part = is_first;
394
395    while !remaining.is_empty() {
396        let effective_width = if first_part {
397            width
398        } else {
399            width.saturating_sub(indent)
400        };
401
402        if effective_width == 0 {
403            // Can't fit anything - just return what we have
404            break;
405        }
406
407        let remaining_width = measure_text_width(remaining);
408        if remaining_width <= effective_width {
409            // Rest fits
410            let prefix = if first_part {
411                String::new()
412            } else {
413                " ".repeat(indent)
414            };
415            parts.push(format!("{}{}", prefix, remaining));
416            break;
417        }
418
419        // Need to break - leave room for ellipsis to indicate continuation
420        let break_width = effective_width.saturating_sub(1); // -1 for "…"
421        if break_width == 0 {
422            // Not enough room even for one char + ellipsis
423            let prefix = if first_part {
424                String::new()
425            } else {
426                " ".repeat(indent)
427            };
428            parts.push(format!("{}…", prefix));
429            break;
430        }
431
432        let prefix = if first_part {
433            String::new()
434        } else {
435            " ".repeat(indent)
436        };
437        let truncated = truncate_to_display_width(remaining, break_width);
438        parts.push(format!("{}{}…", prefix, truncated));
439
440        // Find where we actually cut in the original string
441        let truncated_len = truncated.chars().count();
442        remaining = &remaining[remaining
443            .char_indices()
444            .nth(truncated_len)
445            .map(|(i, _)| i)
446            .unwrap_or(remaining.len())..];
447        first_part = false;
448    }
449
450    parts
451}
452
453// --- Internal helpers ---
454
455/// Truncate string to fit display width, keeping characters from the start.
456/// Handles ANSI escape codes properly.
457fn truncate_to_display_width(s: &str, max_width: usize) -> String {
458    if max_width == 0 {
459        return String::new();
460    }
461
462    // Fast path: if string fits, return as-is
463    if measure_text_width(s) <= max_width {
464        return s.to_string();
465    }
466
467    // We need to walk through the string carefully, tracking both
468    // printable width and ANSI escape sequences
469    let mut result = String::new();
470    let mut current_width = 0;
471    let chars = s.chars().peekable();
472    let mut in_escape = false;
473
474    for c in chars {
475        if c == '\x1b' {
476            // Start of ANSI escape sequence - include it all
477            result.push(c);
478            in_escape = true;
479            continue;
480        }
481
482        if in_escape {
483            result.push(c);
484            // ANSI CSI sequences end with a letter (@ through ~)
485            if c.is_ascii_alphabetic() || c == '~' {
486                in_escape = false;
487            }
488            continue;
489        }
490
491        // Regular character - check width
492        let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
493        if current_width + char_width > max_width {
494            break;
495        }
496        result.push(c);
497        current_width += char_width;
498    }
499
500    result
501}
502
503/// Find the longest suffix of s that has display width <= max_width.
504fn find_suffix_with_width(s: &str, max_width: usize) -> String {
505    if max_width == 0 {
506        return String::new();
507    }
508
509    let total_width = measure_text_width(s);
510    if total_width <= max_width {
511        return s.to_string();
512    }
513
514    // Linear scan from the start to find where to cut.
515    // We need to skip (total_width - max_width) display columns.
516    let skip_width = total_width - max_width;
517
518    let mut current_width = 0;
519    let mut byte_offset = 0;
520    let mut in_escape = false;
521
522    for (i, c) in s.char_indices() {
523        if c == '\x1b' {
524            in_escape = true;
525            byte_offset = i + c.len_utf8();
526            continue;
527        }
528
529        if in_escape {
530            byte_offset = i + c.len_utf8();
531            if c.is_ascii_alphabetic() || c == '~' {
532                in_escape = false;
533            }
534            continue;
535        }
536
537        let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
538        current_width += char_width;
539        byte_offset = i + c.len_utf8();
540
541        if current_width >= skip_width {
542            break;
543        }
544    }
545
546    s[byte_offset..].to_string()
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    // --- display_width tests ---
554
555    #[test]
556    fn display_width_ascii() {
557        assert_eq!(display_width("hello"), 5);
558        assert_eq!(display_width(""), 0);
559        assert_eq!(display_width(" "), 1);
560    }
561
562    #[test]
563    fn display_width_ansi() {
564        assert_eq!(display_width("\x1b[31mred\x1b[0m"), 3);
565        assert_eq!(display_width("\x1b[1;32mbold green\x1b[0m"), 10);
566        assert_eq!(display_width("\x1b[38;5;196mcolor\x1b[0m"), 5);
567    }
568
569    #[test]
570    fn display_width_unicode() {
571        assert_eq!(display_width("日本語"), 6); // 3 chars, 2 columns each
572        assert_eq!(display_width("café"), 4);
573        assert_eq!(display_width("🎉"), 2); // Emoji typically 2 columns
574    }
575
576    // --- truncate_end tests ---
577
578    #[test]
579    fn truncate_end_no_truncation() {
580        assert_eq!(truncate_end("hello", 10, "…"), "hello");
581        assert_eq!(truncate_end("hello", 5, "…"), "hello");
582    }
583
584    #[test]
585    fn truncate_end_basic() {
586        assert_eq!(truncate_end("hello world", 8, "…"), "hello w…");
587        assert_eq!(truncate_end("hello world", 6, "…"), "hello…");
588    }
589
590    #[test]
591    fn truncate_end_multi_char_ellipsis() {
592        assert_eq!(truncate_end("hello world", 8, "..."), "hello...");
593    }
594
595    #[test]
596    fn truncate_end_exact_fit() {
597        assert_eq!(truncate_end("hello", 5, "…"), "hello");
598    }
599
600    #[test]
601    fn truncate_end_tiny_width() {
602        assert_eq!(truncate_end("hello", 1, "…"), "…");
603        assert_eq!(truncate_end("hello", 0, "…"), "");
604    }
605
606    #[test]
607    fn truncate_end_ansi() {
608        let styled = "\x1b[31mhello world\x1b[0m";
609        let result = truncate_end(styled, 8, "…");
610        assert_eq!(display_width(&result), 8);
611        assert!(result.contains("\x1b[31m")); // ANSI preserved
612    }
613
614    #[test]
615    fn truncate_end_cjk() {
616        assert_eq!(truncate_end("日本語テスト", 7, "…"), "日本語…"); // 3 chars (6 cols) + ellipsis
617    }
618
619    // --- truncate_start tests ---
620
621    #[test]
622    fn truncate_start_no_truncation() {
623        assert_eq!(truncate_start("hello", 10, "…"), "hello");
624    }
625
626    #[test]
627    fn truncate_start_basic() {
628        assert_eq!(truncate_start("hello world", 8, "…"), "…o world");
629    }
630
631    #[test]
632    fn truncate_start_path() {
633        assert_eq!(truncate_start("/path/to/file.rs", 12, "…"), "…/to/file.rs");
634    }
635
636    #[test]
637    fn truncate_start_tiny_width() {
638        assert_eq!(truncate_start("hello", 1, "…"), "…");
639        assert_eq!(truncate_start("hello", 0, "…"), "");
640    }
641
642    // --- truncate_middle tests ---
643
644    #[test]
645    fn truncate_middle_no_truncation() {
646        assert_eq!(truncate_middle("hello", 10, "…"), "hello");
647    }
648
649    #[test]
650    fn truncate_middle_basic() {
651        assert_eq!(truncate_middle("hello world", 8, "…"), "hel…orld");
652    }
653
654    #[test]
655    fn truncate_middle_multi_char_ellipsis() {
656        assert_eq!(truncate_middle("abcdefghij", 7, "..."), "ab...ij");
657    }
658
659    #[test]
660    fn truncate_middle_tiny_width() {
661        assert_eq!(truncate_middle("hello", 1, "…"), "…");
662        assert_eq!(truncate_middle("hello", 0, "…"), "");
663    }
664
665    #[test]
666    fn truncate_middle_even_split() {
667        // 10 chars, max 6, ellipsis 1 = 5 available, split 2/3 (bias toward end)
668        assert_eq!(truncate_middle("abcdefghij", 6, "…"), "ab…hij");
669    }
670
671    // --- pad_left tests ---
672
673    #[test]
674    fn pad_left_basic() {
675        assert_eq!(pad_left("42", 5), "   42");
676        assert_eq!(pad_left("hello", 10), "     hello");
677    }
678
679    #[test]
680    fn pad_left_no_padding_needed() {
681        assert_eq!(pad_left("hello", 5), "hello");
682        assert_eq!(pad_left("hello", 3), "hello"); // No truncation
683    }
684
685    #[test]
686    fn pad_left_empty() {
687        assert_eq!(pad_left("", 5), "     ");
688    }
689
690    #[test]
691    fn pad_left_ansi() {
692        let styled = "\x1b[31mhi\x1b[0m";
693        let result = pad_left(styled, 5);
694        assert!(result.ends_with("\x1b[0m"));
695        assert_eq!(display_width(&result), 5);
696    }
697
698    // --- pad_right tests ---
699
700    #[test]
701    fn pad_right_basic() {
702        assert_eq!(pad_right("42", 5), "42   ");
703        assert_eq!(pad_right("hello", 10), "hello     ");
704    }
705
706    #[test]
707    fn pad_right_no_padding_needed() {
708        assert_eq!(pad_right("hello", 5), "hello");
709        assert_eq!(pad_right("hello", 3), "hello");
710    }
711
712    #[test]
713    fn pad_right_empty() {
714        assert_eq!(pad_right("", 5), "     ");
715    }
716
717    // --- pad_center tests ---
718
719    #[test]
720    fn pad_center_basic() {
721        assert_eq!(pad_center("hi", 6), "  hi  ");
722    }
723
724    #[test]
725    fn pad_center_odd_space() {
726        assert_eq!(pad_center("hi", 5), " hi  "); // Extra space on right
727    }
728
729    #[test]
730    fn pad_center_no_padding() {
731        assert_eq!(pad_center("hello", 5), "hello");
732        assert_eq!(pad_center("hello", 3), "hello");
733    }
734
735    #[test]
736    fn pad_center_empty() {
737        assert_eq!(pad_center("", 4), "    ");
738    }
739
740    // --- Edge cases ---
741
742    #[test]
743    fn empty_string_operations() {
744        assert_eq!(display_width(""), 0);
745        assert_eq!(truncate_end("", 5, "…"), "");
746        assert_eq!(truncate_start("", 5, "…"), "");
747        assert_eq!(truncate_middle("", 5, "…"), "");
748        assert_eq!(pad_left("", 0), "");
749        assert_eq!(pad_right("", 0), "");
750    }
751
752    #[test]
753    fn zero_width_target() {
754        assert_eq!(truncate_end("hello", 0, "…"), "");
755        assert_eq!(truncate_start("hello", 0, "…"), "");
756        assert_eq!(truncate_middle("hello", 0, "…"), "");
757    }
758
759    // --- wrap tests ---
760
761    #[test]
762    fn wrap_single_line_fits() {
763        assert_eq!(wrap("hello world", 20), vec!["hello world"]);
764        assert_eq!(wrap("short", 10), vec!["short"]);
765    }
766
767    #[test]
768    fn wrap_basic_multiline() {
769        assert_eq!(wrap("hello world foo", 11), vec!["hello world", "foo"]);
770        assert_eq!(
771            wrap("one two three four", 10),
772            vec!["one two", "three four"]
773        );
774    }
775
776    #[test]
777    fn wrap_exact_fit() {
778        assert_eq!(wrap("hello", 5), vec!["hello"]);
779        assert_eq!(wrap("hello world", 11), vec!["hello world"]);
780    }
781
782    #[test]
783    fn wrap_empty_string() {
784        let result: Vec<String> = wrap("", 10);
785        assert!(result.is_empty());
786    }
787
788    #[test]
789    fn wrap_whitespace_only() {
790        let result: Vec<String> = wrap("   ", 10);
791        assert!(result.is_empty());
792    }
793
794    #[test]
795    fn wrap_zero_width() {
796        let result: Vec<String> = wrap("hello", 0);
797        assert!(result.is_empty());
798    }
799
800    #[test]
801    fn wrap_single_word_per_line() {
802        assert_eq!(wrap("a b c d", 1), vec!["a", "b", "c", "d"]);
803    }
804
805    #[test]
806    fn wrap_long_word_force_break() {
807        // "supercalifragilistic" is 20 chars, width 10
808        // With ellipsis breaks: "supercali…" (10), "fragilis…" (10), "tic" (3)
809        let result = wrap("supercalifragilistic", 10);
810        assert!(result.len() >= 2, "should produce multiple lines");
811        for line in &result {
812            assert!(display_width(line) <= 10, "line '{}' exceeds width", line);
813        }
814    }
815
816    #[test]
817    fn wrap_preserves_word_boundaries() {
818        let result = wrap("hello world test", 10);
819        // Should not break "hello" or "world" in the middle
820        assert_eq!(result[0], "hello");
821        assert_eq!(result[1], "world test");
822    }
823
824    #[test]
825    fn wrap_multiple_spaces_normalized_when_wrapping() {
826        // When wrapping occurs, multiple spaces between words get normalized
827        // because we split_whitespace and rejoin with single spaces
828        let result = wrap("hello    world    foo", 12);
829        // "hello world" (11) fits, "foo" goes to next line
830        assert_eq!(result, vec!["hello world", "foo"]);
831    }
832
833    // --- wrap_indent tests ---
834
835    #[test]
836    fn wrap_indent_basic() {
837        let result = wrap_indent("hello world foo bar", 12, 2);
838        assert_eq!(result.len(), 2);
839        assert_eq!(result[0], "hello world");
840        assert!(result[1].starts_with("  ")); // 2-space indent
841    }
842
843    #[test]
844    fn wrap_indent_no_wrap_needed() {
845        assert_eq!(wrap_indent("short", 20, 4), vec!["short"]);
846    }
847
848    #[test]
849    fn wrap_indent_multiple_lines() {
850        let result = wrap_indent("one two three four five six", 10, 2);
851        // First line: no indent, up to 10 chars
852        // Subsequent: 2-char indent, effective width 8
853        assert!(!result[0].starts_with(' '));
854        for line in result.iter().skip(1) {
855            assert!(line.starts_with("  "), "continuation should be indented");
856        }
857    }
858
859    #[test]
860    fn wrap_indent_zero_indent() {
861        // Same as regular wrap
862        let result = wrap_indent("hello world foo", 11, 0);
863        assert_eq!(result, vec!["hello world", "foo"]);
864    }
865
866    #[test]
867    fn wrap_cjk_characters() {
868        // CJK characters are 2 columns each
869        // "日本語" is 6 columns
870        let result = wrap("日本語 テスト", 8);
871        assert_eq!(result.len(), 2);
872        for line in &result {
873            assert!(display_width(line) <= 8);
874        }
875    }
876}
877
878#[cfg(test)]
879mod proptests {
880    use super::*;
881    use proptest::prelude::*;
882
883    proptest! {
884        #[test]
885        fn truncate_end_respects_max_width(
886            s in "[a-zA-Z0-9 ]{0,100}",
887            max_width in 0usize..50,
888        ) {
889            let result = truncate_end(&s, max_width, "…");
890            let result_width = display_width(&result);
891            prop_assert!(
892                result_width <= max_width,
893                "truncate_end exceeded max_width: result '{}' has width {}, max was {}",
894                result, result_width, max_width
895            );
896        }
897
898        #[test]
899        fn truncate_start_respects_max_width(
900            s in "[a-zA-Z0-9 ]{0,100}",
901            max_width in 0usize..50,
902        ) {
903            let result = truncate_start(&s, max_width, "…");
904            let result_width = display_width(&result);
905            prop_assert!(
906                result_width <= max_width,
907                "truncate_start exceeded max_width: result '{}' has width {}, max was {}",
908                result, result_width, max_width
909            );
910        }
911
912        #[test]
913        fn truncate_middle_respects_max_width(
914            s in "[a-zA-Z0-9 ]{0,100}",
915            max_width in 0usize..50,
916        ) {
917            let result = truncate_middle(&s, max_width, "…");
918            let result_width = display_width(&result);
919            prop_assert!(
920                result_width <= max_width,
921                "truncate_middle exceeded max_width: result '{}' has width {}, max was {}",
922                result, result_width, max_width
923            );
924        }
925
926        #[test]
927        fn truncate_preserves_short_strings(
928            s in "[a-zA-Z0-9]{0,20}",
929            extra_width in 0usize..30,
930        ) {
931            let width = display_width(&s);
932            let max_width = width + extra_width;
933
934            // If string fits, it should be unchanged
935            prop_assert_eq!(truncate_end(&s, max_width, "…"), s.clone());
936            prop_assert_eq!(truncate_start(&s, max_width, "…"), s.clone());
937            prop_assert_eq!(truncate_middle(&s, max_width, "…"), s);
938        }
939
940        #[test]
941        fn pad_produces_exact_width_when_larger(
942            s in "[a-zA-Z0-9]{0,20}",
943            extra in 1usize..30,
944        ) {
945            let original_width = display_width(&s);
946            let target_width = original_width + extra;
947
948            prop_assert_eq!(display_width(&pad_left(&s, target_width)), target_width);
949            prop_assert_eq!(display_width(&pad_right(&s, target_width)), target_width);
950            prop_assert_eq!(display_width(&pad_center(&s, target_width)), target_width);
951        }
952
953        #[test]
954        fn pad_preserves_content_when_smaller(
955            s in "[a-zA-Z0-9]{1,30}",
956        ) {
957            let original_width = display_width(&s);
958            let target_width = original_width.saturating_sub(5);
959
960            // When target is smaller, string should be unchanged
961            prop_assert_eq!(pad_left(&s, target_width), s.clone());
962            prop_assert_eq!(pad_right(&s, target_width), s.clone());
963            prop_assert_eq!(pad_center(&s, target_width), s);
964        }
965
966        #[test]
967        fn truncate_end_contains_ellipsis_when_truncated(
968            s in "[a-zA-Z0-9]{10,50}",
969            max_width in 3usize..9,
970        ) {
971            let result = truncate_end(&s, max_width, "…");
972            if display_width(&s) > max_width {
973                prop_assert!(
974                    result.contains("…"),
975                    "truncated string should contain ellipsis"
976                );
977            }
978        }
979
980        #[test]
981        fn truncate_start_contains_ellipsis_when_truncated(
982            s in "[a-zA-Z0-9]{10,50}",
983            max_width in 3usize..9,
984        ) {
985            let result = truncate_start(&s, max_width, "…");
986            if display_width(&s) > max_width {
987                prop_assert!(
988                    result.contains("…"),
989                    "truncated string should contain ellipsis"
990                );
991            }
992        }
993
994        #[test]
995        fn truncate_middle_contains_ellipsis_when_truncated(
996            s in "[a-zA-Z0-9]{10,50}",
997            max_width in 3usize..9,
998        ) {
999            let result = truncate_middle(&s, max_width, "…");
1000            if display_width(&s) > max_width {
1001                prop_assert!(
1002                    result.contains("…"),
1003                    "truncated string should contain ellipsis"
1004                );
1005            }
1006        }
1007
1008        #[test]
1009        fn wrap_all_lines_respect_width(
1010            s in "[a-zA-Z]{1,10}( [a-zA-Z]{1,10}){0,10}",
1011            width in 5usize..30,
1012        ) {
1013            let lines = wrap(&s, width);
1014            for line in &lines {
1015                let line_width = display_width(line);
1016                prop_assert!(
1017                    line_width <= width,
1018                    "wrap produced line '{}' with width {}, max was {}",
1019                    line, line_width, width
1020                );
1021            }
1022        }
1023
1024        #[test]
1025        fn wrap_preserves_all_words(
1026            words in prop::collection::vec("[a-zA-Z]{1,8}", 1..10),
1027            width in 10usize..40,
1028        ) {
1029            let input = words.join(" ");
1030            let lines = wrap(&input, width);
1031            let rejoined = lines.join(" ");
1032
1033            // All original words should appear in the output
1034            for word in &words {
1035                prop_assert!(
1036                    rejoined.contains(word),
1037                    "word '{}' missing from wrapped output",
1038                    word
1039                );
1040            }
1041        }
1042
1043        #[test]
1044        fn wrap_indent_continuation_lines_are_indented(
1045            s in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){3,8}",
1046            width in 10usize..20,
1047            indent in 1usize..4,
1048        ) {
1049            let lines = wrap_indent(&s, width, indent);
1050            if lines.len() > 1 {
1051                let indent_str: String = " ".repeat(indent);
1052                for line in lines.iter().skip(1) {
1053                    prop_assert!(
1054                        line.starts_with(&indent_str),
1055                        "continuation line '{}' should start with {} spaces",
1056                        line, indent
1057                    );
1058                }
1059            }
1060        }
1061
1062        #[test]
1063        fn wrap_nonempty_input_produces_nonempty_output(
1064            s in "[a-zA-Z]{1,20}",
1065            width in 1usize..30,
1066        ) {
1067            let lines = wrap(&s, width);
1068            prop_assert!(
1069                !lines.is_empty(),
1070                "non-empty input '{}' should produce non-empty output",
1071                s
1072            );
1073        }
1074    }
1075}