1macro_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
46pub 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}