1#![expect(
2 clippy::string_slice,
3 unused_results,
4 reason = "Formatting uses ASCII delimiters and intentionally ignores infallible String mutation results."
5)]
6
7pub fn format_size(size: u64) -> String {
11 const KB: u64 = 1024;
12 const MB: u64 = KB * 1024;
13 const GB: u64 = MB * 1024;
14
15 if size >= GB {
16 format!("{:.1}GB", size as f64 / GB as f64)
17 } else if size >= MB {
18 format!("{:.1}MB", size as f64 / MB as f64)
19 } else if size >= KB {
20 format!("{:.1}KB", size as f64 / KB as f64)
21 } else {
22 format!("{size}B")
23 }
24}
25
26pub fn indent_block(text: &str, indent: &str) -> String {
28 if indent.is_empty() || text.is_empty() {
29 return text.to_string();
30 }
31 let mut indented = String::with_capacity(text.len() + indent.len() * text.lines().count());
32 for (idx, line) in text.split('\n').enumerate() {
33 if idx > 0 {
34 indented.push('\n');
35 }
36 if !line.is_empty() {
37 indented.push_str(indent);
38 }
39 indented.push_str(line);
40 }
41 indented
42}
43
44pub fn truncate_text(text: &str, max_len: usize, ellipsis: &str) -> String {
46 if text.chars().count() <= max_len {
47 return text.to_string();
48 }
49
50 let mut truncated = text.chars().take(max_len).collect::<String>();
51 truncated.push_str(ellipsis);
52 truncated
53}
54
55pub fn truncate_within(text: &str, max_len: usize, ellipsis: &str) -> String {
69 if text.chars().count() <= max_len {
70 return text.to_string();
71 }
72 let keep = max_len.saturating_sub(ellipsis.chars().count());
73 let mut truncated = text.chars().take(keep).collect::<String>();
74 truncated.push_str(ellipsis);
75 truncated
76}
77
78pub fn truncate_middle(text: &str, max_len: usize) -> String {
88 if max_len == 0 {
89 return String::new();
90 }
91 let sanitized: String = text
92 .chars()
93 .map(|c| if matches!(c, '\n' | '\r' | '\t') { ' ' } else { c })
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_str: String = path.chars().take(head_budget).collect();
144 let head_break = head_str.rfind('/').unwrap_or(head_budget);
145
146 let tail_chars: Vec<char> = path.chars().rev().take(tail_budget).collect();
148 let tail_str: String = tail_chars.iter().rev().collect();
149 let tail_break_from_end = tail_str.find('/').map(|pos| tail_str.len() - pos).unwrap_or(tail_budget);
150
151 let head: String = path.chars().take(head_break).collect();
152 let tail: String = path
153 .chars()
154 .rev()
155 .take(tail_break_from_end)
156 .collect::<Vec<_>>()
157 .into_iter()
158 .rev()
159 .collect();
160
161 format!("{head}…{tail}")
162}
163
164pub fn head_tail_truncate(value: &str, max_chars: usize, marker: &str) -> (String, bool) {
178 const SUFFIX: &str = " [truncated]";
179
180 let total_chars = value.chars().count();
181 if total_chars <= max_chars {
182 return (value.to_string(), false);
183 }
184
185 let marker_chars = marker.chars().count();
186 if max_chars <= marker_chars + 16 {
187 let suffix_len = SUFFIX.chars().count();
188 let truncated = if max_chars > suffix_len {
189 let available = max_chars - suffix_len;
190 let mut result = value.chars().take(available).collect::<String>();
191 result.push_str(SUFFIX);
192 result
193 } else {
194 value.chars().take(max_chars).collect::<String>()
195 };
196 return (truncated, true);
197 }
198
199 let available = max_chars.saturating_sub(marker_chars);
200 let head_chars = (available * 2) / 3;
201 let tail_chars = available.saturating_sub(head_chars);
202 let head = value.chars().take(head_chars).collect::<String>();
203 let tail = value.chars().skip(total_chars.saturating_sub(tail_chars)).collect::<String>();
204 let mut truncated = String::with_capacity(max_chars + 20);
205 truncated.push_str(&head);
206 truncated.push_str(marker);
207 truncated.push_str(&tail);
208 (truncated, true)
209}
210
211pub fn wrap_text_words(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
225 let trimmed = text.trim();
226 if trimmed.is_empty() {
227 return Vec::new();
228 }
229
230 let mut result = Vec::new();
231 let mut remaining = trimmed;
232 let mut width = first_width.max(1);
233
234 while remaining.chars().take(width + 1).count() > width {
235 let split = split_at_word_boundary(remaining, width);
236 let (head, tail) = remaining.split_at(split);
237 let head = head.trim();
238 if head.is_empty() {
239 break;
240 }
241 result.push(head.to_string());
242 remaining = tail.trim_start();
243 if remaining.is_empty() {
244 break;
245 }
246 width = continuation_width.max(1);
247 }
248
249 if !remaining.is_empty() {
250 result.push(remaining.to_string());
251 }
252 result
253}
254
255fn split_at_word_boundary(input: &str, width: usize) -> usize {
256 let mut last_space: Option<usize> = None;
257 for (seen, (idx, ch)) in input.char_indices().enumerate() {
258 if seen > width {
259 break;
260 }
261 if ch.is_whitespace() {
262 last_space = Some(idx);
263 }
264 }
265 match last_space {
266 Some(pos) => pos,
267 None => byte_index_for_char_count(input, width),
268 }
269}
270
271fn byte_index_for_char_count(input: &str, chars: usize) -> usize {
272 if chars == 0 {
273 return 0;
274 }
275 let mut seen = 0usize;
276 for (idx, ch) in input.char_indices() {
277 seen += 1;
278 if seen == chars {
279 return idx + ch.len_utf8();
280 }
281 }
282 input.len()
283}
284
285pub fn truncate_byte_budget(text: &str, max_bytes: usize, suffix: &str) -> String {
289 if text.len() <= max_bytes {
290 return text.to_string();
291 }
292 let mut end = max_bytes.min(text.len());
293 while end > 0 && !text.is_char_boundary(end) {
294 end -= 1;
295 }
296 format!("{}{suffix}", &text[..end])
297}
298
299#[inline]
313pub fn is_markdown_fence_delimiter(line: &str) -> bool {
314 let indent = line.len() - line.trim_start().len();
315 if indent > 3 {
316 return false;
317 }
318 let trimmed = line.trim_start();
319 trimmed.starts_with("```") || trimmed.starts_with("~~~")
320}
321
322pub fn lowercase_leading_word(text: &str) -> String {
335 let mut chars = text.chars();
336 let Some(first) = chars.next() else {
337 return String::new();
338 };
339 let second = chars.clone().next();
340 let plain_word =
341 first.is_uppercase() && first != 'I' && second.is_none_or(|c| c.is_lowercase() || c.is_whitespace());
342 if plain_word {
343 let mut lowered: String = first.to_lowercase().collect();
344 lowered.push_str(chars.as_str());
345 lowered
346 } else {
347 text.to_string()
348 }
349}
350
351#[inline]
359pub fn collapse_whitespace(text: &str) -> String {
360 let mut result = String::with_capacity(text.len());
361 let mut pending_space = false;
362 for ch in text.chars() {
363 if ch.is_whitespace() {
364 pending_space = true;
365 } else {
366 if pending_space && !result.is_empty() {
367 result.push(' ');
368 }
369 result.push(ch);
370 pending_space = false;
371 }
372 }
373 result
374}
375
376pub fn clean_reasoning_text(text: &str) -> String {
385 text.lines()
386 .map(str::trim_end)
387 .filter(|line| !line.trim().is_empty())
388 .collect::<Vec<_>>()
389 .join("\n")
390}
391
392pub fn compact_reasoning_text(text: &str) -> String {
408 let mut out: Vec<&str> = Vec::with_capacity(text.lines().count());
409 let mut prev_blank = false;
410 for line in text.lines() {
411 let trimmed = line.trim();
412 let is_blank = trimmed.is_empty();
413 if is_blank {
414 if prev_blank {
415 continue;
416 }
417 out.push("");
418 prev_blank = true;
419 } else {
420 out.push(trimmed);
421 prev_blank = false;
422 }
423 }
424 while out.first().is_some_and(|l| l.trim().is_empty()) {
425 out.remove(0);
426 }
427 while out.last().is_some_and(|l| l.trim().is_empty()) {
428 out.pop();
429 }
430 out.join("\n")
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn truncate_byte_budget_ascii() {
439 assert_eq!(truncate_byte_budget("hello world", 5, "..."), "hello...");
440 assert_eq!(truncate_byte_budget("hi", 10, "..."), "hi");
441 }
442
443 #[test]
444 fn truncate_byte_budget_cjk_no_panic() {
445 let jp = "こんにちは";
447 assert_eq!(truncate_byte_budget(jp, 5, "…"), "こ…");
449 assert_eq!(truncate_byte_budget(jp, 6, "…"), "こん…");
451 }
452
453 #[test]
454 fn truncate_byte_budget_mixed_ascii_cjk() {
455 let mixed = "AB日本語CD";
456 assert_eq!(truncate_byte_budget(mixed, 4, ".."), "AB.."); assert_eq!(truncate_byte_budget(mixed, 5, ".."), "AB日.."); }
460
461 #[test]
462 fn truncate_byte_budget_emoji() {
463 let emoji = "👋🌍"; assert_eq!(truncate_byte_budget(emoji, 5, "!"), "👋!");
465 }
466
467 #[test]
468 fn truncate_byte_budget_zero() {
469 assert_eq!(truncate_byte_budget("abc", 0, "..."), "...");
470 }
471
472 #[test]
473 fn compact_reasoning_text_collapses_blank_runs() {
474 assert_eq!(compact_reasoning_text("line1\n\n\n\nline2\n"), "line1\n\nline2");
475 assert_eq!(compact_reasoning_text("a\n\n\n\n\n\nb"), "a\n\nb");
476 }
477
478 #[test]
479 fn compact_reasoning_text_preserves_single_paragraph_breaks() {
480 assert_eq!(compact_reasoning_text("para one\n\npara two\n"), "para one\n\npara two");
481 }
482
483 #[test]
484 fn compact_reasoning_text_trims_trailing_whitespace() {
485 assert_eq!(compact_reasoning_text(" a \n\n\n b \n"), "a\n\nb");
486 }
487
488 #[test]
489 fn compact_reasoning_text_strips_leading_trailing_blanks() {
490 assert_eq!(compact_reasoning_text("\n\n\nmid\n\n\n"), "mid");
491 assert_eq!(compact_reasoning_text("\n\n\n"), "");
492 assert_eq!(compact_reasoning_text(""), "");
493 }
494
495 #[test]
496 fn wrap_text_words_basic_and_continuation_width() {
497 assert_eq!(wrap_text_words("the quick brown fox", 9, 9), vec!["the quick", "brown fox"]);
498 assert_eq!(wrap_text_words("alpha beta gamma delta", 11, 5), vec!["alpha beta", "gamma", "delta"]);
500 }
501
502 #[test]
503 fn wrap_text_words_blank_and_unicode() {
504 assert!(wrap_text_words(" ", 5, 5).is_empty());
505 let wrapped = wrap_text_words("あいう えお かきく", 3, 3);
507 assert_eq!(wrapped, vec!["あいう", "えお", "かきく"]);
508 }
509
510 #[test]
511 fn truncate_within_reserves_ellipsis_budget() {
512 assert_eq!(truncate_within("hello world", 8, "..."), "hello...");
514 assert_eq!(truncate_within("hi", 8, "..."), "hi");
515 assert_eq!(truncate_within("abcdef", 4, "…"), "abc…");
518 }
519
520 #[test]
521 fn truncate_within_counts_chars() {
522 let jp = "あいうえお"; assert_eq!(truncate_within(jp, 5, "…"), jp);
524 assert_eq!(truncate_within(jp, 3, "…"), "あい…");
525 }
526
527 #[test]
528 fn head_tail_truncate_keeps_both_ends() {
529 let value = "0123456789".repeat(10); let (out, truncated) = head_tail_truncate(&value, 40, " ... [truncated] ... ");
531 assert!(truncated);
532 assert!(out.chars().count() <= 40);
533 assert!(out.starts_with("012"));
534 assert!(out.contains("[truncated]"));
535 assert!(out.ends_with('9'));
536 }
537
538 #[test]
539 fn head_tail_truncate_passes_through_when_short() {
540 let (out, truncated) = head_tail_truncate("short", 64, " ... ");
541 assert_eq!(out, "short");
542 assert!(!truncated);
543 }
544
545 #[test]
546 fn head_tail_truncate_small_budget_falls_back_to_prefix() {
547 let marker = " ... [truncated] ... ";
548 let (out, truncated) = head_tail_truncate("abcdefghij", 5, marker);
551 assert!(truncated);
552 assert_eq!(out, "abcde");
553
554 let long_text = "abcdefghijklmnopqrstuvwxyz";
557 let (out2, truncated2) = head_tail_truncate(long_text, 17, marker);
558 assert!(truncated2);
559 assert_eq!(out2, "abcde [truncated]");
560 assert_eq!(out2.chars().count(), 17);
561 }
562
563 #[test]
564 fn truncate_text_counts_chars_not_bytes() {
565 let jp = "あいうえお"; assert_eq!(truncate_text(jp, 3, "…"), "あいう…");
567 assert_eq!(truncate_text(jp, 5, "…"), "あいうえお");
568 }
569
570 #[test]
571 fn truncate_middle_keeps_both_ends() {
572 assert_eq!(truncate_middle("short", 80), "short");
573 assert_eq!(truncate_middle("abcdefghij", 5), "ab…ij");
574 assert_eq!(truncate_middle("a b c", 80), "a b c");
575 assert_eq!(truncate_middle("abc", 0), "");
577 assert_eq!(truncate_middle("abc", 1), "…");
578 assert_eq!(truncate_middle("a\nb\tc", 80), "a b c");
580 }
581
582 #[test]
583 fn truncate_path_middle_breaks_at_separator() {
584 assert_eq!(truncate_path_middle("src/lib.rs", 80), "src/lib.rs");
585 assert_eq!(truncate_path_middle("foo/bar/baz/qux", 12), "foo…/qux");
586 assert_eq!(truncate_path_middle("abc", 0), "");
587 }
588}