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 {
static BPE: std::sync::OnceLock<tiktoken_rs::CoreBPE> = std::sync::OnceLock::new();
let bpe = BPE.get_or_init(|| {
tiktoken_rs::cl100k_base().expect("bundled cl100k_base encoding is corrupt")
});
bpe.encode_with_special_tokens(text).len()
}
pub fn compress(output: &str, options: &CompressOptions) -> Result<CompressResult, ExecError> {
let mut compressor = StreamCompressor::new(options)?;
compressor.push(output.as_bytes());
Ok(compressor.finish())
}
pub struct StreamCompressor {
matcher: KeepMatcher,
head_lines: usize,
tail_lines: usize,
collapse_threshold: usize,
total: usize,
head: Vec<String>,
ring: std::collections::VecDeque<(String, bool)>,
kept_mid: Vec<(usize, String)>,
run: usize,
raw_tokens: usize,
prefix: Vec<u8>,
prefix_lines: usize,
pending: Vec<u8>,
}
impl StreamCompressor {
pub fn new(options: &CompressOptions) -> Result<Self, ExecError> {
Ok(Self {
matcher: KeepMatcher::new(options)?,
head_lines: options.head_lines,
tail_lines: options.tail_lines,
collapse_threshold: options.collapse_threshold,
total: 0,
head: Vec::new(),
ring: std::collections::VecDeque::new(),
kept_mid: Vec::new(),
run: 0,
raw_tokens: 0,
prefix: Vec::new(),
prefix_lines: 0,
pending: Vec::new(),
})
}
pub fn push(&mut self, bytes: &[u8]) {
self.pending.extend_from_slice(bytes);
while let Some(pos) = self.pending.iter().position(|b| *b == b'\n') {
let chunk: Vec<u8> = self.pending.drain(..=pos).collect();
let mut line = chunk.as_slice();
line = &line[..line.len() - 1]; if line.last() == Some(&b'\r') {
line = &line[..line.len() - 1];
}
self.consume(chunk.as_slice(), line);
}
}
pub fn finish(mut self) -> CompressResult {
if !self.pending.is_empty() {
let chunk = std::mem::take(&mut self.pending);
self.consume(&chunk, &chunk);
}
if self.total <= self.collapse_threshold {
let text = String::from_utf8_lossy(&self.prefix).into_owned();
let tokens = estimate_tokens(&text);
return CompressResult {
text,
stats: CompressStats {
total_lines: self.total,
kept_lines: self.total,
omitted_lines: 0,
original_tokens: tokens,
compressed_tokens: tokens,
saved_percent: 0,
},
};
}
let kept_lines = self.head.len() + self.kept_mid.len() + self.ring.len();
let mut out = String::new();
let mut first = true;
for line in &self.head {
if !first {
out.push('\n');
}
out.push_str(line);
first = false;
}
for (omitted, line) in &self.kept_mid {
if *omitted > 0 {
if !first {
out.push('\n');
}
out.push_str(&omit_marker(*omitted));
first = false;
}
if !first {
out.push('\n');
}
out.push_str(line);
first = false;
}
if self.run > 0 {
if !first {
out.push('\n');
}
out.push_str(&omit_marker(self.run));
first = false;
}
for (line, _) in &self.ring {
if !first {
out.push('\n');
}
out.push_str(line);
first = false;
}
let compressed_tokens = estimate_tokens(&out);
CompressResult {
text: out,
stats: CompressStats {
total_lines: self.total,
kept_lines,
omitted_lines: self.total - kept_lines,
original_tokens: self.raw_tokens,
compressed_tokens,
saved_percent: saved_pct(self.raw_tokens, compressed_tokens),
},
}
}
fn consume(&mut self, chunk: &[u8], line: &[u8]) {
self.total += 1;
self.raw_tokens += estimate_tokens(&String::from_utf8_lossy(chunk));
if self.total <= self.collapse_threshold {
self.prefix.extend_from_slice(chunk);
self.prefix_lines += 1;
return;
}
if !self.prefix.is_empty() {
let prefix = std::mem::take(&mut self.prefix);
let mut rest: &[u8] = &prefix;
let mut line_no = 0usize;
while let Some(pos) = rest.iter().position(|b| *b == b'\n') {
let mut l = &rest[..pos];
if l.last() == Some(&b'\r') {
l = &l[..l.len() - 1];
}
line_no += 1;
self.feed(l, line_no);
rest = &rest[pos + 1..];
}
self.prefix_lines = 0;
}
self.feed(line, self.total);
}
fn feed(&mut self, line: &[u8], line_no: usize) {
let text = String::from_utf8_lossy(line);
let matched = self.matcher.is_match(&text);
if line_no <= self.head_lines {
self.head.push(text.into_owned());
} else {
self.ring.push_back((text.into_owned(), matched));
if self.ring.len() > self.tail_lines {
let (l, m) = self.ring.pop_front().expect("ring not empty");
if m {
self.kept_mid.push((self.run, l));
self.run = 0;
} else {
self.run += 1;
}
}
}
}
}
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))
}
}