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