Skip to main content

mandible_core/
text.rs

1//! [`Text`]: the single point through which untrusted, tool-produced strings
2//! enter mandible's intermediate representation.
3//!
4//! See spec §4.1. Every string mandible did not author itself — help output,
5//! man page prose, completion script comments, catalog descriptions — must be
6//! wrapped in [`Text::sanitize`] before it can reach a widget. The type is
7//! deliberately awkward to construct any other way: its field is private and
8//! there is no `From<String>` or `From<&str>` impl.
9
10use serde::{Deserialize, Serialize};
11use std::fmt;
12
13/// Hard cap on sanitized text length, in `char`s. Applied after all other
14/// normalization. Generous enough for any legitimate flag description or
15/// man page section, small enough that a pathological multi-megabyte string
16/// from a misbehaving tool cannot make its way into a render buffer.
17pub const MAX_TEXT_CHARS: usize = 8192;
18
19/// Sanitized, display-safe text.
20///
21/// Constructing a `Text` always goes through [`Text::sanitize`] (directly, or
22/// indirectly via `Deserialize`), which strips control characters and
23/// terminal escape sequences, resolves backspace-overstrike, expands tabs,
24/// collapses whitespace runs, normalizes newlines (preserving paragraph
25/// breaks), and truncates to [`MAX_TEXT_CHARS`]. Widgets and other consumers
26/// may assume a `Text` is safe to place directly into a rendering surface.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
28pub struct Text(String);
29
30impl Text {
31    /// The only way to build a `Text` from raw, untrusted input.
32    ///
33    /// Pipeline (see spec §4.1 and §13.3 for the adversarial cases this
34    /// must survive):
35    /// 1. Strip ANSI/OSC/other terminal escape sequences.
36    /// 2. Resolve backspace-overstrike (`_\bX`, `X\bX`, and any stray `\b`).
37    /// 3. Strip remaining C0 control characters and DEL.
38    /// 4. Expand tabs to spaces at 8-column stops.
39    /// 5. Normalize line endings to `\n`.
40    /// 6. Unwrap hard-wrapped paragraphs: a single `\n` inside a paragraph
41    ///    joins to a space (so a later re-wrap at the pane's actual width
42    ///    produces clean lines instead of re-wrapping already-short,
43    ///    pre-broken lines raggedly); `\n\n` stays a paragraph break;
44    ///    indented/code-like lines and list items (`- `, `* `, `1. `) are
45    ///    never joined to a neighbor, preserving block structure.
46    /// 7. Collapse runs of horizontal whitespace to a single space.
47    /// 8. Trim leading/trailing whitespace.
48    /// 9. Truncate to [`MAX_TEXT_CHARS`] characters, at a char boundary.
49    pub fn sanitize(raw: &str) -> Text {
50        let no_escapes = strip_escapes(raw);
51        Text(Self::finish_pipeline(&no_escapes))
52    }
53
54    /// Like [`Text::sanitize`], but for text known to originate as
55    /// markdown-flavored prose (carapace-spec's `description`/
56    /// `documentation` fields, which use `[label](uri)` links — including
57    /// custom schemes like `man://` and `cmd://` — plus inline code,
58    /// `**bold**`, and `*em*`/`_em_` markers).
59    ///
60    /// This is a conservative, targeted normalizer, not a general markdown
61    /// parser: it recognizes exactly those four constructs and leaves
62    /// anything else untouched. In particular it does not touch `[value]`
63    /// usage-string brackets (no following `(...)`), and it requires
64    /// non-word characters immediately outside `*em*`/`_em_` delimiters so
65    /// it doesn't misfire on identifiers like `GIT_DIR` or globs.
66    /// Recognized markup is replaced with its inner text; the surrounding
67    /// URI/delimiters are discarded (plain-text fallback, since the detail
68    /// pane doesn't yet render hyperlinks).
69    pub fn sanitize_markdown(raw: &str) -> Text {
70        let no_escapes = strip_escapes(raw);
71        let normalized = normalize_markdown(&no_escapes);
72        Text(Self::finish_pipeline(&normalized))
73    }
74
75    /// The tail of the sanitization pipeline, shared by [`Text::sanitize`]
76    /// and [`Text::sanitize_markdown`] (which differ only in what happens
77    /// to the text *before* this point: markdown normalization, if any,
78    /// always runs immediately after escape-stripping and before anything
79    /// else, so it never has to reason about tabs/backspace/control chars).
80    fn finish_pipeline(after_escapes: &str) -> String {
81        let overstruck = resolve_backspace(after_escapes);
82        let no_control = strip_c0(&overstruck);
83        let tabs_expanded = expand_tabs(&no_control, 8);
84        let newlines_normalized = normalize_newlines(&tabs_expanded);
85        let unwrapped = unwrap_paragraphs(&newlines_normalized);
86        let collapsed = collapse_horizontal_whitespace(&unwrapped);
87        let trimmed = trim_lines_and_whole(&collapsed);
88        truncate_chars(&trimmed, MAX_TEXT_CHARS)
89    }
90
91    /// Borrow the sanitized string.
92    pub fn as_str(&self) -> &str {
93        &self.0
94    }
95
96    /// True if the sanitized text is empty.
97    pub fn is_empty(&self) -> bool {
98        self.0.is_empty()
99    }
100
101    /// Collapse to a single display line (paragraph breaks and internal
102    /// newlines become a single space), for contexts like tree rows that
103    /// have no room for multi-line text. The tree pane is expected to call
104    /// this at render time rather than store a second copy of the text.
105    pub fn single_line(&self) -> String {
106        let mut out = String::with_capacity(self.0.len());
107        let mut last_was_space = false;
108        for ch in self.0.chars() {
109            let c = if ch == '\n' { ' ' } else { ch };
110            if c == ' ' {
111                if !last_was_space && !out.is_empty() {
112                    out.push(' ');
113                }
114                last_was_space = true;
115            } else {
116                out.push(c);
117                last_was_space = false;
118            }
119        }
120        out.trim_end().to_string()
121    }
122}
123
124impl fmt::Display for Text {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        f.write_str(&self.0)
127    }
128}
129
130impl Serialize for Text {
131    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
132    where
133        S: serde::Serializer,
134    {
135        self.0.serialize(serializer)
136    }
137}
138
139impl<'de> Deserialize<'de> for Text {
140    /// Deserialization re-runs [`Text::sanitize`] rather than trusting the
141    /// stored bytes verbatim. This keeps the invariant airtight even when a
142    /// `Text` is round-tripped through the on-disk cache (spec §11): a
143    /// tampered or corrupted cache file cannot smuggle unsanitized bytes
144    /// back into the IR. `sanitize` is idempotent, so this costs nothing
145    /// extra for cache entries that were already clean.
146    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
147    where
148        D: serde::Deserializer<'de>,
149    {
150        let raw = String::deserialize(deserializer)?;
151        Ok(Text::sanitize(&raw))
152    }
153}
154
155/// Strip ANSI CSI/OSC/DCS escape sequences and other `ESC`-prefixed
156/// sequences. Hand-written state machine rather than a regex crate
157/// dependency; the grammar is small and well-known.
158fn strip_escapes(input: &str) -> String {
159    let mut out = String::with_capacity(input.len());
160    let mut chars = input.chars().peekable();
161    while let Some(c) = chars.next() {
162        if c != '\u{1b}' {
163            out.push(c);
164            continue;
165        }
166        match chars.peek() {
167            Some('[') => {
168                // CSI: ESC [ ... final-byte in 0x40..=0x7E
169                chars.next();
170                for c2 in chars.by_ref() {
171                    if ('\u{40}'..='\u{7e}').contains(&c2) {
172                        break;
173                    }
174                }
175            }
176            Some(']') => {
177                // OSC: ESC ] ... BEL or ESC \
178                chars.next();
179                loop {
180                    match chars.next() {
181                        None => break,
182                        Some('\u{07}') => break,
183                        Some('\u{1b}') => {
184                            if chars.peek() == Some(&'\\') {
185                                chars.next();
186                            }
187                            break;
188                        }
189                        Some(_) => continue,
190                    }
191                }
192            }
193            Some('P') | Some('X') | Some('^') | Some('_') => {
194                // DCS / SOS / PM / APC: ESC x ... ESC \
195                chars.next();
196                loop {
197                    match chars.next() {
198                        None => break,
199                        Some('\u{1b}') => {
200                            if chars.peek() == Some(&'\\') {
201                                chars.next();
202                            }
203                            break;
204                        }
205                        Some(_) => continue,
206                    }
207                }
208            }
209            Some(_) => {
210                // Two-character escape (e.g. charset selection ESC ( B).
211                chars.next();
212            }
213            None => {}
214        }
215    }
216    out
217}
218
219/// Resolve backspace-overstrike sequences as emitted by rendered man pages
220/// (`_\bX` for underline, `X\bX` for bold). A backspace deletes the
221/// previously emitted character; whatever follows becomes the visible glyph.
222/// This also silently absorbs any stray backspace with nothing to delete.
223fn resolve_backspace(input: &str) -> String {
224    let mut out: Vec<char> = Vec::with_capacity(input.len());
225    for c in input.chars() {
226        if c == '\u{8}' {
227            out.pop();
228        } else {
229            out.push(c);
230        }
231    }
232    out.into_iter().collect()
233}
234
235/// Strip remaining C0 control characters and DEL, preserving `\t`, `\n`,
236/// `\r` for the later tab/newline passes.
237fn strip_c0(input: &str) -> String {
238    input
239        .chars()
240        .filter(|&c| {
241            let is_c0 = ('\u{0}'..='\u{1f}').contains(&c);
242            let keep = c == '\t' || c == '\n' || c == '\r';
243            !(is_c0 && !keep) && c != '\u{7f}'
244        })
245        .collect()
246}
247
248/// Expand tabs to spaces at fixed-width stops, tracking column position
249/// relative to the last newline.
250fn expand_tabs(input: &str, stop: usize) -> String {
251    let mut out = String::with_capacity(input.len());
252    let mut col = 0usize;
253    for c in input.chars() {
254        match c {
255            '\t' => {
256                let spaces = stop - (col % stop);
257                for _ in 0..spaces {
258                    out.push(' ');
259                }
260                col += spaces;
261            }
262            '\n' => {
263                out.push('\n');
264                col = 0;
265            }
266            _ => {
267                out.push(c);
268                col += 1;
269            }
270        }
271    }
272    out
273}
274
275/// Normalize `\r\n` and lone `\r` to `\n`.
276fn normalize_newlines(input: &str) -> String {
277    let mut out = String::with_capacity(input.len());
278    let mut chars = input.chars().peekable();
279    while let Some(c) = chars.next() {
280        if c == '\r' {
281            if chars.peek() == Some(&'\n') {
282                chars.next();
283            }
284            out.push('\n');
285        } else {
286            out.push(c);
287        }
288    }
289    out
290}
291
292/// Unwrap hard-wrapped paragraphs: within a block of text (separated by
293/// blank lines), a `\n` that merely continues a sentence is replaced with a
294/// space, so a later re-wrap at the render width produces clean lines
295/// instead of re-wrapping already-short, pre-broken lines raggedly. Blank
296/// lines (paragraph breaks) are preserved. Lines that look like list items
297/// (`- `, `* `, `1. `) or that are indented (leading whitespace — treated
298/// as code-like) are never joined to a neighboring line in either
299/// direction, so genuine block structure survives.
300///
301/// Must run before [`collapse_horizontal_whitespace`], which would
302/// otherwise erase the leading-whitespace signal this function uses to
303/// detect indented/code-like lines.
304fn unwrap_paragraphs(input: &str) -> String {
305    #[derive(Clone, Copy, PartialEq, Eq)]
306    enum Prev {
307        /// Start of input, or immediately after a blank line: the next
308        /// line always starts fresh, no join decision to make.
309        Fresh,
310        /// The previous line was ordinary prose that may continue.
311        Joinable,
312        /// The previous line was a list item or indented/code-like line.
313        Standalone,
314    }
315
316    let mut out = String::with_capacity(input.len());
317    let mut prev = Prev::Fresh;
318    for line in input.split('\n') {
319        if line.trim().is_empty() {
320            out.push_str("\n\n");
321            prev = Prev::Fresh;
322            continue;
323        }
324        let standalone = is_standalone_line(line);
325        match prev {
326            Prev::Fresh => out.push_str(line),
327            Prev::Joinable if !standalone => {
328                out.push(' ');
329                out.push_str(line.trim_start());
330            }
331            Prev::Joinable | Prev::Standalone => {
332                out.push('\n');
333                out.push_str(line);
334            }
335        }
336        prev = if standalone {
337            Prev::Standalone
338        } else {
339            Prev::Joinable
340        };
341    }
342    out
343}
344
345/// A line that should never be joined to a neighbor when unwrapping
346/// paragraphs: indented (code-like), or a list item (`- `, `* `, `+ `, or
347/// `N. `).
348fn is_standalone_line(line: &str) -> bool {
349    if line.starts_with(' ') || line.starts_with('\t') {
350        return true;
351    }
352    if line.starts_with("- ") || line.starts_with("* ") || line.starts_with("+ ") {
353        return true;
354    }
355    if let Some(dot) = line.find(". ") {
356        if dot > 0 && line.as_bytes()[..dot].iter().all(|b| b.is_ascii_digit()) {
357            return true;
358        }
359    }
360    false
361}
362
363/// Recognize and normalize the small, closed set of markdown constructs
364/// spec.md's carapace mapping notes call out: `[label](uri)` links (any
365/// scheme, including `man://`/`cmd://`), inline `` `code` ``, `**bold**`,
366/// and `*em*`/`_em_`. Anything else is left untouched — this is
367/// deliberately not a general markdown parser (see [`Text::sanitize_markdown`]).
368fn normalize_markdown(input: &str) -> String {
369    let s = strip_markdown_links(input);
370    let s = strip_paired_delim(&s, "`");
371    let s = strip_paired_delim(&s, "**");
372    let s = strip_emphasis_single_char(&s, '*');
373    strip_emphasis_single_char(&s, '_')
374}
375
376/// Replace `[label](uri)` with `label`. Narrow by construction: the label
377/// must be non-empty with no nested `[`/newline, and the uri must be
378/// non-empty with no whitespace/newline/nested `(` — so this never
379/// misfires on `[value]` usage-string brackets that aren't followed by
380/// `(...)`.
381fn strip_markdown_links(input: &str) -> String {
382    let chars: Vec<char> = input.chars().collect();
383    let mut out = String::with_capacity(input.len());
384    let mut i = 0;
385    while i < chars.len() {
386        if chars[i] == '[' {
387            if let Some((label, next_i)) = try_parse_link(&chars, i) {
388                out.push_str(&label);
389                i = next_i;
390                continue;
391            }
392        }
393        out.push(chars[i]);
394        i += 1;
395    }
396    out
397}
398
399/// If a valid `[label](uri)` starts at `chars[start]` (which must be
400/// `'['`), return the label text and the index just past the closing `)`.
401fn try_parse_link(chars: &[char], start: usize) -> Option<(String, usize)> {
402    let mut j = start + 1;
403    while j < chars.len() && chars[j] != ']' {
404        if chars[j] == '\n' || chars[j] == '[' {
405            return None;
406        }
407        j += 1;
408    }
409    if j >= chars.len() || j == start + 1 {
410        return None;
411    }
412    if chars.get(j + 1) != Some(&'(') {
413        return None;
414    }
415    let mut k = j + 2;
416    while k < chars.len() && chars[k] != ')' {
417        if chars[k] == '\n' || chars[k] == '(' || chars[k].is_whitespace() {
418            return None;
419        }
420        k += 1;
421    }
422    if k >= chars.len() || k == j + 2 {
423        return None;
424    }
425    let label: String = chars[start + 1..j].iter().collect();
426    Some((label, k + 1))
427}
428
429/// Replace occurrences of `delim` + content + `delim` with just the
430/// content, where content is non-empty and contains neither `delim` nor a
431/// newline (the newline restriction is what keeps this from spanning a
432/// multi-line fenced code block by accident). Used for backtick code spans
433/// and `**bold**`.
434fn strip_paired_delim(input: &str, delim: &str) -> String {
435    let mut out = String::with_capacity(input.len());
436    let mut rest = input;
437    loop {
438        let Some(open_idx) = rest.find(delim) else {
439            out.push_str(rest);
440            break;
441        };
442        let after_open = &rest[open_idx + delim.len()..];
443        if let Some(close_rel) = after_open.find(delim) {
444            let content = &after_open[..close_rel];
445            if !content.is_empty() && !content.contains('\n') {
446                out.push_str(&rest[..open_idx]);
447                out.push_str(content);
448                rest = &after_open[close_rel + delim.len()..];
449                continue;
450            }
451        }
452        out.push_str(&rest[..open_idx + delim.len()]);
453        rest = &rest[open_idx + delim.len()..];
454    }
455    out
456}
457
458/// Replace `*em*`/`_em_`-style single-character emphasis with its inner
459/// text, requiring a non-word character (or start/end of text)
460/// immediately outside each delimiter. That boundary rule is what keeps
461/// this from misfiring on `SNAKE_CASE_IDENTIFIERS` (an underscore inside a
462/// word is never treated as an opening delimiter) or on stray asterisks in
463/// glob-like text.
464fn strip_emphasis_single_char(input: &str, delim: char) -> String {
465    let chars: Vec<char> = input.chars().collect();
466    let mut out = String::with_capacity(input.len());
467    let mut i = 0;
468    while i < chars.len() {
469        if chars[i] == delim && (i == 0 || !is_word_char(chars[i - 1])) {
470            if let Some((content, after)) = try_parse_emphasis(&chars, i, delim) {
471                out.push_str(&content);
472                i = after;
473                continue;
474            }
475        }
476        out.push(chars[i]);
477        i += 1;
478    }
479    out
480}
481
482fn try_parse_emphasis(chars: &[char], open: usize, delim: char) -> Option<(String, usize)> {
483    let mut j = open + 1;
484    while j < chars.len() && chars[j] != '\n' {
485        if chars[j] == delim {
486            let content: String = chars[open + 1..j].iter().collect();
487            let after_ok = chars.get(j + 1).map(|c| !is_word_char(*c)).unwrap_or(true);
488            let content_ok = !content.is_empty()
489                && !content.starts_with(char::is_whitespace)
490                && !content.ends_with(char::is_whitespace)
491                && !content.contains(delim);
492            return if after_ok && content_ok {
493                Some((content, j + 1))
494            } else {
495                None
496            };
497        }
498        j += 1;
499    }
500    None
501}
502
503fn is_word_char(c: char) -> bool {
504    c.is_alphanumeric() || c == '_'
505}
506
507/// Collapse runs of horizontal whitespace (spaces, after tab expansion) to a
508/// single space, and collapse runs of 3+ newlines down to exactly 2 so a
509/// `\n\n` paragraph break survives while pathological vertical whitespace
510/// does not.
511fn collapse_horizontal_whitespace(input: &str) -> String {
512    let mut out = String::with_capacity(input.len());
513    let mut space_run = false;
514    let mut newline_run = 0usize;
515    for c in input.chars() {
516        match c {
517            ' ' => {
518                space_run = true;
519                newline_run = 0;
520            }
521            '\n' => {
522                if space_run {
523                    // Trailing spaces before a newline are dropped, not kept.
524                    space_run = false;
525                }
526                newline_run += 1;
527                if newline_run <= 2 {
528                    out.push('\n');
529                }
530            }
531            _ => {
532                if space_run {
533                    out.push(' ');
534                    space_run = false;
535                }
536                newline_run = 0;
537                out.push(c);
538            }
539        }
540    }
541    if space_run {
542        out.push(' ');
543    }
544    out
545}
546
547/// Trim leading/trailing whitespace on each line and on the whole text.
548fn trim_lines_and_whole(input: &str) -> String {
549    let lines: Vec<&str> = input.lines().map(|l| l.trim_end_matches(' ')).collect();
550    lines.join("\n").trim().to_string()
551}
552
553/// Truncate to at most `max_chars` characters, respecting char boundaries.
554fn truncate_chars(input: &str, max_chars: usize) -> String {
555    if input.chars().count() <= max_chars {
556        return input.to_string();
557    }
558    input.chars().take(max_chars).collect()
559}
560
561#[cfg(test)]
562mod markdown_tests {
563    use super::*;
564
565    #[test]
566    fn strips_link_keeping_label() {
567        let t = Text::sanitize_markdown("See [gittutorial](man://gittutorial/7) to start");
568        assert_eq!(t.as_str(), "See gittutorial to start");
569    }
570
571    #[test]
572    fn strips_link_with_https_scheme() {
573        let t = Text::sanitize_markdown("visit [docs](https://example.com/docs) now");
574        assert_eq!(t.as_str(), "visit docs now");
575    }
576
577    #[test]
578    fn strips_link_with_cmd_scheme() {
579        let t = Text::sanitize_markdown("use [gh pr create](cmd://gh/pr/create) instead");
580        assert_eq!(t.as_str(), "use gh pr create instead");
581    }
582
583    #[test]
584    fn does_not_touch_bracket_without_following_paren() {
585        // Usage-string bracket syntax must survive untouched.
586        let t = Text::sanitize_markdown("[OPTIONS] COMMAND [ARG...]");
587        assert_eq!(t.as_str(), "[OPTIONS] COMMAND [ARG...]");
588    }
589
590    #[test]
591    fn strips_inline_code_backticks() {
592        let t = Text::sanitize_markdown("run `git bisect start` to begin");
593        assert_eq!(t.as_str(), "run git bisect start to begin");
594    }
595
596    #[test]
597    fn strips_bold() {
598        let t = Text::sanitize_markdown("- **Configured providers** defined here");
599        assert_eq!(t.as_str(), "- Configured providers defined here");
600    }
601
602    #[test]
603    fn strips_single_asterisk_emphasis() {
604        let t = Text::sanitize_markdown("changed *any* property of the project");
605        assert_eq!(t.as_str(), "changed any property of the project");
606    }
607
608    #[test]
609    fn strips_underscore_emphasis() {
610        let t = Text::sanitize_markdown("run with _<cmd>_ and _<arg>_ should exit");
611        assert_eq!(t.as_str(), "run with <cmd> and <arg> should exit");
612    }
613
614    #[test]
615    fn does_not_touch_snake_case_identifiers() {
616        let t = Text::sanitize_markdown("sync from $ANDROID_PRODUCT_OUT to the device");
617        assert_eq!(t.as_str(), "sync from $ANDROID_PRODUCT_OUT to the device");
618    }
619
620    #[test]
621    fn does_not_touch_multiple_underscore_env_vars_in_backticks() {
622        let t = Text::sanitize_markdown("Use `GH_TOKEN` and `GH_DEBUG` for auth and logging");
623        assert_eq!(t.as_str(), "Use GH_TOKEN and GH_DEBUG for auth and logging");
624    }
625
626    #[test]
627    fn leaves_unpaired_delimiters_alone() {
628        let t = Text::sanitize_markdown("this * has an unmatched asterisk");
629        assert_eq!(t.as_str(), "this * has an unmatched asterisk");
630    }
631
632    #[test]
633    fn does_not_span_multiline_code_fence() {
634        let raw = "before\n```\nsome\ncode\n```\nafter";
635        let t = Text::sanitize_markdown(raw);
636        // Must not collapse the whole fenced block into one "code span";
637        // backticks with a newline between them are left alone.
638        assert!(t.as_str().contains('`'));
639    }
640
641    #[test]
642    fn markdown_sanitize_is_idempotent() {
643        let raw = "See [x](man://x/1) and `code` and **bold** and *em* and _em_";
644        let once = Text::sanitize_markdown(raw);
645        let twice = Text::sanitize_markdown(once.as_str());
646        assert_eq!(once, twice);
647    }
648
649    #[test]
650    fn unwrap_preserves_list_items() {
651        let raw = "Intro line one\nIntro line two\n\n- item one\n- item two\n- item three";
652        let t = Text::sanitize_markdown(raw);
653        assert_eq!(
654            t.as_str(),
655            "Intro line one Intro line two\n\n- item one\n- item two\n- item three"
656        );
657    }
658
659    #[test]
660    fn unwrap_preserves_indented_lines() {
661        let raw = "some prose\n    code line one\n    code line two\nmore prose";
662        let t = Text::sanitize(raw);
663        // Indented lines stay on their own line, not joined to neighbors.
664        assert!(t.as_str().contains("some prose\n"));
665        assert!(t.as_str().contains("code line one\n"));
666    }
667
668    #[test]
669    fn hard_wrapped_paragraph_reflows_to_one_line() {
670        let raw = "Git is a fast, scalable, distributed revision\ncontrol system with an\nunusually rich command set.";
671        let t = Text::sanitize(raw);
672        assert_eq!(
673            t.as_str(),
674            "Git is a fast, scalable, distributed revision control system with an unusually rich command set."
675        );
676    }
677}
678
679#[cfg(test)]
680mod fixture_tests {
681    use super::*;
682    use std::collections::HashMap;
683
684    fn fixtures() -> HashMap<String, String> {
685        let json = include_str!("../tests/fixtures/carapace_markdown_samples.json");
686        serde_json::from_str(json).expect("fixture file is valid JSON")
687    }
688
689    /// Defect A: raw markup must never leak into rendered text. Checked
690    /// against every real fixture pulled from the vendored catalog (git,
691    /// gh, adb, crush), not just synthetic strings.
692    #[test]
693    fn no_fixture_leaks_raw_markdown_link_syntax() {
694        for (name, raw) in fixtures() {
695            let sanitized = Text::sanitize_markdown(raw.as_str());
696            assert!(
697                !sanitized.as_str().contains("]("),
698                "fixture {name:?} leaked raw markdown link syntax: {:?}",
699                sanitized.as_str()
700            );
701        }
702    }
703
704    #[test]
705    fn git_root_doc_links_become_plain_labels() {
706        let fixtures = fixtures();
707        let raw = &fixtures["git_root"];
708        let sanitized = Text::sanitize_markdown(raw);
709        let s = sanitized.as_str();
710        assert!(
711            s.contains("gittutorial"),
712            "label text should survive: {s:?}"
713        );
714        assert!(
715            !s.contains("man://"),
716            "raw URI scheme should not leak: {s:?}"
717        );
718        assert!(!s.contains("]("), "{s:?}");
719    }
720
721    #[test]
722    fn genuine_emphasis_fixture_strips_markers_without_mangling_identifiers() {
723        // This fixture (git's `bisect` documentation) is long enough to
724        // exceed MAX_TEXT_CHARS on its own, so the underscore-emphasized
725        // placeholders near the end (`_<cmd>_`) may legitimately be
726        // truncated away — this test checks the part that's guaranteed to
727        // survive (early in the doc) plus that nothing panics on the much
728        // messier surrounding text (headings, fenced code, asciidoc-style
729        // definition lists).
730        let fixtures = fixtures();
731        let raw = &fixtures["genuine_emphasis"];
732        let sanitized = Text::sanitize_markdown(raw);
733        let s = sanitized.as_str();
734        assert!(s.contains("git bisect picks a commit"), "{s:?}");
735        assert!(
736            s.contains("any property of your project"),
737            "em marker around 'any' should be stripped: {s:?}"
738        );
739        assert!(s.chars().count() <= MAX_TEXT_CHARS);
740    }
741
742    #[test]
743    fn underscore_emphasis_survives_when_not_truncated_away() {
744        // Isolate just the tail fragment (well under the char cap) to
745        // directly verify the `_<cmd>_`/`_<arg>_` markers are stripped.
746        let raw = "Note that _<cmd>_ run with _<arg>_  should exit\nwith code 0";
747        let sanitized = Text::sanitize_markdown(raw);
748        let s = sanitized.as_str();
749        assert!(s.contains("<cmd>"), "{s:?}");
750        assert!(s.contains("<arg>"), "{s:?}");
751        assert!(!s.contains('_'), "{s:?}");
752    }
753
754    #[test]
755    fn snake_case_fixture_is_untouched_by_emphasis_stripping() {
756        let fixtures = fixtures();
757        let raw = &fixtures["snake_case_false_positive"];
758        let sanitized = Text::sanitize_markdown(raw);
759        assert!(sanitized.as_str().contains("ANDROID_PRODUCT_OUT"));
760    }
761
762    #[test]
763    fn env_var_fixture_backticks_stripped_underscores_preserved() {
764        let fixtures = fixtures();
765        let raw = &fixtures["gh_env_vars"];
766        let sanitized = Text::sanitize_markdown(raw);
767        let s = sanitized.as_str();
768        assert!(s.contains("GH_TOKEN"), "{s:?}");
769        assert!(s.contains("GH_DEBUG"), "{s:?}");
770        assert!(!s.contains('`'), "backticks should be stripped: {s:?}");
771    }
772
773    #[test]
774    fn bold_list_fixture_strips_bold_and_links_keeps_list_structure() {
775        let fixtures = fixtures();
776        let raw = &fixtures["bold_sample"];
777        let sanitized = Text::sanitize_markdown(raw);
778        let s = sanitized.as_str();
779        assert!(s.contains("Configured providers"), "{s:?}");
780        assert!(!s.contains("**"), "{s:?}");
781        assert!(!s.contains("]("), "{s:?}");
782        // List item lines survive as their own lines.
783        assert!(s.contains("\n- Configured providers"), "{s:?}");
784        assert!(s.contains("\n- Known providers"), "{s:?}");
785    }
786
787    /// Defect B: a hard-wrapped source paragraph reflows into one logical
788    /// line per paragraph (ready for the render-time re-wrap), rather than
789    /// keeping its original ragged short lines.
790    #[test]
791    fn hard_wrapped_git_archive_doc_reflows_paragraphs() {
792        let fixtures = fixtures();
793        let raw = &fixtures["git_archive_hardwrap"];
794        let sanitized = Text::sanitize_markdown(raw);
795        let s = sanitized.as_str();
796        // The original has "...the tree\nstructure for the named tree..."
797        // hard-wrapped mid-sentence; after unwrapping there must be no
798        // newline between "tree" and "structure".
799        assert!(s.contains("tree structure for the named tree"), "{s:?}");
800        // Paragraph breaks (blank line in the source) must still exist.
801        assert!(s.contains("\n\n"), "paragraph break should survive: {s:?}");
802    }
803
804    #[test]
805    fn list_items_fixture_keeps_each_bullet_on_its_own_line() {
806        let fixtures = fixtures();
807        let raw = &fixtures["list_items_sample"];
808        let sanitized = Text::sanitize_markdown(raw);
809        let s = sanitized.as_str();
810        let bullet_lines: Vec<&str> = s.lines().filter(|l| l.starts_with("- ")).collect();
811        assert!(
812            bullet_lines.len() >= 3,
813            "expected multiple preserved bullet lines, got {bullet_lines:?} in {s:?}"
814        );
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    #[test]
823    fn strips_c0_controls() {
824        let t = Text::sanitize("hello\x01\x02world");
825        assert_eq!(t.as_str(), "helloworld");
826    }
827
828    #[test]
829    fn strips_ansi_csi() {
830        let t = Text::sanitize("\x1b[31mred\x1b[0m text");
831        assert_eq!(t.as_str(), "red text");
832    }
833
834    #[test]
835    fn strips_osc_sequence() {
836        let t = Text::sanitize("\x1b]0;window title\x07visible");
837        assert_eq!(t.as_str(), "visible");
838    }
839
840    #[test]
841    fn strips_osc_sequence_st_terminated() {
842        let t = Text::sanitize("\x1b]8;;http://example.com\x1b\\link\x1b]8;;\x1b\\");
843        assert_eq!(t.as_str(), "link");
844    }
845
846    #[test]
847    fn resolves_underline_overstrike() {
848        // "_\bH_\be_\bl_\bl_\bo" -> "Hello"
849        let raw = "_\u{8}H_\u{8}e_\u{8}l_\u{8}l_\u{8}o";
850        let t = Text::sanitize(raw);
851        assert_eq!(t.as_str(), "Hello");
852    }
853
854    #[test]
855    fn resolves_bold_overstrike() {
856        let raw = "H\u{8}He\u{8}el\u{8}ll\u{8}lo\u{8}o";
857        let t = Text::sanitize(raw);
858        assert_eq!(t.as_str(), "Hello");
859    }
860
861    #[test]
862    fn stray_backspace_is_absorbed() {
863        let t = Text::sanitize("\u{8}\u{8}\u{8}hello");
864        assert_eq!(t.as_str(), "hello");
865    }
866
867    #[test]
868    fn tab_becomes_whitespace_then_collapses_like_any_other_run() {
869        // Tabs are expanded to column-aligned spaces, but the subsequent
870        // whitespace-collapse pass (spec §4.1) then reduces that run to a
871        // single space, same as any other run of horizontal whitespace.
872        // `Text` renders prose, not columnar layout, so this is correct:
873        // preserving tab-stop alignment would only matter for structural
874        // (pre-sanitization) parsing of raw tool output, which happens
875        // upstream of `Text::sanitize`, not on already-segmented fields.
876        let t = Text::sanitize("a\tb");
877        assert_eq!(t.as_str(), "a b");
878    }
879
880    #[test]
881    fn tabs_do_not_leak_through_as_raw_characters() {
882        let t = Text::sanitize("col1\tcol2\tcol3");
883        assert!(!t.as_str().contains('\t'));
884    }
885
886    #[test]
887    fn collapses_whitespace_runs() {
888        let t = Text::sanitize("a     b");
889        assert_eq!(t.as_str(), "a b");
890    }
891
892    #[test]
893    fn normalizes_crlf() {
894        // Single \r\n / \r within a paragraph are, after normalization to
895        // \n, subject to the same hard-wrap unwrapping as any other single
896        // newline (see unwraps_single_newlines_within_a_paragraph below) —
897        // this test only asserts CRLF/CR are normalized to LF, using
898        // list-item lines so unwrap_paragraphs doesn't join them and mask
899        // what's being tested.
900        let t = Text::sanitize("- a\r\n- b\r- c");
901        assert_eq!(t.as_str(), "- a\n- b\n- c");
902    }
903
904    #[test]
905    fn unwraps_single_newlines_within_a_paragraph() {
906        let t = Text::sanitize("a\nb\nc");
907        assert_eq!(t.as_str(), "a b c");
908    }
909
910    #[test]
911    fn keeps_paragraph_breaks() {
912        let t = Text::sanitize("para one\n\npara two");
913        assert_eq!(t.as_str(), "para one\n\npara two");
914    }
915
916    #[test]
917    fn collapses_excess_newlines_to_paragraph_break() {
918        let t = Text::sanitize("para one\n\n\n\n\npara two");
919        assert_eq!(t.as_str(), "para one\n\npara two");
920    }
921
922    #[test]
923    fn trims_whole_text() {
924        let t = Text::sanitize("   hello world   ");
925        assert_eq!(t.as_str(), "hello world");
926    }
927
928    #[test]
929    fn truncates_pathological_length() {
930        let raw = "x".repeat(10 * 1024 * 1024);
931        let t = Text::sanitize(&raw);
932        assert!(t.as_str().chars().count() <= MAX_TEXT_CHARS);
933    }
934
935    #[test]
936    fn truncates_at_char_boundary_with_multibyte() {
937        let raw = "\u{1F600}".repeat(MAX_TEXT_CHARS + 100);
938        let t = Text::sanitize(&raw);
939        assert!(t.as_str().chars().count() <= MAX_TEXT_CHARS);
940        // Must still be valid UTF-8 (guaranteed by String) and not panic.
941        assert!(t.as_str().chars().all(|c| c == '\u{1F600}'));
942    }
943
944    #[test]
945    fn preserves_cjk_and_emoji() {
946        let t = Text::sanitize("日本語 emoji 🎉 test");
947        assert_eq!(t.as_str(), "日本語 emoji 🎉 test");
948    }
949
950    #[test]
951    fn single_line_collapses_newlines() {
952        let t = Text::sanitize("line one\nline two\n\nline three");
953        assert_eq!(t.single_line(), "line one line two line three");
954    }
955
956    #[test]
957    fn sanitize_is_idempotent() {
958        let raw = "\x1b[1mBold\x1b[0m\ttext\r\nwith\n\n\n\nparagraphs   and   spaces  ";
959        let once = Text::sanitize(raw);
960        let twice = Text::sanitize(once.as_str());
961        assert_eq!(once, twice);
962    }
963
964    #[test]
965    fn deserialize_sanitizes() {
966        let json = "\"\\u001b[31mred\\u0007\"";
967        let t: Text = serde_json::from_str(json).unwrap();
968        assert_eq!(t.as_str(), "red");
969    }
970
971    #[test]
972    fn serialize_roundtrip() {
973        let t = Text::sanitize("hello world");
974        let json = serde_json::to_string(&t).unwrap();
975        let back: Text = serde_json::from_str(&json).unwrap();
976        assert_eq!(t, back);
977    }
978}