Skip to main content

lean_ctx_patterns/
cargo.rs

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