Skip to main content

mermaid_model/utils/
text.rs

1use crate::constants::WEB_CONTENT_MAX_CHARS;
2
3/// Truncate content to a maximum character count, keeping the HEAD (char-boundary
4/// safe). Prefer [`truncate_middle`] where the tail matters (command/tool output);
5/// this remains for per-item web caps where head-only is acceptable.
6#[must_use]
7pub fn truncate_content(content: &str, max_chars: usize) -> String {
8    if content.len() <= max_chars {
9        return content.to_string();
10    }
11    if let Some((byte_end, _)) = content.char_indices().nth(max_chars) {
12        format!("{}...[truncated]", &content[..byte_end])
13    } else {
14        content.to_string()
15    }
16}
17
18/// Truncate `content` to about `max_chars` characters, keeping the HEAD and the
19/// TAIL with an elision marker in the middle (char-boundary safe). Command/tool
20/// output and web pages put the most important content — compiler errors, exit
21/// summaries, page footers — at the END, so head-only truncation discarded
22/// exactly what mattered. Content that already fits is returned unchanged.
23#[must_use]
24pub fn truncate_middle(content: &str, max_chars: usize) -> String {
25    // Fast path: fits by bytes ⇒ fits by chars (every char is ≥ 1 byte).
26    if content.len() <= max_chars {
27        return content.to_string();
28    }
29    let total_chars = content.chars().count();
30    if total_chars <= max_chars {
31        return content.to_string();
32    }
33    let head_chars = max_chars / 2;
34    let tail_chars = max_chars - head_chars;
35    let elided = total_chars - head_chars - tail_chars;
36    let head_end = content
37        .char_indices()
38        .nth(head_chars)
39        .map(|(i, _)| i)
40        .unwrap_or(content.len());
41    let tail_start = content
42        .char_indices()
43        .nth(total_chars - tail_chars)
44        .map(|(i, _)| i)
45        .unwrap_or(content.len());
46    format!(
47        "{}\n…[{elided} chars elided]…\n{}",
48        &content[..head_end],
49        &content[tail_start..]
50    )
51}
52
53/// Truncate to an exact UTF-8 byte budget while preserving both ends.
54///
55/// This differs from [`truncate_middle`], whose budget is measured in Unicode
56/// scalar values. Protocol envelopes and tool-result limits are byte budgets,
57/// so multi-byte text must be cut on a character boundary without exceeding
58/// `max_bytes` after the marker is included.
59#[must_use]
60pub fn truncate_middle_bytes(content: &str, max_bytes: usize) -> String {
61    if content.len() <= max_bytes {
62        return content.to_string();
63    }
64
65    const MARKER: &str = "\n...[content truncated]...\n";
66    if max_bytes <= MARKER.len() {
67        let end = content.floor_char_boundary(max_bytes);
68        return content[..end].to_string();
69    }
70
71    let keep = max_bytes - MARKER.len();
72    let head_budget = keep / 2;
73    let tail_budget = keep - head_budget;
74    let head_end = content.floor_char_boundary(head_budget);
75    let mut tail_start = content.len().saturating_sub(tail_budget);
76    while tail_start < content.len() && !content.is_char_boundary(tail_start) {
77        tail_start += 1;
78    }
79
80    format!("{}{MARKER}{}", &content[..head_end], &content[tail_start..])
81}
82
83/// Truncate web content using the default limit, keeping head and tail.
84#[must_use]
85pub fn truncate_web_content(content: &str) -> String {
86    truncate_middle(content, WEB_CONTENT_MAX_CHARS)
87}
88
89/// How far back into the previous chunk's tail to search for an echo. Bounds
90/// the cost of [`continuation_overlap`] and keeps a coincidental match deep
91/// inside the previous text from being mistaken for a resume-echo.
92const CONTINUATION_OVERLAP_WINDOW_BYTES: usize = 400;
93/// Minimum echo length worth trimming. Short exact matches ("the ", "- ", a
94/// repeated word) are as likely to be legitimate new prose as an echo, and
95/// trimming a false positive silently deletes real output — so anything under
96/// this threshold is kept verbatim.
97const CONTINUATION_OVERLAP_MIN_BYTES: usize = 16;
98
99/// Length in bytes of the longest exact overlap between the tail of `prev`
100/// and the head of `continuation` — the "resume echo" a model sometimes emits
101/// when continuing a reply that was cut by a per-response output cap.
102///
103/// Deliberately conservative: exact match only, bounded search window,
104/// minimum overlap threshold. Used for DISPLAY/output joining only — the
105/// canonical conversation history is never trimmed — so a false negative
106/// costs a few repeated words on screen, while a false positive would delete
107/// real content. Returns a char-boundary-safe byte offset into `continuation`.
108#[must_use]
109pub fn continuation_overlap(prev: &str, continuation: &str) -> usize {
110    // Window into prev's tail, aligned to a char boundary.
111    let mut window_start = prev.len().saturating_sub(CONTINUATION_OVERLAP_WINDOW_BYTES);
112    while window_start < prev.len() && !prev.is_char_boundary(window_start) {
113        window_start += 1;
114    }
115    let tail = &prev[window_start..];
116    let max_len = tail.len().min(continuation.len());
117    if max_len < CONTINUATION_OVERLAP_MIN_BYTES {
118        return 0;
119    }
120    // Longest suffix-of-prev == prefix-of-continuation, on char boundaries.
121    for len in (CONTINUATION_OVERLAP_MIN_BYTES..=max_len).rev() {
122        if !continuation.is_char_boundary(len) {
123            continue;
124        }
125        let head = &continuation[..len];
126        if tail.ends_with(head) {
127            return len;
128        }
129    }
130    0
131}
132
133/// Format a duration in seconds as a human-readable string.
134///
135/// Uses decimal precision for sub-minute durations (e.g., "12.3s"),
136/// and integer components for longer durations (e.g., "1m 47s", "2h 5m 0s").
137#[must_use]
138pub fn format_duration(total_secs: f64) -> String {
139    let secs = total_secs as u64;
140    if secs < 60 {
141        return format!("{total_secs:.1}s");
142    }
143    let days = secs / 86400;
144    let hours = (secs % 86400) / 3600;
145    let mins = (secs % 3600) / 60;
146    let remainder = secs % 60;
147    if days > 0 {
148        format!("{days}d {hours}h {mins}m {remainder}s")
149    } else if hours > 0 {
150        format!("{hours}h {mins}m {remainder}s")
151    } else {
152        format!("{mins}m {remainder}s")
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_format_duration_sub_minute() {
162        assert_eq!(format_duration(0.0), "0.0s");
163        assert_eq!(format_duration(12.3), "12.3s");
164        assert_eq!(format_duration(59.9), "59.9s");
165    }
166
167    #[test]
168    fn test_format_duration_minutes_and_above() {
169        assert_eq!(format_duration(60.0), "1m 0s");
170        assert_eq!(format_duration(107.0), "1m 47s");
171        assert_eq!(format_duration(3600.0), "1h 0m 0s");
172        assert_eq!(format_duration(86400.0), "1d 0h 0m 0s");
173        assert_eq!(format_duration(90061.0), "1d 1h 1m 1s");
174    }
175
176    #[test]
177    fn continuation_overlap_trims_a_resume_echo() {
178        // The model repeats the tail of the cut reply before continuing.
179        let prev = "The resolver clamps the budget to the window room";
180        let cont = "to the window room, then omits the field entirely.";
181        assert_eq!(continuation_overlap(prev, cont), "to the window room".len());
182    }
183
184    #[test]
185    fn continuation_overlap_keeps_short_ambiguous_matches() {
186        // "the " is as likely legitimate prose as an echo — below the minimum
187        // threshold nothing is trimmed (a false trim deletes real output).
188        assert_eq!(
189            continuation_overlap("…and then the ", "the answer is 42"),
190            0
191        );
192        // No overlap at all.
193        assert_eq!(continuation_overlap("first half", "second half"), 0);
194        // Empty inputs.
195        assert_eq!(continuation_overlap("", "anything"), 0);
196        assert_eq!(continuation_overlap("anything", ""), 0);
197    }
198
199    #[test]
200    fn continuation_overlap_prefers_the_longest_echo() {
201        // Both "cap. " and the full sentence match; take the longest.
202        let prev = "It hit the cap. It hit the cap. ";
203        let cont = "It hit the cap. Continuing now.";
204        assert_eq!(continuation_overlap(prev, cont), "It hit the cap. ".len());
205    }
206
207    #[test]
208    fn continuation_overlap_is_window_bounded() {
209        // An echo of text further back than the search window is not found —
210        // deep coincidental matches must not trigger trimming.
211        let echo = "a distinctive sentence that repeats";
212        let prev = format!("{echo}{}", "x".repeat(500));
213        assert_eq!(continuation_overlap(&prev, echo), 0);
214    }
215
216    #[test]
217    fn continuation_overlap_respects_char_boundaries() {
218        // Multi-byte content: the returned offset must be sliceable.
219        let prev = "código con acentuación específica";
220        let cont = "acentuación específica y más contenido";
221        let n = continuation_overlap(prev, cont);
222        assert_eq!(&cont[..n], "acentuación específica");
223        let _ = &cont[n..]; // must not panic
224    }
225
226    #[test]
227    fn truncate_middle_keeps_head_and_tail() {
228        let short = "hello";
229        assert_eq!(truncate_middle(short, 100), "hello");
230
231        // 200 'H's + a distinctive tail; truncating to 50 must keep BOTH ends.
232        let long = format!("{}TAIL_ERROR", "H".repeat(200));
233        let truncated = truncate_middle(&long, 50);
234        assert!(
235            truncated.starts_with("HHHH"),
236            "head must survive: {truncated}"
237        );
238        assert!(
239            truncated.ends_with("TAIL_ERROR"),
240            "tail must survive: {truncated}"
241        );
242        assert!(
243            truncated.contains("elided"),
244            "must mark elision: {truncated}"
245        );
246        assert!(truncated.chars().count() < long.chars().count());
247    }
248
249    #[test]
250    fn truncate_middle_bytes_never_exceeds_utf8_budget() {
251        for unit in ["a", "é", "界"] {
252            let input = unit.repeat(40_000);
253            for budget in [0, 1, 8, 29, 30_000] {
254                let output = truncate_middle_bytes(&input, budget);
255                assert!(output.len() <= budget, "{} > {budget}", output.len());
256                assert!(std::str::from_utf8(output.as_bytes()).is_ok());
257            }
258        }
259    }
260}