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