use serde::Serialize;
pub const DEFAULT_MAX_LINES: usize = 2000;
pub const DEFAULT_MAX_BYTES: usize = 50 * 1024;
pub const GREP_MAX_LINE_LENGTH: usize = 500;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TruncatedBy {
Lines,
Bytes,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Truncation {
pub content: String,
pub truncated: bool,
pub truncated_by: Option<TruncatedBy>,
pub total_lines: usize,
pub total_bytes: usize,
pub output_lines: usize,
pub output_bytes: usize,
pub last_line_partial: bool,
pub first_line_exceeds_limit: bool,
pub max_lines: usize,
pub max_bytes: usize,
}
fn count_lines(content: &str) -> Vec<&str> {
if content.is_empty() {
return Vec::new();
}
let mut lines: Vec<&str> = content.split('\n').collect();
if content.ends_with('\n') {
lines.pop();
}
lines
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MiddleTruncation {
pub content: String,
pub truncated: bool,
pub total_bytes: usize,
pub total_lines: usize,
}
pub fn truncate_middle(content: &str, max_bytes: usize) -> MiddleTruncation {
let total_lines = count_lines(content).len();
let total_bytes = content.len();
if total_bytes <= max_bytes {
return MiddleTruncation {
content: content.to_owned(),
truncated: false,
total_bytes,
total_lines,
};
}
let mut head_end = max_bytes / 2;
while head_end > 0 && !content.is_char_boundary(head_end) {
head_end -= 1;
}
let mut tail_start = total_bytes - (max_bytes - max_bytes / 2);
while tail_start < total_bytes && !content.is_char_boundary(tail_start) {
tail_start += 1;
}
let removed = content[head_end..tail_start].chars().count();
MiddleTruncation {
content: format!(
"{}…{removed} chars truncated…{}",
&content[..head_end],
&content[tail_start..]
),
truncated: true,
total_bytes,
total_lines,
}
}
pub fn format_size(bytes: usize) -> String {
if bytes < 1024 {
format!("{bytes}B")
} else if bytes < 1024 * 1024 {
format!("{:.1}KB", bytes as f64 / 1024.0)
} else {
format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
}
}
fn untruncated(content: &str, lines: usize, max_lines: usize, max_bytes: usize) -> Truncation {
Truncation {
content: content.to_owned(),
truncated: false,
truncated_by: None,
total_lines: lines,
total_bytes: content.len(),
output_lines: lines,
output_bytes: content.len(),
last_line_partial: false,
first_line_exceeds_limit: false,
max_lines,
max_bytes,
}
}
pub fn truncate_head(content: &str, max_lines: usize, max_bytes: usize) -> Truncation {
let lines = count_lines(content);
let total_bytes = content.len();
if lines.len() <= max_lines && total_bytes <= max_bytes {
return untruncated(content, lines.len(), max_lines, max_bytes);
}
let base = Truncation {
content: String::new(),
truncated: true,
truncated_by: Some(TruncatedBy::Bytes),
total_lines: lines.len(),
total_bytes,
output_lines: 0,
output_bytes: 0,
last_line_partial: false,
first_line_exceeds_limit: false,
max_lines,
max_bytes,
};
if lines.first().is_some_and(|line| line.len() > max_bytes) {
return Truncation {
first_line_exceeds_limit: true,
..base
};
}
let mut kept: Vec<&str> = Vec::new();
let mut bytes = 0;
let mut by = TruncatedBy::Lines;
for (index, line) in lines.iter().enumerate().take(max_lines) {
let size = line.len() + usize::from(index > 0);
if bytes + size > max_bytes {
by = TruncatedBy::Bytes;
break;
}
kept.push(line);
bytes += size;
}
if kept.len() >= max_lines && bytes <= max_bytes {
by = TruncatedBy::Lines;
}
let content = kept.join("\n");
Truncation {
output_lines: kept.len(),
output_bytes: content.len(),
content,
truncated_by: Some(by),
..base
}
}
pub fn truncate_tail(content: &str, max_lines: usize, max_bytes: usize) -> Truncation {
let lines = count_lines(content);
let total_bytes = content.len();
if lines.len() <= max_lines && total_bytes <= max_bytes {
return untruncated(content, lines.len(), max_lines, max_bytes);
}
let mut kept: Vec<String> = Vec::new();
let mut bytes = 0;
let mut by = TruncatedBy::Lines;
let mut partial = false;
for line in lines.iter().rev() {
if kept.len() >= max_lines {
break;
}
let size = line.len() + usize::from(!kept.is_empty());
if bytes + size > max_bytes {
by = TruncatedBy::Bytes;
if kept.is_empty() {
let tail = tail_bytes(line, max_bytes);
bytes = tail.len();
kept.push(tail.to_owned());
partial = true;
}
break;
}
kept.push((*line).to_owned());
bytes += size;
}
if kept.len() >= max_lines && bytes <= max_bytes {
by = TruncatedBy::Lines;
}
kept.reverse();
let content = kept.join("\n");
Truncation {
output_lines: kept.len(),
output_bytes: content.len(),
content,
truncated: true,
truncated_by: Some(by),
total_lines: lines.len(),
total_bytes,
last_line_partial: partial,
first_line_exceeds_limit: false,
max_lines,
max_bytes,
}
}
pub fn tail_bytes(text: &str, max_bytes: usize) -> &str {
if text.len() <= max_bytes {
return text;
}
let mut start = text.len() - max_bytes;
while !text.is_char_boundary(start) {
start += 1;
}
&text[start..]
}
pub fn truncate_line(line: &str, max_chars: usize) -> (String, bool) {
let units: Vec<u16> = line.encode_utf16().collect();
if units.len() <= max_chars {
return (line.to_owned(), false);
}
(
format!(
"{}... [truncated]",
String::from_utf16_lossy(&units[..max_chars])
),
true,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn head_keeps_whole_lines() {
let text = "a\nbb\nccc\n";
let result = truncate_head(text, 2, 100);
assert_eq!(result.content, "a\nbb");
assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
assert_eq!(result.total_lines, 3);
let result = truncate_head(text, 10, 4);
assert_eq!(result.content, "a\nbb");
assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
assert!(truncate_head("toolong\nx", 10, 3).first_line_exceeds_limit);
}
#[test]
fn tail_keeps_end() {
let result = truncate_tail("1\n2\n3\n4", 2, 100);
assert_eq!(result.content, "3\n4");
let result = truncate_tail("short\nwayyyyyyyy too long", 10, 8);
assert_eq!(result.content, "too long");
assert!(result.last_line_partial);
assert_eq!(tail_bytes("aé", 1), "");
}
#[test]
fn formats_sizes_like_pi() {
assert_eq!(format_size(512), "512B");
assert_eq!(format_size(51200), "50.0KB");
assert_eq!(format_size(1536 * 1024), "1.5MB");
}
}