Skip to main content

lean_ctx/core/patterns/
cargo.rs

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}