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