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}