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 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}