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