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    None
51}
52
53fn compress_build(output: &str) -> String {
54    let mut crate_count = 0u32;
55    let mut errors = Vec::new();
56    let mut warnings = 0u32;
57    let mut time = String::new();
58
59    for line in output.lines() {
60        if compiling_re().is_match(line) {
61            crate_count += 1;
62        }
63        if let Some(caps) = error_re().captures(line) {
64            errors.push(format!("E{}: {}", &caps[1], &caps[2]));
65        }
66        if warning_re().is_match(line) && !line.contains("generated") {
67            warnings += 1;
68        }
69        if let Some(caps) = finished_re().captures(line) {
70            time = caps[1].to_string();
71        }
72    }
73
74    let mut parts = Vec::new();
75    if crate_count > 0 {
76        parts.push(format!("compiled {crate_count} crates"));
77    }
78    if !errors.is_empty() {
79        parts.push(format!("{} errors:", errors.len()));
80        for e in &errors {
81            parts.push(format!("  {e}"));
82        }
83    }
84    if warnings > 0 {
85        parts.push(format!("{warnings} warnings"));
86    }
87    if !time.is_empty() {
88        parts.push(format!("({time})"));
89    }
90
91    if parts.is_empty() {
92        return "ok".to_string();
93    }
94    parts.join("\n")
95}
96
97fn compress_test(output: &str) -> String {
98    let mut results = Vec::new();
99    let mut failed_tests = Vec::new();
100    let mut time = String::new();
101
102    for line in output.lines() {
103        if let Some(caps) = test_result_re().captures(line) {
104            results.push(format!(
105                "{}: {} pass, {} fail, {} skip",
106                &caps[1], &caps[2], &caps[3], &caps[4]
107            ));
108        }
109        if line.contains("FAILED") && line.contains("---") {
110            let name = line.split_whitespace().nth(1).unwrap_or("?");
111            failed_tests.push(name.to_string());
112        }
113        if let Some(caps) = finished_re().captures(line) {
114            time = caps[1].to_string();
115        }
116    }
117
118    let mut parts = Vec::new();
119    if !results.is_empty() {
120        parts.extend(results);
121    }
122    if !failed_tests.is_empty() {
123        parts.push(format!("failed: {}", failed_tests.join(", ")));
124    }
125    if !time.is_empty() {
126        parts.push(format!("({time})"));
127    }
128
129    if parts.is_empty() {
130        return "ok".to_string();
131    }
132    parts.join("\n")
133}
134
135fn compress_clippy(output: &str) -> String {
136    let mut warnings = Vec::new();
137    let mut errors = Vec::new();
138
139    for line in output.lines() {
140        if let Some(caps) = error_re().captures(line) {
141            errors.push(caps[2].to_string());
142        } else if let Some(caps) = warning_re().captures(line) {
143            let msg = &caps[1];
144            if !msg.contains("generated") && !msg.starts_with('`') {
145                warnings.push(msg.to_string());
146            }
147        }
148    }
149
150    let mut parts = Vec::new();
151    if !errors.is_empty() {
152        parts.push(format!("{} errors: {}", errors.len(), errors.join("; ")));
153    }
154    if !warnings.is_empty() {
155        parts.push(format!("{} warnings", warnings.len()));
156    }
157
158    if parts.is_empty() {
159        return "clean".to_string();
160    }
161    parts.join("\n")
162}
163
164fn compress_doc(output: &str) -> String {
165    let mut crate_count = 0u32;
166    let mut warnings = 0u32;
167    let mut time = String::new();
168
169    for line in output.lines() {
170        if line.contains("Documenting ") || compiling_re().is_match(line) {
171            crate_count += 1;
172        }
173        if warning_re().is_match(line) && !line.contains("generated") {
174            warnings += 1;
175        }
176        if let Some(caps) = finished_re().captures(line) {
177            time = caps[1].to_string();
178        }
179    }
180
181    let mut parts = Vec::new();
182    if crate_count > 0 {
183        parts.push(format!("documented {crate_count} crates"));
184    }
185    if warnings > 0 {
186        parts.push(format!("{warnings} warnings"));
187    }
188    if !time.is_empty() {
189        parts.push(format!("({time})"));
190    }
191    if parts.is_empty() {
192        "ok".to_string()
193    } else {
194        parts.join("\n")
195    }
196}
197
198fn compress_tree(output: &str) -> String {
199    let lines: Vec<&str> = output.lines().collect();
200    if lines.len() <= 20 {
201        return output.to_string();
202    }
203
204    let direct: Vec<&str> = lines
205        .iter()
206        .filter(|l| !l.starts_with(' ') || l.starts_with("├── ") || l.starts_with("└── "))
207        .copied()
208        .collect();
209
210    if direct.is_empty() {
211        let shown = &lines[..20.min(lines.len())];
212        return format!(
213            "{}\n... ({} more lines)",
214            shown.join("\n"),
215            lines.len() - 20
216        );
217    }
218
219    format!(
220        "{} direct deps ({} total lines):\n{}",
221        direct.len(),
222        lines.len(),
223        direct.join("\n")
224    )
225}
226
227fn compress_fmt(output: &str) -> String {
228    let trimmed = output.trim();
229    if trimmed.is_empty() {
230        return "ok (formatted)".to_string();
231    }
232
233    let diffs: Vec<&str> = trimmed
234        .lines()
235        .filter(|l| l.starts_with("Diff in ") || l.starts_with("  --> "))
236        .collect();
237
238    if !diffs.is_empty() {
239        return format!("{} formatting issues:\n{}", diffs.len(), diffs.join("\n"));
240    }
241
242    let lines: Vec<&str> = trimmed.lines().filter(|l| !l.trim().is_empty()).collect();
243    if lines.len() <= 5 {
244        lines.join("\n")
245    } else {
246        format!(
247            "{}\n... ({} more lines)",
248            lines[..5].join("\n"),
249            lines.len() - 5
250        )
251    }
252}
253
254fn compress_update(output: &str) -> String {
255    let mut updated = Vec::new();
256    let mut unchanged = 0u32;
257
258    for line in output.lines() {
259        let trimmed = line.trim();
260        if trimmed.starts_with("Updating ") || trimmed.starts_with("    Updating ") {
261            updated.push(trimmed.trim_start_matches("    ").to_string());
262        } else if trimmed.starts_with("Unchanged ") || trimmed.contains("Unchanged") {
263            unchanged += 1;
264        }
265    }
266
267    if updated.is_empty() && unchanged == 0 {
268        let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
269        if lines.is_empty() {
270            return "ok (up-to-date)".to_string();
271        }
272        if lines.len() <= 5 {
273            return lines.join("\n");
274        }
275        return format!(
276            "{}\n... ({} more lines)",
277            lines[..5].join("\n"),
278            lines.len() - 5
279        );
280    }
281
282    let mut parts = Vec::new();
283    if !updated.is_empty() {
284        parts.push(format!("{} updated:", updated.len()));
285        for u in updated.iter().take(15) {
286            parts.push(format!("  {u}"));
287        }
288        if updated.len() > 15 {
289            parts.push(format!("  ... +{} more", updated.len() - 15));
290        }
291    }
292    if unchanged > 0 {
293        parts.push(format!("{unchanged} unchanged"));
294    }
295    parts.join("\n")
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn cargo_build_success() {
304        let output = "   Compiling lean-ctx v2.1.1\n    Finished release profile [optimized] target(s) in 30.5s";
305        let result = compress("cargo build", output).unwrap();
306        assert!(result.contains("compiled"), "should mention compilation");
307        assert!(result.contains("30.5s"), "should include build time");
308    }
309
310    #[test]
311    fn cargo_build_with_errors() {
312        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";
313        let result = compress("cargo build", output).unwrap();
314        assert!(result.contains("E0308"), "should contain error code");
315    }
316
317    #[test]
318    fn cargo_test_success() {
319        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";
320        let result = compress("cargo test", output).unwrap();
321        assert!(result.contains("5 pass"), "should show passed count");
322    }
323
324    #[test]
325    fn cargo_test_failure() {
326        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";
327        let result = compress("cargo test", output).unwrap();
328        assert!(result.contains("FAIL"), "should indicate failure");
329    }
330
331    #[test]
332    fn cargo_clippy_clean() {
333        let output = "    Checking lean-ctx v2.1.1\n    Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.2s";
334        let result = compress("cargo clippy", output).unwrap();
335        assert!(result.contains("clean"), "clean clippy should say clean");
336    }
337
338    #[test]
339    fn cargo_check_routes_to_build() {
340        let output = "    Checking lean-ctx v2.1.1\n    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.1s";
341        let result = compress("cargo check", output);
342        assert!(
343            result.is_some(),
344            "cargo check should route to build compressor"
345        );
346    }
347}