Skip to main content

lean_ctx/core/patterns/
cargo.rs

1use regex::Regex;
2use std::sync::OnceLock;
3
4static COMPILING_RE: OnceLock<Regex> = OnceLock::new();
5static ERROR_RE: OnceLock<Regex> = OnceLock::new();
6static WARNING_RE: OnceLock<Regex> = OnceLock::new();
7static TEST_RESULT_RE: OnceLock<Regex> = OnceLock::new();
8static FINISHED_RE: OnceLock<Regex> = OnceLock::new();
9
10fn compiling_re() -> &'static Regex {
11    COMPILING_RE.get_or_init(|| Regex::new(r"Compiling (\S+) v(\S+)").unwrap())
12}
13fn error_re() -> &'static Regex {
14    ERROR_RE.get_or_init(|| Regex::new(r"error\[E(\d+)\]: (.+)").unwrap())
15}
16fn warning_re() -> &'static Regex {
17    WARNING_RE.get_or_init(|| Regex::new(r"warning: (.+)").unwrap())
18}
19fn test_result_re() -> &'static Regex {
20    TEST_RESULT_RE.get_or_init(|| {
21        Regex::new(r"test result: (\w+)\. (\d+) passed; (\d+) failed; (\d+) ignored").unwrap()
22    })
23}
24fn finished_re() -> &'static Regex {
25    FINISHED_RE.get_or_init(|| Regex::new(r"Finished .+ in (\d+\.?\d*s)").unwrap())
26}
27
28pub fn compress(command: &str, output: &str) -> Option<String> {
29    if command.contains("build") || command.contains("check") {
30        return Some(compress_build(output));
31    }
32    if command.contains("test") {
33        return Some(compress_test(output));
34    }
35    if command.contains("clippy") {
36        return Some(compress_clippy(output));
37    }
38    if command.contains("doc") {
39        return Some(compress_doc(output));
40    }
41    if command.contains("tree") {
42        return Some(compress_tree(output));
43    }
44    if command.contains("fmt") {
45        return Some(compress_fmt(output));
46    }
47    if command.contains("update") {
48        return Some(compress_update(output));
49    }
50    if command.contains("metadata") {
51        return Some(compress_metadata(output));
52    }
53    None
54}
55
56fn compress_build(output: &str) -> String {
57    let mut crate_count = 0u32;
58    let mut errors = Vec::new();
59    let mut warnings = 0u32;
60    let mut time = String::new();
61
62    for line in output.lines() {
63        if compiling_re().is_match(line) {
64            crate_count += 1;
65        }
66        if let Some(caps) = error_re().captures(line) {
67            errors.push(format!("E{}: {}", &caps[1], &caps[2]));
68        }
69        if warning_re().is_match(line) && !line.contains("generated") {
70            warnings += 1;
71        }
72        if let Some(caps) = finished_re().captures(line) {
73            time = caps[1].to_string();
74        }
75    }
76
77    let mut parts = Vec::new();
78    if crate_count > 0 {
79        parts.push(format!("compiled {crate_count} crates"));
80    }
81    if !errors.is_empty() {
82        parts.push(format!("{} errors:", errors.len()));
83        for e in &errors {
84            parts.push(format!("  {e}"));
85        }
86    }
87    if warnings > 0 {
88        parts.push(format!("{warnings} warnings"));
89    }
90    if !time.is_empty() {
91        parts.push(format!("({time})"));
92    }
93
94    if parts.is_empty() {
95        return "ok".to_string();
96    }
97    parts.join("\n")
98}
99
100fn compress_test(output: &str) -> String {
101    let mut results = Vec::new();
102    let mut failed_tests = Vec::new();
103    let mut time = String::new();
104
105    for line in output.lines() {
106        if let Some(caps) = test_result_re().captures(line) {
107            results.push(format!(
108                "{}: {} pass, {} fail, {} skip",
109                &caps[1], &caps[2], &caps[3], &caps[4]
110            ));
111        }
112        if line.contains("FAILED") && line.contains("---") {
113            let name = line.split_whitespace().nth(1).unwrap_or("?");
114            failed_tests.push(name.to_string());
115        }
116        if let Some(caps) = finished_re().captures(line) {
117            time = caps[1].to_string();
118        }
119    }
120
121    let mut parts = Vec::new();
122    if !results.is_empty() {
123        parts.extend(results);
124    }
125    if !failed_tests.is_empty() {
126        parts.push(format!("failed: {}", failed_tests.join(", ")));
127    }
128    if !time.is_empty() {
129        parts.push(format!("({time})"));
130    }
131
132    if parts.is_empty() {
133        return "ok".to_string();
134    }
135    parts.join("\n")
136}
137
138fn compress_clippy(output: &str) -> String {
139    let mut warnings = Vec::new();
140    let mut errors = Vec::new();
141
142    for line in output.lines() {
143        if let Some(caps) = error_re().captures(line) {
144            errors.push(caps[2].to_string());
145        } else if let Some(caps) = warning_re().captures(line) {
146            let msg = &caps[1];
147            if !msg.contains("generated") && !msg.starts_with('`') {
148                warnings.push(msg.to_string());
149            }
150        }
151    }
152
153    let mut parts = Vec::new();
154    if !errors.is_empty() {
155        parts.push(format!("{} errors: {}", errors.len(), errors.join("; ")));
156    }
157    if !warnings.is_empty() {
158        parts.push(format!("{} warnings", warnings.len()));
159    }
160
161    if parts.is_empty() {
162        return "clean".to_string();
163    }
164    parts.join("\n")
165}
166
167fn compress_doc(output: &str) -> String {
168    let mut crate_count = 0u32;
169    let mut warnings = 0u32;
170    let mut time = String::new();
171
172    for line in output.lines() {
173        if line.contains("Documenting ") || compiling_re().is_match(line) {
174            crate_count += 1;
175        }
176        if warning_re().is_match(line) && !line.contains("generated") {
177            warnings += 1;
178        }
179        if let Some(caps) = finished_re().captures(line) {
180            time = caps[1].to_string();
181        }
182    }
183
184    let mut parts = Vec::new();
185    if crate_count > 0 {
186        parts.push(format!("documented {crate_count} crates"));
187    }
188    if warnings > 0 {
189        parts.push(format!("{warnings} warnings"));
190    }
191    if !time.is_empty() {
192        parts.push(format!("({time})"));
193    }
194    if parts.is_empty() {
195        "ok".to_string()
196    } else {
197        parts.join("\n")
198    }
199}
200
201fn compress_tree(output: &str) -> String {
202    let lines: Vec<&str> = output.lines().collect();
203    if lines.len() <= 20 {
204        return output.to_string();
205    }
206
207    let direct: Vec<&str> = lines
208        .iter()
209        .filter(|l| !l.starts_with(' ') || l.starts_with("├── ") || l.starts_with("└── "))
210        .copied()
211        .collect();
212
213    if direct.is_empty() {
214        let shown = &lines[..20.min(lines.len())];
215        return format!(
216            "{}\n... ({} more lines)",
217            shown.join("\n"),
218            lines.len() - 20
219        );
220    }
221
222    format!(
223        "{} direct deps ({} total lines):\n{}",
224        direct.len(),
225        lines.len(),
226        direct.join("\n")
227    )
228}
229
230fn compress_fmt(output: &str) -> String {
231    let trimmed = output.trim();
232    if trimmed.is_empty() {
233        return "ok (formatted)".to_string();
234    }
235
236    let diffs: Vec<&str> = trimmed
237        .lines()
238        .filter(|l| l.starts_with("Diff in ") || l.starts_with("  --> "))
239        .collect();
240
241    if !diffs.is_empty() {
242        return format!("{} formatting issues:\n{}", diffs.len(), diffs.join("\n"));
243    }
244
245    let lines: Vec<&str> = trimmed.lines().filter(|l| !l.trim().is_empty()).collect();
246    if lines.len() <= 5 {
247        lines.join("\n")
248    } else {
249        format!(
250            "{}\n... ({} more lines)",
251            lines[..5].join("\n"),
252            lines.len() - 5
253        )
254    }
255}
256
257fn compress_update(output: &str) -> String {
258    let mut updated = Vec::new();
259    let mut unchanged = 0u32;
260
261    for line in output.lines() {
262        let trimmed = line.trim();
263        if trimmed.starts_with("Updating ") || trimmed.starts_with("    Updating ") {
264            updated.push(trimmed.trim_start_matches("    ").to_string());
265        } else if trimmed.starts_with("Unchanged ") || trimmed.contains("Unchanged") {
266            unchanged += 1;
267        }
268    }
269
270    if updated.is_empty() && unchanged == 0 {
271        let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
272        if lines.is_empty() {
273            return "ok (up-to-date)".to_string();
274        }
275        if lines.len() <= 5 {
276            return lines.join("\n");
277        }
278        return format!(
279            "{}\n... ({} more lines)",
280            lines[..5].join("\n"),
281            lines.len() - 5
282        );
283    }
284
285    let mut parts = Vec::new();
286    if !updated.is_empty() {
287        parts.push(format!("{} updated:", updated.len()));
288        for u in updated.iter().take(15) {
289            parts.push(format!("  {u}"));
290        }
291        if updated.len() > 15 {
292            parts.push(format!("  ... +{} more", updated.len() - 15));
293        }
294    }
295    if unchanged > 0 {
296        parts.push(format!("{unchanged} unchanged"));
297    }
298    parts.join("\n")
299}
300
301fn compress_metadata(output: &str) -> String {
302    let parsed: Result<serde_json::Value, _> = serde_json::from_str(output);
303    let json = match parsed {
304        Ok(v) => v,
305        Err(_) => {
306            let lines: Vec<&str> = output.lines().collect();
307            if lines.len() <= 20 {
308                return output.to_string();
309            }
310            return format!(
311                "{}\n... ({} more lines, non-JSON metadata)",
312                lines[..10].join("\n"),
313                lines.len() - 10
314            );
315        }
316    };
317
318    let mut parts = Vec::new();
319
320    if let Some(workspace_members) = json.get("workspace_members").and_then(|v| v.as_array()) {
321        parts.push(format!("workspace_members: {}", workspace_members.len()));
322        for m in workspace_members.iter().take(20) {
323            if let Some(s) = m.as_str() {
324                let short = s.split(' ').take(2).collect::<Vec<_>>().join(" ");
325                parts.push(format!("  {short}"));
326            }
327        }
328        if workspace_members.len() > 20 {
329            parts.push(format!("  ... +{} more", workspace_members.len() - 20));
330        }
331    }
332
333    if let Some(target_dir) = json.get("target_directory").and_then(|v| v.as_str()) {
334        parts.push(format!("target_directory: {target_dir}"));
335    }
336
337    if let Some(workspace_root) = json.get("workspace_root").and_then(|v| v.as_str()) {
338        parts.push(format!("workspace_root: {workspace_root}"));
339    }
340
341    if let Some(packages) = json.get("packages").and_then(|v| v.as_array()) {
342        parts.push(format!("packages: {}", packages.len()));
343        for pkg in packages.iter().take(30) {
344            let name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("?");
345            let version = pkg.get("version").and_then(|v| v.as_str()).unwrap_or("?");
346            let features: Vec<&str> = pkg
347                .get("features")
348                .and_then(|v| v.as_object())
349                .map(|f| f.keys().map(|k| k.as_str()).collect())
350                .unwrap_or_default();
351            if features.is_empty() {
352                parts.push(format!("  {name} v{version}"));
353            } else {
354                parts.push(format!(
355                    "  {name} v{version} [features: {}]",
356                    features.join(", ")
357                ));
358            }
359        }
360        if packages.len() > 30 {
361            parts.push(format!("  ... +{} more", packages.len() - 30));
362        }
363    }
364
365    if let Some(resolve) = json.get("resolve") {
366        if let Some(nodes) = resolve.get("nodes").and_then(|v| v.as_array()) {
367            let total_deps: usize = nodes
368                .iter()
369                .map(|n| {
370                    n.get("deps")
371                        .and_then(|v| v.as_array())
372                        .map(|a| a.len())
373                        .unwrap_or(0)
374                })
375                .sum();
376            parts.push(format!(
377                "resolve: {} nodes, {} dep edges",
378                nodes.len(),
379                total_deps
380            ));
381        }
382    }
383
384    if parts.is_empty() {
385        "cargo metadata: ok (empty)".to_string()
386    } else {
387        parts.join("\n")
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn cargo_build_success() {
397        let output = "   Compiling lean-ctx v2.1.1\n    Finished release profile [optimized] target(s) in 30.5s";
398        let result = compress("cargo build", output).unwrap();
399        assert!(result.contains("compiled"), "should mention compilation");
400        assert!(result.contains("30.5s"), "should include build time");
401    }
402
403    #[test]
404    fn cargo_build_with_errors() {
405        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";
406        let result = compress("cargo build", output).unwrap();
407        assert!(result.contains("E0308"), "should contain error code");
408    }
409
410    #[test]
411    fn cargo_test_success() {
412        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";
413        let result = compress("cargo test", output).unwrap();
414        assert!(result.contains("5 pass"), "should show passed count");
415    }
416
417    #[test]
418    fn cargo_test_failure() {
419        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";
420        let result = compress("cargo test", output).unwrap();
421        assert!(result.contains("FAIL"), "should indicate failure");
422    }
423
424    #[test]
425    fn cargo_clippy_clean() {
426        let output = "    Checking lean-ctx v2.1.1\n    Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.2s";
427        let result = compress("cargo clippy", output).unwrap();
428        assert!(result.contains("clean"), "clean clippy should say clean");
429    }
430
431    #[test]
432    fn cargo_check_routes_to_build() {
433        let output = "    Checking lean-ctx v2.1.1\n    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.1s";
434        let result = compress("cargo check", output);
435        assert!(
436            result.is_some(),
437            "cargo check should route to build compressor"
438        );
439    }
440
441    #[test]
442    fn cargo_metadata_json() {
443        let json = r#"{
444            "packages": [
445                {"name": "lean-ctx", "version": "3.2.9", "features": {"tree-sitter": ["dep:tree-sitter"]}},
446                {"name": "serde", "version": "1.0.200", "features": {"derive": ["serde_derive"]}}
447            ],
448            "workspace_members": ["lean-ctx 3.2.9 (path+file:///foo)"],
449            "workspace_root": "/foo",
450            "target_directory": "/foo/target",
451            "resolve": {
452                "nodes": [
453                    {"id": "lean-ctx", "deps": [{"name": "serde"}]},
454                    {"id": "serde", "deps": []}
455                ]
456            }
457        }"#;
458        let result = compress("cargo metadata", json).unwrap();
459        assert!(
460            result.contains("workspace_members: 1"),
461            "should list workspace members"
462        );
463        assert!(result.contains("packages: 2"), "should list packages");
464        assert!(
465            result.contains("resolve: 2 nodes"),
466            "should summarize resolve graph"
467        );
468        assert!(
469            result.len() < json.len(),
470            "compressed output should be shorter"
471        );
472    }
473
474    #[test]
475    fn cargo_metadata_non_json() {
476        let output = "error: `cargo metadata` exited with an error\nsome detailed error";
477        let result = compress("cargo metadata", output).unwrap();
478        assert!(
479            result.contains("error"),
480            "should pass through non-JSON output"
481        );
482    }
483}