1pub mod biome;
21pub mod builtin_filters;
22pub mod bun;
23pub mod caps;
24pub mod cargo;
25pub mod eslint;
26pub mod generic;
27pub mod git;
28pub mod go;
29pub mod mypy;
30pub mod next;
31pub mod npm;
32pub mod playwright;
33pub mod pnpm;
34pub mod prettier;
35pub mod pytest;
36pub mod ruff;
37pub mod toml_filter;
38pub mod trust;
39pub mod tsc;
40pub mod vitest;
41
42use crate::context::AppContext;
43use crate::harness::Harness;
44use biome::BiomeCompressor;
45use bun::BunCompressor;
46use caps::DropClass;
47use cargo::CargoCompressor;
48use eslint::EslintCompressor;
49use generic::{strip_ansi, GenericCompressor};
50use git::GitCompressor;
51use go::{GoCompressor, GolangciLintCompressor};
52use mypy::MypyCompressor;
53use next::NextCompressor;
54use npm::NpmCompressor;
55use playwright::PlaywrightCompressor;
56use pnpm::PnpmCompressor;
57use prettier::PrettierCompressor;
58use pytest::PytestCompressor;
59use ruff::RuffCompressor;
60use std::collections::BTreeMap;
61use std::fs;
62use std::path::{Path, PathBuf};
63use std::sync::{Arc, RwLock};
64use toml_filter::{apply_filter_with_exit_code, FilterRegistry};
65use tsc::TscCompressor;
66use vitest::VitestCompressor;
67
68pub type SharedFilterRegistry = Arc<RwLock<FilterRegistry>>;
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub enum Specificity {
92 Specific,
93 PackageManager,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct CompressionResult {
98 pub text: String,
99 pub dropped_by_class: BTreeMap<DropClass, usize>,
100 pub had_inner_drop: bool,
101 pub offset_hint_eligible: bool,
102 pub offset_start_line: Option<usize>,
103}
104
105impl CompressionResult {
106 pub fn new(text: impl Into<String>) -> Self {
107 Self {
108 text: text.into(),
109 dropped_by_class: BTreeMap::new(),
110 had_inner_drop: false,
111 offset_hint_eligible: true,
112 offset_start_line: None,
113 }
114 }
115
116 pub fn with_class_drops(
117 text: impl Into<String>,
118 dropped_by_class: BTreeMap<DropClass, usize>,
119 ) -> Self {
120 let had_inner_drop = !dropped_by_class.is_empty();
121 Self {
122 text: text.into(),
123 dropped_by_class,
124 had_inner_drop,
125 offset_hint_eligible: !had_inner_drop,
126 offset_start_line: None,
127 }
128 }
129
130 pub fn with_inner_drop(text: impl Into<String>, offset_hint_eligible: bool) -> Self {
131 Self {
132 text: text.into(),
133 dropped_by_class: BTreeMap::new(),
134 had_inner_drop: true,
135 offset_hint_eligible,
136 offset_start_line: None,
137 }
138 }
139
140 pub fn with_prefix_drop(text: impl Into<String>, offset_start_line: usize) -> Self {
141 Self {
142 text: text.into(),
143 dropped_by_class: BTreeMap::new(),
144 had_inner_drop: true,
145 offset_hint_eligible: true,
146 offset_start_line: Some(offset_start_line),
147 }
148 }
149
150 pub fn has_semantic_drops(&self) -> bool {
151 !self.dropped_by_class.is_empty()
152 }
153
154 pub fn has_any_drop(&self) -> bool {
155 self.had_inner_drop || self.has_semantic_drops()
156 }
157
158 pub fn map_text<F>(mut self, f: F) -> Self
159 where
160 F: FnOnce(&str) -> String,
161 {
162 self.text = f(&self.text);
163 self
164 }
165}
166
167impl std::fmt::Display for CompressionResult {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 f.write_str(&self.text)
170 }
171}
172
173impl std::ops::Deref for CompressionResult {
174 type Target = str;
175
176 fn deref(&self) -> &Self::Target {
177 &self.text
178 }
179}
180
181impl PartialEq<&str> for CompressionResult {
182 fn eq(&self, other: &&str) -> bool {
183 self.text == *other
184 }
185}
186
187impl PartialEq<String> for CompressionResult {
188 fn eq(&self, other: &String) -> bool {
189 self.text == *other
190 }
191}
192
193impl From<String> for CompressionResult {
194 fn from(text: String) -> Self {
195 Self::new(text)
196 }
197}
198
199impl From<&str> for CompressionResult {
200 fn from(text: &str) -> Self {
201 Self::new(text)
202 }
203}
204
205pub trait Compressor: Send + Sync {
208 fn matches(&self, command: &str) -> bool;
211
212 fn compress(&self, command: &str, output: &str) -> CompressionResult {
214 self.compress_with_exit_code(command, output, None)
215 }
216
217 fn compress_with_exit_code(
219 &self,
220 command: &str,
221 output: &str,
222 exit_code: Option<i32>,
223 ) -> CompressionResult;
224
225 fn specificity(&self) -> Specificity {
226 Specificity::Specific
227 }
228
229 fn matches_output(&self, _output: &str) -> bool {
234 false
235 }
236
237 fn compress_output_match(&self, output: &str) -> CompressionResult {
239 self.compress_output_match_with_exit_code(output, None)
240 }
241
242 fn compress_output_match_with_exit_code(
245 &self,
246 output: &str,
247 exit_code: Option<i32>,
248 ) -> CompressionResult {
249 self.compress_with_exit_code("", output, exit_code)
250 }
251}
252pub fn compress(command: &str, output: String, ctx: &AppContext) -> CompressionResult {
258 compress_with_exit_code(command, output, None, ctx)
259}
260
261pub fn compress_with_exit_code(
262 command: &str,
263 output: String,
264 exit_code: Option<i32>,
265 ctx: &AppContext,
266) -> CompressionResult {
267 if !ctx.config().experimental_bash_compress {
268 return CompressionResult::new(output);
269 }
270 let registry_handle = ctx.shared_filter_registry();
271 let guard = match registry_handle.read() {
272 Ok(g) => g,
273 Err(poisoned) => poisoned.into_inner(),
274 };
275 compress_with_registry_exit_code(command, &output, exit_code, &guard)
276}
277
278pub fn compress_with_registry(
284 command: &str,
285 output: &str,
286 registry: &FilterRegistry,
287) -> CompressionResult {
288 compress_with_registry_exit_code(command, output, None, registry)
289}
290
291pub fn compress_with_registry_exit_code(
292 command: &str,
293 output: &str,
294 exit_code: Option<i32>,
295 registry: &FilterRegistry,
296) -> CompressionResult {
297 let stripped_for_generic = strip_ansi(output);
298
299 let normalized = normalize_command_for_dispatch(command);
305 let dispatch_cmd = normalized.as_deref().unwrap_or(command);
306
307 let compressors: [&dyn Compressor; 17] = [
308 &GitCompressor,
309 &CargoCompressor,
310 &TscCompressor,
311 &NpmCompressor,
312 &BunCompressor,
313 &PnpmCompressor,
314 &PytestCompressor,
315 &EslintCompressor,
316 &VitestCompressor,
317 &BiomeCompressor,
318 &PrettierCompressor,
319 &RuffCompressor,
320 &MypyCompressor,
321 &GoCompressor,
322 &GolangciLintCompressor,
323 &PlaywrightCompressor,
324 &NextCompressor,
325 ];
326
327 for compressor in compressors
329 .iter()
330 .filter(|c| c.specificity() == Specificity::Specific)
331 {
332 if compressor.matches(dispatch_cmd) {
333 let result =
334 compressor.compress_with_exit_code(dispatch_cmd, &stripped_for_generic, exit_code);
335 return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
336 }
337 }
338
339 for specificity in [Specificity::Specific, Specificity::PackageManager] {
345 for compressor in compressors
346 .iter()
347 .filter(|c| c.specificity() == specificity)
348 {
349 if compressor.matches_output(&stripped_for_generic) {
350 let result = compressor
351 .compress_output_match_with_exit_code(&stripped_for_generic, exit_code);
352 return failure_preserving_result(
353 command,
354 &stripped_for_generic,
355 result,
356 exit_code,
357 );
358 }
359 }
360 }
361
362 for compressor in compressors
364 .iter()
365 .filter(|c| c.specificity() == Specificity::PackageManager)
366 {
367 if compressor.matches(dispatch_cmd) {
368 let result =
369 compressor.compress_with_exit_code(dispatch_cmd, &stripped_for_generic, exit_code);
370 return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
371 }
372 }
373
374 if let Some(filter) = registry.lookup(dispatch_cmd) {
377 let result = apply_filter_with_exit_code(filter, output, exit_code);
378 return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
379 }
380
381 GenericCompressor.compress_with_exit_code(command, &stripped_for_generic, exit_code)
383}
384
385fn failure_preserving_result(
386 command: &str,
387 stripped_raw_output: &str,
388 result: CompressionResult,
389 exit_code: Option<i32>,
390) -> CompressionResult {
391 if !matches!(exit_code, Some(code) if code != 0) {
392 return result;
393 }
394
395 if result_looks_successful(&result.text)
396 || raw_failure_signal_absent_from_compressed(stripped_raw_output, &result.text)
397 {
398 GenericCompressor.compress_with_exit_code(command, stripped_raw_output, exit_code)
399 } else {
400 result
401 }
402}
403
404fn raw_failure_signal_absent_from_compressed(raw_output: &str, compressed_text: &str) -> bool {
405 let mut saw_signal_line = false;
406 for line in raw_output.lines() {
407 let trimmed = line.trim();
408 if trimmed.is_empty() || !line_has_failure_signal(trimmed) {
409 continue;
410 }
411 saw_signal_line = true;
412 if compressed_text.contains(trimmed) {
413 return false;
414 }
415 }
416
417 saw_signal_line && !text_has_failure_signal(compressed_text)
418}
419
420fn result_looks_successful(text: &str) -> bool {
421 let lower = text.to_ascii_lowercase();
422 !text_has_failure_signal(text)
423 && (lower.contains("clean")
424 || lower.contains(" ok")
425 || lower.contains(":ok")
426 || lower.contains(": ok")
427 || lower.contains("passed")
428 || lower.contains("no errors")
429 || lower.contains("all checks passed")
430 || lower.contains("formatted")
431 || lower.contains("0 fail")
432 || lower.contains("found 0")
433 || lower.contains("up to date")
434 || lower.contains("up-to-date"))
435}
436
437fn text_has_failure_signal(text: &str) -> bool {
438 text.lines()
439 .any(|line| line_has_failure_signal(line.trim()))
440}
441
442fn line_has_failure_signal(line: &str) -> bool {
443 line.contains("error[")
444 || line.contains("error:")
445 || line.contains("Error")
446 || line.contains("FAILED")
447 || line.contains("FAIL")
448 || contains_nonzero_failure_word(line, "failed")
449 || contains_nonzero_failure_word(line, "failure")
450 || contains_nonzero_failure_word(line, "failures")
451 || line.contains("panic")
452 || line.contains("cannot find")
453 || line.contains("not found")
454 || line.contains("no such")
455}
456
457fn contains_nonzero_failure_word(line: &str, word: &str) -> bool {
458 let lower = line.to_ascii_lowercase();
459 for (index, _) in lower.match_indices(word) {
460 let prefix = lower[..index].trim_end();
461 let digits_start = prefix
462 .char_indices()
463 .rev()
464 .take_while(|(_, ch)| ch.is_ascii_digit())
465 .last()
466 .map(|(idx, _)| idx);
467 let Some(digits_start) = digits_start else {
468 return true;
469 };
470 let digits = &prefix[digits_start..];
471 if digits.parse::<usize>().ok() != Some(0) {
472 return true;
473 }
474 }
475 false
476}
477
478pub fn build_registry_for_context(ctx: &AppContext) -> FilterRegistry {
487 let harness = ctx.harness.borrow().unwrap_or(Harness::Opencode);
488 let config = ctx.config();
489 let storage_dir = config.storage_dir.clone();
490 let project_root = config.project_root.clone();
491 drop(config);
492
493 let user_dir = storage_dir.as_ref().map(|dir| {
494 repair_legacy_user_filter_dir(dir, harness);
495 user_filter_dir(dir, harness)
496 });
497 let project_dir = match (project_root.as_ref(), storage_dir.as_ref()) {
498 (Some(root), Some(storage)) => {
499 if trust::is_project_trusted(Some(storage), root) {
500 Some(root.join(".aft").join("filters"))
501 } else {
502 None
503 }
504 }
505 _ => None,
506 };
507
508 toml_filter::build_registry(
509 builtin_filters::ALL,
510 user_dir.as_deref(),
511 project_dir.as_deref(),
512 )
513}
514
515pub fn normalize_command_for_dispatch(command: &str) -> Option<String> {
539 let trimmed = command.trim_start();
540 if trimmed.is_empty() {
541 return None;
542 }
543
544 let (open_paren, after_paren) = if let Some(rest) = trimmed.strip_prefix('(') {
546 (true, rest.trim_start())
547 } else {
548 (false, trimmed)
549 };
550
551 let mut current = after_paren.to_string();
552 let mut changed = open_paren;
553
554 loop {
556 if let Some(stripped) = strip_leading_assignment_prefix(¤t) {
560 current = stripped;
561 changed = true;
562 continue;
563 }
564
565 let head: String = current.split_whitespace().next().unwrap_or("").to_string();
566
567 if head == "cd" {
569 if let Some(stripped) = strip_cd_prefix(¤t) {
572 current = stripped;
573 changed = true;
574 continue;
575 }
576 }
577
578 if head == "env" {
580 if let Some(stripped) = strip_env_prefix(¤t) {
581 current = stripped;
582 changed = true;
583 continue;
584 }
585 }
586
587 if head == "timeout" {
589 if let Some(stripped) = strip_timeout_prefix(¤t) {
590 current = stripped;
591 changed = true;
592 continue;
593 }
594 }
595
596 if head == "nohup" {
598 if let Some(rest) = current.strip_prefix("nohup").and_then(|s| {
599 let trimmed = s.trim_start();
600 if trimmed.is_empty() {
601 None
602 } else {
603 Some(trimmed.to_string())
604 }
605 }) {
606 current = rest;
607 changed = true;
608 continue;
609 }
610 }
611
612 break;
613 }
614
615 if changed {
616 Some(current)
617 } else {
618 None
619 }
620}
621
622fn strip_cd_prefix(command: &str) -> Option<String> {
623 let bytes = command.as_bytes();
625 let mut in_single = false;
626 let mut in_double = false;
627 let mut i = 0;
628 while i < bytes.len() {
629 let ch = bytes[i] as char;
630 if !in_double && ch == '\'' {
631 in_single = !in_single;
632 } else if !in_single && ch == '"' {
633 in_double = !in_double;
634 } else if !in_single && !in_double {
635 if ch == '&' && i + 1 < bytes.len() && bytes[i + 1] as char == '&' {
636 let rest = command[i + 2..].trim_start();
637 if rest.is_empty() {
638 return None;
639 }
640 return Some(rest.to_string());
641 }
642 if ch == ';' {
643 let rest = command[i + 1..].trim_start();
644 if rest.is_empty() {
645 return None;
646 }
647 return Some(rest.to_string());
648 }
649 }
650 i += 1;
651 }
652 None
653}
654
655fn strip_env_prefix(command: &str) -> Option<String> {
656 let rest = command.strip_prefix("env")?.trim_start();
658 strip_leading_assignment_prefix(rest)
659}
660
661fn strip_leading_assignment_prefix(command: &str) -> Option<String> {
662 let mut index = 0usize;
663 let mut consumed_assignment = false;
664
665 loop {
666 index = skip_whitespace(command, index);
667 if index >= command.len() {
668 break;
669 }
670
671 let word_end = shell_word_end(command, index)?;
672 if word_end == index {
673 break;
674 }
675
676 let word = &command[index..word_end];
677 if !is_env_assignment(word) {
678 break;
679 }
680
681 consumed_assignment = true;
682 index = word_end;
683 }
684
685 if !consumed_assignment {
686 return None;
687 }
688
689 let after = command[index..].trim_start();
690 if after.is_empty() {
691 None
692 } else {
693 Some(after.to_string())
694 }
695}
696
697fn skip_whitespace(input: &str, mut index: usize) -> usize {
698 while index < input.len() {
699 let Some(ch) = input[index..].chars().next() else {
700 break;
701 };
702 if !ch.is_whitespace() {
703 break;
704 }
705 index += ch.len_utf8();
706 }
707 index
708}
709
710fn shell_word_end(command: &str, start: usize) -> Option<usize> {
711 let mut in_single = false;
712 let mut in_double = false;
713 let mut escaped = false;
714
715 for (offset, ch) in command[start..].char_indices() {
716 let index = start + offset;
717
718 if escaped {
719 escaped = false;
720 continue;
721 }
722
723 if ch == '\\' && !in_single {
724 escaped = true;
725 continue;
726 }
727
728 if ch == '\'' && !in_double {
729 in_single = !in_single;
730 continue;
731 }
732
733 if ch == '"' && !in_single {
734 in_double = !in_double;
735 continue;
736 }
737
738 if !in_single && !in_double && (ch.is_whitespace() || matches!(ch, ';' | '&' | '|')) {
739 return Some(index);
740 }
741 }
742
743 if in_single || in_double || escaped {
744 None
745 } else {
746 Some(command.len())
747 }
748}
749
750fn is_env_assignment(token: &str) -> bool {
751 if token.starts_with('-') {
752 return false;
753 }
754 let Some((name, _value)) = token.split_once('=') else {
755 return false;
756 };
757 let mut chars = name.chars();
758 let Some(first) = chars.next() else {
759 return false;
760 };
761 (first.is_ascii_alphabetic() || first == '_')
762 && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
763}
764
765fn strip_timeout_prefix(command: &str) -> Option<String> {
766 let rest = command.strip_prefix("timeout")?.trim_start();
767 let mut iter = rest.splitn(2, char::is_whitespace);
769 let duration = iter.next()?;
770 let after = iter.next()?.trim_start();
771 if after.is_empty() || !looks_like_duration(duration) {
772 return None;
773 }
774 Some(after.to_string())
775}
776
777fn looks_like_duration(token: &str) -> bool {
778 if token.is_empty() {
779 return false;
780 }
781 let mut chars = token.chars().peekable();
782 let mut saw_digit = false;
783 while let Some(&ch) = chars.peek() {
784 if ch.is_ascii_digit() {
785 saw_digit = true;
786 chars.next();
787 } else {
788 break;
789 }
790 }
791 if !saw_digit {
792 return false;
793 }
794 match chars.next() {
795 None => true,
796 Some(unit) => matches!(unit, 's' | 'm' | 'h' | 'd') && chars.next().is_none(),
797 }
798}
799
800pub fn user_filter_dir(storage_dir: &Path, harness: Harness) -> PathBuf {
803 storage_dir.join(harness.as_str()).join("filters")
804}
805
806fn legacy_user_filter_dir(storage_dir: &Path) -> PathBuf {
807 storage_dir.join("filters")
808}
809
810pub(crate) fn repair_legacy_user_filter_dir(storage_dir: &Path, harness: Harness) {
814 let legacy_dir = legacy_user_filter_dir(storage_dir);
815 if !legacy_dir.exists() {
816 return;
817 }
818
819 let entries = match fs::read_dir(&legacy_dir) {
820 Ok(entries) => entries.filter_map(Result::ok).collect::<Vec<_>>(),
821 Err(_) => return,
822 };
823 if entries.is_empty() {
824 let _ = fs::remove_dir(&legacy_dir);
825 return;
826 }
827
828 let harness_dir = user_filter_dir(storage_dir, harness);
829 if fs::create_dir_all(&harness_dir).is_err() {
830 return;
831 }
832
833 for entry in entries {
834 let target = harness_dir.join(entry.file_name());
835 if target.exists() {
836 continue;
837 }
838 let _ = fs::rename(entry.path(), target);
839 }
840
841 if fs::read_dir(&legacy_dir)
842 .map(|mut entries| entries.next().is_none())
843 .unwrap_or(false)
844 {
845 let _ = fs::remove_dir(&legacy_dir);
846 }
847}
848
849pub fn project_filter_dir(project_root: &Path) -> PathBuf {
853 project_root.join(".aft").join("filters")
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859
860 #[test]
861 fn user_and_project_filter_dir_helpers() {
862 let storage = Path::new("/tmp/aft-storage");
863 assert_eq!(
864 user_filter_dir(storage, Harness::Opencode),
865 Path::new("/tmp/aft-storage/opencode/filters")
866 );
867
868 let project = Path::new("/repo");
869 assert_eq!(project_filter_dir(project), Path::new("/repo/.aft/filters"));
870 }
871
872 #[test]
873 fn repair_legacy_user_filter_dir_moves_root_filters_without_overwrite() {
874 let temp = tempfile::tempdir().unwrap();
875 let storage = temp.path();
876 fs::create_dir_all(storage.join("filters")).unwrap();
877 fs::create_dir_all(storage.join("opencode/filters")).unwrap();
878 fs::write(storage.join("filters/root-only.toml"), "root").unwrap();
879 fs::write(storage.join("filters/collides.toml"), "root").unwrap();
880 fs::write(storage.join("opencode/filters/collides.toml"), "harness").unwrap();
881
882 repair_legacy_user_filter_dir(storage, Harness::Opencode);
883
884 assert_eq!(
885 fs::read_to_string(storage.join("opencode/filters/root-only.toml")).unwrap(),
886 "root"
887 );
888 assert_eq!(
889 fs::read_to_string(storage.join("opencode/filters/collides.toml")).unwrap(),
890 "harness"
891 );
892 assert_eq!(
893 fs::read_to_string(storage.join("filters/collides.toml")).unwrap(),
894 "root"
895 );
896 assert!(!storage.join("filters/root-only.toml").exists());
897 }
898}
899
900#[cfg(test)]
901mod dispatch_specificity_tests {
902 use super::*;
903 use crate::compress::toml_filter::FilterRegistry;
904
905 fn empty_registry() -> FilterRegistry {
906 FilterRegistry::default()
907 }
908
909 fn dispatch(cmd: &str, output: &str) -> String {
914 compress_with_registry(cmd, output, &empty_registry()).text
915 }
916
917 #[test]
918 fn generic_dispatch_does_not_classify_error_or_warning_words() {
919 let result = compress_with_registry(
920 "unknown-tool",
921 "error: this is just a log line\nwarning: this too",
922 &empty_registry(),
923 );
924
925 assert!(result.dropped_by_class.is_empty());
926 assert!(!result.had_inner_drop);
927 assert!(result.text.contains("error: this is just a log line"));
928 }
929
930 #[test]
931 fn bun_run_vitest_routes_to_vitest_not_generic() {
932 let output = "Test Files 1 passed (1)\n Tests 4 passed (4)\n Start at 10:00:00\n Duration 120ms\n";
937 let compressed = dispatch("bun run vitest", output);
938 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
940 }
941
942 #[test]
943 fn npm_test_routes_to_vitest_when_output_is_vitest_shaped() {
944 let output = "RERUN src/foo.test.ts x1\nFAIL src/foo.test.ts\nTest Files 1 failed (1)\nDuration 120ms\n";
947 let compressed = dispatch("npm test", output);
948 assert!(compressed.contains("FAIL src/foo.test.ts"));
949 assert!(compressed.contains("Duration 120ms"));
950 assert!(!compressed.contains("RERUN"));
951 }
952
953 #[test]
954 fn bun_run_vitest_token_match_wins_over_bun_head_match() {
955 let output = "PASS src/a.test.ts (1)\n PASS src/b.test.ts (1)\nTest Files 2 passed (2)\n Tests 4 passed (4)\n";
958 let compressed = dispatch("bun run vitest run", output);
959 assert!(compressed.contains("Test Files") || compressed.contains("PASS"));
961 }
962
963 #[test]
964 fn bunx_jest_routes_to_vitest_module() {
965 let output = "PASS src/foo.test.js (1.2s)\nTest Suites: 1 passed, 1 total\nTests: 3 passed, 3 total\n";
966 let compressed = dispatch("bunx jest --json", output);
967 assert!(compressed.contains("Tests:") && compressed.contains("Test Suites"));
968 }
969
970 #[test]
971 fn pnpm_run_vitest_routes_to_vitest() {
972 let output = "Test Files 1 passed (1)\n Tests 10 passed (10)\n";
973 let compressed = dispatch("pnpm run vitest", output);
974 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
975 }
976
977 #[test]
978 fn npx_eslint_routes_to_eslint_not_generic() {
979 let output = "\n/tmp/a.js\n 1:1 error 'foo' is defined but never used no-unused-vars\n\n✖ 1 problem (1 error, 0 warnings)\n";
980 let compressed = dispatch("npx eslint .", output);
981 assert!(compressed.contains("no-unused-vars") || compressed.contains("✖"));
983 }
984
985 #[test]
986 fn npm_run_lint_without_linter_output_shape_falls_back() {
987 let output = "> my-project@1.0.0 lint\n> eslint .\n\nAll good.\n";
990 let compressed = dispatch("npm run lint", output);
991 assert!(compressed.contains("All good."));
992 }
993
994 #[test]
995 fn bun_test_still_routes_to_bun_test_compressor() {
996 let output = "bun test v1.3.14\n\nsrc/foo.test.ts:\n(pass) my test [0.5ms]\n\n 1 pass\n 0 fail\n 1 expect() calls\nRan 1 tests across 1 files. [1.00ms]\n";
1004 let compressed = dispatch("bun test", output);
1005 assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
1006 }
1007
1008 #[test]
1009 fn bunx_vitest_routes_to_vitest() {
1010 let output = "Test Files 1 passed (1)\n Tests 3 passed (3)\n";
1011 let compressed = dispatch("bunx vitest run", output);
1012 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1013 }
1014
1015 #[test]
1016 fn cargo_test_still_routes_to_cargo() {
1017 let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
1020 let compressed = dispatch("cargo test", output);
1021 assert!(compressed.contains("failed") || compressed.contains("FAILED"));
1023 }
1024
1025 #[test]
1026 fn git_status_still_routes_to_git() {
1027 let output =
1029 "On branch main\nYour branch is up to date.\n\nnothing to commit, working tree clean\n";
1030 let compressed = dispatch("git status", output);
1031 assert!(compressed.contains("branch") || compressed.contains("clean"));
1032 }
1033
1034 #[test]
1035 fn pnpm_install_still_routes_to_pnpm() {
1036 let output = "Progress: resolved 100, downloaded 50, added 50\nAdded 50 packages\n";
1038 let compressed = dispatch("pnpm install", output);
1039 assert!(compressed.contains("Added") || compressed.contains("Progress"));
1041 }
1042}
1043
1044#[cfg(test)]
1045mod exit_code_safety_tests {
1046 use super::*;
1047 use crate::compress::toml_filter::{build_registry, FilterRegistry};
1048
1049 fn empty_registry() -> FilterRegistry {
1050 FilterRegistry::default()
1051 }
1052
1053 #[test]
1054 fn go_build_nonzero_preserves_missing_module_error_but_zero_keeps_old_summary() {
1055 let output = "go: go.mod file not found in current directory or any parent directory; see 'go help modules'\n";
1056
1057 let failed =
1058 compress_with_registry_exit_code("go build ./...", output, Some(1), &empty_registry());
1059 assert!(!failed.text.contains("go build: ok"));
1060 assert!(failed.text.contains("go.mod file not found"));
1061
1062 let successful =
1063 compress_with_registry_exit_code("go build ./...", output, Some(0), &empty_registry());
1064 assert_eq!(successful.text, "go build: ok");
1065 }
1066
1067 #[test]
1068 fn playwright_nonzero_crash_does_not_become_passed_summary() {
1069 let output = r#"Running 4 tests using 2 workers
1070
1071 ✓ 1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
1072 ✓ 2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
1073 ✓ 3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
1074 ✓ 4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
1075
1076 4 passed (6.3s)
1077Error: browserType.launch: Target page, context or browser has been closed
1078"#;
1079
1080 let failed = compress_with_registry_exit_code(
1081 "npx playwright test",
1082 output,
1083 Some(1),
1084 &empty_registry(),
1085 );
1086 assert!(!failed.text.starts_with("playwright: 4 tests passed"));
1087 assert!(failed.text.contains("browserType.launch"));
1088 }
1089
1090 #[test]
1091 fn cargo_test_compile_error_nonzero_preserves_error_code_diagnostic() {
1092 let output = r#" Compiling demo v0.1.0 (/tmp/demo)
1093error[E0432]: unresolved import `crate::missing`
1094 --> src/lib.rs:1:5
1095 |
10961 | use crate::missing;
1097 | ^^^^^^^^^^^^^^ no `missing` in the root
1098
1099error: could not compile `demo` (lib test) due to 1 previous error
1100"#;
1101
1102 let failed =
1103 compress_with_registry_exit_code("cargo test", output, Some(101), &empty_registry());
1104 assert!(failed.text.contains("error[E0432]"));
1105 assert!(failed.text.contains("unresolved import"));
1106 assert!(failed.text.contains("error: could not compile"));
1107 }
1108
1109 #[test]
1110 fn chained_mypy_success_then_later_failure_uses_failure_preserving_output() {
1111 let output = "Success: no issues found in 1 source file\nError: node process exploded\n";
1112
1113 let failed = compress_with_registry_exit_code(
1114 "mypy src && node fail.js",
1115 output,
1116 Some(1),
1117 &empty_registry(),
1118 );
1119 assert_ne!(failed.text, "mypy: clean");
1120 assert!(failed.text.contains("Error: node process exploded"));
1121 }
1122
1123 #[test]
1124 fn toml_shortcircuit_is_skipped_for_nonzero_exit() {
1125 let registry = build_registry(
1126 &[(
1127 "wget",
1128 r#"[filter]
1129matches = ["wget"]
1130
1131[shortcircuit]
1132when = '(?s).*'
1133replacement = "wget: ok"
1134"#,
1135 )],
1136 None,
1137 None,
1138 );
1139 let output = "Connecting to example.invalid\nerror: connection refused\n";
1140
1141 let failed = compress_with_registry_exit_code(
1142 "wget https://example.invalid",
1143 output,
1144 Some(1),
1145 ®istry,
1146 );
1147 assert_ne!(failed.text, "wget: ok");
1148 assert!(failed.text.contains("error: connection refused"));
1149 }
1150
1151 #[test]
1152 fn unknown_exit_code_keeps_byte_identical_legacy_compressor_output() {
1153 let output =
1154 "Success: no issues found in 1 source file\nError: later chained command failed\n";
1155
1156 let legacy = compress_with_registry_exit_code(
1157 "mypy src && node fail.js",
1158 output,
1159 None,
1160 &empty_registry(),
1161 );
1162 assert_eq!(legacy.text, "mypy: clean");
1163 }
1164
1165 #[test]
1166 fn successful_exit_still_gets_concise_success_summary() {
1167 let output = r#"Running 4 tests using 2 workers
1168
1169 ✓ 1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
1170 ✓ 2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
1171 ✓ 3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
1172 ✓ 4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
1173
1174 4 passed (6.3s)
1175"#;
1176
1177 let successful =
1178 compress_with_registry_exit_code("playwright test", output, Some(0), &empty_registry());
1179 assert_eq!(successful.text, "playwright: 4 tests passed (6.3s)");
1180 }
1181}
1182
1183#[cfg(test)]
1184mod normalize_command_tests {
1185 use super::*;
1186
1187 #[test]
1188 fn passes_bare_commands_unchanged() {
1189 assert_eq!(normalize_command_for_dispatch("bun test"), None);
1190 assert_eq!(normalize_command_for_dispatch("cargo build"), None);
1191 assert_eq!(normalize_command_for_dispatch("git status"), None);
1192 }
1193
1194 #[test]
1195 fn strips_cd_and_amp_prefix() {
1196 assert_eq!(
1197 normalize_command_for_dispatch("cd /repo && bun test").as_deref(),
1198 Some("bun test")
1199 );
1200 assert_eq!(
1201 normalize_command_for_dispatch("cd /repo/packages/aft && cargo test --release")
1202 .as_deref(),
1203 Some("cargo test --release")
1204 );
1205 }
1206
1207 #[test]
1208 fn strips_cd_and_semicolon_prefix() {
1209 assert_eq!(
1210 normalize_command_for_dispatch("cd /repo; bun test").as_deref(),
1211 Some("bun test")
1212 );
1213 }
1214
1215 #[test]
1216 fn strips_cd_with_quoted_path() {
1217 assert_eq!(
1218 normalize_command_for_dispatch("cd \"/path with space\" && npm install").as_deref(),
1219 Some("npm install")
1220 );
1221 }
1222
1223 #[test]
1224 fn strips_env_assignments() {
1225 assert_eq!(
1226 normalize_command_for_dispatch("env FOO=bar npm install").as_deref(),
1227 Some("npm install")
1228 );
1229 assert_eq!(
1230 normalize_command_for_dispatch("env FOO=bar BAZ=qux RUST_LOG=info cargo test")
1231 .as_deref(),
1232 Some("cargo test")
1233 );
1234 }
1235
1236 #[test]
1237 fn strips_bare_assignment_prefixes() {
1238 assert_eq!(
1239 normalize_command_for_dispatch("NODE_ENV=production npm install").as_deref(),
1240 Some("npm install")
1241 );
1242 assert_eq!(
1243 normalize_command_for_dispatch("FOO=1 BAR=2 cargo test").as_deref(),
1244 Some("cargo test")
1245 );
1246 assert_eq!(
1247 normalize_command_for_dispatch("RUSTFLAGS='-C debug' cargo build").as_deref(),
1248 Some("cargo build")
1249 );
1250 }
1251
1252 #[test]
1253 fn does_not_strip_later_assignment_arguments() {
1254 assert_eq!(normalize_command_for_dispatch("npm install foo=bar"), None);
1255 }
1256
1257 #[test]
1258 fn env_without_assignments_returns_none() {
1259 assert_eq!(
1261 normalize_command_for_dispatch("env npm install").as_deref(),
1262 None
1263 );
1264 }
1265
1266 #[test]
1267 fn strips_timeout_prefix() {
1268 assert_eq!(
1269 normalize_command_for_dispatch("timeout 30 cargo test").as_deref(),
1270 Some("cargo test")
1271 );
1272 assert_eq!(
1273 normalize_command_for_dispatch("timeout 5m bun test").as_deref(),
1274 Some("bun test")
1275 );
1276 }
1277
1278 #[test]
1279 fn strips_nohup_prefix() {
1280 assert_eq!(
1281 normalize_command_for_dispatch("nohup ./long-running-script.sh").as_deref(),
1282 Some("./long-running-script.sh")
1283 );
1284 }
1285
1286 #[test]
1287 fn strips_paren_then_cd_and_amp() {
1288 assert_eq!(
1289 normalize_command_for_dispatch("(cd /repo && bun test").as_deref(),
1290 Some("bun test")
1291 );
1292 }
1293
1294 #[test]
1295 fn chains_multiple_prefixes() {
1296 assert_eq!(
1298 normalize_command_for_dispatch("env FOO=bar timeout 30 cargo test").as_deref(),
1299 Some("cargo test")
1300 );
1301 assert_eq!(
1303 normalize_command_for_dispatch("cd /repo && env FOO=bar npm install").as_deref(),
1304 Some("npm install")
1305 );
1306 }
1307
1308 fn empty_registry() -> FilterRegistry {
1311 FilterRegistry::default()
1312 }
1313
1314 #[test]
1315 fn cd_prefix_bun_test_still_routes_to_bun_test() {
1316 let output = "bun test v1.3.14\n\nsrc/a.test.ts:\n(pass) ok [0.1ms]\n\n 1 pass\n 0 fail\n 1 expect() calls\nRan 1 tests across 1 files. [1.00ms]\n";
1317 let compressed = compress_with_registry("cd /repo && bun test", output, &empty_registry());
1318 assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
1323 }
1324
1325 #[test]
1326 fn cd_prefix_cargo_test_still_routes_to_cargo() {
1327 let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
1328 let compressed =
1329 compress_with_registry("cd /repo && cargo test", output, &empty_registry());
1330 assert!(compressed.contains("FAILED") || compressed.contains("failed"));
1331 }
1332
1333 #[test]
1334 fn env_prefix_npm_install_still_routes_to_npm() {
1335 let output = "added 50 packages, and audited 100 packages in 3s\n";
1336 let compressed = compress_with_registry(
1337 "env NODE_ENV=production npm install",
1338 output,
1339 &empty_registry(),
1340 );
1341 assert!(compressed.contains("added") || compressed.contains("audited"));
1343 }
1344
1345 #[test]
1346 fn bare_assignment_prefix_npm_install_routes_to_npm() {
1347 let output = "npm http fetch GET 200 https://registry.npmjs.org/foo 123ms\nnpm WARN deprecated old-pkg@1.0.0: use new-pkg instead\n\nadded 42 packages in 2s\n\naudited 100 packages in 2s\n\nfound 0 vulnerabilities\n";
1348 let compressed =
1349 compress_with_registry("NODE_ENV=production npm install", output, &empty_registry());
1350 assert!(!compressed.contains("npm http fetch"));
1351 assert!(compressed.contains("audited 100 packages"));
1352 }
1353
1354 #[test]
1355 fn bare_assignment_prefix_cargo_test_routes_to_cargo() {
1356 let output = "running 1 test\ntest foo ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n";
1357 let compressed =
1358 compress_with_registry("FOO=1 BAR=2 cargo test", output, &empty_registry());
1359 assert!(compressed.contains("running 1 test"));
1360 assert!(compressed.contains("test result: ok"));
1361 assert!(!compressed.contains("test foo ... ok"));
1362 }
1363
1364 #[test]
1365 fn quoted_assignment_prefix_cargo_build_routes_to_cargo() {
1366 let output = " Compiling foo v0.1.0\nwarning: unused variable: `x`\n --> src/lib.rs:1:9\n |\n1 | let x = 1;\n | ^ help: if this is intentional, prefix it with an underscore: `_x`\n\n Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.12s\n";
1367 let compressed = compress_with_registry(
1368 "RUSTFLAGS='-C debug' cargo build",
1369 output,
1370 &empty_registry(),
1371 );
1372 assert!(!compressed.contains("Compiling foo"));
1373 assert!(compressed.contains("warning: unused variable"));
1374 assert!(compressed.contains("Finished `dev` profile"));
1375 }
1376
1377 #[test]
1378 fn timeout_prefix_cargo_build_still_routes_to_cargo() {
1379 let output =
1380 " Compiling foo v0.1.0\n Finished `dev` profile [unoptimized] target(s) in 5s\n";
1381 let compressed =
1382 compress_with_registry("timeout 30 cargo build", output, &empty_registry());
1383 assert!(compressed.contains("Compiling") || compressed.contains("Finished"));
1385 }
1386}