mermaid_model/utils/
text.rs1use crate::constants::WEB_CONTENT_MAX_CHARS;
2
3#[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#[must_use]
24pub fn truncate_middle(content: &str, max_chars: usize) -> String {
25 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#[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#[must_use]
85pub fn truncate_web_content(content: &str) -> String {
86 truncate_middle(content, WEB_CONTENT_MAX_CHARS)
87}
88
89const CONTINUATION_OVERLAP_WINDOW_BYTES: usize = 400;
93const CONTINUATION_OVERLAP_MIN_BYTES: usize = 16;
98
99#[must_use]
109pub fn continuation_overlap(prev: &str, continuation: &str) -> usize {
110 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 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#[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 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 assert_eq!(
189 continuation_overlap("…and then the ", "the answer is 42"),
190 0
191 );
192 assert_eq!(continuation_overlap("first half", "second half"), 0);
194 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 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 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 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..]; }
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 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}