lean_ctx/core/patterns/
cargo.rs1use regex::Regex;
2use std::sync::OnceLock;
3
4static COMPILING_RE: OnceLock<Regex> = OnceLock::new();
5static ERROR_RE: OnceLock<Regex> = OnceLock::new();
6static WARNING_RE: OnceLock<Regex> = OnceLock::new();
7static TEST_RESULT_RE: OnceLock<Regex> = OnceLock::new();
8static FINISHED_RE: OnceLock<Regex> = OnceLock::new();
9
10fn compiling_re() -> &'static Regex {
11 COMPILING_RE.get_or_init(|| Regex::new(r"Compiling (\S+) v(\S+)").unwrap())
12}
13fn error_re() -> &'static Regex {
14 ERROR_RE.get_or_init(|| Regex::new(r"error\[E(\d+)\]: (.+)").unwrap())
15}
16fn warning_re() -> &'static Regex {
17 WARNING_RE.get_or_init(|| Regex::new(r"warning: (.+)").unwrap())
18}
19fn test_result_re() -> &'static Regex {
20 TEST_RESULT_RE.get_or_init(|| {
21 Regex::new(r"test result: (\w+)\. (\d+) passed; (\d+) failed; (\d+) ignored").unwrap()
22 })
23}
24fn finished_re() -> &'static Regex {
25 FINISHED_RE.get_or_init(|| Regex::new(r"Finished .+ in (\d+\.?\d*s)").unwrap())
26}
27
28pub fn compress(command: &str, output: &str) -> Option<String> {
29 if command.contains("build") || command.contains("check") {
30 return Some(compress_build(output));
31 }
32 if command.contains("test") {
33 return Some(compress_test(output));
34 }
35 if command.contains("clippy") {
36 return Some(compress_clippy(output));
37 }
38 if command.contains("doc") {
39 return Some(compress_doc(output));
40 }
41 if command.contains("tree") {
42 return Some(compress_tree(output));
43 }
44 if command.contains("fmt") {
45 return Some(compress_fmt(output));
46 }
47 if command.contains("update") {
48 return Some(compress_update(output));
49 }
50 None
51}
52
53fn compress_build(output: &str) -> String {
54 let mut crate_count = 0u32;
55 let mut errors = Vec::new();
56 let mut warnings = 0u32;
57 let mut time = String::new();
58
59 for line in output.lines() {
60 if compiling_re().is_match(line) {
61 crate_count += 1;
62 }
63 if let Some(caps) = error_re().captures(line) {
64 errors.push(format!("E{}: {}", &caps[1], &caps[2]));
65 }
66 if warning_re().is_match(line) && !line.contains("generated") {
67 warnings += 1;
68 }
69 if let Some(caps) = finished_re().captures(line) {
70 time = caps[1].to_string();
71 }
72 }
73
74 let mut parts = Vec::new();
75 if crate_count > 0 {
76 parts.push(format!("compiled {crate_count} crates"));
77 }
78 if !errors.is_empty() {
79 parts.push(format!("{} errors:", errors.len()));
80 for e in &errors {
81 parts.push(format!(" {e}"));
82 }
83 }
84 if warnings > 0 {
85 parts.push(format!("{warnings} warnings"));
86 }
87 if !time.is_empty() {
88 parts.push(format!("({time})"));
89 }
90
91 if parts.is_empty() {
92 return "ok".to_string();
93 }
94 parts.join("\n")
95}
96
97fn compress_test(output: &str) -> String {
98 let mut results = Vec::new();
99 let mut failed_tests = Vec::new();
100 let mut time = String::new();
101
102 for line in output.lines() {
103 if let Some(caps) = test_result_re().captures(line) {
104 results.push(format!(
105 "{}: {} pass, {} fail, {} skip",
106 &caps[1], &caps[2], &caps[3], &caps[4]
107 ));
108 }
109 if line.contains("FAILED") && line.contains("---") {
110 let name = line.split_whitespace().nth(1).unwrap_or("?");
111 failed_tests.push(name.to_string());
112 }
113 if let Some(caps) = finished_re().captures(line) {
114 time = caps[1].to_string();
115 }
116 }
117
118 let mut parts = Vec::new();
119 if !results.is_empty() {
120 parts.extend(results);
121 }
122 if !failed_tests.is_empty() {
123 parts.push(format!("failed: {}", failed_tests.join(", ")));
124 }
125 if !time.is_empty() {
126 parts.push(format!("({time})"));
127 }
128
129 if parts.is_empty() {
130 return "ok".to_string();
131 }
132 parts.join("\n")
133}
134
135fn compress_clippy(output: &str) -> String {
136 let mut warnings = Vec::new();
137 let mut errors = Vec::new();
138
139 for line in output.lines() {
140 if let Some(caps) = error_re().captures(line) {
141 errors.push(caps[2].to_string());
142 } else if let Some(caps) = warning_re().captures(line) {
143 let msg = &caps[1];
144 if !msg.contains("generated") && !msg.starts_with('`') {
145 warnings.push(msg.to_string());
146 }
147 }
148 }
149
150 let mut parts = Vec::new();
151 if !errors.is_empty() {
152 parts.push(format!("{} errors: {}", errors.len(), errors.join("; ")));
153 }
154 if !warnings.is_empty() {
155 parts.push(format!("{} warnings", warnings.len()));
156 }
157
158 if parts.is_empty() {
159 return "clean".to_string();
160 }
161 parts.join("\n")
162}
163
164fn compress_doc(output: &str) -> String {
165 let mut crate_count = 0u32;
166 let mut warnings = 0u32;
167 let mut time = String::new();
168
169 for line in output.lines() {
170 if line.contains("Documenting ") || compiling_re().is_match(line) {
171 crate_count += 1;
172 }
173 if warning_re().is_match(line) && !line.contains("generated") {
174 warnings += 1;
175 }
176 if let Some(caps) = finished_re().captures(line) {
177 time = caps[1].to_string();
178 }
179 }
180
181 let mut parts = Vec::new();
182 if crate_count > 0 {
183 parts.push(format!("documented {crate_count} crates"));
184 }
185 if warnings > 0 {
186 parts.push(format!("{warnings} warnings"));
187 }
188 if !time.is_empty() {
189 parts.push(format!("({time})"));
190 }
191 if parts.is_empty() {
192 "ok".to_string()
193 } else {
194 parts.join("\n")
195 }
196}
197
198fn compress_tree(output: &str) -> String {
199 let lines: Vec<&str> = output.lines().collect();
200 if lines.len() <= 20 {
201 return output.to_string();
202 }
203
204 let direct: Vec<&str> = lines
205 .iter()
206 .filter(|l| !l.starts_with(' ') || l.starts_with("├── ") || l.starts_with("└── "))
207 .copied()
208 .collect();
209
210 if direct.is_empty() {
211 let shown = &lines[..20.min(lines.len())];
212 return format!(
213 "{}\n... ({} more lines)",
214 shown.join("\n"),
215 lines.len() - 20
216 );
217 }
218
219 format!(
220 "{} direct deps ({} total lines):\n{}",
221 direct.len(),
222 lines.len(),
223 direct.join("\n")
224 )
225}
226
227fn compress_fmt(output: &str) -> String {
228 let trimmed = output.trim();
229 if trimmed.is_empty() {
230 return "ok (formatted)".to_string();
231 }
232
233 let diffs: Vec<&str> = trimmed
234 .lines()
235 .filter(|l| l.starts_with("Diff in ") || l.starts_with(" --> "))
236 .collect();
237
238 if !diffs.is_empty() {
239 return format!("{} formatting issues:\n{}", diffs.len(), diffs.join("\n"));
240 }
241
242 let lines: Vec<&str> = trimmed.lines().filter(|l| !l.trim().is_empty()).collect();
243 if lines.len() <= 5 {
244 lines.join("\n")
245 } else {
246 format!(
247 "{}\n... ({} more lines)",
248 lines[..5].join("\n"),
249 lines.len() - 5
250 )
251 }
252}
253
254fn compress_update(output: &str) -> String {
255 let mut updated = Vec::new();
256 let mut unchanged = 0u32;
257
258 for line in output.lines() {
259 let trimmed = line.trim();
260 if trimmed.starts_with("Updating ") || trimmed.starts_with(" Updating ") {
261 updated.push(trimmed.trim_start_matches(" ").to_string());
262 } else if trimmed.starts_with("Unchanged ") || trimmed.contains("Unchanged") {
263 unchanged += 1;
264 }
265 }
266
267 if updated.is_empty() && unchanged == 0 {
268 let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
269 if lines.is_empty() {
270 return "ok (up-to-date)".to_string();
271 }
272 if lines.len() <= 5 {
273 return lines.join("\n");
274 }
275 return format!(
276 "{}\n... ({} more lines)",
277 lines[..5].join("\n"),
278 lines.len() - 5
279 );
280 }
281
282 let mut parts = Vec::new();
283 if !updated.is_empty() {
284 parts.push(format!("{} updated:", updated.len()));
285 for u in updated.iter().take(15) {
286 parts.push(format!(" {u}"));
287 }
288 if updated.len() > 15 {
289 parts.push(format!(" ... +{} more", updated.len() - 15));
290 }
291 }
292 if unchanged > 0 {
293 parts.push(format!("{unchanged} unchanged"));
294 }
295 parts.join("\n")
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn cargo_build_success() {
304 let output = " Compiling lean-ctx v2.1.1\n Finished release profile [optimized] target(s) in 30.5s";
305 let result = compress("cargo build", output).unwrap();
306 assert!(result.contains("compiled"), "should mention compilation");
307 assert!(result.contains("30.5s"), "should include build time");
308 }
309
310 #[test]
311 fn cargo_build_with_errors() {
312 let output = " Compiling lean-ctx v2.1.1\nerror[E0308]: mismatched types\n --> src/main.rs:10:5\n |\n10| 1 + \"hello\"\n | ^^^^^^^ expected integer, found &str";
313 let result = compress("cargo build", output).unwrap();
314 assert!(result.contains("E0308"), "should contain error code");
315 }
316
317 #[test]
318 fn cargo_test_success() {
319 let output = "running 5 tests\ntest test_one ... ok\ntest test_two ... ok\ntest test_three ... ok\ntest test_four ... ok\ntest test_five ... ok\n\ntest result: ok. 5 passed; 0 failed; 0 ignored";
320 let result = compress("cargo test", output).unwrap();
321 assert!(result.contains("5 pass"), "should show passed count");
322 }
323
324 #[test]
325 fn cargo_test_failure() {
326 let output = "running 3 tests\ntest test_ok ... ok\ntest test_fail ... FAILED\ntest test_ok2 ... ok\n\ntest result: FAILED. 2 passed; 1 failed; 0 ignored";
327 let result = compress("cargo test", output).unwrap();
328 assert!(result.contains("FAIL"), "should indicate failure");
329 }
330
331 #[test]
332 fn cargo_clippy_clean() {
333 let output = " Checking lean-ctx v2.1.1\n Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.2s";
334 let result = compress("cargo clippy", output).unwrap();
335 assert!(result.contains("clean"), "clean clippy should say clean");
336 }
337
338 #[test]
339 fn cargo_check_routes_to_build() {
340 let output = " Checking lean-ctx v2.1.1\n Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.1s";
341 let result = compress("cargo check", output);
342 assert!(
343 result.is_some(),
344 "cargo check should route to build compressor"
345 );
346 }
347}