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