use serde::{Deserialize, Serialize};
use crate::js;
pub const DEFAULT_MAX_LINES: u64 = 2000;
pub const DEFAULT_MAX_BYTES: u64 = 50 * 1024;
pub const GREP_MAX_LINE_LENGTH: usize = 500;
pub const NO_LINE_LIMIT: u64 = 9_007_199_254_740_991;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TruncatedBy {
Lines,
Bytes,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TruncationResult {
pub content: String,
pub truncated: bool,
pub truncated_by: Option<TruncatedBy>,
pub total_lines: u64,
pub total_bytes: u64,
pub output_lines: u64,
pub output_bytes: u64,
pub last_line_partial: bool,
pub first_line_exceeds_limit: bool,
pub max_lines: u64,
pub max_bytes: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TruncationOptions {
pub max_lines: u64,
pub max_bytes: u64,
}
impl Default for TruncationOptions {
fn default() -> Self {
Self {
max_lines: DEFAULT_MAX_LINES,
max_bytes: DEFAULT_MAX_BYTES,
}
}
}
impl TruncationOptions {
#[must_use]
pub fn bytes_only() -> Self {
Self {
max_lines: NO_LINE_LIMIT,
..Self::default()
}
}
}
fn split_lines_for_counting(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
}
fn count(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
#[must_use]
pub fn format_size(bytes: u64) -> String {
#[allow(clippy::cast_precision_loss, reason = "sizes are far below 2^53")]
let value = bytes as f64;
if bytes < 1024 {
format!("{bytes}B")
} else if bytes < 1024 * 1024 {
format!("{}KB", js::to_fixed(value / 1024.0, 1))
} else {
format!("{}MB", js::to_fixed(value / (1024.0 * 1024.0), 1))
}
}
fn untruncated(content: &str, lines: u64, options: TruncationOptions) -> TruncationResult {
let bytes = count(content.len());
TruncationResult {
content: content.to_owned(),
truncated: false,
truncated_by: None,
total_lines: lines,
total_bytes: bytes,
output_lines: lines,
output_bytes: bytes,
last_line_partial: false,
first_line_exceeds_limit: false,
max_lines: options.max_lines,
max_bytes: options.max_bytes,
}
}
#[must_use]
pub fn truncate_head(content: &str, options: TruncationOptions) -> TruncationResult {
let total_bytes = count(content.len());
let lines = split_lines_for_counting(content);
let total_lines = count(lines.len());
if total_lines <= options.max_lines && total_bytes <= options.max_bytes {
return untruncated(content, total_lines, options);
}
let first_line_bytes = lines.first().map_or(0, |line| count(line.len()));
if first_line_bytes > options.max_bytes {
return TruncationResult {
content: String::new(),
truncated: true,
truncated_by: Some(TruncatedBy::Bytes),
total_lines,
total_bytes,
output_lines: 0,
output_bytes: 0,
last_line_partial: false,
first_line_exceeds_limit: true,
max_lines: options.max_lines,
max_bytes: options.max_bytes,
};
}
let mut kept: Vec<&str> = Vec::new();
let mut kept_bytes = 0_u64;
let mut truncated_by = TruncatedBy::Lines;
for (index, line) in lines.iter().enumerate() {
if count(index) >= options.max_lines {
break;
}
let line_bytes = count(line.len()) + u64::from(index > 0);
if kept_bytes + line_bytes > options.max_bytes {
truncated_by = TruncatedBy::Bytes;
break;
}
kept.push(line);
kept_bytes += line_bytes;
}
if count(kept.len()) >= options.max_lines && kept_bytes <= options.max_bytes {
truncated_by = TruncatedBy::Lines;
}
let output = kept.join("\n");
TruncationResult {
output_bytes: count(output.len()),
output_lines: count(kept.len()),
content: output,
truncated: true,
truncated_by: Some(truncated_by),
total_lines,
total_bytes,
last_line_partial: false,
first_line_exceeds_limit: false,
max_lines: options.max_lines,
max_bytes: options.max_bytes,
}
}
#[must_use]
pub fn truncate_tail(content: &str, options: TruncationOptions) -> TruncationResult {
let total_bytes = count(content.len());
let lines = split_lines_for_counting(content);
let total_lines = count(lines.len());
if total_lines <= options.max_lines && total_bytes <= options.max_bytes {
return untruncated(content, total_lines, options);
}
let mut kept: Vec<&str> = Vec::new();
let mut kept_bytes = 0_u64;
let mut truncated_by = TruncatedBy::Lines;
let mut last_line_partial = false;
for line in lines.iter().rev() {
if count(kept.len()) >= options.max_lines {
break;
}
let line_bytes = count(line.len()) + u64::from(!kept.is_empty());
if kept_bytes + line_bytes > options.max_bytes {
truncated_by = TruncatedBy::Bytes;
if kept.is_empty() {
let tail = tail_within_bytes(line, options.max_bytes);
kept_bytes = count(tail.len());
kept.push(tail);
last_line_partial = true;
}
break;
}
kept.push(line);
kept_bytes += line_bytes;
}
if count(kept.len()) >= options.max_lines && kept_bytes <= options.max_bytes {
truncated_by = TruncatedBy::Lines;
}
kept.reverse();
let output = kept.join("\n");
TruncationResult {
output_bytes: count(output.len()),
output_lines: count(kept.len()),
content: output,
truncated: true,
truncated_by: Some(truncated_by),
total_lines,
total_bytes,
last_line_partial,
first_line_exceeds_limit: false,
max_lines: options.max_lines,
max_bytes: options.max_bytes,
}
}
fn tail_within_bytes(text: &str, max_bytes: u64) -> &str {
let max = usize::try_from(max_bytes).unwrap_or(usize::MAX);
if text.len() <= max {
return text;
}
let mut start = text.len() - max;
while !text.is_char_boundary(start) {
start += 1;
}
&text[start..]
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TruncatedLine {
pub text: String,
pub was_truncated: bool,
}
#[must_use]
pub fn truncate_line(line: &str, max_chars: usize) -> TruncatedLine {
if js::utf16_len(line) <= max_chars {
return TruncatedLine {
text: line.to_owned(),
was_truncated: false,
};
}
TruncatedLine {
text: format!("{}... [truncated]", js::utf16_prefix(line, max_chars)),
was_truncated: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_size_matches_pi() {
assert_eq!(format_size(0), "0B");
assert_eq!(format_size(1023), "1023B");
assert_eq!(format_size(1024), "1.0KB");
assert_eq!(format_size(1280), "1.3KB");
assert_eq!(format_size(51_200), "50.0KB");
assert_eq!(format_size(1024 * 1024 * 3 / 2), "1.5MB");
}
#[test]
fn head_keeps_complete_lines() {
let result = truncate_head(
"aaa\nbbb\nccc",
TruncationOptions {
max_lines: 10,
max_bytes: 8,
},
);
assert_eq!(result.content, "aaa\nbbb");
assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
assert_eq!(result.output_lines, 2);
}
#[test]
fn head_first_line_too_long() {
let result = truncate_head(
"abcdef\nx",
TruncationOptions {
max_lines: 10,
max_bytes: 3,
},
);
assert!(result.first_line_exceeds_limit);
assert_eq!(result.content, "");
}
#[test]
fn tail_partial_line() {
let result = truncate_tail(
"a\nbéééé",
TruncationOptions {
max_lines: 10,
max_bytes: 4,
},
);
assert!(result.last_line_partial);
assert_eq!(result.content, "éé");
}
#[test]
fn line_truncation_counts_utf16() {
assert_eq!(truncate_line("abc", 2).text, "ab... [truncated]");
assert!(!truncate_line("ab", 2).was_truncated);
}
#[test]
fn untruncated_counts_trailing_newline() {
let result = truncate_head("a\nb\n", TruncationOptions::default());
assert_eq!(result.total_lines, 2);
assert!(!result.truncated);
}
}