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