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    let cleaned: String = body
16        .chars()
17        .map(|c| if c.is_control() { ' ' } else { c })
18        .collect();
19    let mut out = String::with_capacity(max_chars);
20    let mut last_was_space = false;
21    for c in cleaned.trim().chars() {
22        if out.chars().count() >= max_chars {
23            out.push('…');
24            break;
25        }
26        if c == ' ' {
27            if last_was_space {
28                continue;
29            }
30            last_was_space = true;
31        } else {
32            last_was_space = false;
33        }
34        out.push(c);
35    }
36    if out.is_empty() {
37        // Unreachable in practice for a model response - an empty body is a
38        // transport failure long before this - but a quoted empty string reads
39        // as a bug in drep.
40        return "<nothing>".to_owned();
41    }
42    out
43}
44
45#[cfg(test)]
46mod tests {
47    use super::excerpt;
48
49    #[test]
50    fn control_characters_are_replaced_not_passed_through() {
51        // The whole point: an escape sequence reaching a terminal is the
52        // failure this guards against.
53        let out = excerpt("a\u{1b}[31mred\u{7}b", 100);
54        assert!(!out.chars().any(char::is_control), "{out:?}");
55        assert!(out.contains("red"), "{out:?}");
56    }
57
58    #[test]
59    fn text_within_the_limit_is_returned_intact_without_a_marker() {
60        assert_eq!(excerpt("short", 100), "short");
61    }
62
63    #[test]
64    fn text_over_the_limit_is_cut_and_marked() {
65        let out = excerpt(&"x".repeat(50), 10);
66        assert_eq!(out.chars().count(), 11, "{out:?}");
67        assert!(out.ends_with('…'), "{out:?}");
68    }
69
70    #[test]
71    fn runs_of_space_collapse_and_the_edges_are_trimmed() {
72        assert_eq!(excerpt("  a\t\t\tb  ", 100), "a b");
73    }
74
75    #[test]
76    fn empty_input_names_itself_rather_than_quoting_nothing() {
77        assert_eq!(excerpt("   ", 100), "<nothing>");
78    }
79
80    #[test]
81    fn the_limit_counts_characters_not_bytes() {
82        // Ten em dashes is thirty bytes. A byte-counting limit would cut this
83        // to three characters, and could cut mid-codepoint.
84        let out = excerpt(&"—".repeat(10), 10);
85        assert_eq!(out.chars().count(), 10, "{out:?}");
86        assert!(!out.ends_with('…'), "{out:?}");
87    }
88}