Skip to main content

jj_cli/
text_util.rs

1// Copyright 2022-2023 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::borrow::Cow;
16use std::cmp;
17use std::io;
18
19use bstr::ByteSlice as _;
20use unicode_width::UnicodeWidthChar as _;
21use unicode_width::UnicodeWidthStr as _;
22
23use crate::formatter::FormatRecorder;
24use crate::formatter::Formatter;
25
26pub fn complete_newline(s: impl Into<String>) -> String {
27    let mut s = s.into();
28    if !s.is_empty() && !s.ends_with('\n') {
29        s.push('\n');
30    }
31    s
32}
33
34pub fn split_email(email: &str) -> (&str, Option<&str>) {
35    if let Some((username, rest)) = email.split_once('@') {
36        (username, Some(rest))
37    } else {
38        (email, None)
39    }
40}
41
42/// Shortens `text` to `max_width` by removing leading characters. `ellipsis` is
43/// added if the `text` gets truncated.
44///
45/// The returned string (including `ellipsis`) never exceeds the `max_width`.
46pub fn elide_start<'a>(
47    text: &'a str,
48    ellipsis: &'a str,
49    max_width: usize,
50) -> (Cow<'a, str>, usize) {
51    let (text_start, text_width) = truncate_start_pos(text, max_width);
52    if text_start == 0 {
53        return (Cow::Borrowed(text), text_width);
54    }
55
56    let (ellipsis_start, ellipsis_width) = truncate_start_pos(ellipsis, max_width);
57    if ellipsis_start != 0 {
58        let ellipsis = trim_start_zero_width_chars(&ellipsis[ellipsis_start..]);
59        return (Cow::Borrowed(ellipsis), ellipsis_width);
60    }
61
62    let text = &text[text_start..];
63    let max_text_width = max_width - ellipsis_width;
64    let (skip, skipped_width) = skip_start_pos(text, text_width.saturating_sub(max_text_width));
65    let text = trim_start_zero_width_chars(&text[skip..]);
66    let concat_width = ellipsis_width + (text_width - skipped_width);
67    assert!(concat_width <= max_width);
68    (Cow::Owned([ellipsis, text].concat()), concat_width)
69}
70
71/// Shortens `text` to `max_width` by removing trailing characters. `ellipsis`
72/// is added if the `text` gets truncated.
73///
74/// The returned string (including `ellipsis`) never exceeds the `max_width`.
75pub fn elide_end<'a>(text: &'a str, ellipsis: &'a str, max_width: usize) -> (Cow<'a, str>, usize) {
76    let (text_end, text_width) = truncate_end_pos(text, max_width);
77    if text_end == text.len() {
78        return (Cow::Borrowed(text), text_width);
79    }
80
81    let (ellipsis_end, ellipsis_width) = truncate_end_pos(ellipsis, max_width);
82    if ellipsis_end != ellipsis.len() {
83        let ellipsis = &ellipsis[..ellipsis_end];
84        return (Cow::Borrowed(ellipsis), ellipsis_width);
85    }
86
87    let text = &text[..text_end];
88    let max_text_width = max_width - ellipsis_width;
89    let (skip, skipped_width) = skip_end_pos(text, text_width.saturating_sub(max_text_width));
90    let text = &text[..skip];
91    let concat_width = (text_width - skipped_width) + ellipsis_width;
92    assert!(concat_width <= max_width);
93    (Cow::Owned([text, ellipsis].concat()), concat_width)
94}
95
96/// Shortens `text` to `max_width` by removing leading characters, returning
97/// `(start_index, width)`.
98///
99/// The truncated string may have 0-width decomposed characters at start.
100fn truncate_start_pos(text: &str, max_width: usize) -> (usize, usize) {
101    truncate_start_pos_with_indices(
102        text.char_indices()
103            .rev()
104            .map(|(start, c)| (start + c.len_utf8(), c)),
105        max_width,
106    )
107}
108
109fn truncate_start_pos_bytes(text: &[u8], max_width: usize) -> (usize, usize) {
110    truncate_start_pos_with_indices(
111        text.char_indices().rev().map(|(_, end, c)| (end, c)),
112        max_width,
113    )
114}
115
116fn truncate_start_pos_with_indices(
117    char_indices_rev: impl Iterator<Item = (usize, char)>,
118    max_width: usize,
119) -> (usize, usize) {
120    let mut acc_width = 0;
121    for (end, c) in char_indices_rev {
122        let new_width = acc_width + c.width().unwrap_or(0);
123        if new_width > max_width {
124            return (end, acc_width);
125        }
126        acc_width = new_width;
127    }
128    (0, acc_width)
129}
130
131/// Shortens `text` to `max_width` by removing trailing characters, returning
132/// `(end_index, width)`.
133fn truncate_end_pos(text: &str, max_width: usize) -> (usize, usize) {
134    truncate_end_pos_with_indices(text.char_indices(), text.len(), max_width)
135}
136
137fn truncate_end_pos_bytes(text: &[u8], max_width: usize) -> (usize, usize) {
138    truncate_end_pos_with_indices(
139        text.char_indices().map(|(start, _, c)| (start, c)),
140        text.len(),
141        max_width,
142    )
143}
144
145fn truncate_end_pos_with_indices(
146    char_indices_fwd: impl Iterator<Item = (usize, char)>,
147    text_len: usize,
148    max_width: usize,
149) -> (usize, usize) {
150    let mut acc_width = 0;
151    for (start, c) in char_indices_fwd {
152        let new_width = acc_width + c.width().unwrap_or(0);
153        if new_width > max_width {
154            return (start, acc_width);
155        }
156        acc_width = new_width;
157    }
158    (text_len, acc_width)
159}
160
161/// Skips `width` leading characters, returning `(start_index, skipped_width)`.
162///
163/// The `skipped_width` may exceed the given `width` if `width` is not at
164/// character boundary.
165///
166/// The truncated string may have 0-width decomposed characters at start.
167fn skip_start_pos(text: &str, width: usize) -> (usize, usize) {
168    skip_start_pos_with_indices(text.char_indices(), text.len(), width)
169}
170
171fn skip_start_pos_with_indices(
172    char_indices_fwd: impl Iterator<Item = (usize, char)>,
173    text_len: usize,
174    width: usize,
175) -> (usize, usize) {
176    let mut acc_width = 0;
177    for (start, c) in char_indices_fwd {
178        if acc_width >= width {
179            return (start, acc_width);
180        }
181        acc_width += c.width().unwrap_or(0);
182    }
183    (text_len, acc_width)
184}
185
186/// Skips `width` trailing characters, returning `(end_index, skipped_width)`.
187///
188/// The `skipped_width` may exceed the given `width` if `width` is not at
189/// character boundary.
190fn skip_end_pos(text: &str, width: usize) -> (usize, usize) {
191    skip_end_pos_with_indices(
192        text.char_indices()
193            .rev()
194            .map(|(start, c)| (start + c.len_utf8(), c)),
195        width,
196    )
197}
198
199fn skip_end_pos_with_indices(
200    char_indices_rev: impl Iterator<Item = (usize, char)>,
201    width: usize,
202) -> (usize, usize) {
203    let mut acc_width = 0;
204    for (end, c) in char_indices_rev {
205        if acc_width >= width {
206            return (end, acc_width);
207        }
208        acc_width += c.width().unwrap_or(0);
209    }
210    (0, acc_width)
211}
212
213/// Removes leading 0-width characters.
214fn trim_start_zero_width_chars(text: &str) -> &str {
215    text.trim_start_matches(|c: char| c.width().unwrap_or(0) == 0)
216}
217
218/// Returns bytes length of leading 0-width characters.
219fn count_start_zero_width_chars_bytes(text: &[u8]) -> usize {
220    text.char_indices()
221        .find(|(_, _, c)| c.width().unwrap_or(0) != 0)
222        .map(|(start, _, _)| start)
223        .unwrap_or(text.len())
224}
225
226/// Writes text truncated to `max_width` by removing leading characters. Returns
227/// width of the truncated text, which may be shorter than `max_width`.
228///
229/// The input `recorded_content` should be a single-line text.
230pub fn write_truncated_start(
231    formatter: &mut dyn Formatter,
232    recorded_content: &FormatRecorder,
233    recorded_ellipsis: &FormatRecorder,
234    max_width: usize,
235) -> io::Result<usize> {
236    let data = recorded_content.data();
237    let data_width = String::from_utf8_lossy(data).width();
238    let ellipsis_data = recorded_ellipsis.data();
239    let ellipsis_width = String::from_utf8_lossy(ellipsis_data).width();
240
241    let (start, mut truncated_width) = if data_width > max_width {
242        truncate_start_pos_bytes(data, max_width.saturating_sub(ellipsis_width))
243    } else {
244        (0, data_width)
245    };
246
247    let mut replay_truncated = |recorded: &FormatRecorder, truncated_start: usize| {
248        recorded.replay_with(formatter, |formatter, range| {
249            let start = cmp::max(range.start, truncated_start);
250            if start < range.end {
251                formatter.write_all(&recorded.data()[start..range.end])?;
252            }
253            Ok(())
254        })
255    };
256
257    if data_width > max_width {
258        // The ellipsis itself may be larger than max_width, so maybe truncate it too.
259        let (start, ellipsis_width) = truncate_start_pos_bytes(ellipsis_data, max_width);
260        let truncated_start = start + count_start_zero_width_chars_bytes(&ellipsis_data[start..]);
261        truncated_width += ellipsis_width;
262        replay_truncated(recorded_ellipsis, truncated_start)?;
263    }
264    let truncated_start = start + count_start_zero_width_chars_bytes(&data[start..]);
265    replay_truncated(recorded_content, truncated_start)?;
266    Ok(truncated_width)
267}
268
269/// Writes text truncated to `max_width` by removing trailing characters.
270/// Returns width of the truncated text, which may be shorter than `max_width`.
271///
272/// The input `recorded_content` should be a single-line text.
273pub fn write_truncated_end(
274    formatter: &mut dyn Formatter,
275    recorded_content: &FormatRecorder,
276    recorded_ellipsis: &FormatRecorder,
277    max_width: usize,
278) -> io::Result<usize> {
279    let data = recorded_content.data();
280    let data_width = String::from_utf8_lossy(data).width();
281    let ellipsis_data = recorded_ellipsis.data();
282    let ellipsis_width = String::from_utf8_lossy(ellipsis_data).width();
283
284    let (truncated_end, mut truncated_width) = if data_width > max_width {
285        truncate_end_pos_bytes(data, max_width.saturating_sub(ellipsis_width))
286    } else {
287        (data.len(), data_width)
288    };
289
290    let mut replay_truncated = |recorded: &FormatRecorder, truncated_end: usize| {
291        recorded.replay_with(formatter, |formatter, range| {
292            let end = cmp::min(range.end, truncated_end);
293            if range.start < end {
294                formatter.write_all(&recorded.data()[range.start..end])?;
295            }
296            Ok(())
297        })
298    };
299
300    replay_truncated(recorded_content, truncated_end)?;
301    if data_width > max_width {
302        // The ellipsis itself may be larger than max_width, so maybe truncate it too.
303        let (truncated_end, ellipsis_width) = truncate_end_pos_bytes(ellipsis_data, max_width);
304        truncated_width += ellipsis_width;
305        replay_truncated(recorded_ellipsis, truncated_end)?;
306    }
307    Ok(truncated_width)
308}
309
310/// Writes text padded to `min_width` by adding leading fill characters.
311///
312/// The input `recorded_content` should be a single-line text. The
313/// `recorded_fill_char` should be bytes of 1-width character.
314pub fn write_padded_start(
315    formatter: &mut dyn Formatter,
316    recorded_content: &FormatRecorder,
317    recorded_fill_char: &FormatRecorder,
318    min_width: usize,
319) -> io::Result<()> {
320    // We don't care about the width of non-UTF-8 bytes, but should not panic.
321    let width = String::from_utf8_lossy(recorded_content.data()).width();
322    let fill_width = min_width.saturating_sub(width);
323    write_padding(formatter, recorded_fill_char, fill_width)?;
324    recorded_content.replay(formatter)?;
325    Ok(())
326}
327
328/// Writes text padded to `min_width` by adding leading fill characters.
329///
330/// The input `recorded_content` should be a single-line text. The
331/// `recorded_fill_char` should be bytes of 1-width character.
332pub fn write_padded_end(
333    formatter: &mut dyn Formatter,
334    recorded_content: &FormatRecorder,
335    recorded_fill_char: &FormatRecorder,
336    min_width: usize,
337) -> io::Result<()> {
338    // We don't care about the width of non-UTF-8 bytes, but should not panic.
339    let width = String::from_utf8_lossy(recorded_content.data()).width();
340    let fill_width = min_width.saturating_sub(width);
341    recorded_content.replay(formatter)?;
342    write_padding(formatter, recorded_fill_char, fill_width)?;
343    Ok(())
344}
345
346/// Writes text padded to `min_width` by adding leading and trailing fill
347/// characters.
348///
349/// The input `recorded_content` should be a single-line text. The
350/// `recorded_fill_char` should be bytes of a 1-width character.
351pub fn write_padded_centered(
352    formatter: &mut dyn Formatter,
353    recorded_content: &FormatRecorder,
354    recorded_fill_char: &FormatRecorder,
355    min_width: usize,
356) -> io::Result<()> {
357    // We don't care about the width of non-UTF-8 bytes, but should not panic.
358    let width = String::from_utf8_lossy(recorded_content.data()).width();
359    let fill_width = min_width.saturating_sub(width);
360    let fill_left = fill_width / 2;
361    let fill_right = fill_width - fill_left;
362    write_padding(formatter, recorded_fill_char, fill_left)?;
363    recorded_content.replay(formatter)?;
364    write_padding(formatter, recorded_fill_char, fill_right)?;
365    Ok(())
366}
367
368fn write_padding(
369    formatter: &mut dyn Formatter,
370    recorded_fill_char: &FormatRecorder,
371    fill_width: usize,
372) -> io::Result<()> {
373    if fill_width == 0 {
374        return Ok(());
375    }
376    let data = recorded_fill_char.data();
377    recorded_fill_char.replay_with(formatter, |formatter, range| {
378        // Don't emit labels repeatedly, just repeat content. Suppose fill char
379        // is a single character, the byte sequence shouldn't be broken up to
380        // multiple labeled regions.
381        for _ in 0..fill_width {
382            formatter.write_all(&data[range.clone()])?;
383        }
384        Ok(())
385    })
386}
387
388/// Indents each line by the given prefix preserving labels.
389pub fn write_indented(
390    formatter: &mut dyn Formatter,
391    recorded_content: &FormatRecorder,
392    mut write_prefix: impl FnMut(&mut dyn Formatter) -> io::Result<()>,
393) -> io::Result<()> {
394    let data = recorded_content.data();
395    let mut new_line = true;
396    recorded_content.replay_with(formatter, |formatter, range| {
397        for line in data[range].split_inclusive(|&c| c == b'\n') {
398            if new_line && line != b"\n" {
399                // Prefix inherits the current labels. This is implementation detail
400                // and may be fixed later.
401                write_prefix(formatter)?;
402            }
403            formatter.write_all(line)?;
404            new_line = line.ends_with(b"\n");
405        }
406        Ok(())
407    })
408}
409
410/// Word with trailing whitespace.
411#[derive(Clone, Copy, Debug, Eq, PartialEq)]
412struct ByteFragment<'a> {
413    word: &'a [u8],
414    whitespace_len: usize,
415    word_width: usize,
416}
417
418impl<'a> ByteFragment<'a> {
419    fn new(word: &'a [u8], whitespace_len: usize) -> Self {
420        // We don't care about the width of non-UTF-8 bytes, but should not panic.
421        let word_width = textwrap::core::display_width(&String::from_utf8_lossy(word));
422        Self {
423            word,
424            whitespace_len,
425            word_width,
426        }
427    }
428
429    fn offset_in(&self, text: &[u8]) -> usize {
430        byte_offset_from(text, self.word)
431    }
432}
433
434impl textwrap::core::Fragment for ByteFragment<'_> {
435    fn width(&self) -> f64 {
436        self.word_width as f64
437    }
438
439    fn whitespace_width(&self) -> f64 {
440        self.whitespace_len as f64
441    }
442
443    fn penalty_width(&self) -> f64 {
444        0.0
445    }
446}
447
448fn byte_offset_from(outer: &[u8], inner: &[u8]) -> usize {
449    let outer_start = outer.as_ptr() as usize;
450    let inner_start = inner.as_ptr() as usize;
451    assert!(outer_start <= inner_start);
452    assert!(inner_start + inner.len() <= outer_start + outer.len());
453    inner_start - outer_start
454}
455
456fn split_byte_line_to_words(line: &[u8]) -> Vec<ByteFragment<'_>> {
457    let mut words = Vec::new();
458    let mut tail = line;
459    while let Some(word_end) = tail.iter().position(|&c| c == b' ') {
460        let word = &tail[..word_end];
461        let ws_end = tail[word_end + 1..]
462            .iter()
463            .position(|&c| c != b' ')
464            .map(|p| p + word_end + 1)
465            .unwrap_or(tail.len());
466        words.push(ByteFragment::new(word, ws_end - word_end));
467        tail = &tail[ws_end..];
468    }
469    if !tail.is_empty() {
470        words.push(ByteFragment::new(tail, 0));
471    }
472    words
473}
474
475/// Wraps lines at the given width, returns a vector of lines (excluding "\n".)
476///
477/// Existing newline characters will never be removed. For `str` content, you
478/// can use `textwrap::refill()` to refill a pre-formatted text.
479///
480/// Each line is a sub-slice of the given text, even if the line is empty.
481///
482/// The wrapping logic is more restricted than the default of the `textwrap`.
483/// Notably, this doesn't support hyphenation nor unicode line break. The
484/// display width is calculated based on unicode property in the same manner
485/// as `textwrap::wrap()`.
486pub fn wrap_bytes(text: &[u8], width: usize) -> Vec<&[u8]> {
487    let mut split_lines = Vec::new();
488    for line in text.split(|&c| c == b'\n') {
489        let words = split_byte_line_to_words(line);
490        let split = textwrap::wrap_algorithms::wrap_first_fit(&words, &[width as f64]);
491        split_lines.extend(split.iter().map(|words| match words {
492            [] => &line[..0], // Empty line
493            [a] => a.word,
494            [a, .., b] => {
495                let start = a.offset_in(line);
496                let end = b.offset_in(line) + b.word.len();
497                &line[start..end]
498            }
499        }));
500    }
501    split_lines
502}
503
504/// Wraps lines at the given width preserving labels.
505///
506/// `textwrap::wrap()` can also process text containing ANSI escape sequences.
507/// The main difference is that this function will reset the style for each line
508/// and recreate it on the following line if the output `formatter` is
509/// a `ColorFormatter`.
510pub fn write_wrapped(
511    formatter: &mut dyn Formatter,
512    recorded_content: &FormatRecorder,
513    width: usize,
514) -> io::Result<()> {
515    let data = recorded_content.data();
516    let mut line_ranges = wrap_bytes(data, width)
517        .into_iter()
518        .map(|line| {
519            let start = byte_offset_from(data, line);
520            start..start + line.len()
521        })
522        .peekable();
523    // The recorded data ranges are contiguous, and the line ranges are increasing
524    // sequence (with some holes.) Both ranges should start from data[0].
525    recorded_content.replay_with(formatter, |formatter, data_range| {
526        while let Some(line_range) = line_ranges.peek() {
527            let start = cmp::max(data_range.start, line_range.start);
528            let end = cmp::min(data_range.end, line_range.end);
529            if start < end {
530                formatter.write_all(&data[start..end])?;
531            }
532            if data_range.end <= line_range.end {
533                break; // No more lines in this data range
534            }
535            line_ranges.next().unwrap();
536            if line_ranges.peek().is_some() {
537                writeln!(formatter)?; // Not the last line
538            }
539        }
540        Ok(())
541    })
542}
543
544pub fn parse_author(author: &str) -> Result<(String, String), &'static str> {
545    let re = regex::Regex::new(r"(?<name>.*?)\s*<(?<email>.+)>$").unwrap();
546    let captures = re.captures(author).ok_or("Invalid author string")?;
547    Ok((captures["name"].to_string(), captures["email"].to_string()))
548}
549
550#[cfg(test)]
551mod tests {
552    use std::io::Write as _;
553
554    use indoc::indoc;
555    use jj_lib::config::ConfigLayer;
556    use jj_lib::config::ConfigSource;
557    use jj_lib::config::StackedConfig;
558    use testutils::TestResult;
559
560    use super::*;
561    use crate::formatter::ColorFormatter;
562    use crate::formatter::PlainTextFormatter;
563
564    fn format_colored(write: impl FnOnce(&mut dyn Formatter) -> io::Result<()>) -> String {
565        let mut config = StackedConfig::empty();
566        config.add_layer(
567            ConfigLayer::parse(
568                ConfigSource::Default,
569                indoc! {"
570                    colors.cyan = 'cyan'
571                    colors.red = 'red'
572                "},
573            )
574            .unwrap(),
575        );
576        let mut output = Vec::new();
577        let mut formatter = ColorFormatter::for_config(&mut output, &config, false).unwrap();
578        write(&mut formatter).unwrap();
579        drop(formatter);
580        String::from_utf8(output).unwrap()
581    }
582
583    fn format_plain_text(write: impl FnOnce(&mut dyn Formatter) -> io::Result<()>) -> String {
584        let mut output = Vec::new();
585        let mut formatter = PlainTextFormatter::new(&mut output);
586        write(&mut formatter).unwrap();
587        String::from_utf8(output).unwrap()
588    }
589
590    #[test]
591    fn test_complete_newline() {
592        assert_eq!(complete_newline(""), "");
593        assert_eq!(complete_newline(" "), " \n");
594        assert_eq!(complete_newline("\n "), "\n \n");
595        assert_eq!(complete_newline("\t"), "\t\n");
596        assert_eq!(complete_newline("\n"), "\n");
597        assert_eq!(complete_newline("\n\n"), "\n\n");
598        assert_eq!(complete_newline("a\nb\nc"), "a\nb\nc\n");
599        assert_eq!(complete_newline("a\nb\nc\n"), "a\nb\nc\n");
600    }
601
602    #[test]
603    fn test_split_email() {
604        assert_eq!(split_email(""), ("", None));
605        assert_eq!(split_email("abc"), ("abc", None));
606        assert_eq!(split_email("example.com"), ("example.com", None));
607        assert_eq!(split_email("@example.com"), ("", Some("example.com")));
608        assert_eq!(
609            split_email("user@example.com"),
610            ("user", Some("example.com"))
611        );
612        assert_eq!(
613            split_email("user+tag@example.com"),
614            ("user+tag", Some("example.com"))
615        );
616        assert_eq!(
617            split_email(" user @ example.com "),
618            (" user ", Some(" example.com "))
619        );
620        assert_eq!(
621            split_email("user@extra@example.com"),
622            ("user", Some("extra@example.com"))
623        );
624    }
625
626    #[test]
627    fn test_elide_start() {
628        // Empty string
629        assert_eq!(elide_start("", "", 1), ("".into(), 0));
630
631        // Basic truncation
632        assert_eq!(elide_start("abcdef", "", 6), ("abcdef".into(), 6));
633        assert_eq!(elide_start("abcdef", "", 5), ("bcdef".into(), 5));
634        assert_eq!(elide_start("abcdef", "", 1), ("f".into(), 1));
635        assert_eq!(elide_start("abcdef", "", 0), ("".into(), 0));
636        assert_eq!(elide_start("abcdef", "-=~", 6), ("abcdef".into(), 6));
637        assert_eq!(elide_start("abcdef", "-=~", 5), ("-=~ef".into(), 5));
638        assert_eq!(elide_start("abcdef", "-=~", 4), ("-=~f".into(), 4));
639        assert_eq!(elide_start("abcdef", "-=~", 3), ("-=~".into(), 3));
640        assert_eq!(elide_start("abcdef", "-=~", 2), ("=~".into(), 2));
641        assert_eq!(elide_start("abcdef", "-=~", 1), ("~".into(), 1));
642        assert_eq!(elide_start("abcdef", "-=~", 0), ("".into(), 0));
643
644        // East Asian characters (char.width() == 2)
645        assert_eq!(elide_start("一二三", "", 6), ("一二三".into(), 6));
646        assert_eq!(elide_start("一二三", "", 5), ("二三".into(), 4));
647        assert_eq!(elide_start("一二三", "", 4), ("二三".into(), 4));
648        assert_eq!(elide_start("一二三", "", 1), ("".into(), 0));
649        assert_eq!(elide_start("一二三", "-=~", 6), ("一二三".into(), 6));
650        assert_eq!(elide_start("一二三", "-=~", 5), ("-=~三".into(), 5));
651        assert_eq!(elide_start("一二三", "-=~", 4), ("-=~".into(), 3));
652        assert_eq!(elide_start("一二三", "略", 6), ("一二三".into(), 6));
653        assert_eq!(elide_start("一二三", "略", 5), ("略三".into(), 4));
654        assert_eq!(elide_start("一二三", "略", 4), ("略三".into(), 4));
655        assert_eq!(elide_start("一二三", "略", 2), ("略".into(), 2));
656        assert_eq!(elide_start("一二三", "略", 1), ("".into(), 0));
657        assert_eq!(elide_start("一二三", ".", 5), (".二三".into(), 5));
658        assert_eq!(elide_start("一二三", ".", 4), (".三".into(), 3));
659        assert_eq!(elide_start("一二三", "略.", 5), ("略.三".into(), 5));
660        assert_eq!(elide_start("一二三", "略.", 4), ("略.".into(), 3));
661
662        // Multi-byte character at boundary
663        assert_eq!(elide_start("àbcdè", "", 5), ("àbcdè".into(), 5));
664        assert_eq!(elide_start("àbcdè", "", 4), ("bcdè".into(), 4));
665        assert_eq!(elide_start("àbcdè", "", 1), ("è".into(), 1));
666        assert_eq!(elide_start("àbcdè", "", 0), ("".into(), 0));
667        assert_eq!(elide_start("àbcdè", "ÀÇÈ", 4), ("ÀÇÈè".into(), 4));
668        assert_eq!(elide_start("àbcdè", "ÀÇÈ", 3), ("ÀÇÈ".into(), 3));
669        assert_eq!(elide_start("àbcdè", "ÀÇÈ", 2), ("ÇÈ".into(), 2));
670
671        // Decomposed character at boundary
672        assert_eq!(
673            elide_start("a\u{300}bcde\u{300}", "", 5),
674            ("a\u{300}bcde\u{300}".into(), 5)
675        );
676        assert_eq!(
677            elide_start("a\u{300}bcde\u{300}", "", 4),
678            ("bcde\u{300}".into(), 4)
679        );
680        assert_eq!(
681            elide_start("a\u{300}bcde\u{300}", "", 1),
682            ("e\u{300}".into(), 1)
683        );
684        assert_eq!(elide_start("a\u{300}bcde\u{300}", "", 0), ("".into(), 0));
685        assert_eq!(
686            elide_start("a\u{300}bcde\u{300}", "A\u{300}CE\u{300}", 4),
687            ("A\u{300}CE\u{300}e\u{300}".into(), 4)
688        );
689        assert_eq!(
690            elide_start("a\u{300}bcde\u{300}", "A\u{300}CE\u{300}", 3),
691            ("A\u{300}CE\u{300}".into(), 3)
692        );
693        assert_eq!(
694            elide_start("a\u{300}bcde\u{300}", "A\u{300}CE\u{300}", 2),
695            ("CE\u{300}".into(), 2)
696        );
697    }
698
699    #[test]
700    fn test_elide_end() {
701        // Empty string
702        assert_eq!(elide_end("", "", 1), ("".into(), 0));
703
704        // Basic truncation
705        assert_eq!(elide_end("abcdef", "", 6), ("abcdef".into(), 6));
706        assert_eq!(elide_end("abcdef", "", 5), ("abcde".into(), 5));
707        assert_eq!(elide_end("abcdef", "", 1), ("a".into(), 1));
708        assert_eq!(elide_end("abcdef", "", 0), ("".into(), 0));
709        assert_eq!(elide_end("abcdef", "-=~", 6), ("abcdef".into(), 6));
710        assert_eq!(elide_end("abcdef", "-=~", 5), ("ab-=~".into(), 5));
711        assert_eq!(elide_end("abcdef", "-=~", 4), ("a-=~".into(), 4));
712        assert_eq!(elide_end("abcdef", "-=~", 3), ("-=~".into(), 3));
713        assert_eq!(elide_end("abcdef", "-=~", 2), ("-=".into(), 2));
714        assert_eq!(elide_end("abcdef", "-=~", 1), ("-".into(), 1));
715        assert_eq!(elide_end("abcdef", "-=~", 0), ("".into(), 0));
716
717        // East Asian characters (char.width() == 2)
718        assert_eq!(elide_end("一二三", "", 6), ("一二三".into(), 6));
719        assert_eq!(elide_end("一二三", "", 5), ("一二".into(), 4));
720        assert_eq!(elide_end("一二三", "", 4), ("一二".into(), 4));
721        assert_eq!(elide_end("一二三", "", 1), ("".into(), 0));
722        assert_eq!(elide_end("一二三", "-=~", 6), ("一二三".into(), 6));
723        assert_eq!(elide_end("一二三", "-=~", 5), ("一-=~".into(), 5));
724        assert_eq!(elide_end("一二三", "-=~", 4), ("-=~".into(), 3));
725        assert_eq!(elide_end("一二三", "略", 6), ("一二三".into(), 6));
726        assert_eq!(elide_end("一二三", "略", 5), ("一略".into(), 4));
727        assert_eq!(elide_end("一二三", "略", 4), ("一略".into(), 4));
728        assert_eq!(elide_end("一二三", "略", 2), ("略".into(), 2));
729        assert_eq!(elide_end("一二三", "略", 1), ("".into(), 0));
730        assert_eq!(elide_end("一二三", ".", 5), ("一二.".into(), 5));
731        assert_eq!(elide_end("一二三", ".", 4), ("一.".into(), 3));
732        assert_eq!(elide_end("一二三", "略.", 5), ("一略.".into(), 5));
733        assert_eq!(elide_end("一二三", "略.", 4), ("略.".into(), 3));
734
735        // Multi-byte character at boundary
736        assert_eq!(elide_end("àbcdè", "", 5), ("àbcdè".into(), 5));
737        assert_eq!(elide_end("àbcdè", "", 4), ("àbcd".into(), 4));
738        assert_eq!(elide_end("àbcdè", "", 1), ("à".into(), 1));
739        assert_eq!(elide_end("àbcdè", "", 0), ("".into(), 0));
740        assert_eq!(elide_end("àbcdè", "ÀÇÈ", 4), ("àÀÇÈ".into(), 4));
741        assert_eq!(elide_end("àbcdè", "ÀÇÈ", 3), ("ÀÇÈ".into(), 3));
742        assert_eq!(elide_end("àbcdè", "ÀÇÈ", 2), ("ÀÇ".into(), 2));
743
744        // Decomposed character at boundary
745        assert_eq!(
746            elide_end("a\u{300}bcde\u{300}", "", 5),
747            ("a\u{300}bcde\u{300}".into(), 5)
748        );
749        assert_eq!(
750            elide_end("a\u{300}bcde\u{300}", "", 4),
751            ("a\u{300}bcd".into(), 4)
752        );
753        assert_eq!(
754            elide_end("a\u{300}bcde\u{300}", "", 1),
755            ("a\u{300}".into(), 1)
756        );
757        assert_eq!(elide_end("a\u{300}bcde\u{300}", "", 0), ("".into(), 0));
758        assert_eq!(
759            elide_end("a\u{300}bcde\u{300}", "A\u{300}CE\u{300}", 4),
760            ("a\u{300}A\u{300}CE\u{300}".into(), 4)
761        );
762        assert_eq!(
763            elide_end("a\u{300}bcde\u{300}", "A\u{300}CE\u{300}", 3),
764            ("A\u{300}CE\u{300}".into(), 3)
765        );
766        assert_eq!(
767            elide_end("a\u{300}bcde\u{300}", "A\u{300}CE\u{300}", 2),
768            ("A\u{300}C".into(), 2)
769        );
770    }
771
772    #[test]
773    fn test_write_truncated_labeled() -> TestResult {
774        let ellipsis_recorder = FormatRecorder::new(false);
775        let mut recorder = FormatRecorder::new(false);
776        for (label, word) in [("red", "foo"), ("cyan", "bar")] {
777            recorder.push_label(label);
778            write!(recorder, "{word}")?;
779            recorder.pop_label();
780        }
781
782        // Truncate start
783        insta::assert_snapshot!(
784            format_colored(|formatter| {
785                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 6).map(|_| ())
786            }),
787            @"foobar"
788        );
789        insta::assert_snapshot!(
790            format_colored(|formatter| {
791                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 5).map(|_| ())
792            }),
793            @"oobar"
794        );
795        insta::assert_snapshot!(
796            format_colored(|formatter| {
797                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 3).map(|_| ())
798            }),
799            @"bar"
800        );
801        insta::assert_snapshot!(
802            format_colored(|formatter| {
803                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 2).map(|_| ())
804            }),
805            @"ar"
806        );
807        insta::assert_snapshot!(
808            format_colored(|formatter| {
809                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ())
810            }),
811            @""
812        );
813
814        // Truncate end
815        insta::assert_snapshot!(
816            format_colored(|formatter| {
817                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 6).map(|_| ())
818            }),
819            @"foobar"
820        );
821        insta::assert_snapshot!(
822            format_colored(|formatter| {
823                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 5).map(|_| ())
824            }),
825            @"fooba"
826        );
827        insta::assert_snapshot!(
828            format_colored(|formatter| {
829                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 3).map(|_| ())
830            }),
831            @"foo"
832        );
833        insta::assert_snapshot!(
834            format_colored(|formatter| {
835                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 2).map(|_| ())
836            }),
837            @"fo"
838        );
839        insta::assert_snapshot!(
840            format_colored(|formatter| {
841                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ())
842            }),
843            @""
844        );
845        Ok(())
846    }
847
848    #[test]
849    fn test_write_truncated_non_ascii_chars() -> TestResult {
850        let ellipsis_recorder = FormatRecorder::new(false);
851        let mut recorder = FormatRecorder::new(false);
852        write!(recorder, "a\u{300}bc\u{300}一二三")?;
853
854        // Truncate start
855        insta::assert_snapshot!(
856            format_colored(|formatter| {
857                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ())
858            }),
859            @""
860        );
861        insta::assert_snapshot!(
862            format_colored(|formatter| {
863                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 2).map(|_| ())
864            }),
865            @"三"
866        );
867        insta::assert_snapshot!(
868            format_colored(|formatter| {
869                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 3).map(|_| ())
870            }),
871            @"三"
872        );
873        insta::assert_snapshot!(
874            format_colored(|formatter| {
875                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 6).map(|_| ())
876            }),
877            @"一二三"
878        );
879        insta::assert_snapshot!(
880            format_colored(|formatter| {
881                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 7).map(|_| ())
882            }),
883            @"c̀一二三"
884        );
885        insta::assert_snapshot!(
886            format_colored(|formatter| {
887                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 9).map(|_| ())
888            }),
889            @"àbc̀一二三"
890        );
891        insta::assert_snapshot!(
892            format_colored(|formatter| {
893                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 10).map(|_| ())
894            }),
895            @"àbc̀一二三"
896        );
897
898        // Truncate end
899        insta::assert_snapshot!(
900            format_colored(|formatter| {
901                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ())
902            }),
903            @"à"
904        );
905        insta::assert_snapshot!(
906            format_colored(|formatter| {
907                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 4).map(|_| ())
908            }),
909            @"àbc̀"
910        );
911        insta::assert_snapshot!(
912            format_colored(|formatter| {
913                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 5).map(|_| ())
914            }),
915            @"àbc̀一"
916        );
917        insta::assert_snapshot!(
918            format_colored(|formatter| {
919                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 9).map(|_| ())
920            }),
921            @"àbc̀一二三"
922        );
923        insta::assert_snapshot!(
924            format_colored(|formatter| {
925                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 10).map(|_| ())
926            }),
927            @"àbc̀一二三"
928        );
929        Ok(())
930    }
931
932    #[test]
933    fn test_write_truncated_empty_content() {
934        let ellipsis_recorder = FormatRecorder::new(false);
935        let recorder = FormatRecorder::new(false);
936
937        // Truncate start
938        insta::assert_snapshot!(
939            format_colored(|formatter| {
940                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ())
941            }),
942            @""
943        );
944        insta::assert_snapshot!(
945            format_colored(|formatter| {
946                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ())
947            }),
948            @""
949        );
950
951        // Truncate end
952        insta::assert_snapshot!(
953            format_colored(|formatter| {
954                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ())
955            }),
956            @""
957        );
958        insta::assert_snapshot!(
959            format_colored(|formatter| {
960                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ())
961            }),
962            @""
963        );
964    }
965
966    #[test]
967    fn test_write_truncated_ellipsis_labeled() -> TestResult {
968        let ellipsis_recorder = FormatRecorder::with_data("..");
969        let mut recorder = FormatRecorder::new(false);
970        for (label, word) in [("red", "foo"), ("cyan", "bar")] {
971            recorder.push_label(label);
972            write!(recorder, "{word}")?;
973            recorder.pop_label();
974        }
975
976        // Truncate start
977        insta::assert_snapshot!(
978            format_colored(|formatter| {
979                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 6).map(|_| ())
980            }),
981            @"foobar"
982        );
983        insta::assert_snapshot!(
984            format_colored(|formatter| {
985                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 5).map(|_| ())
986            }),
987            @"..bar"
988        );
989        insta::assert_snapshot!(
990            format_colored(|formatter| {
991                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 3).map(|_| ())
992            }),
993            @"..r"
994        );
995        insta::assert_snapshot!(
996            format_colored(|formatter| {
997                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 2).map(|_| ())
998            }),
999            @".."
1000        );
1001        insta::assert_snapshot!(
1002            format_colored(|formatter| {
1003                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ())
1004            }),
1005            @"."
1006        );
1007        insta::assert_snapshot!(
1008            format_colored(|formatter| {
1009                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ())
1010            }),
1011            @""
1012        );
1013
1014        // Truncate end
1015        insta::assert_snapshot!(
1016            format_colored(|formatter| {
1017                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 6).map(|_| ())
1018            }),
1019            @"foobar"
1020        );
1021        insta::assert_snapshot!(
1022            format_colored(|formatter| {
1023                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 5).map(|_| ())
1024            }),
1025            @"foo.."
1026        );
1027        insta::assert_snapshot!(
1028            format_colored(|formatter| {
1029                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 3).map(|_| ())
1030            }),
1031            @"f.."
1032        );
1033        insta::assert_snapshot!(
1034            format_colored(|formatter| {
1035                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 2).map(|_| ())
1036            }),
1037            @".."
1038        );
1039        insta::assert_snapshot!(
1040            format_colored(|formatter| {
1041                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ())
1042            }),
1043            @"."
1044        );
1045        insta::assert_snapshot!(
1046            format_colored(|formatter| {
1047                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ())
1048            }),
1049            @""
1050        );
1051        Ok(())
1052    }
1053
1054    #[test]
1055    fn test_write_truncated_ellipsis_non_ascii_chars() -> TestResult {
1056        let ellipsis_recorder = FormatRecorder::with_data("..");
1057        let mut recorder = FormatRecorder::new(false);
1058        write!(recorder, "a\u{300}bc\u{300}一二三")?;
1059
1060        // Truncate start
1061        insta::assert_snapshot!(
1062            format_colored(|formatter| {
1063                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ())
1064            }),
1065            @"."
1066        );
1067        insta::assert_snapshot!(
1068            format_colored(|formatter| {
1069                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 2).map(|_| ())
1070            }),
1071            @".."
1072        );
1073        insta::assert_snapshot!(
1074            format_colored(|formatter| {
1075                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 4).map(|_| ())
1076            }),
1077            @"..三"
1078        );
1079        insta::assert_snapshot!(
1080            format_colored(|formatter| {
1081                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 7).map(|_| ())
1082            }),
1083            @"..二三"
1084        );
1085
1086        // Truncate end
1087        insta::assert_snapshot!(
1088            format_colored(|formatter| {
1089                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ())
1090            }),
1091            @"."
1092        );
1093        insta::assert_snapshot!(
1094            format_colored(|formatter| {
1095                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 4).map(|_| ())
1096            }),
1097            @"àb.."
1098        );
1099        insta::assert_snapshot!(
1100            format_colored(|formatter| {
1101                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 5).map(|_| ())
1102            }),
1103            @"àbc̀.."
1104        );
1105        insta::assert_snapshot!(
1106            format_colored(|formatter| {
1107                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 9).map(|_| ())
1108            }),
1109            @"àbc̀一二三"
1110        );
1111        insta::assert_snapshot!(
1112            format_colored(|formatter| {
1113                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 10).map(|_| ())
1114            }),
1115            @"àbc̀一二三"
1116        );
1117        Ok(())
1118    }
1119
1120    #[test]
1121    fn test_write_truncated_ellipsis_empty_content() {
1122        let ellipsis_recorder = FormatRecorder::with_data("..");
1123        let recorder = FormatRecorder::new(false);
1124
1125        // Truncate start, empty content
1126        insta::assert_snapshot!(
1127            format_colored(|formatter| {
1128                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ())
1129            }),
1130            @""
1131        );
1132        insta::assert_snapshot!(
1133            format_colored(|formatter| {
1134                write_truncated_start(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ())
1135            }),
1136            @""
1137        );
1138
1139        // Truncate end
1140        insta::assert_snapshot!(
1141            format_colored(|formatter| {
1142                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ())
1143            }),
1144            @""
1145        );
1146        insta::assert_snapshot!(
1147            format_colored(|formatter| {
1148                write_truncated_end(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ())
1149            }),
1150            @""
1151        );
1152    }
1153
1154    #[test]
1155    fn test_write_padded_labeled_content() -> TestResult {
1156        let mut recorder = FormatRecorder::new(false);
1157        for (label, word) in [("red", "foo"), ("cyan", "bar")] {
1158            recorder.push_label(label);
1159            write!(recorder, "{word}")?;
1160            recorder.pop_label();
1161        }
1162        let fill = FormatRecorder::with_data("=");
1163
1164        // Pad start
1165        insta::assert_snapshot!(
1166            format_colored(|formatter| write_padded_start(formatter, &recorder, &fill, 6)),
1167            @"foobar"
1168        );
1169        insta::assert_snapshot!(
1170            format_colored(|formatter| write_padded_start(formatter, &recorder, &fill, 7)),
1171            @"=foobar"
1172        );
1173        insta::assert_snapshot!(
1174            format_colored(|formatter| write_padded_start(formatter, &recorder, &fill, 8)),
1175            @"==foobar"
1176        );
1177
1178        // Pad end
1179        insta::assert_snapshot!(
1180            format_colored(|formatter| write_padded_end(formatter, &recorder, &fill, 6)),
1181            @"foobar"
1182        );
1183        insta::assert_snapshot!(
1184            format_colored(|formatter| write_padded_end(formatter, &recorder, &fill, 7)),
1185            @"foobar="
1186        );
1187        insta::assert_snapshot!(
1188            format_colored(|formatter| write_padded_end(formatter, &recorder, &fill, 8)),
1189            @"foobar=="
1190        );
1191
1192        // Pad centered
1193        insta::assert_snapshot!(
1194            format_colored(|formatter| write_padded_centered(formatter, &recorder, &fill, 6)),
1195            @"foobar"
1196        );
1197        insta::assert_snapshot!(
1198            format_colored(|formatter| write_padded_centered(formatter, &recorder, &fill, 7)),
1199            @"foobar="
1200        );
1201        insta::assert_snapshot!(
1202            format_colored(|formatter| write_padded_centered(formatter, &recorder, &fill, 8)),
1203            @"=foobar="
1204        );
1205        insta::assert_snapshot!(
1206            format_colored(|formatter| write_padded_centered(formatter, &recorder, &fill, 13)),
1207            @"===foobar===="
1208        );
1209        Ok(())
1210    }
1211
1212    #[test]
1213    fn test_write_padded_labeled_fill_char() -> TestResult {
1214        let recorder = FormatRecorder::with_data("foo");
1215        let mut fill = FormatRecorder::new(false);
1216        fill.push_label("red");
1217        write!(fill, "=")?;
1218        fill.pop_label();
1219
1220        // Pad start
1221        insta::assert_snapshot!(
1222            format_colored(|formatter| write_padded_start(formatter, &recorder, &fill, 5)),
1223            @"==foo"
1224        );
1225
1226        // Pad end
1227        insta::assert_snapshot!(
1228            format_colored(|formatter| write_padded_end(formatter, &recorder, &fill, 6)),
1229            @"foo==="
1230        );
1231
1232        // Pad centered
1233        insta::assert_snapshot!(
1234            format_colored(|formatter| write_padded_centered(formatter, &recorder, &fill, 6)),
1235            @"=foo=="
1236        );
1237        Ok(())
1238    }
1239
1240    #[test]
1241    fn test_write_padded_non_ascii_chars() {
1242        let recorder = FormatRecorder::with_data("a\u{300}bc\u{300}一二三");
1243        let fill = FormatRecorder::with_data("=");
1244
1245        // Pad start
1246        insta::assert_snapshot!(
1247            format_colored(|formatter| write_padded_start(formatter, &recorder, &fill, 9)),
1248            @"àbc̀一二三"
1249        );
1250        insta::assert_snapshot!(
1251            format_colored(|formatter| write_padded_start(formatter, &recorder, &fill, 10)),
1252            @"=àbc̀一二三"
1253        );
1254
1255        // Pad end
1256        insta::assert_snapshot!(
1257            format_colored(|formatter| write_padded_end(formatter, &recorder, &fill, 9)),
1258            @"àbc̀一二三"
1259        );
1260        insta::assert_snapshot!(
1261            format_colored(|formatter| write_padded_end(formatter, &recorder, &fill, 10)),
1262            @"àbc̀一二三="
1263        );
1264
1265        // Pad centered
1266        insta::assert_snapshot!(
1267            format_colored(|formatter| write_padded_centered(formatter, &recorder, &fill, 9)),
1268            @"àbc̀一二三"
1269        );
1270        insta::assert_snapshot!(
1271            format_colored(|formatter| write_padded_centered(formatter, &recorder, &fill, 10)),
1272            @"àbc̀一二三="
1273        );
1274        insta::assert_snapshot!(
1275            format_colored(|formatter| write_padded_centered(formatter, &recorder, &fill, 13)),
1276            @"==àbc̀一二三=="
1277        );
1278    }
1279
1280    #[test]
1281    fn test_write_padded_empty_content() {
1282        let recorder = FormatRecorder::new(false);
1283        let fill = FormatRecorder::with_data("=");
1284
1285        // Pad start
1286        insta::assert_snapshot!(
1287            format_colored(|formatter| write_padded_start(formatter, &recorder, &fill, 0)),
1288            @""
1289        );
1290        insta::assert_snapshot!(
1291            format_colored(|formatter| write_padded_start(formatter, &recorder, &fill, 1)),
1292            @"="
1293        );
1294
1295        // Pad end
1296        insta::assert_snapshot!(
1297            format_colored(|formatter| write_padded_end(formatter, &recorder, &fill, 0)),
1298            @""
1299        );
1300        insta::assert_snapshot!(
1301            format_colored(|formatter| write_padded_end(formatter, &recorder, &fill, 1)),
1302            @"="
1303        );
1304
1305        // Pad centered
1306        insta::assert_snapshot!(
1307            format_colored(|formatter| write_padded_centered(formatter, &recorder, &fill, 0)),
1308            @""
1309        );
1310        insta::assert_snapshot!(
1311            format_colored(|formatter| write_padded_centered(formatter, &recorder, &fill, 1)),
1312            @"="
1313        );
1314    }
1315
1316    #[test]
1317    fn test_split_byte_line_to_words() {
1318        assert_eq!(split_byte_line_to_words(b""), vec![]);
1319        assert_eq!(
1320            split_byte_line_to_words(b"foo"),
1321            vec![ByteFragment {
1322                word: b"foo",
1323                whitespace_len: 0,
1324                word_width: 3
1325            }],
1326        );
1327        assert_eq!(
1328            split_byte_line_to_words(b"  foo"),
1329            vec![
1330                ByteFragment {
1331                    word: b"",
1332                    whitespace_len: 2,
1333                    word_width: 0
1334                },
1335                ByteFragment {
1336                    word: b"foo",
1337                    whitespace_len: 0,
1338                    word_width: 3
1339                },
1340            ],
1341        );
1342        assert_eq!(
1343            split_byte_line_to_words(b"foo  "),
1344            vec![ByteFragment {
1345                word: b"foo",
1346                whitespace_len: 2,
1347                word_width: 3
1348            }],
1349        );
1350        assert_eq!(
1351            split_byte_line_to_words(b"a b  foo bar "),
1352            vec![
1353                ByteFragment {
1354                    word: b"a",
1355                    whitespace_len: 1,
1356                    word_width: 1
1357                },
1358                ByteFragment {
1359                    word: b"b",
1360                    whitespace_len: 2,
1361                    word_width: 1
1362                },
1363                ByteFragment {
1364                    word: b"foo",
1365                    whitespace_len: 1,
1366                    word_width: 3,
1367                },
1368                ByteFragment {
1369                    word: b"bar",
1370                    whitespace_len: 1,
1371                    word_width: 3,
1372                },
1373            ],
1374        );
1375    }
1376
1377    #[test]
1378    fn test_write_indented() -> TestResult {
1379        let write_prefix = |formatter: &mut dyn Formatter| {
1380            formatter.write_all(b">>")?;
1381            Ok(())
1382        };
1383
1384        // Basic tests
1385        let recorder = FormatRecorder::new(true);
1386        insta::assert_snapshot!(
1387            format_colored(
1388                |formatter| write_indented(formatter, &recorder, |fmt| write_prefix(fmt))
1389            ),
1390            @""
1391        );
1392        let recorder = FormatRecorder::with_data("abc");
1393        insta::assert_snapshot!(
1394            format_colored(
1395                |formatter| write_indented(formatter, &recorder, |fmt| write_prefix(fmt))
1396            ),
1397            @">>abc"
1398        );
1399
1400        // Indent each line
1401        let recorder = FormatRecorder::with_data("a\nb\nc");
1402        insta::assert_snapshot!(
1403            format_colored(
1404                |formatter| write_indented(formatter, &recorder, |fmt| write_prefix(fmt))
1405            ),
1406            @"
1407        >>a
1408        >>b
1409        >>c
1410        "
1411        );
1412
1413        // Only indent non-empty lines
1414        // Leading newline confuses insta
1415        let recorder = FormatRecorder::with_data("\na\n\nb\n\nc\n");
1416        assert_eq!(
1417            format_colored(
1418                |formatter| write_indented(formatter, &recorder, |fmt| write_prefix(fmt))
1419            ),
1420            "\n>>a\n\n>>b\n\n>>c\n"
1421        );
1422
1423        // Preserve labels
1424        let mut recorder = FormatRecorder::new(true);
1425        for (label, word) in [("red", "foo"), ("cyan", "bar\nbaz\n\nquux")] {
1426            recorder.push_label(label);
1427            write!(recorder, "{word}")?;
1428            recorder.pop_label();
1429            writeln!(recorder)?;
1430        }
1431        insta::assert_snapshot!(
1432            format_colored(
1433                |formatter| write_indented(formatter, &recorder, |fmt| write_prefix(fmt))
1434            ),
1435            @"
1436        >>foo
1437        >>bar
1438        >>baz
1439        
1440        >>quux
1441        "
1442        );
1443        Ok(())
1444    }
1445
1446    #[test]
1447    fn test_wrap_bytes() {
1448        assert_eq!(wrap_bytes(b"foo", 10), [b"foo".as_ref()]);
1449        assert_eq!(wrap_bytes(b"foo bar", 10), [b"foo bar".as_ref()]);
1450        assert_eq!(
1451            wrap_bytes(b"foo bar baz", 10),
1452            [b"foo bar".as_ref(), b"baz".as_ref()],
1453        );
1454
1455        // Empty text is represented as [""]
1456        assert_eq!(wrap_bytes(b"", 10), [b"".as_ref()]);
1457        assert_eq!(wrap_bytes(b" ", 10), [b"".as_ref()]);
1458
1459        // Whitespace in the middle should be preserved
1460        assert_eq!(
1461            wrap_bytes(b"foo  bar   baz", 8),
1462            [b"foo  bar".as_ref(), b"baz".as_ref()],
1463        );
1464        assert_eq!(
1465            wrap_bytes(b"foo  bar   x", 7),
1466            [b"foo".as_ref(), b"bar   x".as_ref()],
1467        );
1468        assert_eq!(
1469            wrap_bytes(b"foo bar \nx", 7),
1470            [b"foo bar".as_ref(), b"x".as_ref()],
1471        );
1472        assert_eq!(
1473            wrap_bytes(b"foo bar\n x", 7),
1474            [b"foo bar".as_ref(), b" x".as_ref()],
1475        );
1476        assert_eq!(
1477            wrap_bytes(b"foo bar x", 4),
1478            [b"foo".as_ref(), b"bar".as_ref(), b"x".as_ref()],
1479        );
1480
1481        // Ends with "\n"
1482        assert_eq!(wrap_bytes(b"foo\n", 10), [b"foo".as_ref(), b"".as_ref()]);
1483        assert_eq!(wrap_bytes(b"foo\n", 3), [b"foo".as_ref(), b"".as_ref()]);
1484        assert_eq!(wrap_bytes(b"\n", 10), [b"".as_ref(), b"".as_ref()]);
1485
1486        // Overflow
1487        assert_eq!(wrap_bytes(b"foo x", 2), [b"foo".as_ref(), b"x".as_ref()]);
1488        assert_eq!(wrap_bytes(b"x y", 0), [b"x".as_ref(), b"y".as_ref()]);
1489
1490        // Invalid UTF-8 bytes should not cause panic
1491        assert_eq!(wrap_bytes(b"foo\x80", 10), [b"foo\x80".as_ref()]);
1492    }
1493
1494    #[test]
1495    fn test_wrap_bytes_slice_ptr() {
1496        let text = b"\nfoo\n\nbar baz\n";
1497        let lines = wrap_bytes(text, 10);
1498        assert_eq!(
1499            lines,
1500            [
1501                b"".as_ref(),
1502                b"foo".as_ref(),
1503                b"".as_ref(),
1504                b"bar baz".as_ref(),
1505                b"".as_ref()
1506            ],
1507        );
1508        // Each line should be a sub-slice of the source text
1509        assert_eq!(lines[0].as_ptr(), text[0..].as_ptr());
1510        assert_eq!(lines[1].as_ptr(), text[1..].as_ptr());
1511        assert_eq!(lines[2].as_ptr(), text[5..].as_ptr());
1512        assert_eq!(lines[3].as_ptr(), text[6..].as_ptr());
1513        assert_eq!(lines[4].as_ptr(), text[14..].as_ptr());
1514    }
1515
1516    #[test]
1517    fn test_write_wrapped() -> TestResult {
1518        // Split single label chunk
1519        let mut recorder = FormatRecorder::new(false);
1520        recorder.push_label("red");
1521        write!(recorder, "foo bar baz\nqux quux\n")?;
1522        recorder.pop_label();
1523        insta::assert_snapshot!(
1524            format_colored(|formatter| write_wrapped(formatter, &recorder, 7)),
1525            @"
1526        foo bar
1527        baz
1528        qux
1529        quux
1530        "
1531        );
1532
1533        // Multiple label chunks in a line
1534        let mut recorder = FormatRecorder::new(false);
1535        for (i, word) in ["foo ", "bar ", "baz\n", "qux ", "quux"].iter().enumerate() {
1536            recorder.push_label(["red", "cyan"][i & 1]);
1537            write!(recorder, "{word}")?;
1538            recorder.pop_label();
1539        }
1540        insta::assert_snapshot!(
1541            format_colored(|formatter| write_wrapped(formatter, &recorder, 7)),
1542            @"
1543        foo bar
1544        baz
1545        qux
1546        quux
1547        "
1548        );
1549
1550        // Empty lines should not cause panic
1551        let mut recorder = FormatRecorder::new(false);
1552        for (i, word) in ["", "foo", "", "bar baz", ""].iter().enumerate() {
1553            recorder.push_label(["red", "cyan"][i & 1]);
1554            writeln!(recorder, "{word}")?;
1555            recorder.pop_label();
1556        }
1557        insta::assert_snapshot!(
1558            format_colored(|formatter| write_wrapped(formatter, &recorder, 10)),
1559            @"
1560        
1561        foo
1562        
1563        bar baz
1564        
1565        "
1566        );
1567
1568        // Split at label boundary
1569        let mut recorder = FormatRecorder::new(false);
1570        recorder.push_label("red");
1571        write!(recorder, "foo bar")?;
1572        recorder.pop_label();
1573        write!(recorder, " ")?;
1574        recorder.push_label("cyan");
1575        writeln!(recorder, "baz")?;
1576        recorder.pop_label();
1577        insta::assert_snapshot!(
1578            format_colored(|formatter| write_wrapped(formatter, &recorder, 10)),
1579            @"
1580        foo bar
1581        baz
1582        "
1583        );
1584
1585        // Do not split at label boundary "ba|z" (since it's a single word)
1586        let mut recorder = FormatRecorder::new(false);
1587        recorder.push_label("red");
1588        write!(recorder, "foo bar ba")?;
1589        recorder.pop_label();
1590        recorder.push_label("cyan");
1591        writeln!(recorder, "z")?;
1592        recorder.pop_label();
1593        insta::assert_snapshot!(
1594            format_colored(|formatter| write_wrapped(formatter, &recorder, 10)),
1595            @"
1596        foo bar
1597        baz
1598        "
1599        );
1600        Ok(())
1601    }
1602
1603    #[test]
1604    fn test_write_wrapped_leading_labeled_whitespace() -> TestResult {
1605        let mut recorder = FormatRecorder::new(false);
1606        recorder.push_label("red");
1607        write!(recorder, " ")?;
1608        recorder.pop_label();
1609        write!(recorder, "foo")?;
1610        insta::assert_snapshot!(
1611            format_colored(|formatter| write_wrapped(formatter, &recorder, 10)),
1612            @" foo"
1613        );
1614        Ok(())
1615    }
1616
1617    #[test]
1618    fn test_write_wrapped_trailing_labeled_whitespace() -> TestResult {
1619        // data: "foo" " "
1620        // line:  ---
1621        let mut recorder = FormatRecorder::new(false);
1622        write!(recorder, "foo")?;
1623        recorder.push_label("red");
1624        write!(recorder, " ")?;
1625        recorder.pop_label();
1626        assert_eq!(
1627            format_plain_text(|formatter| write_wrapped(formatter, &recorder, 10)),
1628            "foo",
1629        );
1630
1631        // data: "foo" "\n"
1632        // line:  ---     -
1633        let mut recorder = FormatRecorder::new(false);
1634        write!(recorder, "foo")?;
1635        recorder.push_label("red");
1636        writeln!(recorder)?;
1637        recorder.pop_label();
1638        assert_eq!(
1639            format_plain_text(|formatter| write_wrapped(formatter, &recorder, 10)),
1640            "foo\n",
1641        );
1642
1643        // data: "foo\n" " "
1644        // line:  ---    -
1645        let mut recorder = FormatRecorder::new(false);
1646        writeln!(recorder, "foo")?;
1647        recorder.push_label("red");
1648        write!(recorder, " ")?;
1649        recorder.pop_label();
1650        assert_eq!(
1651            format_plain_text(|formatter| write_wrapped(formatter, &recorder, 10)),
1652            "foo\n",
1653        );
1654        Ok(())
1655    }
1656
1657    #[test]
1658    fn test_parse_author() {
1659        let expected_name = "Example";
1660        let expected_email = "example@example.com";
1661        let parsed = parse_author(&format!("{expected_name} <{expected_email}>")).unwrap();
1662        assert_eq!(
1663            (expected_name.to_string(), expected_email.to_string()),
1664            parsed
1665        );
1666    }
1667
1668    #[test]
1669    fn test_parse_author_with_utf8() {
1670        let expected_name = "Ąćęłńóśżź";
1671        let expected_email = "example@example.com";
1672        let parsed = parse_author(&format!("{expected_name} <{expected_email}>")).unwrap();
1673        assert_eq!(
1674            (expected_name.to_string(), expected_email.to_string()),
1675            parsed
1676        );
1677    }
1678
1679    #[test]
1680    fn test_parse_author_without_name() {
1681        let expected_email = "example@example.com";
1682        let parsed = parse_author(&format!("<{expected_email}>")).unwrap();
1683        assert_eq!(("".to_string(), expected_email.to_string()), parsed);
1684    }
1685}