1use regex::{Regex, RegexBuilder};
17use serde::Serialize;
18
19pub const DEFAULT_HEAD_LINES: usize = 5;
21
22pub const DEFAULT_TAIL_LINES: usize = 5;
24
25pub const DEFAULT_KEEP_PATTERNS: &[&str] = &["error", "warning", "failed", "panic", "fatal"];
28
29pub const DEFAULT_COLLAPSE_THRESHOLD: usize = 20;
31
32#[derive(Debug, thiserror::Error)]
34pub enum ExecError {
35 #[error("invalid regex pattern `{pattern}`: {message}")]
36 InvalidPattern { pattern: String, message: String },
37}
38
39#[derive(Debug, Clone)]
41pub struct CompressOptions {
42 pub keep_patterns: Vec<String>,
46 pub head_lines: usize,
48 pub tail_lines: usize,
50 pub collapse_threshold: usize,
52}
53
54impl Default for CompressOptions {
55 fn default() -> Self {
56 Self {
57 keep_patterns: DEFAULT_KEEP_PATTERNS
58 .iter()
59 .map(|s| s.to_string())
60 .collect(),
61 head_lines: DEFAULT_HEAD_LINES,
62 tail_lines: DEFAULT_TAIL_LINES,
63 collapse_threshold: DEFAULT_COLLAPSE_THRESHOLD,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
70pub struct CompressStats {
71 pub total_lines: usize,
72 pub kept_lines: usize,
73 pub omitted_lines: usize,
74 pub original_tokens: usize,
75 pub compressed_tokens: usize,
76 pub saved_percent: u32,
77}
78
79#[derive(Debug, Clone)]
81pub struct CompressResult {
82 pub text: String,
83 pub stats: CompressStats,
84}
85
86pub fn estimate_tokens(text: &str) -> usize {
90 static BPE: std::sync::OnceLock<tiktoken_rs::CoreBPE> = std::sync::OnceLock::new();
91 let bpe = BPE.get_or_init(|| {
92 tiktoken_rs::cl100k_base().expect("bundled cl100k_base encoding is corrupt")
93 });
94 bpe.encode_with_special_tokens(text).len()
95}
96
97pub fn compress(output: &str, options: &CompressOptions) -> Result<CompressResult, ExecError> {
108 let mut compressor = StreamCompressor::new(options)?;
109 compressor.push(output.as_bytes());
110 Ok(compressor.finish())
111}
112
113pub struct StreamCompressor {
123 matcher: KeepMatcher,
124 head_lines: usize,
125 tail_lines: usize,
126 collapse_threshold: usize,
127 total: usize,
129 head: Vec<String>,
131 ring: std::collections::VecDeque<(String, bool)>,
133 kept_mid: Vec<(usize, String)>,
135 run: usize,
137 raw_tokens: usize,
139 prefix: Vec<u8>,
142 prefix_lines: usize,
144 pending: Vec<u8>,
146}
147
148impl StreamCompressor {
149 pub fn new(options: &CompressOptions) -> Result<Self, ExecError> {
151 Ok(Self {
152 matcher: KeepMatcher::new(options)?,
153 head_lines: options.head_lines,
154 tail_lines: options.tail_lines,
155 collapse_threshold: options.collapse_threshold,
156 total: 0,
157 head: Vec::new(),
158 ring: std::collections::VecDeque::new(),
159 kept_mid: Vec::new(),
160 run: 0,
161 raw_tokens: 0,
162 prefix: Vec::new(),
163 prefix_lines: 0,
164 pending: Vec::new(),
165 })
166 }
167
168 pub fn push(&mut self, bytes: &[u8]) {
170 self.pending.extend_from_slice(bytes);
171 while let Some(pos) = self.pending.iter().position(|b| *b == b'\n') {
172 let chunk: Vec<u8> = self.pending.drain(..=pos).collect();
173 let mut line = chunk.as_slice();
174 line = &line[..line.len() - 1]; if line.last() == Some(&b'\r') {
176 line = &line[..line.len() - 1];
177 }
178 self.consume(chunk.as_slice(), line);
179 }
180 }
181
182 pub fn finish(mut self) -> CompressResult {
184 if !self.pending.is_empty() {
185 let chunk = std::mem::take(&mut self.pending);
188 self.consume(&chunk, &chunk);
189 }
190 if self.total <= self.collapse_threshold {
191 let text = String::from_utf8_lossy(&self.prefix).into_owned();
192 let tokens = estimate_tokens(&text);
193 return CompressResult {
194 text,
195 stats: CompressStats {
196 total_lines: self.total,
197 kept_lines: self.total,
198 omitted_lines: 0,
199 original_tokens: tokens,
200 compressed_tokens: tokens,
201 saved_percent: 0,
202 },
203 };
204 }
205 let kept_lines = self.head.len() + self.kept_mid.len() + self.ring.len();
206 let mut out = String::new();
207 let mut first = true;
208 for line in &self.head {
209 if !first {
210 out.push('\n');
211 }
212 out.push_str(line);
213 first = false;
214 }
215 for (omitted, line) in &self.kept_mid {
216 if *omitted > 0 {
217 if !first {
218 out.push('\n');
219 }
220 out.push_str(&omit_marker(*omitted));
221 first = false;
222 }
223 if !first {
224 out.push('\n');
225 }
226 out.push_str(line);
227 first = false;
228 }
229 if self.run > 0 {
230 if !first {
231 out.push('\n');
232 }
233 out.push_str(&omit_marker(self.run));
234 first = false;
235 }
236 for (line, _) in &self.ring {
237 if !first {
238 out.push('\n');
239 }
240 out.push_str(line);
241 first = false;
242 }
243 let compressed_tokens = estimate_tokens(&out);
244 CompressResult {
245 text: out,
246 stats: CompressStats {
247 total_lines: self.total,
248 kept_lines,
249 omitted_lines: self.total - kept_lines,
250 original_tokens: self.raw_tokens,
251 compressed_tokens,
252 saved_percent: saved_pct(self.raw_tokens, compressed_tokens),
253 },
254 }
255 }
256
257 fn consume(&mut self, chunk: &[u8], line: &[u8]) {
260 self.total += 1;
261 self.raw_tokens += estimate_tokens(&String::from_utf8_lossy(chunk));
267 if self.total <= self.collapse_threshold {
268 self.prefix.extend_from_slice(chunk);
269 self.prefix_lines += 1;
270 return;
271 }
272 if !self.prefix.is_empty() {
273 let prefix = std::mem::take(&mut self.prefix);
274 let mut rest: &[u8] = &prefix;
275 let mut line_no = 0usize;
276 while let Some(pos) = rest.iter().position(|b| *b == b'\n') {
277 let mut l = &rest[..pos];
278 if l.last() == Some(&b'\r') {
279 l = &l[..l.len() - 1];
280 }
281 line_no += 1;
282 self.feed(l, line_no);
283 rest = &rest[pos + 1..];
284 }
285 self.prefix_lines = 0;
286 }
287 self.feed(line, self.total);
288 }
289
290 fn feed(&mut self, line: &[u8], line_no: usize) {
293 let text = String::from_utf8_lossy(line);
294 let matched = self.matcher.is_match(&text);
295 if line_no <= self.head_lines {
296 self.head.push(text.into_owned());
297 } else {
298 self.ring.push_back((text.into_owned(), matched));
299 if self.ring.len() > self.tail_lines {
300 let (l, m) = self.ring.pop_front().expect("ring not empty");
301 if m {
302 self.kept_mid.push((self.run, l));
303 self.run = 0;
304 } else {
305 self.run += 1;
306 }
307 }
308 }
309 }
310}
311
312fn omit_marker(n: usize) -> String {
313 format!("... [{n} lines omitted]")
314}
315
316fn saved_pct(original: usize, compressed: usize) -> u32 {
317 if original == 0 {
318 return 0;
319 }
320 ((original - compressed.min(original)) * 100 / original) as u32
321}
322
323struct KeepMatcher {
327 patterns: Vec<Regex>,
328}
329
330impl KeepMatcher {
331 fn new(options: &CompressOptions) -> Result<Self, ExecError> {
332 let mut patterns = Vec::with_capacity(options.keep_patterns.len());
333 for pattern in &options.keep_patterns {
334 patterns.push(
335 RegexBuilder::new(pattern)
336 .case_insensitive(true)
337 .build()
338 .map_err(|e| ExecError::InvalidPattern {
339 pattern: pattern.clone(),
340 message: e.to_string(),
341 })?,
342 );
343 }
344 Ok(Self { patterns })
345 }
346
347 fn is_match(&self, line: &str) -> bool {
348 self.patterns.iter().any(|re| re.is_match(line))
349 }
350}