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