1macro_rules! static_regex {
2 ($pattern:expr) => {{
3 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
4 RE.get_or_init(|| {
5 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
6 })
7 }};
8}
9
10fn compiling_re() -> &'static regex::Regex {
11 static_regex!(r"Compiling (\S+) v(\S+)")
12}
13fn error_re() -> &'static regex::Regex {
14 static_regex!(r"error\[E(\d+)\]: (.+)")
15}
16fn warning_re() -> &'static regex::Regex {
17 static_regex!(r"warning: (.+)")
18}
19fn test_result_re() -> &'static regex::Regex {
20 static_regex!(r"test result: (\w+)\. (\d+) passed; (\d+) failed; (\d+) ignored")
21}
22fn finished_re() -> &'static regex::Regex {
23 static_regex!(r"Finished .+ in (\d+\.?\d*s)")
24}
25
26pub fn compress(command: &str, output: &str) -> Option<String> {
27 if command.contains("build") || command.contains("check") {
28 return Some(compress_build(output));
29 }
30 if command.contains("test") {
31 return Some(compress_test(output));
32 }
33 if command.contains("clippy") {
34 return Some(compress_clippy(output));
35 }
36 if command.contains("doc") {
37 return Some(compress_doc(output));
38 }
39 if command.contains("tree") {
40 return Some(compress_tree(output));
41 }
42 if command.contains("fmt") {
43 return Some(compress_fmt(output));
44 }
45 if command.contains("update") {
46 return Some(compress_update(output));
47 }
48 if command.contains("metadata") {
49 return Some(compress_metadata(output));
50 }
51 if command.contains("run") {
52 return Some(compress_run(output));
53 }
54 if command.contains("bench") {
55 return Some(compress_bench(output));
56 }
57 None
58}
59
60fn compress_build(output: &str) -> String {
61 let mut crate_count = 0u32;
62 let mut errors = Vec::new();
63 let mut warnings = 0u32;
64 let mut time = String::new();
65
66 for line in output.lines() {
67 if compiling_re().is_match(line) {
68 crate_count += 1;
69 }
70 if let Some(caps) = error_re().captures(line) {
71 errors.push(format!("E{}: {}", &caps[1], &caps[2]));
72 }
73 if warning_re().is_match(line) && !line.contains("generated") {
74 warnings += 1;
75 }
76 if let Some(caps) = finished_re().captures(line) {
77 time = caps[1].to_string();
78 }
79 }
80
81 let mut parts = Vec::new();
82 if crate_count > 0 {
83 parts.push(format!("compiled {crate_count} crates"));
84 }
85 if !errors.is_empty() {
86 parts.push(format!("{} errors:", errors.len()));
87 for e in &errors {
88 parts.push(format!(" {e}"));
89 }
90 }
91 if warnings > 0 {
92 parts.push(format!("{warnings} warnings"));
93 }
94 if !time.is_empty() {
95 parts.push(format!("({time})"));
96 }
97
98 if parts.is_empty() {
99 return "ok".to_string();
100 }
101 parts.join("\n")
102}
103
104fn compress_test(output: &str) -> String {
105 let mut results = Vec::new();
106 let mut failed_tests = Vec::new();
107 let mut time = String::new();
108
109 for line in output.lines() {
110 if let Some(caps) = test_result_re().captures(line) {
111 results.push(format!(
112 "{}: {} pass, {} fail, {} skip",
113 &caps[1], &caps[2], &caps[3], &caps[4]
114 ));
115 }
116 if line.contains("FAILED") && line.contains("---") {
117 let name = line.split_whitespace().nth(1).unwrap_or("?");
118 failed_tests.push(name.to_string());
119 }
120 if let Some(caps) = finished_re().captures(line) {
121 time = caps[1].to_string();
122 }
123 }
124
125 let mut parts = Vec::new();
126 if !results.is_empty() {
127 parts.extend(results);
128 }
129 if !failed_tests.is_empty() {
130 parts.push(format!("failed: {}", failed_tests.join(", ")));
131 }
132 if !time.is_empty() {
133 parts.push(format!("({time})"));
134 }
135
136 if parts.is_empty() {
137 return "ok".to_string();
138 }
139 parts.join("\n")
140}
141
142fn compress_clippy(output: &str) -> String {
143 let mut warnings = Vec::new();
144 let mut errors = Vec::new();
145
146 for line in output.lines() {
147 if let Some(caps) = error_re().captures(line) {
148 errors.push(caps[2].to_string());
149 } else if let Some(caps) = warning_re().captures(line) {
150 let msg = &caps[1];
151 if !msg.contains("generated") && !msg.starts_with('`') {
152 warnings.push(msg.to_string());
153 }
154 }
155 }
156
157 let mut parts = Vec::new();
158 if !errors.is_empty() {
159 parts.push(format!("{} errors: {}", errors.len(), errors.join("; ")));
160 }
161 if !warnings.is_empty() {
162 parts.push(format!("{} warnings", warnings.len()));
163 }
164
165 if parts.is_empty() {
166 return "clean".to_string();
167 }
168 parts.join("\n")
169}
170
171fn compress_doc(output: &str) -> String {
172 let mut crate_count = 0u32;
173 let mut warnings = 0u32;
174 let mut time = String::new();
175
176 for line in output.lines() {
177 if line.contains("Documenting ") || compiling_re().is_match(line) {
178 crate_count += 1;
179 }
180 if warning_re().is_match(line) && !line.contains("generated") {
181 warnings += 1;
182 }
183 if let Some(caps) = finished_re().captures(line) {
184 time = caps[1].to_string();
185 }
186 }
187
188 let mut parts = Vec::new();
189 if crate_count > 0 {
190 parts.push(format!("documented {crate_count} crates"));
191 }
192 if warnings > 0 {
193 parts.push(format!("{warnings} warnings"));
194 }
195 if !time.is_empty() {
196 parts.push(format!("({time})"));
197 }
198 if parts.is_empty() {
199 "ok".to_string()
200 } else {
201 parts.join("\n")
202 }
203}
204
205fn compress_tree(output: &str) -> String {
206 let lines: Vec<&str> = output.lines().collect();
207 if lines.len() <= 20 {
208 return output.to_string();
209 }
210
211 let direct: Vec<&str> = lines
212 .iter()
213 .filter(|l| !l.starts_with(' ') || l.starts_with("├── ") || l.starts_with("└── "))
214 .copied()
215 .collect();
216
217 if direct.is_empty() {
218 let shown = &lines[..20.min(lines.len())];
219 return format!(
220 "{}\n... ({} more lines)",
221 shown.join("\n"),
222 lines.len() - 20
223 );
224 }
225
226 format!(
227 "{} direct deps ({} total lines):\n{}",
228 direct.len(),
229 lines.len(),
230 direct.join("\n")
231 )
232}
233
234fn compress_fmt(output: &str) -> String {
235 let trimmed = output.trim();
236 if trimmed.is_empty() {
237 return "ok (formatted)".to_string();
238 }
239
240 let diffs: Vec<&str> = trimmed
241 .lines()
242 .filter(|l| l.starts_with("Diff in ") || l.starts_with(" --> "))
243 .collect();
244
245 if !diffs.is_empty() {
246 return format!("{} formatting issues:\n{}", diffs.len(), diffs.join("\n"));
247 }
248
249 let lines: Vec<&str> = trimmed.lines().filter(|l| !l.trim().is_empty()).collect();
250 if lines.len() <= 5 {
251 lines.join("\n")
252 } else {
253 format!(
254 "{}\n... ({} more lines)",
255 lines[..5].join("\n"),
256 lines.len() - 5
257 )
258 }
259}
260
261fn compress_update(output: &str) -> String {
262 let mut updated = Vec::new();
263 let mut unchanged = 0u32;
264
265 for line in output.lines() {
266 let trimmed = line.trim();
267 if trimmed.starts_with("Updating ") || trimmed.starts_with(" Updating ") {
268 updated.push(trimmed.trim_start_matches(" ").to_string());
269 } else if trimmed.starts_with("Unchanged ") || trimmed.contains("Unchanged") {
270 unchanged += 1;
271 }
272 }
273
274 if updated.is_empty() && unchanged == 0 {
275 let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
276 if lines.is_empty() {
277 return "ok (up-to-date)".to_string();
278 }
279 if lines.len() <= 5 {
280 return lines.join("\n");
281 }
282 return format!(
283 "{}\n... ({} more lines)",
284 lines[..5].join("\n"),
285 lines.len() - 5
286 );
287 }
288
289 let mut parts = Vec::new();
290 if !updated.is_empty() {
291 parts.push(format!("{} updated:", updated.len()));
292 for u in updated.iter().take(15) {
293 parts.push(format!(" {u}"));
294 }
295 if updated.len() > 15 {
296 parts.push(format!(" ... +{} more", updated.len() - 15));
297 }
298 }
299 if unchanged > 0 {
300 parts.push(format!("{unchanged} unchanged"));
301 }
302 parts.join("\n")
303}
304
305fn compress_run(output: &str) -> String {
306 let mut program_lines = Vec::new();
307 let mut compiling = 0u32;
308 let mut time = String::new();
309
310 for line in output.lines() {
311 let trimmed = line.trim();
312 if compiling_re().is_match(trimmed) || trimmed.starts_with("Compiling ") {
313 compiling += 1;
314 continue;
315 }
316 if trimmed.starts_with("Downloading ")
317 || trimmed.starts_with("Downloaded ")
318 || trimmed.starts_with("Blocking waiting")
319 || trimmed.starts_with("Locking ")
320 {
321 continue;
322 }
323 if trimmed.starts_with("Running `") || trimmed.starts_with("Running ") {
324 continue;
325 }
326 if let Some(caps) = finished_re().captures(trimmed) {
327 time = caps[1].to_string();
328 continue;
329 }
330 program_lines.push(line);
331 }
332
333 let mut result = String::new();
334 if compiling > 0 {
335 result.push_str(&format!("(compiled {compiling} crates"));
336 if !time.is_empty() {
337 result.push_str(&format!(", {time}"));
338 }
339 result.push_str(")\n");
340 }
341
342 if program_lines.len() <= 50 {
343 result.push_str(&program_lines.join("\n"));
344 } else {
345 result.push_str(&program_lines[..25].join("\n"));
346 result.push_str(&format!(
347 "\n... ({} lines omitted)\n",
348 program_lines.len() - 50
349 ));
350 result.push_str(&program_lines[program_lines.len() - 25..].join("\n"));
351 }
352
353 if result.trim().is_empty() {
354 return "ok".to_string();
355 }
356 result
357}
358
359fn compress_bench(output: &str) -> String {
360 let mut compiling = 0u32;
361 let mut bench_results = Vec::new();
362 let mut time = String::new();
363 let mut errors = Vec::new();
364
365 for line in output.lines() {
366 let trimmed = line.trim();
367 if compiling_re().is_match(trimmed) || trimmed.starts_with("Compiling ") {
368 compiling += 1;
369 continue;
370 }
371 if trimmed.starts_with("Downloading ")
372 || trimmed.starts_with("Downloaded ")
373 || trimmed.starts_with("Blocking waiting")
374 || trimmed.starts_with("Locking ")
375 {
376 continue;
377 }
378 if trimmed.starts_with("Benchmarking ")
379 || trimmed.starts_with("Gnuplot ")
380 || trimmed.starts_with("Collecting ")
381 || trimmed.starts_with("Warming up")
382 || trimmed.starts_with("Analyzing ")
383 {
384 continue;
385 }
386 if trimmed.starts_with("Running ") && trimmed.contains("target") {
387 continue;
388 }
389 if let Some(caps) = finished_re().captures(trimmed) {
390 time = caps[1].to_string();
391 continue;
392 }
393 if let Some(caps) = error_re().captures(trimmed) {
394 errors.push(format!("E{}: {}", &caps[1], &caps[2]));
395 continue;
396 }
397 if trimmed.starts_with("test ") && trimmed.contains("bench:") {
398 bench_results.push(trimmed.to_string());
399 continue;
400 }
401 if trimmed.contains("time:") || trimmed.contains("thrpt:") {
402 bench_results.push(trimmed.to_string());
403 continue;
404 }
405 if let Some(caps) = test_result_re().captures(trimmed) {
406 bench_results.push(format!(
407 "{}: {} pass, {} fail, {} skip",
408 &caps[1], &caps[2], &caps[3], &caps[4]
409 ));
410 }
411 }
412
413 let mut parts = Vec::new();
414
415 if !errors.is_empty() {
416 parts.push(format!("{} errors:", errors.len()));
417 for e in &errors {
418 parts.push(format!(" {e}"));
419 }
420 return parts.join("\n");
421 }
422
423 if compiling > 0 {
424 let mut header = format!("compiled {compiling} crates");
425 if !time.is_empty() {
426 header.push_str(&format!(" ({time})"));
427 }
428 parts.push(header);
429 }
430
431 if bench_results.is_empty() {
432 parts.push("no benchmark results captured".to_string());
433 } else {
434 parts.push(format!("{} benchmarks:", bench_results.len()));
435 for b in &bench_results {
436 parts.push(format!(" {b}"));
437 }
438 }
439
440 if parts.is_empty() {
441 return "ok".to_string();
442 }
443 parts.join("\n")
444}
445
446fn compress_metadata(output: &str) -> String {
447 let parsed: Result<serde_json::Value, _> = serde_json::from_str(output);
448 let Ok(json) = parsed else {
449 let lines: Vec<&str> = output.lines().collect();
450 if lines.len() <= 20 {
451 return output.to_string();
452 }
453 return format!(
454 "{}\n... ({} more lines, non-JSON metadata)",
455 lines[..10].join("\n"),
456 lines.len() - 10
457 );
458 };
459
460 let mut parts = Vec::new();
461
462 if let Some(workspace_members) = json.get("workspace_members").and_then(|v| v.as_array()) {
463 parts.push(format!("workspace_members: {}", workspace_members.len()));
464 for m in workspace_members.iter().take(20) {
465 if let Some(s) = m.as_str() {
466 let short = s.split(' ').take(2).collect::<Vec<_>>().join(" ");
467 parts.push(format!(" {short}"));
468 }
469 }
470 if workspace_members.len() > 20 {
471 parts.push(format!(" ... +{} more", workspace_members.len() - 20));
472 }
473 }
474
475 if let Some(target_dir) = json.get("target_directory").and_then(|v| v.as_str()) {
476 parts.push(format!("target_directory: {target_dir}"));
477 }
478
479 if let Some(workspace_root) = json.get("workspace_root").and_then(|v| v.as_str()) {
480 parts.push(format!("workspace_root: {workspace_root}"));
481 }
482
483 if let Some(packages) = json.get("packages").and_then(|v| v.as_array()) {
484 parts.push(format!("packages: {}", packages.len()));
485 for pkg in packages.iter().take(30) {
486 let name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("?");
487 let version = pkg.get("version").and_then(|v| v.as_str()).unwrap_or("?");
488 let features: Vec<&str> = pkg
489 .get("features")
490 .and_then(|v| v.as_object())
491 .map(|f| f.keys().map(std::string::String::as_str).collect())
492 .unwrap_or_default();
493 if features.is_empty() {
494 parts.push(format!(" {name} v{version}"));
495 } else {
496 parts.push(format!(
497 " {name} v{version} [features: {}]",
498 features.join(", ")
499 ));
500 }
501 }
502 if packages.len() > 30 {
503 parts.push(format!(" ... +{} more", packages.len() - 30));
504 }
505 }
506
507 if let Some(resolve) = json.get("resolve") {
508 if let Some(nodes) = resolve.get("nodes").and_then(|v| v.as_array()) {
509 let total_deps: usize = nodes
510 .iter()
511 .map(|n| {
512 n.get("deps")
513 .and_then(|v| v.as_array())
514 .map_or(0, std::vec::Vec::len)
515 })
516 .sum();
517 parts.push(format!(
518 "resolve: {} nodes, {} dep edges",
519 nodes.len(),
520 total_deps
521 ));
522 }
523 }
524
525 if parts.is_empty() {
526 "cargo metadata: ok (empty)".to_string()
527 } else {
528 parts.join("\n")
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn cargo_build_success() {
538 let output = " Compiling lean-ctx v2.1.1\n Finished release profile [optimized] target(s) in 30.5s";
539 let result = compress("cargo build", output).unwrap();
540 assert!(result.contains("compiled"), "should mention compilation");
541 assert!(result.contains("30.5s"), "should include build time");
542 }
543
544 #[test]
545 fn cargo_build_with_errors() {
546 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";
547 let result = compress("cargo build", output).unwrap();
548 assert!(result.contains("E0308"), "should contain error code");
549 }
550
551 #[test]
552 fn cargo_test_success() {
553 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";
554 let result = compress("cargo test", output).unwrap();
555 assert!(result.contains("5 pass"), "should show passed count");
556 }
557
558 #[test]
559 fn cargo_test_failure() {
560 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";
561 let result = compress("cargo test", output).unwrap();
562 assert!(result.contains("FAIL"), "should indicate failure");
563 }
564
565 #[test]
566 fn cargo_clippy_clean() {
567 let output = " Checking lean-ctx v2.1.1\n Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.2s";
568 let result = compress("cargo clippy", output).unwrap();
569 assert!(result.contains("clean"), "clean clippy should say clean");
570 }
571
572 #[test]
573 fn cargo_check_routes_to_build() {
574 let output = " Checking lean-ctx v2.1.1\n Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.1s";
575 let result = compress("cargo check", output);
576 assert!(
577 result.is_some(),
578 "cargo check should route to build compressor"
579 );
580 }
581
582 #[test]
583 fn cargo_metadata_json() {
584 let json = r#"{
585 "packages": [
586 {"name": "lean-ctx", "version": "3.2.9", "features": {"tree-sitter": ["dep:tree-sitter"]}},
587 {"name": "serde", "version": "1.0.200", "features": {"derive": ["serde_derive"]}}
588 ],
589 "workspace_members": ["lean-ctx 3.2.9 (path+file:///foo)"],
590 "workspace_root": "/foo",
591 "target_directory": "/foo/target",
592 "resolve": {
593 "nodes": [
594 {"id": "lean-ctx", "deps": [{"name": "serde"}]},
595 {"id": "serde", "deps": []}
596 ]
597 }
598 }"#;
599 let result = compress("cargo metadata", json).unwrap();
600 assert!(
601 result.contains("workspace_members: 1"),
602 "should list workspace members"
603 );
604 assert!(result.contains("packages: 2"), "should list packages");
605 assert!(
606 result.contains("resolve: 2 nodes"),
607 "should summarize resolve graph"
608 );
609 assert!(
610 result.len() < json.len(),
611 "compressed output should be shorter"
612 );
613 }
614
615 #[test]
616 fn cargo_run_strips_compilation() {
617 let output = " Compiling lean-ctx v2.1.1\n Finished `dev` profile [unoptimized] target(s) in 5.2s\n Running `target/debug/lean-ctx`\nHello, world!\nResult: 42";
618 let result = compress("cargo run", output).unwrap();
619 assert!(
620 !result.contains("Running `target"),
621 "should strip Running line"
622 );
623 assert!(
624 result.contains("Hello, world!"),
625 "should keep program output"
626 );
627 assert!(result.contains("compiled"), "should summarize compilation");
628 }
629
630 #[test]
631 fn cargo_bench_keeps_results() {
632 let output = " Compiling lean-ctx v2.1.1\n Finished `bench` profile [optimized] target(s) in 12.0s\n Running benches/main.rs\ntest bench_parse ... bench: 1,234 ns/iter (+/- 56)\ntest bench_render ... bench: 5,678 ns/iter (+/- 123)\n\ntest result: ok. 0 passed; 0 failed; 2 ignored";
633 let result = compress("cargo bench", output).unwrap();
634 assert!(result.contains("bench_parse"), "should keep bench results");
635 assert!(result.contains("bench_render"), "should keep bench results");
636 assert!(result.contains("compiled"), "should summarize compilation");
637 }
638
639 #[test]
640 fn cargo_bench_with_criterion() {
641 let output = " Compiling bench-suite v0.1.0\nBenchmarking parser/parse_large\nCollecting 100 samples\nWarming up for 3.0000 s\nAnalyzing results...\nparser/parse_large time: [1.2345 ms 1.3000 ms 1.3500 ms]";
642 let result = compress("cargo bench", output).unwrap();
643 assert!(
644 result.contains("time:"),
645 "should keep criterion timing lines"
646 );
647 assert!(!result.contains("Collecting"), "should strip progress");
648 }
649
650 #[test]
651 fn cargo_metadata_non_json() {
652 let output = "error: `cargo metadata` exited with an error\nsome detailed error";
653 let result = compress("cargo metadata", output).unwrap();
654 assert!(
655 result.contains("error"),
656 "should pass through non-JSON output"
657 );
658 }
659}