Skip to main content

lean_ctx/core/patterns/
cargo.rs

1macro_rules! static_regex {
2    ($pattern:expr_2021) => {{
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 checking_re() -> &'static regex::Regex {
14    static_regex!(r"Checking (\S+) v(\S+)")
15}
16fn error_re() -> &'static regex::Regex {
17    static_regex!(r"error\[E(\d+)\]: (.+)")
18}
19fn warning_re() -> &'static regex::Regex {
20    static_regex!(r"warning(?:\[clippy::([^\]]+)\])?: (.+)")
21}
22fn generated_warnings_re() -> &'static regex::Regex {
23    static_regex!(r"generated (\d+) warnings?")
24}
25fn generic_error_re() -> &'static regex::Regex {
26    static_regex!(r"error(?:\[E\d+\])?: (.+)")
27}
28fn clippy_rule_re() -> &'static regex::Regex {
29    static_regex!(r"clippy::([A-Za-z0-9_-]+)")
30}
31fn failed_test_re() -> &'static regex::Regex {
32    static_regex!(r"^test (.+) \.\.\. FAILED$")
33}
34fn failed_test_header_re() -> &'static regex::Regex {
35    static_regex!(r"^---- (.+) stdout ----$")
36}
37fn test_result_re() -> &'static regex::Regex {
38    static_regex!(r"test result: (\w+)\. (\d+) passed; (\d+) failed; (\d+) ignored")
39}
40fn finished_re() -> &'static regex::Regex {
41    static_regex!(r"Finished .+ in (\d+\.?\d*s)")
42}
43
44/// Compress output from a recognized Cargo subcommand.
45pub fn compress(command: &str, output: &str) -> Option<String> {
46    let args = command.strip_prefix("cargo ").unwrap_or(command);
47    let subcmd = args.split_whitespace().next().unwrap_or("");
48    match subcmd {
49        "build" | "b" | "check" | "c" => Some(compress_build(output)),
50        "test" | "t" | "nextest" => Some(compress_test(output)),
51        "clippy" => Some(compress_clippy(output)),
52        "clean" => Some(compress_clean(output)),
53        "install" => Some(compress_install(output)),
54        "add" => Some(compress_add(output)),
55        "remove" | "rm" => Some(compress_remove(output)),
56        "doc" | "d" => Some(compress_doc(output)),
57        "tree" => Some(compress_tree(output)),
58        "fmt" => Some(compress_fmt(output)),
59        "update" | "up" => Some(compress_update(output)),
60        "metadata" => Some(compress_metadata(output)),
61        "run" | "r" => Some(compress_run(output)),
62        "bench" => Some(compress_bench(output)),
63        _ => None,
64    }
65}
66
67fn compress_build(output: &str) -> String {
68    let mut compiled = 0u32;
69    let mut checked = 0u32;
70    let mut errors = Vec::new();
71    let mut time = String::new();
72
73    for line in output.lines() {
74        if compiling_re().is_match(line) {
75            compiled += 1;
76        } else if checking_re().is_match(line) {
77            checked += 1;
78        }
79        if let Some(caps) = error_re().captures(line) {
80            errors.push(format!("E{}: {}", &caps[1], &caps[2]));
81        }
82        if let Some(caps) = finished_re().captures(line) {
83            time = caps[1].to_string();
84        }
85    }
86
87    let mut parts = Vec::new();
88    if compiled > 0 {
89        parts.push(counted_crates("compiled", compiled));
90    }
91    if checked > 0 {
92        parts.push(counted_crates("checked", checked));
93    }
94    if !errors.is_empty() {
95        parts.push(format!("{} errors:", errors.len()));
96        for e in &errors {
97            parts.push(format!("  {e}"));
98        }
99    }
100    let warning_groups = group_warnings(output);
101    let warning_total = warning_total(output, &warning_groups);
102    if warning_total > 0 {
103        parts.push(format_warning_groups(&warning_groups, warning_total));
104    }
105    if !time.is_empty() {
106        parts.push(format!("({time})"));
107    }
108
109    if parts.is_empty() {
110        return "ok".to_string();
111    }
112    parts.join("\n")
113}
114
115fn compress_test(output: &str) -> String {
116    let mut failed_tests = Vec::new();
117    let mut passed = 0u32;
118    let mut failed = 0u32;
119    let mut skipped = 0u32;
120    let mut time = String::new();
121    let mut compile_lines = Vec::new();
122    let mut passed_names: Vec<String> = Vec::new();
123    let mut in_test_phase = false;
124
125    for line in output.lines() {
126        if let Some(caps) = test_result_re().captures(line) {
127            passed += caps[2].parse::<u32>().unwrap_or_default();
128            failed += caps[3].parse::<u32>().unwrap_or_default();
129            skipped += caps[4].parse::<u32>().unwrap_or_default();
130            in_test_phase = true;
131        } else if line.trim_start().starts_with("running ") {
132            in_test_phase = true;
133        } else if !in_test_phase {
134            compile_lines.push(line);
135        }
136        let trimmed = line.trim();
137        if trimmed.starts_with("test ") && trimmed.ends_with("... ok") {
138            if let Some(name) = trimmed
139                .strip_prefix("test ")
140                .and_then(|r| r.strip_suffix(" ... ok"))
141            {
142                passed_names.push(name.to_string());
143            }
144        }
145        if let Some(caps) = failed_test_re().captures(trimmed) {
146            failed_tests.push(caps[1].to_string());
147        } else if let Some(caps) = failed_test_header_re().captures(trimmed) {
148            failed_tests.push(caps[1].to_string());
149        }
150        if let Some(caps) = finished_re().captures(line) {
151            time = caps[1].to_string();
152        }
153    }
154
155    let mut parts = Vec::new();
156    let compile_summary = compile_phase_summary(&compile_lines.join("\n"));
157    if !compile_summary.is_empty() {
158        parts.push(format!("[{compile_summary}]"));
159    }
160    if passed > 0 || failed > 0 || skipped > 0 {
161        let mut result = format!("cargo test: {passed} passed, {failed} failed");
162        if skipped > 0 {
163            result.push_str(&format!(", {skipped} skipped"));
164        }
165        failed_tests.sort_unstable();
166        failed_tests.dedup();
167        if !failed_tests.is_empty() {
168            let shown = failed_tests.iter().take(5).cloned().collect::<Vec<_>>();
169            let suffix = if failed_tests.len() > shown.len() {
170                format!(", ... +{} more", failed_tests.len() - shown.len())
171            } else {
172                String::new()
173            };
174            result.push_str(&format!(" ({}){suffix}", shown.join(", ")));
175        }
176        if failed_tests.is_empty() && !passed_names.is_empty() {
177            let total = passed_names.len();
178            let shown: Vec<_> = passed_names.iter().take(5).cloned().collect();
179            let suffix = if total > 5 {
180                format!(" ...+{} more", total - 5)
181            } else {
182                String::new()
183            };
184            result.push_str(&format!(
185                "
186  ran: {}{suffix}",
187                shown.join(", ")
188            ));
189        }
190        parts.push(result);
191    }
192    if !time.is_empty() {
193        parts.push(format!("({time})"));
194    }
195
196    if parts.is_empty() {
197        return "ok".to_string();
198    }
199    parts.join("\n")
200}
201
202fn compress_clippy(output: &str) -> String {
203    let errors = group_clippy_errors(output);
204    let warnings = group_warnings(output);
205    let warning_total = warning_total(output, &warnings);
206
207    let mut parts = Vec::new();
208    if !errors.is_empty() {
209        let error_total = errors.iter().map(|(_, count)| count).sum();
210        parts.push(format_rule_groups(
211            &errors,
212            error_total,
213            "error",
214            "errors",
215            "rules",
216        ));
217    }
218    if warning_total > 0 {
219        parts.push(format_rule_groups(
220            &warnings,
221            warning_total,
222            "warning",
223            "warnings",
224            "rules",
225        ));
226    }
227
228    if parts.is_empty() {
229        return "clean".to_string();
230    }
231    parts.join("\n")
232}
233
234fn group_warnings(output: &str) -> Vec<(String, u32)> {
235    let mut counts = std::collections::BTreeMap::new();
236    for line in output.lines() {
237        let Some(caps) = warning_re().captures(line.trim()) else {
238            continue;
239        };
240        let message = caps.get(2).map_or("", |capture| capture.as_str());
241        if message.contains("generated ") {
242            continue;
243        }
244        let rule = caps
245            .get(1)
246            .map(|capture| normalize_rule(capture.as_str()))
247            .unwrap_or_else(|| message_prefix(message));
248        *counts.entry(rule).or_insert(0) += 1;
249    }
250    let mut groups: Vec<_> = counts.into_iter().collect();
251    groups.sort_unstable_by_key(|(name, count)| (std::cmp::Reverse(*count), name.clone()));
252    groups
253}
254
255fn group_clippy_errors(output: &str) -> Vec<(String, u32)> {
256    let mut rules = Vec::new();
257    let mut last_error = None;
258    for line in output.lines() {
259        let trimmed = line.trim();
260        if let Some(caps) = generic_error_re().captures(trimmed) {
261            let message = caps.get(1).map_or("", |capture| capture.as_str());
262            let rule = clippy_rule_re()
263                .captures(trimmed)
264                .and_then(|rule_caps| rule_caps.get(1))
265                .map_or_else(
266                    || message_prefix(message),
267                    |capture| normalize_rule(capture.as_str()),
268                );
269            rules.push(rule);
270            last_error = Some(rules.len() - 1);
271        } else if let Some(index) = last_error
272            && let Some(caps) = clippy_rule_re().captures(trimmed)
273            && let Some(rule) = caps.get(1)
274        {
275            rules[index] = normalize_rule(rule.as_str());
276        }
277    }
278    group_named_rules(rules)
279}
280
281fn group_named_rules(rules: Vec<String>) -> Vec<(String, u32)> {
282    let mut counts = std::collections::BTreeMap::new();
283    for rule in rules {
284        *counts.entry(rule).or_insert(0) += 1;
285    }
286    let mut groups: Vec<_> = counts.into_iter().collect();
287    groups.sort_unstable_by_key(|(name, count)| (std::cmp::Reverse(*count), name.clone()));
288    groups
289}
290
291fn format_warning_groups(groups: &[(String, u32)], total: u32) -> String {
292    format_rule_groups(groups, total, "warning", "warnings", "others")
293}
294
295fn format_rule_groups(
296    groups: &[(String, u32)],
297    total: u32,
298    singular: &str,
299    plural: &str,
300    remainder_label: &str,
301) -> String {
302    let noun = if total == 1 { singular } else { plural };
303    let shown = groups
304        .iter()
305        .take(5)
306        .map(|(rule, count)| format!("{rule} ×{count}"))
307        .collect::<Vec<_>>();
308    let remainder = groups.len().saturating_sub(shown.len());
309    let suffix = if remainder > 0 {
310        format!(", +{remainder} {remainder_label}")
311    } else {
312        String::new()
313    };
314    format!("{total} {noun} ({}){suffix}", shown.join(", "))
315}
316
317fn warning_total(output: &str, groups: &[(String, u32)]) -> u32 {
318    let generated = output
319        .lines()
320        .filter_map(|line| generated_warnings_re().captures(line))
321        .filter_map(|caps| caps[1].parse::<u32>().ok())
322        .sum();
323    if generated == 0 {
324        groups.iter().map(|(_, count)| count).sum()
325    } else {
326        generated
327    }
328}
329
330fn compile_phase_summary(output: &str) -> String {
331    let compiled = output
332        .lines()
333        .filter(|line| compiling_re().is_match(line))
334        .count() as u32;
335    let checked = output
336        .lines()
337        .filter(|line| checking_re().is_match(line))
338        .count() as u32;
339    let groups = group_warnings(output);
340    let warnings = warning_total(output, &groups);
341    let mut parts = Vec::new();
342    if compiled > 0 {
343        parts.push(counted_crates("compiled", compiled));
344    }
345    if checked > 0 {
346        parts.push(counted_crates("checked", checked));
347    }
348    if warnings > 0 {
349        let noun = if warnings == 1 { "warning" } else { "warnings" };
350        parts.push(format!("{warnings} {noun}"));
351    }
352    parts.join(", ")
353}
354
355fn counted_crates(action: &str, count: u32) -> String {
356    let noun = if count == 1 { "crate" } else { "crates" };
357    format!("{action} {count} {noun}")
358}
359
360fn message_prefix(message: &str) -> String {
361    message
362        .split_whitespace()
363        .next()
364        .map(normalize_rule)
365        .unwrap_or_else(|| "unknown".to_string())
366}
367
368fn normalize_rule(rule: &str) -> String {
369    rule.trim_matches('`').replace('-', "_")
370}
371
372fn compress_clean(output: &str) -> String {
373    output
374        .lines()
375        .find_map(|line| line.trim().strip_prefix("Removed "))
376        .map_or_else(
377            || "cleaned".to_string(),
378            |removed| {
379                format!(
380                    "removed {}",
381                    removed.split_once(',').map_or(removed, |(files, _)| files)
382                )
383            },
384        )
385}
386
387fn compress_install(output: &str) -> String {
388    summarize_dependency_action(output, "Installed ", "installed")
389}
390
391fn compress_add(output: &str) -> String {
392    summarize_dependency_action(output, "Adding ", "added")
393}
394
395fn compress_remove(output: &str) -> String {
396    summarize_dependency_action(output, "Removing ", "removed")
397}
398
399fn summarize_dependency_action(output: &str, prefix: &str, action: &str) -> String {
400    output
401        .lines()
402        .find_map(|line| line.trim().strip_prefix(prefix))
403        .and_then(|dependency| dependency.split_whitespace().next())
404        .map_or_else(
405            || action.to_string(),
406            |dependency| format!("{action} {dependency}"),
407        )
408}
409
410fn compress_doc(output: &str) -> String {
411    let mut crate_count = 0u32;
412    let mut warnings = 0u32;
413    let mut time = String::new();
414
415    for line in output.lines() {
416        if line.contains("Documenting ") || compiling_re().is_match(line) {
417            crate_count += 1;
418        }
419        if warning_re().is_match(line) && !line.contains("generated") {
420            warnings += 1;
421        }
422        if let Some(caps) = finished_re().captures(line) {
423            time = caps[1].to_string();
424        }
425    }
426
427    let mut parts = Vec::new();
428    if crate_count > 0 {
429        parts.push(format!("documented {crate_count} crates"));
430    }
431    if warnings > 0 {
432        parts.push(format!("{warnings} warnings"));
433    }
434    if !time.is_empty() {
435        parts.push(format!("({time})"));
436    }
437    if parts.is_empty() {
438        "ok".to_string()
439    } else {
440        parts.join("\n")
441    }
442}
443
444fn compress_tree(output: &str) -> String {
445    let lines: Vec<&str> = output.lines().collect();
446    if lines.len() <= 20 {
447        return output.to_string();
448    }
449
450    let direct: Vec<&str> = lines
451        .iter()
452        .filter(|l| !l.starts_with(' ') || l.starts_with("├── ") || l.starts_with("└── "))
453        .copied()
454        .collect();
455
456    if direct.is_empty() {
457        let shown = &lines[..20.min(lines.len())];
458        return format!(
459            "{}\n... ({} more lines)",
460            shown.join("\n"),
461            lines.len() - 20
462        );
463    }
464
465    format!(
466        "{} direct deps ({} total lines):\n{}",
467        direct.len(),
468        lines.len(),
469        direct.join("\n")
470    )
471}
472
473fn compress_fmt(output: &str) -> String {
474    let trimmed = output.trim();
475    if trimmed.is_empty() {
476        return "ok (formatted)".to_string();
477    }
478
479    let diffs: Vec<&str> = trimmed
480        .lines()
481        .filter(|l| l.starts_with("Diff in ") || l.starts_with("  --> "))
482        .collect();
483
484    if !diffs.is_empty() {
485        return format!("{} formatting issues:\n{}", diffs.len(), diffs.join("\n"));
486    }
487
488    let lines: Vec<&str> = trimmed.lines().filter(|l| !l.trim().is_empty()).collect();
489    if lines.len() <= 5 {
490        lines.join("\n")
491    } else {
492        format!(
493            "{}\n... ({} more lines)",
494            lines[..5].join("\n"),
495            lines.len() - 5
496        )
497    }
498}
499
500fn compress_update(output: &str) -> String {
501    let mut updated = Vec::new();
502    let mut unchanged = 0u32;
503
504    for line in output.lines() {
505        let trimmed = line.trim();
506        if trimmed.starts_with("Updating ") || trimmed.starts_with("    Updating ") {
507            updated.push(trimmed.trim_start_matches("    ").to_string());
508        } else if trimmed.starts_with("Unchanged ") || trimmed.contains("Unchanged") {
509            unchanged += 1;
510        }
511    }
512
513    if updated.is_empty() && unchanged == 0 {
514        let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
515        if lines.is_empty() {
516            return "ok (up-to-date)".to_string();
517        }
518        if lines.len() <= 5 {
519            return lines.join("\n");
520        }
521        return format!(
522            "{}\n... ({} more lines)",
523            lines[..5].join("\n"),
524            lines.len() - 5
525        );
526    }
527
528    let mut parts = Vec::new();
529    if !updated.is_empty() {
530        parts.push(format!("{} updated:", updated.len()));
531        for u in updated.iter().take(15) {
532            parts.push(format!("  {u}"));
533        }
534        if updated.len() > 15 {
535            parts.push(format!("  ... +{} more", updated.len() - 15));
536        }
537    }
538    if unchanged > 0 {
539        parts.push(format!("{unchanged} unchanged"));
540    }
541    parts.join("\n")
542}
543
544fn compress_run(output: &str) -> String {
545    let mut program_lines = Vec::new();
546    let mut compiling = 0u32;
547    let mut time = String::new();
548
549    for line in output.lines() {
550        let trimmed = line.trim();
551        if compiling_re().is_match(trimmed) || trimmed.starts_with("Compiling ") {
552            compiling += 1;
553            continue;
554        }
555        if trimmed.starts_with("Downloading ")
556            || trimmed.starts_with("Downloaded ")
557            || trimmed.starts_with("Blocking waiting")
558            || trimmed.starts_with("Locking ")
559        {
560            continue;
561        }
562        if trimmed.starts_with("Running `") || trimmed.starts_with("Running ") {
563            continue;
564        }
565        if let Some(caps) = finished_re().captures(trimmed) {
566            time = caps[1].to_string();
567            continue;
568        }
569        program_lines.push(line);
570    }
571
572    let mut result = String::new();
573    if compiling > 0 {
574        result.push_str(&format!("(compiled {compiling} crates"));
575        if !time.is_empty() {
576            result.push_str(&format!(", {time}"));
577        }
578        result.push_str(")\n");
579    }
580
581    if program_lines.len() <= 50 {
582        result.push_str(&program_lines.join("\n"));
583    } else {
584        result.push_str(&program_lines[..25].join("\n"));
585        result.push_str(&format!(
586            "\n... ({} lines omitted)\n",
587            program_lines.len() - 50
588        ));
589        result.push_str(&program_lines[program_lines.len() - 25..].join("\n"));
590    }
591
592    if result.trim().is_empty() {
593        return "ok".to_string();
594    }
595    result
596}
597
598fn compress_bench(output: &str) -> String {
599    let mut compiling = 0u32;
600    let mut bench_results = Vec::new();
601    let mut time = String::new();
602    let mut errors = Vec::new();
603
604    for line in output.lines() {
605        let trimmed = line.trim();
606        if compiling_re().is_match(trimmed) || trimmed.starts_with("Compiling ") {
607            compiling += 1;
608            continue;
609        }
610        if trimmed.starts_with("Downloading ")
611            || trimmed.starts_with("Downloaded ")
612            || trimmed.starts_with("Blocking waiting")
613            || trimmed.starts_with("Locking ")
614        {
615            continue;
616        }
617        if trimmed.starts_with("Benchmarking ")
618            || trimmed.starts_with("Gnuplot ")
619            || trimmed.starts_with("Collecting ")
620            || trimmed.starts_with("Warming up")
621            || trimmed.starts_with("Analyzing ")
622        {
623            continue;
624        }
625        if trimmed.starts_with("Running ") && trimmed.contains("target") {
626            continue;
627        }
628        if let Some(caps) = finished_re().captures(trimmed) {
629            time = caps[1].to_string();
630            continue;
631        }
632        if let Some(caps) = error_re().captures(trimmed) {
633            errors.push(format!("E{}: {}", &caps[1], &caps[2]));
634            continue;
635        }
636        if trimmed.starts_with("test ") && trimmed.contains("bench:") {
637            bench_results.push(trimmed.to_string());
638            continue;
639        }
640        if trimmed.contains("time:") || trimmed.contains("thrpt:") {
641            bench_results.push(trimmed.to_string());
642            continue;
643        }
644        if let Some(caps) = test_result_re().captures(trimmed) {
645            bench_results.push(format!(
646                "{}: {} pass, {} fail, {} skip",
647                &caps[1], &caps[2], &caps[3], &caps[4]
648            ));
649        }
650    }
651
652    let mut parts = Vec::new();
653
654    if !errors.is_empty() {
655        parts.push(format!("{} errors:", errors.len()));
656        for e in &errors {
657            parts.push(format!("  {e}"));
658        }
659        return parts.join("\n");
660    }
661
662    if compiling > 0 {
663        let mut header = format!("compiled {compiling} crates");
664        if !time.is_empty() {
665            header.push_str(&format!(" ({time})"));
666        }
667        parts.push(header);
668    }
669
670    if bench_results.is_empty() {
671        parts.push("no benchmark results captured".to_string());
672    } else {
673        parts.push(format!("{} benchmarks:", bench_results.len()));
674        for b in &bench_results {
675            parts.push(format!("  {b}"));
676        }
677    }
678
679    if parts.is_empty() {
680        return "ok".to_string();
681    }
682    parts.join("\n")
683}
684
685fn compress_metadata(output: &str) -> String {
686    let parsed: Result<serde_json::Value, _> = serde_json::from_str(output);
687    let Ok(json) = parsed else {
688        let lines: Vec<&str> = output.lines().collect();
689        if lines.len() <= 20 {
690            return output.to_string();
691        }
692        return format!(
693            "{}\n... ({} more lines, non-JSON metadata)",
694            lines[..10].join("\n"),
695            lines.len() - 10
696        );
697    };
698
699    let mut parts = Vec::new();
700
701    if let Some(workspace_members) = json.get("workspace_members").and_then(|v| v.as_array()) {
702        parts.push(format!("workspace_members: {}", workspace_members.len()));
703        for m in workspace_members.iter().take(20) {
704            if let Some(s) = m.as_str() {
705                let short = s.split(' ').take(2).collect::<Vec<_>>().join(" ");
706                parts.push(format!("  {short}"));
707            }
708        }
709        if workspace_members.len() > 20 {
710            parts.push(format!("  ... +{} more", workspace_members.len() - 20));
711        }
712    }
713
714    if let Some(target_dir) = json.get("target_directory").and_then(|v| v.as_str()) {
715        parts.push(format!("target_directory: {target_dir}"));
716    }
717
718    if let Some(workspace_root) = json.get("workspace_root").and_then(|v| v.as_str()) {
719        parts.push(format!("workspace_root: {workspace_root}"));
720    }
721
722    if let Some(packages) = json.get("packages").and_then(|v| v.as_array()) {
723        parts.push(format!("packages: {}", packages.len()));
724        for pkg in packages.iter().take(30) {
725            let name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("?");
726            let version = pkg.get("version").and_then(|v| v.as_str()).unwrap_or("?");
727            let features: Vec<&str> = pkg
728                .get("features")
729                .and_then(|v| v.as_object())
730                .map(|f| f.keys().map(std::string::String::as_str).collect())
731                .unwrap_or_default();
732            if features.is_empty() {
733                parts.push(format!("  {name} v{version}"));
734            } else {
735                parts.push(format!(
736                    "  {name} v{version} [features: {}]",
737                    features.join(", ")
738                ));
739            }
740        }
741        if packages.len() > 30 {
742            parts.push(format!("  ... +{} more", packages.len() - 30));
743        }
744    }
745
746    if let Some(resolve) = json.get("resolve")
747        && let Some(nodes) = resolve.get("nodes").and_then(|v| v.as_array())
748    {
749        let total_deps: usize = nodes
750            .iter()
751            .map(|n| {
752                n.get("deps")
753                    .and_then(|v| v.as_array())
754                    .map_or(0, std::vec::Vec::len)
755            })
756            .sum();
757        parts.push(format!(
758            "resolve: {} nodes, {} dep edges",
759            nodes.len(),
760            total_deps
761        ));
762    }
763
764    if parts.is_empty() {
765        "cargo metadata: ok (empty)".to_string()
766    } else {
767        parts.join("\n")
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::compress;
774
775    #[test]
776    fn cargo_build_success() {
777        let output = "   Compiling lean-ctx v2.1.1\n    Finished release profile [optimized] target(s) in 30.5s";
778        let result = compress("cargo build", output).unwrap();
779        assert!(result.contains("compiled"), "should mention compilation");
780        assert!(result.contains("30.5s"), "should include build time");
781    }
782
783    #[test]
784    fn cargo_build_with_errors() {
785        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";
786        let result = compress("cargo build", output).unwrap();
787        assert!(result.contains("E0308"), "should contain error code");
788    }
789
790    #[test]
791    fn cargo_test_success() {
792        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";
793        let result = compress("cargo test", output).unwrap();
794        assert!(result.contains("5 pass"), "should show passed count");
795    }
796
797    #[test]
798    fn cargo_test_failure() {
799        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";
800        let result = compress("cargo test", output).unwrap();
801        assert!(result.contains("1 failed"), "should indicate failure");
802        assert!(result.contains("test_fail"), "should name failed test");
803    }
804
805    #[test]
806    fn test_build_with_checking() {
807        let output = "Compiling app v0.1.0\nChecking dep v1.0.0\nChecking dep-two v2.0.0\nFinished dev profile in 1.2s";
808        let result = compress("cargo check", output).unwrap();
809        assert!(result.contains("compiled 1 crate"));
810        assert!(result.contains("checked 2 crates"));
811    }
812
813    #[test]
814    fn test_build_with_warnings() {
815        let warnings = ["warning: unused value"; 10].join("\n");
816        let output =
817            format!("Compiling app v0.1.0\n{warnings}\nwarning: app generated 10 warnings");
818        let result = compress("cargo build", &output).unwrap();
819        assert!(result.contains("10 warnings (unused ×10)"));
820    }
821
822    #[test]
823    fn test_clippy_grouped() {
824        let output = "warning[clippy::needless-borrow]: needless borrow\nwarning[clippy::unused-imports]: unused import\nwarning[clippy::dead-code]: dead code\nwarning[clippy::manual-map]: manual map\nwarning[clippy::map-clone]: map clone\nwarning[clippy::redundant-closure]: redundant closure";
825        let result = compress("cargo clippy", output).unwrap();
826        assert!(result.contains("6 warnings"));
827        assert!(result.contains("needless_borrow ×1"));
828        assert!(result.contains("+1 rules"));
829    }
830
831    #[test]
832    fn test_test_with_compile_warnings() {
833        let output = "Compiling app v0.1.0\nwarning: unused value\nrunning 2 tests\ntest one ... ok\ntest two ... ok\ntest result: ok. 2 passed; 0 failed; 0 ignored\nFinished test profile in 1.4s";
834        let result = compress("cargo test", output).unwrap();
835        assert!(result.contains("[compiled 1 crate, 1 warning]"));
836        assert!(result.contains("cargo test: 2 passed, 0 failed"));
837    }
838
839    #[test]
840    fn test_dispatch_precision() {
841        assert!(
842            compress(
843                "cargo test",
844                "test result: ok. 0 passed; 0 failed; 0 ignored"
845            )
846            .is_some()
847        );
848        assert!(compress("cargo latest", "latest version").is_none());
849    }
850
851    #[test]
852    fn test_clean_handler() {
853        let result = compress("cargo clean", "Removed 42 files, 12.3MiB total").unwrap();
854        assert_eq!(result, "removed 42 files");
855    }
856
857    #[test]
858    fn cargo_clippy_clean() {
859        let output = "    Checking lean-ctx v2.1.1\n    Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.2s";
860        let result = compress("cargo clippy", output).unwrap();
861        assert!(result.contains("clean"), "clean clippy should say clean");
862    }
863
864    #[test]
865    fn cargo_check_routes_to_build() {
866        let output = "    Checking lean-ctx v2.1.1\n    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.1s";
867        let result = compress("cargo check", output);
868        assert!(
869            result.is_some(),
870            "cargo check should route to build compressor"
871        );
872    }
873
874    #[test]
875    fn cargo_metadata_json() {
876        let json = r#"{
877            "packages": [
878                {"name": "lean-ctx", "version": "3.2.9", "features": {"tree-sitter": ["dep:tree-sitter"]}},
879                {"name": "serde", "version": "1.0.200", "features": {"derive": ["serde_derive"]}}
880            ],
881            "workspace_members": ["lean-ctx 3.2.9 (path+file:///foo)"],
882            "workspace_root": "/foo",
883            "target_directory": "/foo/target",
884            "resolve": {
885                "nodes": [
886                    {"id": "lean-ctx", "deps": [{"name": "serde"}]},
887                    {"id": "serde", "deps": []}
888                ]
889            }
890        }"#;
891        let result = compress("cargo metadata", json).unwrap();
892        assert!(
893            result.contains("workspace_members: 1"),
894            "should list workspace members"
895        );
896        assert!(result.contains("packages: 2"), "should list packages");
897        assert!(
898            result.contains("resolve: 2 nodes"),
899            "should summarize resolve graph"
900        );
901        assert!(
902            result.len() < json.len(),
903            "compressed output should be shorter"
904        );
905    }
906
907    #[test]
908    fn cargo_run_strips_compilation() {
909        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";
910        let result = compress("cargo run", output).unwrap();
911        assert!(
912            !result.contains("Running `target"),
913            "should strip Running line"
914        );
915        assert!(
916            result.contains("Hello, world!"),
917            "should keep program output"
918        );
919        assert!(result.contains("compiled"), "should summarize compilation");
920    }
921
922    #[test]
923    fn cargo_bench_keeps_results() {
924        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";
925        let result = compress("cargo bench", output).unwrap();
926        assert!(result.contains("bench_parse"), "should keep bench results");
927        assert!(result.contains("bench_render"), "should keep bench results");
928        assert!(result.contains("compiled"), "should summarize compilation");
929    }
930
931    #[test]
932    fn cargo_bench_with_criterion() {
933        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]";
934        let result = compress("cargo bench", output).unwrap();
935        assert!(
936            result.contains("time:"),
937            "should keep criterion timing lines"
938        );
939        assert!(!result.contains("Collecting"), "should strip progress");
940    }
941
942    #[test]
943    fn cargo_metadata_non_json() {
944        let output = "error: `cargo metadata` exited with an error\nsome detailed error";
945        let result = compress("cargo metadata", output).unwrap();
946        assert!(
947            result.contains("error"),
948            "should pass through non-JSON output"
949        );
950    }
951}