1pub fn format_size(size: u64) -> String {
5 const KB: u64 = 1024;
6 const MB: u64 = KB * 1024;
7 const GB: u64 = MB * 1024;
8
9 if size >= GB {
10 format!("{:.1}GB", size as f64 / GB as f64)
11 } else if size >= MB {
12 format!("{:.1}MB", size as f64 / MB as f64)
13 } else if size >= KB {
14 format!("{:.1}KB", size as f64 / KB as f64)
15 } else {
16 format!("{size}B")
17 }
18}
19
20pub fn indent_block(text: &str, indent: &str) -> String {
22 if indent.is_empty() || text.is_empty() {
23 return text.to_string();
24 }
25 let mut indented = String::with_capacity(text.len() + indent.len() * text.lines().count());
26 for (idx, line) in text.split('\n').enumerate() {
27 if idx > 0 {
28 indented.push('\n');
29 }
30 if !line.is_empty() {
31 indented.push_str(indent);
32 }
33 indented.push_str(line);
34 }
35 indented
36}
37
38pub fn truncate_text(text: &str, max_len: usize, ellipsis: &str) -> String {
40 if text.chars().count() <= max_len {
41 return text.to_string();
42 }
43
44 let mut truncated = text.chars().take(max_len).collect::<String>();
45 truncated.push_str(ellipsis);
46 truncated
47}
48
49pub fn truncate_within(text: &str, max_len: usize, ellipsis: &str) -> String {
63 if text.chars().count() <= max_len {
64 return text.to_string();
65 }
66 let keep = max_len.saturating_sub(ellipsis.chars().count());
67 let mut truncated = text.chars().take(keep).collect::<String>();
68 truncated.push_str(ellipsis);
69 truncated
70}
71
72pub fn head_tail_truncate(value: &str, max_chars: usize, marker: &str) -> (String, bool) {
86 const SUFFIX: &str = " [truncated]";
87
88 let total_chars = value.chars().count();
89 if total_chars <= max_chars {
90 return (value.to_string(), false);
91 }
92
93 let marker_chars = marker.chars().count();
94 if max_chars <= marker_chars + 16 {
95 let suffix_len = SUFFIX.chars().count();
96 let truncated = if max_chars > suffix_len {
97 let available = max_chars - suffix_len;
98 let mut result = value.chars().take(available).collect::<String>();
99 result.push_str(SUFFIX);
100 result
101 } else {
102 value.chars().take(max_chars).collect::<String>()
103 };
104 return (truncated, true);
105 }
106
107 let available = max_chars.saturating_sub(marker_chars);
108 let head_chars = (available * 2) / 3;
109 let tail_chars = available.saturating_sub(head_chars);
110 let head = value.chars().take(head_chars).collect::<String>();
111 let tail = value
112 .chars()
113 .skip(total_chars.saturating_sub(tail_chars))
114 .collect::<String>();
115 let mut truncated = String::with_capacity(max_chars + 20);
116 truncated.push_str(&head);
117 truncated.push_str(marker);
118 truncated.push_str(&tail);
119 (truncated, true)
120}
121
122pub fn wrap_text_words(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
136 let trimmed = text.trim();
137 if trimmed.is_empty() {
138 return Vec::new();
139 }
140
141 let mut result = Vec::new();
142 let mut remaining = trimmed;
143 let mut width = first_width.max(1);
144
145 while remaining.chars().count() > width {
146 let split = split_at_word_boundary(remaining, width);
147 let (head, tail) = remaining.split_at(split);
148 let head = head.trim();
149 if head.is_empty() {
150 break;
151 }
152 result.push(head.to_string());
153 remaining = tail.trim_start();
154 if remaining.is_empty() {
155 break;
156 }
157 width = continuation_width.max(1);
158 }
159
160 if !remaining.is_empty() {
161 result.push(remaining.to_string());
162 }
163 result
164}
165
166fn split_at_word_boundary(input: &str, width: usize) -> usize {
167 let mut last_space: Option<usize> = None;
168 for (seen, (idx, ch)) in input.char_indices().enumerate() {
169 if seen > width {
170 break;
171 }
172 if ch.is_whitespace() {
173 last_space = Some(idx);
174 }
175 }
176 match last_space {
177 Some(pos) => pos,
178 None => byte_index_for_char_count(input, width),
179 }
180}
181
182fn byte_index_for_char_count(input: &str, chars: usize) -> usize {
183 if chars == 0 {
184 return 0;
185 }
186 let mut seen = 0usize;
187 for (idx, ch) in input.char_indices() {
188 seen += 1;
189 if seen == chars {
190 return idx + ch.len_utf8();
191 }
192 }
193 input.len()
194}
195
196pub fn truncate_byte_budget(text: &str, max_bytes: usize, suffix: &str) -> String {
200 if text.len() <= max_bytes {
201 return text.to_string();
202 }
203 let mut end = max_bytes.min(text.len());
204 while end > 0 && !text.is_char_boundary(end) {
205 end -= 1;
206 }
207 format!("{}{suffix}", &text[..end])
208}
209
210#[inline]
218pub fn collapse_whitespace(text: &str) -> String {
219 let mut result = String::with_capacity(text.len());
220 let mut pending_space = false;
221 for ch in text.chars() {
222 if ch.is_whitespace() {
223 pending_space = true;
224 } else {
225 if pending_space && !result.is_empty() {
226 result.push(' ');
227 }
228 result.push(ch);
229 pending_space = false;
230 }
231 }
232 result
233}
234
235pub fn clean_reasoning_text(text: &str) -> String {
244 text.lines()
245 .map(str::trim_end)
246 .filter(|line| !line.trim().is_empty())
247 .collect::<Vec<_>>()
248 .join("\n")
249}
250
251pub fn compact_reasoning_text(text: &str) -> String {
267 let mut out: Vec<&str> = Vec::with_capacity(text.lines().count());
268 let mut prev_blank = false;
269 for line in text.lines() {
270 let trimmed = line.trim();
271 let is_blank = trimmed.is_empty();
272 if is_blank {
273 if prev_blank {
274 continue;
275 }
276 out.push("");
277 prev_blank = true;
278 } else {
279 out.push(trimmed);
280 prev_blank = false;
281 }
282 }
283 while out.first().is_some_and(|l| l.trim().is_empty()) {
284 out.remove(0);
285 }
286 while out.last().is_some_and(|l| l.trim().is_empty()) {
287 out.pop();
288 }
289 out.join("\n")
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn truncate_byte_budget_ascii() {
298 assert_eq!(truncate_byte_budget("hello world", 5, "..."), "hello...");
299 assert_eq!(truncate_byte_budget("hi", 10, "..."), "hi");
300 }
301
302 #[test]
303 fn truncate_byte_budget_cjk_no_panic() {
304 let jp = "こんにちは";
306 assert_eq!(truncate_byte_budget(jp, 5, "…"), "こ…");
308 assert_eq!(truncate_byte_budget(jp, 6, "…"), "こん…");
310 }
311
312 #[test]
313 fn truncate_byte_budget_mixed_ascii_cjk() {
314 let mixed = "AB日本語CD";
315 assert_eq!(truncate_byte_budget(mixed, 4, ".."), "AB.."); assert_eq!(truncate_byte_budget(mixed, 5, ".."), "AB日.."); }
319
320 #[test]
321 fn truncate_byte_budget_emoji() {
322 let emoji = "👋🌍"; assert_eq!(truncate_byte_budget(emoji, 5, "!"), "👋!");
324 }
325
326 #[test]
327 fn truncate_byte_budget_zero() {
328 assert_eq!(truncate_byte_budget("abc", 0, "..."), "...");
329 }
330
331 #[test]
332 fn compact_reasoning_text_collapses_blank_runs() {
333 assert_eq!(
334 compact_reasoning_text("line1\n\n\n\nline2\n"),
335 "line1\n\nline2"
336 );
337 assert_eq!(compact_reasoning_text("a\n\n\n\n\n\nb"), "a\n\nb");
338 }
339
340 #[test]
341 fn compact_reasoning_text_preserves_single_paragraph_breaks() {
342 assert_eq!(
343 compact_reasoning_text("para one\n\npara two\n"),
344 "para one\n\npara two"
345 );
346 }
347
348 #[test]
349 fn compact_reasoning_text_trims_trailing_whitespace() {
350 assert_eq!(compact_reasoning_text(" a \n\n\n b \n"), "a\n\nb");
351 }
352
353 #[test]
354 fn compact_reasoning_text_strips_leading_trailing_blanks() {
355 assert_eq!(compact_reasoning_text("\n\n\nmid\n\n\n"), "mid");
356 assert_eq!(compact_reasoning_text("\n\n\n"), "");
357 assert_eq!(compact_reasoning_text(""), "");
358 }
359
360 #[test]
361 fn wrap_text_words_basic_and_continuation_width() {
362 assert_eq!(
363 wrap_text_words("the quick brown fox", 9, 9),
364 vec!["the quick", "brown fox"]
365 );
366 assert_eq!(
368 wrap_text_words("alpha beta gamma delta", 11, 5),
369 vec!["alpha beta", "gamma", "delta"]
370 );
371 }
372
373 #[test]
374 fn wrap_text_words_blank_and_unicode() {
375 assert!(wrap_text_words(" ", 5, 5).is_empty());
376 let wrapped = wrap_text_words("あいう えお かきく", 3, 3);
378 assert_eq!(wrapped, vec!["あいう", "えお", "かきく"]);
379 }
380
381 #[test]
382 fn truncate_within_reserves_ellipsis_budget() {
383 assert_eq!(truncate_within("hello world", 8, "..."), "hello...");
385 assert_eq!(truncate_within("hi", 8, "..."), "hi");
386 assert_eq!(truncate_within("abcdef", 4, "…"), "abc…");
389 }
390
391 #[test]
392 fn truncate_within_counts_chars() {
393 let jp = "あいうえお"; assert_eq!(truncate_within(jp, 5, "…"), jp);
395 assert_eq!(truncate_within(jp, 3, "…"), "あい…");
396 }
397
398 #[test]
399 fn head_tail_truncate_keeps_both_ends() {
400 let value = "0123456789".repeat(10); let (out, truncated) = head_tail_truncate(&value, 40, " ... [truncated] ... ");
402 assert!(truncated);
403 assert!(out.chars().count() <= 40);
404 assert!(out.starts_with("012"));
405 assert!(out.contains("[truncated]"));
406 assert!(out.ends_with('9'));
407 }
408
409 #[test]
410 fn head_tail_truncate_passes_through_when_short() {
411 let (out, truncated) = head_tail_truncate("short", 64, " ... ");
412 assert_eq!(out, "short");
413 assert!(!truncated);
414 }
415
416 #[test]
417 fn head_tail_truncate_small_budget_falls_back_to_prefix() {
418 let marker = " ... [truncated] ... ";
419 let (out, truncated) = head_tail_truncate("abcdefghij", 5, marker);
422 assert!(truncated);
423 assert_eq!(out, "abcde");
424
425 let long_text = "abcdefghijklmnopqrstuvwxyz";
428 let (out2, truncated2) = head_tail_truncate(long_text, 17, marker);
429 assert!(truncated2);
430 assert_eq!(out2, "abcde [truncated]");
431 assert_eq!(out2.chars().count(), 17);
432 }
433
434 #[test]
435 fn truncate_text_counts_chars_not_bytes() {
436 let jp = "あいうえお"; assert_eq!(truncate_text(jp, 3, "…"), "あいう…");
438 assert_eq!(truncate_text(jp, 5, "…"), "あいうえお");
439 }
440}