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    /// Like [`Text::sanitize`], but for the raw-help display path (the
92    /// verbatim pane, `t`), whose entire job is showing a tool's own bytes
93    /// as they arrived — not turning them into IR prose. `Text::sanitize`
94    /// is the wrong gate there: its steps 6-8 (unwrap hard-wrapped
95    /// paragraphs, collapse whitespace runs, trim leading/trailing
96    /// whitespace) are exactly what destroy column alignment, and column
97    /// alignment is the one thing a side-by-side "does this match the raw
98    /// pane's ground truth" review depends on.
99    ///
100    /// This still neutralizes terminal control sequences — the one thing
101    /// the raw pane cannot safely pass through, since ANSI/OSC/DCS escapes,
102    /// stray carriage returns, and other C0 controls could scramble the
103    /// reader's terminal or misrepresent what arrived — and nothing else:
104    ///
105    /// 1. Strip ANSI/OSC/DCS escape sequences (shares [`strip_escapes`]
106    ///    with [`Text::sanitize`] — same hazard, same fix).
107    /// 2. Strip remaining C0 control characters and DEL, **including a
108    ///    stray `\r`** — callers pass one already-line-split string at a
109    ///    time (see below), so any `\r` still present did not terminate a
110    ///    line and is exactly the "carriage return that lies about what's
111    ///    on screen" hazard, not useful structure.
112    /// 3. Expand tabs to spaces at 8-column stops. This is a neutralization
113    ///    too, not a formatting choice: `ratatui` does not interpret `\t`
114    ///    as a tab stop the way a real terminal does (`unicode-width`
115    ///    gives it zero display width), so a raw tab left in would
116    ///    *misalign* columns in the pane relative to what the reader's own
117    ///    terminal shows for the same bytes — the opposite of this
118    ///    function's purpose.
119    /// 4. Truncate to [`MAX_TEXT_CHARS`], the same bound [`Text::sanitize`]
120    ///    applies, so a pathological single line cannot blow up the pane.
121    ///
122    /// Deliberately **not** applied: unwrapping, whitespace-collapsing,
123    /// trimming, or paragraph-break normalization — indentation and
124    /// internal column alignment are preserved exactly as fetched, and
125    /// blank lines are whatever the caller's own line-splitting already
126    /// produced.
127    ///
128    /// Only [`mandible-extract`'s `help_text::raw_help*` functions] call
129    /// this; every other consumer of a `--help` probe keeps going through
130    /// [`Text::sanitize`] unchanged — this is an additional path for
131    /// display, not a redefinition of the existing one.
132    pub fn sanitize_preserving_layout(raw: &str) -> Text {
133        let no_escapes = strip_escapes(raw);
134        let no_control = strip_c0_keep_tabs(&no_escapes);
135        let tabs_expanded = expand_tabs(&no_control, 8);
136        Text(truncate_chars(&tabs_expanded, MAX_TEXT_CHARS))
137    }
138
139    /// Borrow the sanitized string.
140    pub fn as_str(&self) -> &str {
141        &self.0
142    }
143
144    /// True if the sanitized text is empty.
145    pub fn is_empty(&self) -> bool {
146        self.0.is_empty()
147    }
148
149    /// Collapse to a single display line (paragraph breaks and internal
150    /// newlines become a single space), for contexts like tree rows that
151    /// have no room for multi-line text. The tree pane is expected to call
152    /// this at render time rather than store a second copy of the text.
153    pub fn single_line(&self) -> String {
154        let mut out = String::with_capacity(self.0.len());
155        let mut last_was_space = false;
156        for ch in self.0.chars() {
157            let c = if ch == '\n' { ' ' } else { ch };
158            if c == ' ' {
159                if !last_was_space && !out.is_empty() {
160                    out.push(' ');
161                }
162                last_was_space = true;
163            } else {
164                out.push(c);
165                last_was_space = false;
166            }
167        }
168        out.trim_end().to_string()
169    }
170}
171
172impl fmt::Display for Text {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        f.write_str(&self.0)
175    }
176}
177
178impl Serialize for Text {
179    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
180    where
181        S: serde::Serializer,
182    {
183        self.0.serialize(serializer)
184    }
185}
186
187impl<'de> Deserialize<'de> for Text {
188    /// Deserialization re-runs [`Text::sanitize`] rather than trusting the
189    /// stored bytes verbatim. This keeps the invariant airtight even when a
190    /// `Text` is round-tripped through the on-disk cache (spec §11): a
191    /// tampered or corrupted cache file cannot smuggle unsanitized bytes
192    /// back into the IR. `sanitize` is idempotent, so this costs nothing
193    /// extra for cache entries that were already clean.
194    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
195    where
196        D: serde::Deserializer<'de>,
197    {
198        let raw = String::deserialize(deserializer)?;
199        Ok(Text::sanitize(&raw))
200    }
201}
202
203/// Strip ANSI CSI/OSC/DCS escape sequences and other `ESC`-prefixed
204/// sequences. Hand-written state machine rather than a regex crate
205/// dependency; the grammar is small and well-known.
206fn strip_escapes(input: &str) -> String {
207    let mut out = String::with_capacity(input.len());
208    let mut chars = input.chars().peekable();
209    while let Some(c) = chars.next() {
210        if c != '\u{1b}' {
211            out.push(c);
212            continue;
213        }
214        match chars.peek() {
215            Some('[') => {
216                // CSI: ESC [ ... final-byte in 0x40..=0x7E
217                chars.next();
218                for c2 in chars.by_ref() {
219                    if ('\u{40}'..='\u{7e}').contains(&c2) {
220                        break;
221                    }
222                }
223            }
224            Some(']') => {
225                // OSC: ESC ] ... BEL or ESC \
226                chars.next();
227                loop {
228                    match chars.next() {
229                        None => break,
230                        Some('\u{07}') => break,
231                        Some('\u{1b}') => {
232                            if chars.peek() == Some(&'\\') {
233                                chars.next();
234                            }
235                            break;
236                        }
237                        Some(_) => continue,
238                    }
239                }
240            }
241            Some('P') | Some('X') | Some('^') | Some('_') => {
242                // DCS / SOS / PM / APC: ESC x ... ESC \
243                chars.next();
244                loop {
245                    match chars.next() {
246                        None => break,
247                        Some('\u{1b}') => {
248                            if chars.peek() == Some(&'\\') {
249                                chars.next();
250                            }
251                            break;
252                        }
253                        Some(_) => continue,
254                    }
255                }
256            }
257            Some(_) => {
258                // Two-character escape (e.g. charset selection ESC ( B).
259                chars.next();
260            }
261            None => {}
262        }
263    }
264    out
265}
266
267/// Resolve backspace-overstrike sequences as emitted by rendered man pages
268/// (`_\bX` for underline, `X\bX` for bold). A backspace deletes the
269/// previously emitted character; whatever follows becomes the visible glyph.
270/// This also silently absorbs any stray backspace with nothing to delete.
271fn resolve_backspace(input: &str) -> String {
272    let mut out: Vec<char> = Vec::with_capacity(input.len());
273    for c in input.chars() {
274        if c == '\u{8}' {
275            out.pop();
276        } else {
277            out.push(c);
278        }
279    }
280    out.into_iter().collect()
281}
282
283/// Strip remaining C0 control characters and DEL, preserving `\t`, `\n`,
284/// `\r` for the later tab/newline passes.
285fn strip_c0(input: &str) -> String {
286    input
287        .chars()
288        .filter(|&c| {
289            let is_c0 = ('\u{0}'..='\u{1f}').contains(&c);
290            let keep = c == '\t' || c == '\n' || c == '\r';
291            !(is_c0 && !keep) && c != '\u{7f}'
292        })
293        .collect()
294}
295
296/// Like [`strip_c0`], but for [`Text::sanitize_preserving_layout`]: strips
297/// every C0 control character and DEL **except** `\t` (kept so
298/// [`expand_tabs`] can still turn it into alignment-preserving spaces
299/// afterward). Unlike `strip_c0`, `\n` and `\r` are *not* kept — this
300/// function's only caller passes one already-line-split string at a time,
301/// so a `\n`/`\r` reaching here did not terminate a line and is exactly the
302/// "control character that could scramble the terminal" hazard
303/// [`Text::sanitize_preserving_layout`] exists to neutralize, not
304/// structure worth preserving.
305fn strip_c0_keep_tabs(input: &str) -> String {
306    input
307        .chars()
308        .filter(|&c| {
309            let is_c0 = ('\u{0}'..='\u{1f}').contains(&c);
310            !(is_c0 && c != '\t') && c != '\u{7f}'
311        })
312        .collect()
313}
314
315/// Expand tabs to spaces at fixed-width stops, tracking column position
316/// relative to the last newline.
317fn expand_tabs(input: &str, stop: usize) -> String {
318    let mut out = String::with_capacity(input.len());
319    let mut col = 0usize;
320    for c in input.chars() {
321        match c {
322            '\t' => {
323                let spaces = stop - (col % stop);
324                for _ in 0..spaces {
325                    out.push(' ');
326                }
327                col += spaces;
328            }
329            '\n' => {
330                out.push('\n');
331                col = 0;
332            }
333            _ => {
334                out.push(c);
335                col += 1;
336            }
337        }
338    }
339    out
340}
341
342/// Normalize `\r\n` and lone `\r` to `\n`.
343fn normalize_newlines(input: &str) -> String {
344    let mut out = String::with_capacity(input.len());
345    let mut chars = input.chars().peekable();
346    while let Some(c) = chars.next() {
347        if c == '\r' {
348            if chars.peek() == Some(&'\n') {
349                chars.next();
350            }
351            out.push('\n');
352        } else {
353            out.push(c);
354        }
355    }
356    out
357}
358
359/// Unwrap hard-wrapped paragraphs: within a block of text (separated by
360/// blank lines), a `\n` that merely continues a sentence is replaced with a
361/// space, so a later re-wrap at the render width produces clean lines
362/// instead of re-wrapping already-short, pre-broken lines raggedly. Blank
363/// lines (paragraph breaks) are preserved. Lines that look like list items
364/// (`- `, `* `, `1. `) or that are indented (leading whitespace — treated
365/// as code-like) are never joined to a neighboring line in either
366/// direction, so genuine block structure survives.
367///
368/// Must run before [`collapse_horizontal_whitespace`], which would
369/// otherwise erase the leading-whitespace signal this function uses to
370/// detect indented/code-like lines.
371fn unwrap_paragraphs(input: &str) -> String {
372    #[derive(Clone, Copy, PartialEq, Eq)]
373    enum Prev {
374        /// Start of input, or immediately after a blank line: the next
375        /// line always starts fresh, no join decision to make.
376        Fresh,
377        /// The previous line was ordinary prose that may continue.
378        Joinable,
379        /// The previous line was a list item or indented/code-like line.
380        Standalone,
381    }
382
383    let mut out = String::with_capacity(input.len());
384    let mut prev = Prev::Fresh;
385    for line in input.split('\n') {
386        if line.trim().is_empty() {
387            out.push_str("\n\n");
388            prev = Prev::Fresh;
389            continue;
390        }
391        let standalone = is_standalone_line(line);
392        match prev {
393            Prev::Fresh => out.push_str(line),
394            Prev::Joinable if !standalone => {
395                out.push(' ');
396                out.push_str(line.trim_start());
397            }
398            Prev::Joinable | Prev::Standalone => {
399                out.push('\n');
400                out.push_str(line);
401            }
402        }
403        prev = if standalone {
404            Prev::Standalone
405        } else {
406            Prev::Joinable
407        };
408    }
409    out
410}
411
412/// A line that should never be joined to a neighbor when unwrapping
413/// paragraphs: indented (code-like), or a list item (`- `, `* `, `+ `, or
414/// `N. `).
415fn is_standalone_line(line: &str) -> bool {
416    if line.starts_with(' ') || line.starts_with('\t') {
417        return true;
418    }
419    if line.starts_with("- ") || line.starts_with("* ") || line.starts_with("+ ") {
420        return true;
421    }
422    if let Some(dot) = line.find(". ") {
423        if dot > 0 && line.as_bytes()[..dot].iter().all(|b| b.is_ascii_digit()) {
424            return true;
425        }
426    }
427    false
428}
429
430/// Recognize and normalize the small, closed set of markdown constructs
431/// spec.md's carapace mapping notes call out: `[label](uri)` links (any
432/// scheme, including `man://`/`cmd://`), inline `` `code` ``, `**bold**`,
433/// and `*em*`/`_em_`. Anything else is left untouched — this is
434/// deliberately not a general markdown parser (see [`Text::sanitize_markdown`]).
435fn normalize_markdown(input: &str) -> String {
436    let s = strip_markdown_links(input);
437    let s = strip_paired_delim(&s, "`");
438    let s = strip_paired_delim(&s, "**");
439    let s = strip_emphasis_single_char(&s, '*');
440    strip_emphasis_single_char(&s, '_')
441}
442
443/// Replace `[label](uri)` with `label`. Narrow by construction: the label
444/// must be non-empty with no nested `[`/newline, and the uri must be
445/// non-empty with no whitespace/newline/nested `(` — so this never
446/// misfires on `[value]` usage-string brackets that aren't followed by
447/// `(...)`.
448fn strip_markdown_links(input: &str) -> String {
449    let chars: Vec<char> = input.chars().collect();
450    let mut out = String::with_capacity(input.len());
451    let mut i = 0;
452    while i < chars.len() {
453        if chars[i] == '[' {
454            if let Some((label, next_i)) = try_parse_link(&chars, i) {
455                out.push_str(&label);
456                i = next_i;
457                continue;
458            }
459        }
460        out.push(chars[i]);
461        i += 1;
462    }
463    out
464}
465
466/// If a valid `[label](uri)` starts at `chars[start]` (which must be
467/// `'['`), return the label text and the index just past the closing `)`.
468fn try_parse_link(chars: &[char], start: usize) -> Option<(String, usize)> {
469    let mut j = start + 1;
470    while j < chars.len() && chars[j] != ']' {
471        if chars[j] == '\n' || chars[j] == '[' {
472            return None;
473        }
474        j += 1;
475    }
476    if j >= chars.len() || j == start + 1 {
477        return None;
478    }
479    if chars.get(j + 1) != Some(&'(') {
480        return None;
481    }
482    let mut k = j + 2;
483    while k < chars.len() && chars[k] != ')' {
484        if chars[k] == '\n' || chars[k] == '(' || chars[k].is_whitespace() {
485            return None;
486        }
487        k += 1;
488    }
489    if k >= chars.len() || k == j + 2 {
490        return None;
491    }
492    let label: String = chars[start + 1..j].iter().collect();
493    Some((label, k + 1))
494}
495
496/// Replace occurrences of `delim` + content + `delim` with just the
497/// content, where content is non-empty and contains neither `delim` nor a
498/// newline (the newline restriction is what keeps this from spanning a
499/// multi-line fenced code block by accident). Used for backtick code spans
500/// and `**bold**`.
501fn strip_paired_delim(input: &str, delim: &str) -> String {
502    let mut out = String::with_capacity(input.len());
503    let mut rest = input;
504    loop {
505        let Some(open_idx) = rest.find(delim) else {
506            out.push_str(rest);
507            break;
508        };
509        let after_open = &rest[open_idx + delim.len()..];
510        if let Some(close_rel) = after_open.find(delim) {
511            let content = &after_open[..close_rel];
512            if !content.is_empty() && !content.contains('\n') {
513                out.push_str(&rest[..open_idx]);
514                out.push_str(content);
515                rest = &after_open[close_rel + delim.len()..];
516                continue;
517            }
518        }
519        out.push_str(&rest[..open_idx + delim.len()]);
520        rest = &rest[open_idx + delim.len()..];
521    }
522    out
523}
524
525/// Replace `*em*`/`_em_`-style single-character emphasis with its inner
526/// text, requiring a non-word character (or start/end of text)
527/// immediately outside each delimiter. That boundary rule is what keeps
528/// this from misfiring on `SNAKE_CASE_IDENTIFIERS` (an underscore inside a
529/// word is never treated as an opening delimiter) or on stray asterisks in
530/// glob-like text.
531fn strip_emphasis_single_char(input: &str, delim: char) -> String {
532    let chars: Vec<char> = input.chars().collect();
533    let mut out = String::with_capacity(input.len());
534    let mut i = 0;
535    while i < chars.len() {
536        if chars[i] == delim && (i == 0 || !is_word_char(chars[i - 1])) {
537            if let Some((content, after)) = try_parse_emphasis(&chars, i, delim) {
538                out.push_str(&content);
539                i = after;
540                continue;
541            }
542        }
543        out.push(chars[i]);
544        i += 1;
545    }
546    out
547}
548
549fn try_parse_emphasis(chars: &[char], open: usize, delim: char) -> Option<(String, usize)> {
550    let mut j = open + 1;
551    while j < chars.len() && chars[j] != '\n' {
552        if chars[j] == delim {
553            let content: String = chars[open + 1..j].iter().collect();
554            let after_ok = chars.get(j + 1).map(|c| !is_word_char(*c)).unwrap_or(true);
555            let content_ok = !content.is_empty()
556                && !content.starts_with(char::is_whitespace)
557                && !content.ends_with(char::is_whitespace)
558                && !content.contains(delim);
559            return if after_ok && content_ok {
560                Some((content, j + 1))
561            } else {
562                None
563            };
564        }
565        j += 1;
566    }
567    None
568}
569
570fn is_word_char(c: char) -> bool {
571    c.is_alphanumeric() || c == '_'
572}
573
574/// Collapse runs of horizontal whitespace (spaces, after tab expansion) to a
575/// single space, and collapse runs of 3+ newlines down to exactly 2 so a
576/// `\n\n` paragraph break survives while pathological vertical whitespace
577/// does not.
578fn collapse_horizontal_whitespace(input: &str) -> String {
579    let mut out = String::with_capacity(input.len());
580    let mut space_run = false;
581    let mut newline_run = 0usize;
582    for c in input.chars() {
583        match c {
584            ' ' => {
585                space_run = true;
586                newline_run = 0;
587            }
588            '\n' => {
589                if space_run {
590                    // Trailing spaces before a newline are dropped, not kept.
591                    space_run = false;
592                }
593                newline_run += 1;
594                if newline_run <= 2 {
595                    out.push('\n');
596                }
597            }
598            _ => {
599                if space_run {
600                    out.push(' ');
601                    space_run = false;
602                }
603                newline_run = 0;
604                out.push(c);
605            }
606        }
607    }
608    if space_run {
609        out.push(' ');
610    }
611    out
612}
613
614/// Trim leading/trailing whitespace on each line and on the whole text.
615fn trim_lines_and_whole(input: &str) -> String {
616    let lines: Vec<&str> = input.lines().map(|l| l.trim_end_matches(' ')).collect();
617    lines.join("\n").trim().to_string()
618}
619
620/// Truncate to at most `max_chars` characters, respecting char boundaries.
621fn truncate_chars(input: &str, max_chars: usize) -> String {
622    if input.chars().count() <= max_chars {
623        return input.to_string();
624    }
625    input.chars().take(max_chars).collect()
626}
627
628#[cfg(test)]
629mod markdown_tests {
630    use super::*;
631
632    #[test]
633    fn strips_link_keeping_label() {
634        let t = Text::sanitize_markdown("See [gittutorial](man://gittutorial/7) to start");
635        assert_eq!(t.as_str(), "See gittutorial to start");
636    }
637
638    #[test]
639    fn strips_link_with_https_scheme() {
640        let t = Text::sanitize_markdown("visit [docs](https://example.com/docs) now");
641        assert_eq!(t.as_str(), "visit docs now");
642    }
643
644    #[test]
645    fn strips_link_with_cmd_scheme() {
646        let t = Text::sanitize_markdown("use [gh pr create](cmd://gh/pr/create) instead");
647        assert_eq!(t.as_str(), "use gh pr create instead");
648    }
649
650    #[test]
651    fn does_not_touch_bracket_without_following_paren() {
652        // Usage-string bracket syntax must survive untouched.
653        let t = Text::sanitize_markdown("[OPTIONS] COMMAND [ARG...]");
654        assert_eq!(t.as_str(), "[OPTIONS] COMMAND [ARG...]");
655    }
656
657    #[test]
658    fn strips_inline_code_backticks() {
659        let t = Text::sanitize_markdown("run `git bisect start` to begin");
660        assert_eq!(t.as_str(), "run git bisect start to begin");
661    }
662
663    #[test]
664    fn strips_bold() {
665        let t = Text::sanitize_markdown("- **Configured providers** defined here");
666        assert_eq!(t.as_str(), "- Configured providers defined here");
667    }
668
669    #[test]
670    fn strips_single_asterisk_emphasis() {
671        let t = Text::sanitize_markdown("changed *any* property of the project");
672        assert_eq!(t.as_str(), "changed any property of the project");
673    }
674
675    #[test]
676    fn strips_underscore_emphasis() {
677        let t = Text::sanitize_markdown("run with _<cmd>_ and _<arg>_ should exit");
678        assert_eq!(t.as_str(), "run with <cmd> and <arg> should exit");
679    }
680
681    #[test]
682    fn does_not_touch_snake_case_identifiers() {
683        let t = Text::sanitize_markdown("sync from $ANDROID_PRODUCT_OUT to the device");
684        assert_eq!(t.as_str(), "sync from $ANDROID_PRODUCT_OUT to the device");
685    }
686
687    #[test]
688    fn does_not_touch_multiple_underscore_env_vars_in_backticks() {
689        let t = Text::sanitize_markdown("Use `GH_TOKEN` and `GH_DEBUG` for auth and logging");
690        assert_eq!(t.as_str(), "Use GH_TOKEN and GH_DEBUG for auth and logging");
691    }
692
693    #[test]
694    fn leaves_unpaired_delimiters_alone() {
695        let t = Text::sanitize_markdown("this * has an unmatched asterisk");
696        assert_eq!(t.as_str(), "this * has an unmatched asterisk");
697    }
698
699    #[test]
700    fn does_not_span_multiline_code_fence() {
701        let raw = "before\n```\nsome\ncode\n```\nafter";
702        let t = Text::sanitize_markdown(raw);
703        // Must not collapse the whole fenced block into one "code span";
704        // backticks with a newline between them are left alone.
705        assert!(t.as_str().contains('`'));
706    }
707
708    #[test]
709    fn markdown_sanitize_is_idempotent() {
710        let raw = "See [x](man://x/1) and `code` and **bold** and *em* and _em_";
711        let once = Text::sanitize_markdown(raw);
712        let twice = Text::sanitize_markdown(once.as_str());
713        assert_eq!(once, twice);
714    }
715
716    #[test]
717    fn unwrap_preserves_list_items() {
718        let raw = "Intro line one\nIntro line two\n\n- item one\n- item two\n- item three";
719        let t = Text::sanitize_markdown(raw);
720        assert_eq!(
721            t.as_str(),
722            "Intro line one Intro line two\n\n- item one\n- item two\n- item three"
723        );
724    }
725
726    #[test]
727    fn unwrap_preserves_indented_lines() {
728        let raw = "some prose\n    code line one\n    code line two\nmore prose";
729        let t = Text::sanitize(raw);
730        // Indented lines stay on their own line, not joined to neighbors.
731        assert!(t.as_str().contains("some prose\n"));
732        assert!(t.as_str().contains("code line one\n"));
733    }
734
735    #[test]
736    fn hard_wrapped_paragraph_reflows_to_one_line() {
737        let raw = "Git is a fast, scalable, distributed revision\ncontrol system with an\nunusually rich command set.";
738        let t = Text::sanitize(raw);
739        assert_eq!(
740            t.as_str(),
741            "Git is a fast, scalable, distributed revision control system with an unusually rich command set."
742        );
743    }
744}
745
746#[cfg(test)]
747mod fixture_tests {
748    use super::*;
749    use std::collections::HashMap;
750
751    fn fixtures() -> HashMap<String, String> {
752        let json = include_str!("../tests/fixtures/carapace_markdown_samples.json");
753        serde_json::from_str(json).expect("fixture file is valid JSON")
754    }
755
756    /// Defect A: raw markup must never leak into rendered text. Checked
757    /// against every real fixture pulled from the vendored catalog (git,
758    /// gh, adb, crush), not just synthetic strings.
759    #[test]
760    fn no_fixture_leaks_raw_markdown_link_syntax() {
761        for (name, raw) in fixtures() {
762            let sanitized = Text::sanitize_markdown(raw.as_str());
763            assert!(
764                !sanitized.as_str().contains("]("),
765                "fixture {name:?} leaked raw markdown link syntax: {:?}",
766                sanitized.as_str()
767            );
768        }
769    }
770
771    #[test]
772    fn git_root_doc_links_become_plain_labels() {
773        let fixtures = fixtures();
774        let raw = &fixtures["git_root"];
775        let sanitized = Text::sanitize_markdown(raw);
776        let s = sanitized.as_str();
777        assert!(
778            s.contains("gittutorial"),
779            "label text should survive: {s:?}"
780        );
781        assert!(
782            !s.contains("man://"),
783            "raw URI scheme should not leak: {s:?}"
784        );
785        assert!(!s.contains("]("), "{s:?}");
786    }
787
788    #[test]
789    fn genuine_emphasis_fixture_strips_markers_without_mangling_identifiers() {
790        // This fixture (git's `bisect` documentation) is long enough to
791        // exceed MAX_TEXT_CHARS on its own, so the underscore-emphasized
792        // placeholders near the end (`_<cmd>_`) may legitimately be
793        // truncated away — this test checks the part that's guaranteed to
794        // survive (early in the doc) plus that nothing panics on the much
795        // messier surrounding text (headings, fenced code, asciidoc-style
796        // definition lists).
797        let fixtures = fixtures();
798        let raw = &fixtures["genuine_emphasis"];
799        let sanitized = Text::sanitize_markdown(raw);
800        let s = sanitized.as_str();
801        assert!(s.contains("git bisect picks a commit"), "{s:?}");
802        assert!(
803            s.contains("any property of your project"),
804            "em marker around 'any' should be stripped: {s:?}"
805        );
806        assert!(s.chars().count() <= MAX_TEXT_CHARS);
807    }
808
809    #[test]
810    fn underscore_emphasis_survives_when_not_truncated_away() {
811        // Isolate just the tail fragment (well under the char cap) to
812        // directly verify the `_<cmd>_`/`_<arg>_` markers are stripped.
813        let raw = "Note that _<cmd>_ run with _<arg>_  should exit\nwith code 0";
814        let sanitized = Text::sanitize_markdown(raw);
815        let s = sanitized.as_str();
816        assert!(s.contains("<cmd>"), "{s:?}");
817        assert!(s.contains("<arg>"), "{s:?}");
818        assert!(!s.contains('_'), "{s:?}");
819    }
820
821    #[test]
822    fn snake_case_fixture_is_untouched_by_emphasis_stripping() {
823        let fixtures = fixtures();
824        let raw = &fixtures["snake_case_false_positive"];
825        let sanitized = Text::sanitize_markdown(raw);
826        assert!(sanitized.as_str().contains("ANDROID_PRODUCT_OUT"));
827    }
828
829    #[test]
830    fn env_var_fixture_backticks_stripped_underscores_preserved() {
831        let fixtures = fixtures();
832        let raw = &fixtures["gh_env_vars"];
833        let sanitized = Text::sanitize_markdown(raw);
834        let s = sanitized.as_str();
835        assert!(s.contains("GH_TOKEN"), "{s:?}");
836        assert!(s.contains("GH_DEBUG"), "{s:?}");
837        assert!(!s.contains('`'), "backticks should be stripped: {s:?}");
838    }
839
840    #[test]
841    fn bold_list_fixture_strips_bold_and_links_keeps_list_structure() {
842        let fixtures = fixtures();
843        let raw = &fixtures["bold_sample"];
844        let sanitized = Text::sanitize_markdown(raw);
845        let s = sanitized.as_str();
846        assert!(s.contains("Configured providers"), "{s:?}");
847        assert!(!s.contains("**"), "{s:?}");
848        assert!(!s.contains("]("), "{s:?}");
849        // List item lines survive as their own lines.
850        assert!(s.contains("\n- Configured providers"), "{s:?}");
851        assert!(s.contains("\n- Known providers"), "{s:?}");
852    }
853
854    /// Defect B: a hard-wrapped source paragraph reflows into one logical
855    /// line per paragraph (ready for the render-time re-wrap), rather than
856    /// keeping its original ragged short lines.
857    #[test]
858    fn hard_wrapped_git_archive_doc_reflows_paragraphs() {
859        let fixtures = fixtures();
860        let raw = &fixtures["git_archive_hardwrap"];
861        let sanitized = Text::sanitize_markdown(raw);
862        let s = sanitized.as_str();
863        // The original has "...the tree\nstructure for the named tree..."
864        // hard-wrapped mid-sentence; after unwrapping there must be no
865        // newline between "tree" and "structure".
866        assert!(s.contains("tree structure for the named tree"), "{s:?}");
867        // Paragraph breaks (blank line in the source) must still exist.
868        assert!(s.contains("\n\n"), "paragraph break should survive: {s:?}");
869    }
870
871    #[test]
872    fn list_items_fixture_keeps_each_bullet_on_its_own_line() {
873        let fixtures = fixtures();
874        let raw = &fixtures["list_items_sample"];
875        let sanitized = Text::sanitize_markdown(raw);
876        let s = sanitized.as_str();
877        let bullet_lines: Vec<&str> = s.lines().filter(|l| l.starts_with("- ")).collect();
878        assert!(
879            bullet_lines.len() >= 3,
880            "expected multiple preserved bullet lines, got {bullet_lines:?} in {s:?}"
881        );
882    }
883}
884
885#[cfg(test)]
886mod tests {
887    use super::*;
888
889    #[test]
890    fn strips_c0_controls() {
891        let t = Text::sanitize("hello\x01\x02world");
892        assert_eq!(t.as_str(), "helloworld");
893    }
894
895    #[test]
896    fn strips_ansi_csi() {
897        let t = Text::sanitize("\x1b[31mred\x1b[0m text");
898        assert_eq!(t.as_str(), "red text");
899    }
900
901    #[test]
902    fn strips_osc_sequence() {
903        let t = Text::sanitize("\x1b]0;window title\x07visible");
904        assert_eq!(t.as_str(), "visible");
905    }
906
907    #[test]
908    fn strips_osc_sequence_st_terminated() {
909        let t = Text::sanitize("\x1b]8;;http://example.com\x1b\\link\x1b]8;;\x1b\\");
910        assert_eq!(t.as_str(), "link");
911    }
912
913    #[test]
914    fn resolves_underline_overstrike() {
915        // "_\bH_\be_\bl_\bl_\bo" -> "Hello"
916        let raw = "_\u{8}H_\u{8}e_\u{8}l_\u{8}l_\u{8}o";
917        let t = Text::sanitize(raw);
918        assert_eq!(t.as_str(), "Hello");
919    }
920
921    #[test]
922    fn resolves_bold_overstrike() {
923        let raw = "H\u{8}He\u{8}el\u{8}ll\u{8}lo\u{8}o";
924        let t = Text::sanitize(raw);
925        assert_eq!(t.as_str(), "Hello");
926    }
927
928    #[test]
929    fn stray_backspace_is_absorbed() {
930        let t = Text::sanitize("\u{8}\u{8}\u{8}hello");
931        assert_eq!(t.as_str(), "hello");
932    }
933
934    #[test]
935    fn tab_becomes_whitespace_then_collapses_like_any_other_run() {
936        // Tabs are expanded to column-aligned spaces, but the subsequent
937        // whitespace-collapse pass (spec §4.1) then reduces that run to a
938        // single space, same as any other run of horizontal whitespace.
939        // `Text` renders prose, not columnar layout, so this is correct:
940        // preserving tab-stop alignment would only matter for structural
941        // (pre-sanitization) parsing of raw tool output, which happens
942        // upstream of `Text::sanitize`, not on already-segmented fields.
943        let t = Text::sanitize("a\tb");
944        assert_eq!(t.as_str(), "a b");
945    }
946
947    #[test]
948    fn tabs_do_not_leak_through_as_raw_characters() {
949        let t = Text::sanitize("col1\tcol2\tcol3");
950        assert!(!t.as_str().contains('\t'));
951    }
952
953    #[test]
954    fn collapses_whitespace_runs() {
955        let t = Text::sanitize("a     b");
956        assert_eq!(t.as_str(), "a b");
957    }
958
959    #[test]
960    fn normalizes_crlf() {
961        // Single \r\n / \r within a paragraph are, after normalization to
962        // \n, subject to the same hard-wrap unwrapping as any other single
963        // newline (see unwraps_single_newlines_within_a_paragraph below) —
964        // this test only asserts CRLF/CR are normalized to LF, using
965        // list-item lines so unwrap_paragraphs doesn't join them and mask
966        // what's being tested.
967        let t = Text::sanitize("- a\r\n- b\r- c");
968        assert_eq!(t.as_str(), "- a\n- b\n- c");
969    }
970
971    #[test]
972    fn unwraps_single_newlines_within_a_paragraph() {
973        let t = Text::sanitize("a\nb\nc");
974        assert_eq!(t.as_str(), "a b c");
975    }
976
977    #[test]
978    fn keeps_paragraph_breaks() {
979        let t = Text::sanitize("para one\n\npara two");
980        assert_eq!(t.as_str(), "para one\n\npara two");
981    }
982
983    #[test]
984    fn collapses_excess_newlines_to_paragraph_break() {
985        let t = Text::sanitize("para one\n\n\n\n\npara two");
986        assert_eq!(t.as_str(), "para one\n\npara two");
987    }
988
989    #[test]
990    fn trims_whole_text() {
991        let t = Text::sanitize("   hello world   ");
992        assert_eq!(t.as_str(), "hello world");
993    }
994
995    #[test]
996    fn truncates_pathological_length() {
997        let raw = "x".repeat(10 * 1024 * 1024);
998        let t = Text::sanitize(&raw);
999        assert!(t.as_str().chars().count() <= MAX_TEXT_CHARS);
1000    }
1001
1002    #[test]
1003    fn truncates_at_char_boundary_with_multibyte() {
1004        let raw = "\u{1F600}".repeat(MAX_TEXT_CHARS + 100);
1005        let t = Text::sanitize(&raw);
1006        assert!(t.as_str().chars().count() <= MAX_TEXT_CHARS);
1007        // Must still be valid UTF-8 (guaranteed by String) and not panic.
1008        assert!(t.as_str().chars().all(|c| c == '\u{1F600}'));
1009    }
1010
1011    #[test]
1012    fn preserves_cjk_and_emoji() {
1013        let t = Text::sanitize("日本語 emoji 🎉 test");
1014        assert_eq!(t.as_str(), "日本語 emoji 🎉 test");
1015    }
1016
1017    #[test]
1018    fn single_line_collapses_newlines() {
1019        let t = Text::sanitize("line one\nline two\n\nline three");
1020        assert_eq!(t.single_line(), "line one line two line three");
1021    }
1022
1023    #[test]
1024    fn sanitize_is_idempotent() {
1025        let raw = "\x1b[1mBold\x1b[0m\ttext\r\nwith\n\n\n\nparagraphs   and   spaces  ";
1026        let once = Text::sanitize(raw);
1027        let twice = Text::sanitize(once.as_str());
1028        assert_eq!(once, twice);
1029    }
1030
1031    #[test]
1032    fn deserialize_sanitizes() {
1033        let json = "\"\\u001b[31mred\\u0007\"";
1034        let t: Text = serde_json::from_str(json).unwrap();
1035        assert_eq!(t.as_str(), "red");
1036    }
1037
1038    #[test]
1039    fn serialize_roundtrip() {
1040        let t = Text::sanitize("hello world");
1041        let json = serde_json::to_string(&t).unwrap();
1042        let back: Text = serde_json::from_str(&json).unwrap();
1043        assert_eq!(t, back);
1044    }
1045
1046    // --- sanitize_preserving_layout: the raw-help display path ---
1047
1048    #[test]
1049    fn preserving_layout_keeps_leading_indentation() {
1050        // The defect this function exists to fix: `Text::sanitize` would
1051        // trim this to "-a, --all  write counts for all files".
1052        let t = Text::sanitize_preserving_layout("  -a, --all  write counts for all files");
1053        assert_eq!(t.as_str(), "  -a, --all  write counts for all files");
1054    }
1055
1056    #[test]
1057    fn preserving_layout_keeps_internal_column_gaps() {
1058        // `Text::sanitize` would collapse the multi-space gap between the
1059        // flag spelling and its description to a single space.
1060        let t = Text::sanitize_preserving_layout("--block-size=SIZE    scale sizes by SIZE");
1061        assert_eq!(t.as_str(), "--block-size=SIZE    scale sizes by SIZE");
1062    }
1063
1064    #[test]
1065    fn preserving_layout_still_strips_ansi_escapes() {
1066        let t = Text::sanitize_preserving_layout("\x1b[31mred\x1b[0m text");
1067        assert_eq!(t.as_str(), "red text");
1068    }
1069
1070    #[test]
1071    fn preserving_layout_strips_osc_sequence() {
1072        let t = Text::sanitize_preserving_layout("\x1b]0;window title\x07visible");
1073        assert_eq!(t.as_str(), "visible");
1074    }
1075
1076    #[test]
1077    fn preserving_layout_strips_stray_carriage_return() {
1078        // A `\r` mid-line (progress-bar style) would otherwise scramble a
1079        // real terminal by moving the cursor back to column 0; the raw
1080        // pane must not pass that through.
1081        let t = Text::sanitize_preserving_layout("done\rDONE");
1082        assert_eq!(t.as_str(), "doneDONE");
1083        assert!(!t.as_str().contains('\r'));
1084    }
1085
1086    #[test]
1087    fn preserving_layout_strips_other_c0_controls() {
1088        let t = Text::sanitize_preserving_layout("hello\x01\x02world");
1089        assert_eq!(t.as_str(), "helloworld");
1090    }
1091
1092    #[test]
1093    fn preserving_layout_expands_tabs_instead_of_leaving_them_raw() {
1094        // ratatui gives `\t` zero display width, so leaving it raw would
1095        // misalign columns rather than preserve them — expansion is the
1096        // neutralization that keeps this function's own promise.
1097        let t = Text::sanitize_preserving_layout("a\tb");
1098        assert_eq!(t.as_str(), "a       b");
1099        assert!(!t.as_str().contains('\t'));
1100    }
1101
1102    #[test]
1103    fn preserving_layout_does_not_trim_or_collapse_whitespace() {
1104        let t = Text::sanitize_preserving_layout("   a    b   ");
1105        assert_eq!(t.as_str(), "   a    b   ");
1106    }
1107
1108    #[test]
1109    fn preserving_layout_bounds_pathological_length() {
1110        let raw = "x".repeat(10 * 1024 * 1024);
1111        let t = Text::sanitize_preserving_layout(&raw);
1112        assert!(t.as_str().chars().count() <= MAX_TEXT_CHARS);
1113    }
1114
1115    #[test]
1116    fn preserving_layout_is_idempotent() {
1117        let raw = "\x1b[1mBold\x1b[0m\t  text  with\rstray CR";
1118        let once = Text::sanitize_preserving_layout(raw);
1119        let twice = Text::sanitize_preserving_layout(once.as_str());
1120        assert_eq!(once, twice);
1121    }
1122}