Skip to main content

drep/
text.rs

1//! Text that comes from outside drep and lands in a terminal.
2
3use std::borrow::Cow;
4
5/// A one-line, bounded, control-character-free excerpt of untrusted text.
6///
7/// Bounded because a reasoning model can return kilobytes, and a URL in a
8/// markdown file can be a multi-kilobyte data: URI; both end up in a terminal
9/// and in `--format json`. Control characters are replaced rather than passed
10/// through: the text is not drep's, and an escape sequence in it would
11/// otherwise be interpreted by the terminal reading the report.
12///
13/// Shared rather than per-caller. The second copy, added for `bare_url`
14/// messages, truncated but did not strip control characters - the one thing
15/// this function exists for.
16pub fn excerpt(body: &str, max_chars: usize) -> String {
17    // Trimmed before cleaning rather than after, and the kept length is
18    // carried rather than recounted. This runs once per rendered finding, and
19    // the previous form built a full cleaned copy of the whole body - of which
20    // `max_chars` survive - then called `out.chars().count()` on every
21    // iteration, rescanning everything kept so far.
22    //
23    // Trimming first is equivalent because cleaning only ever maps a control
24    // character to a space: an edge character that `trim` would have removed
25    // after cleaning is whitespace or a control character before it, and an
26    // interior one still becomes a space below.
27    let trimmed = body.trim_matches(|c: char| c.is_whitespace() || c.is_control());
28    let mut out = String::with_capacity(max_chars);
29    let mut kept = 0usize;
30    let mut last_was_space = false;
31    for c in trimmed
32        .chars()
33        .map(|c| if c.is_control() { ' ' } else { c })
34    {
35        if kept >= max_chars {
36            out.push('…');
37            break;
38        }
39        if c == ' ' {
40            if last_was_space {
41                continue;
42            }
43            last_was_space = true;
44        } else {
45            last_was_space = false;
46        }
47        out.push(c);
48        kept += 1;
49    }
50    if out.is_empty() {
51        // Unreachable in practice for a model response - an empty body is a
52        // transport failure long before this - but a quoted empty string reads
53        // as a bug in drep.
54        return "<nothing>".to_owned();
55    }
56    out
57}
58
59/// A file name as a text report prints it.
60///
61/// The name is the repository's text, and git and the filesystem allow every
62/// byte but `/` and NUL in one, so a branch under review can name a file with
63/// an escape sequence or a newline in it. The diff decodes git's own quoting
64/// back to those bytes, and printed raw they reach the terminal reading the
65/// report, where a newline forges a report line of its own.
66///
67/// Not [`excerpt`]: a name is printed whole, because a shortened or altered one
68/// names a different file. A name holding a control character is printed as a
69/// quoted Rust string literal instead, which escapes every control character
70/// and keeps every other one. Every other name, `café.rs` included, is printed
71/// as it stands.
72pub fn path_label(path: &str) -> Cow<'_, str> {
73    if path.chars().any(char::is_control) {
74        Cow::Owned(format!("{path:?}"))
75    } else {
76        Cow::Borrowed(path)
77    }
78}
79
80/// [`path_label`] for a [`std::path::Path`], converted as `display` does.
81pub fn path_label_of(path: &std::path::Path) -> String {
82    path_label(&path.to_string_lossy()).into_owned()
83}
84
85#[cfg(test)]
86mod tests {
87    use super::{Cow, excerpt, path_label, path_label_of};
88
89    #[test]
90    fn control_characters_are_replaced_not_passed_through() {
91        // The whole point: an escape sequence reaching a terminal is the
92        // failure this guards against.
93        let out = excerpt("a\u{1b}[31mred\u{7}b", 100);
94        assert!(!out.chars().any(char::is_control), "{out:?}");
95        assert!(out.contains("red"), "{out:?}");
96    }
97
98    #[test]
99    fn text_within_the_limit_is_returned_intact_without_a_marker() {
100        assert_eq!(excerpt("short", 100), "short");
101    }
102
103    #[test]
104    fn text_over_the_limit_is_cut_and_marked() {
105        let out = excerpt(&"x".repeat(50), 10);
106        assert_eq!(out.chars().count(), 11, "{out:?}");
107        assert!(out.ends_with('…'), "{out:?}");
108    }
109
110    #[test]
111    fn runs_of_space_collapse_and_the_edges_are_trimmed() {
112        assert_eq!(excerpt("  a\t\t\tb  ", 100), "a b");
113    }
114
115    #[test]
116    fn empty_input_names_itself_rather_than_quoting_nothing() {
117        assert_eq!(excerpt("   ", 100), "<nothing>");
118    }
119
120    #[test]
121    fn the_limit_counts_characters_not_bytes() {
122        // Ten em dashes is thirty bytes. A byte-counting limit would cut this
123        // to three characters, and could cut mid-codepoint.
124        let out = excerpt(&"—".repeat(10), 10);
125        assert_eq!(out.chars().count(), 10, "{out:?}");
126        assert!(!out.ends_with('…'), "{out:?}");
127    }
128
129    #[test]
130    fn every_control_character_class_is_escaped_inside_quotes() {
131        // C0, DEL and C1 - the whole of `char::is_control`.
132        let out = path_label("a\u{1b}[2K\rb\n\u{7f}\u{85}\u{9b}.py");
133        assert!(!out.chars().any(char::is_control), "{out:?}");
134        assert_eq!(out, r#""a\u{1b}[2K\rb\n\u{7f}\u{85}\u{9b}.py""#);
135    }
136
137    #[test]
138    fn a_name_without_one_is_borrowed_as_it_stands() {
139        let out = path_label("src/café \"x\".rs");
140        assert!(matches!(out, Cow::Borrowed(_)), "{out:?}");
141        assert_eq!(out, "src/café \"x\".rs");
142    }
143
144    #[test]
145    fn a_path_is_labelled_like_its_text() {
146        assert_eq!(
147            path_label_of(std::path::Path::new("x\ty.py")),
148            r#""x\ty.py""#
149        );
150    }
151}