Skip to main content

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