mermaid_cli/utils/
text.rs1use crate::constants::WEB_CONTENT_MAX_CHARS;
2
3pub 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
17pub fn truncate_middle(content: &str, max_chars: usize) -> String {
23 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
51pub fn truncate_middle_bytes(content: &str, max_bytes: usize) -> String {
58 if content.len() <= max_bytes {
59 return content.to_string();
60 }
61
62 const MARKER: &str = "\n...[content truncated]...\n";
63 if max_bytes <= MARKER.len() {
64 let end = content.floor_char_boundary(max_bytes);
65 return content[..end].to_string();
66 }
67
68 let keep = max_bytes - MARKER.len();
69 let head_budget = keep / 2;
70 let tail_budget = keep - head_budget;
71 let head_end = content.floor_char_boundary(head_budget);
72 let mut tail_start = content.len().saturating_sub(tail_budget);
73 while tail_start < content.len() && !content.is_char_boundary(tail_start) {
74 tail_start += 1;
75 }
76
77 format!("{}{MARKER}{}", &content[..head_end], &content[tail_start..])
78}
79
80pub fn truncate_web_content(content: &str) -> String {
82 truncate_middle(content, WEB_CONTENT_MAX_CHARS)
83}
84
85const CONTINUATION_OVERLAP_WINDOW_BYTES: usize = 400;
89const CONTINUATION_OVERLAP_MIN_BYTES: usize = 16;
94
95pub fn continuation_overlap(prev: &str, continuation: &str) -> usize {
105 let mut window_start = prev.len().saturating_sub(CONTINUATION_OVERLAP_WINDOW_BYTES);
107 while window_start < prev.len() && !prev.is_char_boundary(window_start) {
108 window_start += 1;
109 }
110 let tail = &prev[window_start..];
111 let max_len = tail.len().min(continuation.len());
112 if max_len < CONTINUATION_OVERLAP_MIN_BYTES {
113 return 0;
114 }
115 for len in (CONTINUATION_OVERLAP_MIN_BYTES..=max_len).rev() {
117 if !continuation.is_char_boundary(len) {
118 continue;
119 }
120 let head = &continuation[..len];
121 if tail.ends_with(head) {
122 return len;
123 }
124 }
125 0
126}
127
128pub fn format_duration(total_secs: f64) -> String {
133 let secs = total_secs as u64;
134 if secs < 60 {
135 return format!("{:.1}s", total_secs);
136 }
137 let days = secs / 86400;
138 let hours = (secs % 86400) / 3600;
139 let mins = (secs % 3600) / 60;
140 let remainder = secs % 60;
141 if days > 0 {
142 format!("{}d {}h {}m {}s", days, hours, mins, remainder)
143 } else if hours > 0 {
144 format!("{}h {}m {}s", hours, mins, remainder)
145 } else {
146 format!("{}m {}s", mins, remainder)
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn test_format_duration_sub_minute() {
156 assert_eq!(format_duration(0.0), "0.0s");
157 assert_eq!(format_duration(12.3), "12.3s");
158 assert_eq!(format_duration(59.9), "59.9s");
159 }
160
161 #[test]
162 fn test_format_duration_minutes_and_above() {
163 assert_eq!(format_duration(60.0), "1m 0s");
164 assert_eq!(format_duration(107.0), "1m 47s");
165 assert_eq!(format_duration(3600.0), "1h 0m 0s");
166 assert_eq!(format_duration(86400.0), "1d 0h 0m 0s");
167 assert_eq!(format_duration(90061.0), "1d 1h 1m 1s");
168 }
169
170 #[test]
171 fn continuation_overlap_trims_a_resume_echo() {
172 let prev = "The resolver clamps the budget to the window room";
174 let cont = "to the window room, then omits the field entirely.";
175 assert_eq!(continuation_overlap(prev, cont), "to the window room".len());
176 }
177
178 #[test]
179 fn continuation_overlap_keeps_short_ambiguous_matches() {
180 assert_eq!(
183 continuation_overlap("…and then the ", "the answer is 42"),
184 0
185 );
186 assert_eq!(continuation_overlap("first half", "second half"), 0);
188 assert_eq!(continuation_overlap("", "anything"), 0);
190 assert_eq!(continuation_overlap("anything", ""), 0);
191 }
192
193 #[test]
194 fn continuation_overlap_prefers_the_longest_echo() {
195 let prev = "It hit the cap. It hit the cap. ";
197 let cont = "It hit the cap. Continuing now.";
198 assert_eq!(continuation_overlap(prev, cont), "It hit the cap. ".len());
199 }
200
201 #[test]
202 fn continuation_overlap_is_window_bounded() {
203 let echo = "a distinctive sentence that repeats";
206 let prev = format!("{echo}{}", "x".repeat(500));
207 assert_eq!(continuation_overlap(&prev, echo), 0);
208 }
209
210 #[test]
211 fn continuation_overlap_respects_char_boundaries() {
212 let prev = "código con acentuación específica";
214 let cont = "acentuación específica y más contenido";
215 let n = continuation_overlap(prev, cont);
216 assert_eq!(&cont[..n], "acentuación específica");
217 let _ = &cont[n..]; }
219
220 #[test]
221 fn truncate_middle_keeps_head_and_tail() {
222 let short = "hello";
223 assert_eq!(truncate_middle(short, 100), "hello");
224
225 let long = format!("{}TAIL_ERROR", "H".repeat(200));
227 let truncated = truncate_middle(&long, 50);
228 assert!(
229 truncated.starts_with("HHHH"),
230 "head must survive: {truncated}"
231 );
232 assert!(
233 truncated.ends_with("TAIL_ERROR"),
234 "tail must survive: {truncated}"
235 );
236 assert!(
237 truncated.contains("elided"),
238 "must mark elision: {truncated}"
239 );
240 assert!(truncated.chars().count() < long.chars().count());
241 }
242
243 #[test]
244 fn truncate_middle_bytes_never_exceeds_utf8_budget() {
245 for unit in ["a", "é", "界"] {
246 let input = unit.repeat(40_000);
247 for budget in [0, 1, 8, 29, 30_000] {
248 let output = truncate_middle_bytes(&input, budget);
249 assert!(output.len() <= budget, "{} > {budget}", output.len());
250 assert!(std::str::from_utf8(output.as_bytes()).is_ok());
251 }
252 }
253 }
254}