Skip to main content

drep/
text.rs

1//! Text that comes from outside drep and lands in a terminal.
2
3/// A one-line, bounded, control-character-free excerpt of untrusted text.
4///
5/// Bounded because a reasoning model can return kilobytes, and a URL in a
6/// markdown file can be a multi-kilobyte data: URI; both end up in a terminal
7/// and in `--format json`. Control characters are replaced rather than passed
8/// through: the text is not drep's, and an escape sequence in it would
9/// otherwise be interpreted by the terminal reading the report.
10///
11/// Shared rather than per-caller. The second copy, added for `bare_url`
12/// messages, truncated but did not strip control characters - the one thing
13/// this function exists for.
14pub fn excerpt(body: &str, max_chars: usize) -> String {
15    // Trimmed before cleaning rather than after, and the kept length is
16    // carried rather than recounted. This runs once per rendered finding, and
17    // the previous form built a full cleaned copy of the whole body - of which
18    // `max_chars` survive - then called `out.chars().count()` on every
19    // iteration, rescanning everything kept so far.
20    //
21    // Trimming first is equivalent because cleaning only ever maps a control
22    // character to a space: an edge character that `trim` would have removed
23    // after cleaning is whitespace or a control character before it, and an
24    // interior one still becomes a space below.
25    let trimmed = body.trim_matches(|c: char| c.is_whitespace() || c.is_control());
26    let mut out = String::with_capacity(max_chars);
27    let mut kept = 0usize;
28    let mut last_was_space = false;
29    for c in trimmed
30        .chars()
31        .map(|c| if c.is_control() { ' ' } else { c })
32    {
33        if kept >= max_chars {
34            out.push('…');
35            break;
36        }
37        if c == ' ' {
38            if last_was_space {
39                continue;
40            }
41            last_was_space = true;
42        } else {
43            last_was_space = false;
44        }
45        out.push(c);
46        kept += 1;
47    }
48    if out.is_empty() {
49        // Unreachable in practice for a model response - an empty body is a
50        // transport failure long before this - but a quoted empty string reads
51        // as a bug in drep.
52        return "<nothing>".to_owned();
53    }
54    out
55}
56
57#[cfg(test)]
58mod tests {
59    use super::excerpt;
60
61    #[test]
62    fn control_characters_are_replaced_not_passed_through() {
63        // The whole point: an escape sequence reaching a terminal is the
64        // failure this guards against.
65        let out = excerpt("a\u{1b}[31mred\u{7}b", 100);
66        assert!(!out.chars().any(char::is_control), "{out:?}");
67        assert!(out.contains("red"), "{out:?}");
68    }
69
70    #[test]
71    fn text_within_the_limit_is_returned_intact_without_a_marker() {
72        assert_eq!(excerpt("short", 100), "short");
73    }
74
75    #[test]
76    fn text_over_the_limit_is_cut_and_marked() {
77        let out = excerpt(&"x".repeat(50), 10);
78        assert_eq!(out.chars().count(), 11, "{out:?}");
79        assert!(out.ends_with('…'), "{out:?}");
80    }
81
82    #[test]
83    fn runs_of_space_collapse_and_the_edges_are_trimmed() {
84        assert_eq!(excerpt("  a\t\t\tb  ", 100), "a b");
85    }
86
87    #[test]
88    fn empty_input_names_itself_rather_than_quoting_nothing() {
89        assert_eq!(excerpt("   ", 100), "<nothing>");
90    }
91
92    #[test]
93    fn the_limit_counts_characters_not_bytes() {
94        // Ten em dashes is thirty bytes. A byte-counting limit would cut this
95        // to three characters, and could cut mid-codepoint.
96        let out = excerpt(&"—".repeat(10), 10);
97        assert_eq!(out.chars().count(), 10, "{out:?}");
98        assert!(!out.ends_with('…'), "{out:?}");
99    }
100}