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 {
89 text.len() / 4
90}
91
92pub fn compress(output: &str, options: &CompressOptions) -> Result<CompressResult, ExecError> {
100 let lines: Vec<&str> = output.lines().collect();
101 let total = lines.len();
102 let original_tokens = estimate_tokens(output);
103 let matcher = KeepMatcher::new(options)?;
104
105 if total <= options.collapse_threshold {
106 return Ok(CompressResult {
107 text: output.to_string(),
108 stats: CompressStats {
109 total_lines: total,
110 kept_lines: total,
111 omitted_lines: 0,
112 original_tokens,
113 compressed_tokens: original_tokens,
114 saved_percent: 0,
115 },
116 });
117 }
118
119 let mut kept = Vec::with_capacity(total);
120 for (i, line) in lines.iter().enumerate() {
121 let in_summary = i < options.head_lines || i >= total.saturating_sub(options.tail_lines);
122 kept.push(in_summary || matcher.is_match(line));
123 }
124 let kept_lines = kept.iter().filter(|k| **k).count();
125
126 let text = render(&lines, &kept);
127 let compressed_tokens = estimate_tokens(&text);
128 let saved_percent = saved_pct(original_tokens, compressed_tokens);
129
130 Ok(CompressResult {
131 text,
132 stats: CompressStats {
133 total_lines: total,
134 kept_lines,
135 omitted_lines: total - kept_lines,
136 original_tokens,
137 compressed_tokens,
138 saved_percent,
139 },
140 })
141}
142
143fn render(lines: &[&str], kept: &[bool]) -> String {
145 let mut out = String::new();
146 let mut omitted = 0usize;
147 let mut first = true;
148 for (i, line) in lines.iter().enumerate() {
149 if kept[i] {
150 if omitted > 0 {
151 if !first {
152 out.push('\n');
153 }
154 out.push_str(&omit_marker(omitted));
155 omitted = 0;
156 first = false;
157 }
158 if !first {
159 out.push('\n');
160 }
161 out.push_str(line);
162 first = false;
163 } else {
164 omitted += 1;
165 }
166 }
167 if omitted > 0 {
168 if !first {
169 out.push('\n');
170 }
171 out.push_str(&omit_marker(omitted));
172 }
173 out
174}
175
176fn omit_marker(n: usize) -> String {
177 format!("... [{n} lines omitted]")
178}
179
180fn saved_pct(original: usize, compressed: usize) -> u32 {
181 if original == 0 {
182 return 0;
183 }
184 ((original - compressed.min(original)) * 100 / original) as u32
185}
186
187struct KeepMatcher {
191 patterns: Vec<Regex>,
192}
193
194impl KeepMatcher {
195 fn new(options: &CompressOptions) -> Result<Self, ExecError> {
196 let mut patterns = Vec::with_capacity(options.keep_patterns.len());
197 for pattern in &options.keep_patterns {
198 patterns.push(
199 RegexBuilder::new(pattern)
200 .case_insensitive(true)
201 .build()
202 .map_err(|e| ExecError::InvalidPattern {
203 pattern: pattern.clone(),
204 message: e.to_string(),
205 })?,
206 );
207 }
208 Ok(Self { patterns })
209 }
210
211 fn is_match(&self, line: &str) -> bool {
212 self.patterns.iter().any(|re| re.is_match(line))
213 }
214}