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
44pub 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}