use regex::{Regex, RegexBuilder};
use serde::Serialize;
pub const DEFAULT_HEAD_LINES: usize = 5;
pub const DEFAULT_TAIL_LINES: usize = 5;
pub const DEFAULT_KEEP_PATTERNS: &[&str] = &["error", "warning", "failed", "panic", "fatal"];
pub const DEFAULT_COLLAPSE_THRESHOLD: usize = 20;
#[derive(Debug, thiserror::Error)]
pub enum ExecError {
#[error("invalid regex pattern `{pattern}`: {message}")]
InvalidPattern { pattern: String, message: String },
}
#[derive(Debug, Clone)]
pub struct CompressOptions {
pub keep_patterns: Vec<String>,
pub head_lines: usize,
pub tail_lines: usize,
pub collapse_threshold: usize,
}
impl Default for CompressOptions {
fn default() -> Self {
Self {
keep_patterns: DEFAULT_KEEP_PATTERNS
.iter()
.map(|s| s.to_string())
.collect(),
head_lines: DEFAULT_HEAD_LINES,
tail_lines: DEFAULT_TAIL_LINES,
collapse_threshold: DEFAULT_COLLAPSE_THRESHOLD,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct CompressStats {
pub total_lines: usize,
pub kept_lines: usize,
pub omitted_lines: usize,
pub original_tokens: usize,
pub compressed_tokens: usize,
pub saved_percent: u32,
}
#[derive(Debug, Clone)]
pub struct CompressResult {
pub text: String,
pub stats: CompressStats,
}
pub fn estimate_tokens(text: &str) -> usize {
text.len() / 4
}
pub fn compress(output: &str, options: &CompressOptions) -> Result<CompressResult, ExecError> {
let lines: Vec<&str> = output.lines().collect();
let total = lines.len();
let original_tokens = estimate_tokens(output);
let matcher = KeepMatcher::new(options)?;
if total <= options.collapse_threshold {
return Ok(CompressResult {
text: output.to_string(),
stats: CompressStats {
total_lines: total,
kept_lines: total,
omitted_lines: 0,
original_tokens,
compressed_tokens: original_tokens,
saved_percent: 0,
},
});
}
let mut kept = Vec::with_capacity(total);
for (i, line) in lines.iter().enumerate() {
let in_summary = i < options.head_lines || i >= total.saturating_sub(options.tail_lines);
kept.push(in_summary || matcher.is_match(line));
}
let kept_lines = kept.iter().filter(|k| **k).count();
let text = render(&lines, &kept);
let compressed_tokens = estimate_tokens(&text);
let saved_percent = saved_pct(original_tokens, compressed_tokens);
Ok(CompressResult {
text,
stats: CompressStats {
total_lines: total,
kept_lines,
omitted_lines: total - kept_lines,
original_tokens,
compressed_tokens,
saved_percent,
},
})
}
fn render(lines: &[&str], kept: &[bool]) -> String {
let mut out = String::new();
let mut omitted = 0usize;
let mut first = true;
for (i, line) in lines.iter().enumerate() {
if kept[i] {
if omitted > 0 {
if !first {
out.push('\n');
}
out.push_str(&omit_marker(omitted));
omitted = 0;
first = false;
}
if !first {
out.push('\n');
}
out.push_str(line);
first = false;
} else {
omitted += 1;
}
}
if omitted > 0 {
if !first {
out.push('\n');
}
out.push_str(&omit_marker(omitted));
}
out
}
fn omit_marker(n: usize) -> String {
format!("... [{n} lines omitted]")
}
fn saved_pct(original: usize, compressed: usize) -> u32 {
if original == 0 {
return 0;
}
((original - compressed.min(original)) * 100 / original) as u32
}
struct KeepMatcher {
patterns: Vec<Regex>,
}
impl KeepMatcher {
fn new(options: &CompressOptions) -> Result<Self, ExecError> {
let mut patterns = Vec::with_capacity(options.keep_patterns.len());
for pattern in &options.keep_patterns {
patterns.push(
RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
.map_err(|e| ExecError::InvalidPattern {
pattern: pattern.clone(),
message: e.to_string(),
})?,
);
}
Ok(Self { patterns })
}
fn is_match(&self, line: &str) -> bool {
self.patterns.iter().any(|re| re.is_match(line))
}
}