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 truncate_middle(text: &str, max_len: usize) -> String {
82 if max_len == 0 {
83 return String::new();
84 }
85 let sanitized: String = text
86 .chars()
87 .map(|c| {
88 if matches!(c, '\n' | '\r' | '\t') {
89 ' '
90 } else {
91 c
92 }
93 })
94 .collect();
95 let char_count = sanitized.chars().count();
96 if char_count <= max_len {
97 return sanitized;
98 }
99 if max_len <= 1 {
100 return "…".to_string();
101 }
102 let head_len = max_len / 2;
103 let tail_len = max_len.saturating_sub(head_len + 1);
104
105 let head: String = sanitized.chars().take(head_len).collect();
106 let mut result = String::with_capacity(head.len() + tail_len + 1);
107 result.push_str(&head);
108 result.push('…');
109 if tail_len > 0 {
110 let mut tail_rev: Vec<char> = sanitized.chars().rev().take(tail_len).collect();
111 tail_rev.reverse();
112 let tail: String = tail_rev.into_iter().collect();
113 result.push_str(&tail);
114 }
115 result
116}
117
118pub fn truncate_path_middle(path: &str, max_len: usize) -> String {
125 if max_len == 0 {
126 return String::new();
127 }
128 let char_count = path.chars().count();
129 if char_count <= max_len {
130 return path.to_string();
131 }
132 if max_len <= 1 {
133 return "…".to_string();
134 }
135
136 let head_budget = max_len / 2;
138 let tail_budget = max_len.saturating_sub(head_budget + 1);
139
140 let head_chars: Vec<char> = path.chars().take(head_budget).collect();
142 let head_str: String = head_chars.iter().collect();
143 let head_break = head_str.rfind('/').unwrap_or(head_budget);
144
145 let tail_chars: Vec<char> = path.chars().rev().take(tail_budget).collect();
147 let tail_str: String = tail_chars.iter().rev().collect();
148 let tail_break_from_end = tail_str
149 .find('/')
150 .map(|pos| tail_str.len() - pos)
151 .unwrap_or(tail_budget);
152
153 let head: String = path.chars().take(head_break).collect();
154 let tail: String = path
155 .chars()
156 .rev()
157 .take(tail_break_from_end)
158 .collect::<Vec<_>>()
159 .into_iter()
160 .rev()
161 .collect();
162
163 format!("{head}…{tail}")
164}
165
166pub fn head_tail_truncate(value: &str, max_chars: usize, marker: &str) -> (String, bool) {
180 const SUFFIX: &str = " [truncated]";
181
182 let total_chars = value.chars().count();
183 if total_chars <= max_chars {
184 return (value.to_string(), false);
185 }
186
187 let marker_chars = marker.chars().count();
188 if max_chars <= marker_chars + 16 {
189 let suffix_len = SUFFIX.chars().count();
190 let truncated = if max_chars > suffix_len {
191 let available = max_chars - suffix_len;
192 let mut result = value.chars().take(available).collect::<String>();
193 result.push_str(SUFFIX);
194 result
195 } else {
196 value.chars().take(max_chars).collect::<String>()
197 };
198 return (truncated, true);
199 }
200
201 let available = max_chars.saturating_sub(marker_chars);
202 let head_chars = (available * 2) / 3;
203 let tail_chars = available.saturating_sub(head_chars);
204 let head = value.chars().take(head_chars).collect::<String>();
205 let tail = value
206 .chars()
207 .skip(total_chars.saturating_sub(tail_chars))
208 .collect::<String>();
209 let mut truncated = String::with_capacity(max_chars + 20);
210 truncated.push_str(&head);
211 truncated.push_str(marker);
212 truncated.push_str(&tail);
213 (truncated, true)
214}
215
216pub fn wrap_text_words(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
230 let trimmed = text.trim();
231 if trimmed.is_empty() {
232 return Vec::new();
233 }
234
235 let mut result = Vec::new();
236 let mut remaining = trimmed;
237 let mut width = first_width.max(1);
238
239 while remaining.chars().count() > width {
240 let split = split_at_word_boundary(remaining, width);
241 let (head, tail) = remaining.split_at(split);
242 let head = head.trim();
243 if head.is_empty() {
244 break;
245 }
246 result.push(head.to_string());
247 remaining = tail.trim_start();
248 if remaining.is_empty() {
249 break;
250 }
251 width = continuation_width.max(1);
252 }
253
254 if !remaining.is_empty() {
255 result.push(remaining.to_string());
256 }
257 result
258}
259
260fn split_at_word_boundary(input: &str, width: usize) -> usize {
261 let mut last_space: Option<usize> = None;
262 for (seen, (idx, ch)) in input.char_indices().enumerate() {
263 if seen > width {
264 break;
265 }
266 if ch.is_whitespace() {
267 last_space = Some(idx);
268 }
269 }
270 match last_space {
271 Some(pos) => pos,
272 None => byte_index_for_char_count(input, width),
273 }
274}
275
276fn byte_index_for_char_count(input: &str, chars: usize) -> usize {
277 if chars == 0 {
278 return 0;
279 }
280 let mut seen = 0usize;
281 for (idx, ch) in input.char_indices() {
282 seen += 1;
283 if seen == chars {
284 return idx + ch.len_utf8();
285 }
286 }
287 input.len()
288}
289
290pub fn truncate_byte_budget(text: &str, max_bytes: usize, suffix: &str) -> String {
294 if text.len() <= max_bytes {
295 return text.to_string();
296 }
297 let mut end = max_bytes.min(text.len());
298 while end > 0 && !text.is_char_boundary(end) {
299 end -= 1;
300 }
301 format!("{}{suffix}", &text[..end])
302}
303
304#[inline]
312pub fn collapse_whitespace(text: &str) -> String {
313 let mut result = String::with_capacity(text.len());
314 let mut pending_space = false;
315 for ch in text.chars() {
316 if ch.is_whitespace() {
317 pending_space = true;
318 } else {
319 if pending_space && !result.is_empty() {
320 result.push(' ');
321 }
322 result.push(ch);
323 pending_space = false;
324 }
325 }
326 result
327}
328
329pub fn clean_reasoning_text(text: &str) -> String {
338 text.lines()
339 .map(str::trim_end)
340 .filter(|line| !line.trim().is_empty())
341 .collect::<Vec<_>>()
342 .join("\n")
343}
344
345pub fn compact_reasoning_text(text: &str) -> String {
361 let mut out: Vec<&str> = Vec::with_capacity(text.lines().count());
362 let mut prev_blank = false;
363 for line in text.lines() {
364 let trimmed = line.trim();
365 let is_blank = trimmed.is_empty();
366 if is_blank {
367 if prev_blank {
368 continue;
369 }
370 out.push("");
371 prev_blank = true;
372 } else {
373 out.push(trimmed);
374 prev_blank = false;
375 }
376 }
377 while out.first().is_some_and(|l| l.trim().is_empty()) {
378 out.remove(0);
379 }
380 while out.last().is_some_and(|l| l.trim().is_empty()) {
381 out.pop();
382 }
383 out.join("\n")
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn truncate_byte_budget_ascii() {
392 assert_eq!(truncate_byte_budget("hello world", 5, "..."), "hello...");
393 assert_eq!(truncate_byte_budget("hi", 10, "..."), "hi");
394 }
395
396 #[test]
397 fn truncate_byte_budget_cjk_no_panic() {
398 let jp = "こんにちは";
400 assert_eq!(truncate_byte_budget(jp, 5, "…"), "こ…");
402 assert_eq!(truncate_byte_budget(jp, 6, "…"), "こん…");
404 }
405
406 #[test]
407 fn truncate_byte_budget_mixed_ascii_cjk() {
408 let mixed = "AB日本語CD";
409 assert_eq!(truncate_byte_budget(mixed, 4, ".."), "AB.."); assert_eq!(truncate_byte_budget(mixed, 5, ".."), "AB日.."); }
413
414 #[test]
415 fn truncate_byte_budget_emoji() {
416 let emoji = "👋🌍"; assert_eq!(truncate_byte_budget(emoji, 5, "!"), "👋!");
418 }
419
420 #[test]
421 fn truncate_byte_budget_zero() {
422 assert_eq!(truncate_byte_budget("abc", 0, "..."), "...");
423 }
424
425 #[test]
426 fn compact_reasoning_text_collapses_blank_runs() {
427 assert_eq!(
428 compact_reasoning_text("line1\n\n\n\nline2\n"),
429 "line1\n\nline2"
430 );
431 assert_eq!(compact_reasoning_text("a\n\n\n\n\n\nb"), "a\n\nb");
432 }
433
434 #[test]
435 fn compact_reasoning_text_preserves_single_paragraph_breaks() {
436 assert_eq!(
437 compact_reasoning_text("para one\n\npara two\n"),
438 "para one\n\npara two"
439 );
440 }
441
442 #[test]
443 fn compact_reasoning_text_trims_trailing_whitespace() {
444 assert_eq!(compact_reasoning_text(" a \n\n\n b \n"), "a\n\nb");
445 }
446
447 #[test]
448 fn compact_reasoning_text_strips_leading_trailing_blanks() {
449 assert_eq!(compact_reasoning_text("\n\n\nmid\n\n\n"), "mid");
450 assert_eq!(compact_reasoning_text("\n\n\n"), "");
451 assert_eq!(compact_reasoning_text(""), "");
452 }
453
454 #[test]
455 fn wrap_text_words_basic_and_continuation_width() {
456 assert_eq!(
457 wrap_text_words("the quick brown fox", 9, 9),
458 vec!["the quick", "brown fox"]
459 );
460 assert_eq!(
462 wrap_text_words("alpha beta gamma delta", 11, 5),
463 vec!["alpha beta", "gamma", "delta"]
464 );
465 }
466
467 #[test]
468 fn wrap_text_words_blank_and_unicode() {
469 assert!(wrap_text_words(" ", 5, 5).is_empty());
470 let wrapped = wrap_text_words("あいう えお かきく", 3, 3);
472 assert_eq!(wrapped, vec!["あいう", "えお", "かきく"]);
473 }
474
475 #[test]
476 fn truncate_within_reserves_ellipsis_budget() {
477 assert_eq!(truncate_within("hello world", 8, "..."), "hello...");
479 assert_eq!(truncate_within("hi", 8, "..."), "hi");
480 assert_eq!(truncate_within("abcdef", 4, "…"), "abc…");
483 }
484
485 #[test]
486 fn truncate_within_counts_chars() {
487 let jp = "あいうえお"; assert_eq!(truncate_within(jp, 5, "…"), jp);
489 assert_eq!(truncate_within(jp, 3, "…"), "あい…");
490 }
491
492 #[test]
493 fn head_tail_truncate_keeps_both_ends() {
494 let value = "0123456789".repeat(10); let (out, truncated) = head_tail_truncate(&value, 40, " ... [truncated] ... ");
496 assert!(truncated);
497 assert!(out.chars().count() <= 40);
498 assert!(out.starts_with("012"));
499 assert!(out.contains("[truncated]"));
500 assert!(out.ends_with('9'));
501 }
502
503 #[test]
504 fn head_tail_truncate_passes_through_when_short() {
505 let (out, truncated) = head_tail_truncate("short", 64, " ... ");
506 assert_eq!(out, "short");
507 assert!(!truncated);
508 }
509
510 #[test]
511 fn head_tail_truncate_small_budget_falls_back_to_prefix() {
512 let marker = " ... [truncated] ... ";
513 let (out, truncated) = head_tail_truncate("abcdefghij", 5, marker);
516 assert!(truncated);
517 assert_eq!(out, "abcde");
518
519 let long_text = "abcdefghijklmnopqrstuvwxyz";
522 let (out2, truncated2) = head_tail_truncate(long_text, 17, marker);
523 assert!(truncated2);
524 assert_eq!(out2, "abcde [truncated]");
525 assert_eq!(out2.chars().count(), 17);
526 }
527
528 #[test]
529 fn truncate_text_counts_chars_not_bytes() {
530 let jp = "あいうえお"; assert_eq!(truncate_text(jp, 3, "…"), "あいう…");
532 assert_eq!(truncate_text(jp, 5, "…"), "あいうえお");
533 }
534
535 #[test]
536 fn truncate_middle_keeps_both_ends() {
537 assert_eq!(truncate_middle("short", 80), "short");
538 assert_eq!(truncate_middle("abcdefghij", 5), "ab…ij");
539 assert_eq!(truncate_middle("a b c", 80), "a b c");
540 assert_eq!(truncate_middle("abc", 0), "");
542 assert_eq!(truncate_middle("abc", 1), "…");
543 assert_eq!(truncate_middle("a\nb\tc", 80), "a b c");
545 }
546
547 #[test]
548 fn truncate_path_middle_breaks_at_separator() {
549 assert_eq!(truncate_path_middle("src/lib.rs", 80), "src/lib.rs");
550 assert_eq!(truncate_path_middle("foo/bar/baz/qux", 12), "foo…/qux");
551 assert_eq!(truncate_path_middle("abc", 0), "");
552 }
553}