pub fn format_size(size: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if size >= GB {
format!("{:.1}GB", size as f64 / GB as f64)
} else if size >= MB {
format!("{:.1}MB", size as f64 / MB as f64)
} else if size >= KB {
format!("{:.1}KB", size as f64 / KB as f64)
} else {
format!("{}B", size)
}
}
pub fn indent_block(text: &str, indent: &str) -> String {
if indent.is_empty() || text.is_empty() {
return text.to_string();
}
let mut indented = String::with_capacity(text.len() + indent.len() * text.lines().count());
for (idx, line) in text.split('\n').enumerate() {
if idx > 0 {
indented.push('\n');
}
if !line.is_empty() {
indented.push_str(indent);
}
indented.push_str(line);
}
indented
}
pub fn truncate_text(text: &str, max_len: usize, ellipsis: &str) -> String {
if text.chars().count() <= max_len {
return text.to_string();
}
let mut truncated = text.chars().take(max_len).collect::<String>();
truncated.push_str(ellipsis);
truncated
}
pub fn truncate_within(text: &str, max_len: usize, ellipsis: &str) -> String {
if text.chars().count() <= max_len {
return text.to_string();
}
let keep = max_len.saturating_sub(ellipsis.chars().count());
let mut truncated = text.chars().take(keep).collect::<String>();
truncated.push_str(ellipsis);
truncated
}
pub fn head_tail_truncate(value: &str, max_chars: usize, marker: &str) -> (String, bool) {
const SUFFIX: &str = " [truncated]";
let total_chars = value.chars().count();
if total_chars <= max_chars {
return (value.to_string(), false);
}
let marker_chars = marker.chars().count();
if max_chars <= marker_chars + 16 {
let suffix_len = SUFFIX.chars().count();
let truncated = if max_chars > suffix_len {
let available = max_chars - suffix_len;
let mut result = value.chars().take(available).collect::<String>();
result.push_str(SUFFIX);
result
} else {
value.chars().take(max_chars).collect::<String>()
};
return (truncated, true);
}
let available = max_chars.saturating_sub(marker_chars);
let head_chars = (available * 2) / 3;
let tail_chars = available.saturating_sub(head_chars);
let head = value.chars().take(head_chars).collect::<String>();
let tail = value
.chars()
.skip(total_chars.saturating_sub(tail_chars))
.collect::<String>();
let mut truncated = String::with_capacity(max_chars + 20);
truncated.push_str(&head);
truncated.push_str(marker);
truncated.push_str(&tail);
(truncated, true)
}
pub fn wrap_text_words(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Vec::new();
}
let mut result = Vec::new();
let mut remaining = trimmed;
let mut width = first_width.max(1);
while remaining.chars().count() > width {
let split = split_at_word_boundary(remaining, width);
let (head, tail) = remaining.split_at(split);
let head = head.trim();
if head.is_empty() {
break;
}
result.push(head.to_string());
remaining = tail.trim_start();
if remaining.is_empty() {
break;
}
width = continuation_width.max(1);
}
if !remaining.is_empty() {
result.push(remaining.to_string());
}
result
}
fn split_at_word_boundary(input: &str, width: usize) -> usize {
let mut last_space: Option<usize> = None;
for (seen, (idx, ch)) in input.char_indices().enumerate() {
if seen > width {
break;
}
if ch.is_whitespace() {
last_space = Some(idx);
}
}
match last_space {
Some(pos) => pos,
None => byte_index_for_char_count(input, width),
}
}
fn byte_index_for_char_count(input: &str, chars: usize) -> usize {
if chars == 0 {
return 0;
}
let mut seen = 0usize;
for (idx, ch) in input.char_indices() {
seen += 1;
if seen == chars {
return idx + ch.len_utf8();
}
}
input.len()
}
pub fn truncate_byte_budget(text: &str, max_bytes: usize, suffix: &str) -> String {
if text.len() <= max_bytes {
return text.to_string();
}
let mut end = max_bytes.min(text.len());
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
format!("{}{suffix}", &text[..end])
}
#[inline]
pub fn collapse_whitespace(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut pending_space = false;
for ch in text.chars() {
if ch.is_whitespace() {
pending_space = true;
} else {
if pending_space && !result.is_empty() {
result.push(' ');
}
result.push(ch);
pending_space = false;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_byte_budget_ascii() {
assert_eq!(truncate_byte_budget("hello world", 5, "..."), "hello...");
assert_eq!(truncate_byte_budget("hi", 10, "..."), "hi");
}
#[test]
fn truncate_byte_budget_cjk_no_panic() {
let jp = "こんにちは";
assert_eq!(truncate_byte_budget(jp, 5, "…"), "こ…");
assert_eq!(truncate_byte_budget(jp, 6, "…"), "こん…");
}
#[test]
fn truncate_byte_budget_mixed_ascii_cjk() {
let mixed = "AB日本語CD";
assert_eq!(truncate_byte_budget(mixed, 4, ".."), "AB.."); assert_eq!(truncate_byte_budget(mixed, 5, ".."), "AB日.."); }
#[test]
fn truncate_byte_budget_emoji() {
let emoji = "👋🌍"; assert_eq!(truncate_byte_budget(emoji, 5, "!"), "👋!");
}
#[test]
fn truncate_byte_budget_zero() {
assert_eq!(truncate_byte_budget("abc", 0, "..."), "...");
}
#[test]
fn wrap_text_words_basic_and_continuation_width() {
assert_eq!(
wrap_text_words("the quick brown fox", 9, 9),
vec!["the quick", "brown fox"]
);
assert_eq!(
wrap_text_words("alpha beta gamma delta", 11, 5),
vec!["alpha beta", "gamma", "delta"]
);
}
#[test]
fn wrap_text_words_blank_and_unicode() {
assert!(wrap_text_words(" ", 5, 5).is_empty());
let wrapped = wrap_text_words("あいう えお かきく", 3, 3);
assert_eq!(wrapped, vec!["あいう", "えお", "かきく"]);
}
#[test]
fn truncate_within_reserves_ellipsis_budget() {
assert_eq!(truncate_within("hello world", 8, "..."), "hello...");
assert_eq!(truncate_within("hi", 8, "..."), "hi");
assert_eq!(truncate_within("abcdef", 4, "…"), "abc…");
}
#[test]
fn truncate_within_counts_chars() {
let jp = "あいうえお"; assert_eq!(truncate_within(jp, 5, "…"), jp);
assert_eq!(truncate_within(jp, 3, "…"), "あい…");
}
#[test]
fn head_tail_truncate_keeps_both_ends() {
let value = "0123456789".repeat(10); let (out, truncated) = head_tail_truncate(&value, 40, " ... [truncated] ... ");
assert!(truncated);
assert!(out.chars().count() <= 40);
assert!(out.starts_with("012"));
assert!(out.contains("[truncated]"));
assert!(out.ends_with('9'));
}
#[test]
fn head_tail_truncate_passes_through_when_short() {
let (out, truncated) = head_tail_truncate("short", 64, " ... ");
assert_eq!(out, "short");
assert!(!truncated);
}
#[test]
fn head_tail_truncate_small_budget_falls_back_to_prefix() {
let marker = " ... [truncated] ... ";
let (out, truncated) = head_tail_truncate("abcdefghij", 5, marker);
assert!(truncated);
assert_eq!(out, "abcde");
let long_text = "abcdefghijklmnopqrstuvwxyz";
let (out2, truncated2) = head_tail_truncate(long_text, 17, marker);
assert!(truncated2);
assert_eq!(out2, "abcde [truncated]");
assert_eq!(out2.chars().count(), 17);
}
#[test]
fn truncate_text_counts_chars_not_bytes() {
let jp = "あいうえお"; assert_eq!(truncate_text(jp, 3, "…"), "あいう…");
assert_eq!(truncate_text(jp, 5, "…"), "あいうえお");
}
}