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