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 tree;
42pub mod trust;
43pub mod tsc;
44pub mod vitest;
45
46use crate::context::AppContext;
47use crate::harness::Harness;
48use biome::BiomeCompressor;
49use bun::BunCompressor;
50use caps::DropClass;
51use cargo::CargoCompressor;
52use eslint::EslintCompressor;
53use find::FindCompressor;
54use generic::{strip_ansi, GenericCompressor};
55use git::GitCompressor;
56use go::{GoCompressor, GolangciLintCompressor};
57use ls::LsCompressor;
58use mypy::MypyCompressor;
59use next::NextCompressor;
60use npm::NpmCompressor;
61use playwright::PlaywrightCompressor;
62use pnpm::PnpmCompressor;
63use prettier::PrettierCompressor;
64use pytest::PytestCompressor;
65use ruff::RuffCompressor;
66use std::cell::OnceCell;
67use std::collections::{BTreeMap, BTreeSet};
68use std::fs;
69use std::path::{Path, PathBuf};
70use std::sync::{Arc, RwLock};
71use toml_filter::{apply_filter_with_exit_code, FilterRegistry};
72use tree::TreeCompressor;
73use tsc::TscCompressor;
74use vitest::VitestCompressor;
75
76pub type SharedFilterRegistry = Arc<RwLock<FilterRegistry>>;
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub enum Specificity {
100 Specific,
101 PackageManager,
102}
103
104pub struct OutputProbe<'a> {
110 output: &'a str,
111 json: OnceCell<Option<serde_json::Value>>,
112}
113
114impl<'a> OutputProbe<'a> {
115 pub fn new(output: &'a str) -> Self {
116 Self {
117 output,
118 json: OnceCell::new(),
119 }
120 }
121
122 pub fn output(&self) -> &'a str {
123 self.output
124 }
125
126 pub fn json(&self) -> Option<&serde_json::Value> {
128 self.json
129 .get_or_init(|| {
130 let trimmed = self.output.trim_start();
131 if !matches!(trimmed.as_bytes().first(), Some(b'{') | Some(b'[')) {
132 return None;
133 }
134 record_output_probe_json_parse();
135 serde_json::from_str(trimmed).ok()
136 })
137 .as_ref()
138 }
139}
140
141#[cfg(debug_assertions)]
142thread_local! {
143 static OUTPUT_PROBE_JSON_PARSE_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
144}
145
146#[cfg(debug_assertions)]
147fn record_output_probe_json_parse() {
148 OUTPUT_PROBE_JSON_PARSE_COUNT.with(|count| count.set(count.get() + 1));
149}
150
151#[cfg(not(debug_assertions))]
152fn record_output_probe_json_parse() {}
153
154#[cfg(all(debug_assertions, test))]
155fn reset_output_probe_json_parse_count() {
156 OUTPUT_PROBE_JSON_PARSE_COUNT.with(|count| count.set(0));
157}
158
159#[cfg(all(debug_assertions, test))]
160fn output_probe_json_parse_count() -> usize {
161 OUTPUT_PROBE_JSON_PARSE_COUNT.with(std::cell::Cell::get)
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct CompressionResult {
166 pub text: String,
167 pub dropped_by_class: BTreeMap<DropClass, usize>,
168 pub had_inner_drop: bool,
169 pub offset_hint_eligible: bool,
170 pub offset_start_line: Option<usize>,
171}
172
173impl CompressionResult {
174 pub fn new(text: impl Into<String>) -> Self {
175 Self {
176 text: text.into(),
177 dropped_by_class: BTreeMap::new(),
178 had_inner_drop: false,
179 offset_hint_eligible: true,
180 offset_start_line: None,
181 }
182 }
183
184 pub fn with_class_drops(
185 text: impl Into<String>,
186 dropped_by_class: BTreeMap<DropClass, usize>,
187 ) -> Self {
188 let had_inner_drop = !dropped_by_class.is_empty();
189 Self {
190 text: text.into(),
191 dropped_by_class,
192 had_inner_drop,
193 offset_hint_eligible: !had_inner_drop,
194 offset_start_line: None,
195 }
196 }
197
198 pub fn with_inner_drop(text: impl Into<String>, offset_hint_eligible: bool) -> Self {
199 Self {
200 text: text.into(),
201 dropped_by_class: BTreeMap::new(),
202 had_inner_drop: true,
203 offset_hint_eligible,
204 offset_start_line: None,
205 }
206 }
207
208 pub fn with_prefix_drop(text: impl Into<String>, offset_start_line: usize) -> Self {
209 Self {
210 text: text.into(),
211 dropped_by_class: BTreeMap::new(),
212 had_inner_drop: true,
213 offset_hint_eligible: true,
214 offset_start_line: Some(offset_start_line),
215 }
216 }
217
218 pub fn has_semantic_drops(&self) -> bool {
219 !self.dropped_by_class.is_empty()
220 }
221
222 pub fn has_any_drop(&self) -> bool {
223 self.had_inner_drop || self.has_semantic_drops()
224 }
225
226 pub fn map_text<F>(mut self, f: F) -> Self
227 where
228 F: FnOnce(&str) -> String,
229 {
230 self.text = f(&self.text);
231 self
232 }
233}
234
235impl std::fmt::Display for CompressionResult {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 f.write_str(&self.text)
238 }
239}
240
241impl std::ops::Deref for CompressionResult {
242 type Target = str;
243
244 fn deref(&self) -> &Self::Target {
245 &self.text
246 }
247}
248
249impl PartialEq<&str> for CompressionResult {
250 fn eq(&self, other: &&str) -> bool {
251 self.text == *other
252 }
253}
254
255impl PartialEq<String> for CompressionResult {
256 fn eq(&self, other: &String) -> bool {
257 self.text == *other
258 }
259}
260
261impl From<String> for CompressionResult {
262 fn from(text: String) -> Self {
263 Self::new(text)
264 }
265}
266
267impl From<&str> for CompressionResult {
268 fn from(text: &str) -> Self {
269 Self::new(text)
270 }
271}
272
273pub trait Compressor: Send + Sync {
276 fn matches(&self, command: &str) -> bool;
279
280 fn compress(&self, command: &str, output: &str) -> CompressionResult {
282 self.compress_with_exit_code(command, output, None)
283 }
284
285 fn compress_with_exit_code(
287 &self,
288 command: &str,
289 output: &str,
290 exit_code: Option<i32>,
291 ) -> CompressionResult;
292
293 fn specificity(&self) -> Specificity {
294 Specificity::Specific
295 }
296
297 fn matches_output(&self, _output: &str) -> bool {
302 false
303 }
304
305 fn matches_output_probe(&self, probe: &OutputProbe<'_>) -> bool {
308 self.matches_output(probe.output())
309 }
310
311 fn compress_output_match(&self, output: &str) -> CompressionResult {
313 self.compress_output_match_with_exit_code(output, None)
314 }
315
316 fn compress_output_match_with_exit_code(
319 &self,
320 output: &str,
321 exit_code: Option<i32>,
322 ) -> CompressionResult {
323 self.compress_with_exit_code("", output, exit_code)
324 }
325}
326pub fn compress(command: &str, output: String, ctx: &AppContext) -> CompressionResult {
332 compress_with_exit_code(command, output, None, ctx)
333}
334
335pub fn compress_with_exit_code(
336 command: &str,
337 output: String,
338 exit_code: Option<i32>,
339 ctx: &AppContext,
340) -> CompressionResult {
341 if !ctx.config().experimental_bash_compress {
342 return CompressionResult::new(output);
343 }
344 let registry_handle = ctx.shared_filter_registry();
345 let guard = match registry_handle.read() {
346 Ok(g) => g,
347 Err(poisoned) => poisoned.into_inner(),
348 };
349 compress_with_registry_exit_code(command, &output, exit_code, &guard)
350}
351
352pub fn compress_with_registry(
358 command: &str,
359 output: &str,
360 registry: &FilterRegistry,
361) -> CompressionResult {
362 compress_with_registry_exit_code(command, output, None, registry)
363}
364
365pub fn compress_with_registry_exit_code(
366 command: &str,
367 output: &str,
368 exit_code: Option<i32>,
369 registry: &FilterRegistry,
370) -> CompressionResult {
371 let stripped_for_generic = strip_ansi(output);
372
373 let dispatch_owned = match resolve_dispatch_target(command) {
380 DispatchTarget::Pipeline(_) | DispatchTarget::ForceGeneric => {
381 return GenericCompressor.compress_with_exit_code(
382 command,
383 &stripped_for_generic,
384 exit_code,
385 );
386 }
387 DispatchTarget::Command(cmd) => cmd,
388 };
389 let dispatch_cmd = dispatch_owned.as_str();
390
391 let compressors = compressors_in_dispatch_order();
392
393 for compressor in compressors
395 .iter()
396 .filter(|c| c.specificity() == Specificity::Specific)
397 {
398 if compressor.matches(dispatch_cmd) {
399 let result =
400 compressor.compress_with_exit_code(dispatch_cmd, &stripped_for_generic, exit_code);
401 return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
402 }
403 }
404
405 let output_probe = OutputProbe::new(&stripped_for_generic);
411 for specificity in [Specificity::Specific, Specificity::PackageManager] {
412 for compressor in compressors
413 .iter()
414 .filter(|c| c.specificity() == specificity)
415 {
416 if compressor.matches_output_probe(&output_probe) {
417 let result = compressor
418 .compress_output_match_with_exit_code(&stripped_for_generic, exit_code);
419 return failure_preserving_result(
420 command,
421 &stripped_for_generic,
422 result,
423 exit_code,
424 );
425 }
426 }
427 }
428
429 for compressor in compressors
431 .iter()
432 .filter(|c| c.specificity() == Specificity::PackageManager)
433 {
434 if compressor.matches(dispatch_cmd) {
435 let result =
436 compressor.compress_with_exit_code(dispatch_cmd, &stripped_for_generic, exit_code);
437 return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
438 }
439 }
440
441 if let Some(filter) = registry.lookup(dispatch_cmd) {
444 let result = apply_filter_with_exit_code(filter, output, exit_code);
445 return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
446 }
447
448 GenericCompressor.compress_with_exit_code(command, &stripped_for_generic, exit_code)
450}
451
452fn compressors_in_dispatch_order() -> [&'static dyn Compressor; 20] {
453 [
454 &GitCompressor,
455 &CargoCompressor,
456 &TscCompressor,
457 &NpmCompressor,
458 &BunCompressor,
459 &PnpmCompressor,
460 &PytestCompressor,
461 &EslintCompressor,
462 &VitestCompressor,
463 &BiomeCompressor,
464 &PrettierCompressor,
465 &RuffCompressor,
466 &MypyCompressor,
467 &GoCompressor,
468 &GolangciLintCompressor,
469 &PlaywrightCompressor,
470 &NextCompressor,
471 &LsCompressor,
472 &FindCompressor,
473 &TreeCompressor,
474 ]
475}
476
477fn failure_preserving_result(
478 command: &str,
479 stripped_raw_output: &str,
480 result: CompressionResult,
481 exit_code: Option<i32>,
482) -> CompressionResult {
483 if !matches!(exit_code, Some(code) if code != 0) {
484 return result;
485 }
486
487 if dropped_failure_or_error_blocks(&result)
488 || !text_has_failure_signal(&result.text)
489 || result_looks_successful(&result.text)
490 {
491 return GenericCompressor.compress_with_exit_code(command, stripped_raw_output, exit_code);
492 }
493
494 let missing = missing_raw_failure_signal_lines(stripped_raw_output, &result.text);
495 if missing.is_empty() {
496 result
497 } else {
498 append_missing_failure_lines(result, &missing)
499 }
500}
501
502fn dropped_failure_or_error_blocks(result: &CompressionResult) -> bool {
503 [DropClass::Error, DropClass::Failure]
504 .into_iter()
505 .any(|class| result.dropped_by_class.get(&class).copied().unwrap_or(0) > 0)
506}
507
508fn append_missing_failure_lines(
509 mut result: CompressionResult,
510 missing_failure_lines: &[String],
511) -> CompressionResult {
512 let mut text = result.text.trim_end().to_string();
513 if !text.is_empty() {
514 text.push('\n');
515 }
516 text.push_str("[raw failure lines preserved by AFT]\n");
517 text.push_str(&missing_failure_lines.join("\n"));
518 result.text = text;
519 result
520}
521
522pub(crate) fn missing_raw_failure_signal_lines(
523 raw_output: &str,
524 compressed_text: &str,
525) -> Vec<String> {
526 let compressed_lines: BTreeSet<String> = compressed_text
527 .lines()
528 .map(str::trim)
529 .filter(|line| !line.is_empty())
530 .map(ToString::to_string)
531 .collect();
532 let mut seen = BTreeSet::new();
533 let mut missing = Vec::new();
534
535 for line in raw_output.lines() {
536 let trimmed = line.trim();
537 if trimmed.is_empty() || !line_has_failure_signal(trimmed) {
538 continue;
539 }
540 if compressed_lines.contains(trimmed) || !seen.insert(trimmed.to_string()) {
541 continue;
542 }
543 missing.push(trimmed.to_string());
544 }
545
546 missing
547}
548
549fn result_looks_successful(text: &str) -> bool {
550 let lower = text.to_ascii_lowercase();
551 lower.contains("clean")
552 || lower.contains(" ok")
553 || lower.contains(":ok")
554 || lower.contains(": ok")
555 || lower.contains("passed")
556 || lower.contains("succeeded")
557 || lower.contains("no errors")
558 || lower.contains("0 errors")
559 || lower.contains("no issues")
560 || lower.contains("no diagnostics")
561 || lower.contains("all checks passed")
562 || lower.contains("formatted")
563 || lower.contains("0 fail")
564 || lower.contains("found 0")
565 || lower.contains("up to date")
566 || lower.contains("up-to-date")
567}
568
569pub(crate) fn text_has_failure_signal(text: &str) -> bool {
570 text.lines()
571 .any(|line| line_has_failure_signal(line.trim()))
572}
573
574fn line_has_failure_signal(line: &str) -> bool {
575 let lower = line.to_ascii_lowercase();
576 line.contains("error[")
577 || lower.contains("error:")
578 || line.contains("Error")
579 || line.contains("ERROR")
580 || lower.contains("internalerror")
581 || lower.contains("traceback")
582 || lower.contains("exception")
583 || lower.contains("no module named")
584 || lower.contains("undefined reference")
585 || lower.contains("linker command failed")
586 || lower.contains("undefined:")
587 || lower.contains("expected declaration")
588 || lower.contains("collect2: error")
589 || lower.contains("ld: error")
590 || lower.contains("fatal error")
591 || line.contains("FAILED")
592 || line.contains("FAIL")
593 || contains_nonzero_failure_word(line, "fail")
594 || contains_nonzero_failure_word(line, "failed")
595 || contains_nonzero_failure_word(line, "failure")
596 || contains_nonzero_failure_word(line, "failures")
597 || lower.contains("panic")
598 || lower.contains("cannot find")
599 || lower.contains("not found")
600 || lower.contains("no such")
601}
602
603fn contains_nonzero_failure_word(line: &str, word: &str) -> bool {
604 let lower = line.to_ascii_lowercase();
605 for (index, _) in lower.match_indices(word) {
606 let end = index + word.len();
607 let before_is_word = lower[..index].chars().next_back().is_some_and(is_word_char);
608 let after_is_word = lower[end..].chars().next().is_some_and(is_word_char);
609 if before_is_word || after_is_word {
610 continue;
611 }
612
613 let prefix = lower[..index].trim_end();
614 let digits_start = prefix
615 .char_indices()
616 .rev()
617 .take_while(|(_, ch)| ch.is_ascii_digit())
618 .last()
619 .map(|(idx, _)| idx);
620 let Some(digits_start) = digits_start else {
621 return true;
622 };
623 let digits = &prefix[digits_start..];
624 if digits.parse::<usize>().ok() != Some(0) {
625 return true;
626 }
627 }
628 false
629}
630
631fn is_word_char(ch: char) -> bool {
632 ch.is_ascii_alphanumeric() || ch == '_'
633}
634
635pub fn build_registry_for_context(ctx: &AppContext) -> FilterRegistry {
644 let harness = ctx.harness.lock().clone().unwrap_or(Harness::Opencode);
645 let config = ctx.config();
646 let storage_dir = config.storage_dir.clone();
647 let project_root = config.project_root.clone();
648 drop(config);
649
650 let user_dir = storage_dir.as_ref().map(|dir| {
651 repair_legacy_user_filter_dir(dir, harness.clone());
652 user_filter_dir(dir, harness)
653 });
654 let project_dir = match (project_root.as_ref(), storage_dir.as_ref()) {
655 (Some(root), Some(storage)) => {
656 if trust::is_project_trusted(Some(storage), root) {
657 Some(project_filter_dir(root))
658 } else {
659 None
660 }
661 }
662 _ => None,
663 };
664
665 toml_filter::build_registry(
666 builtin_filters::ALL,
667 user_dir.as_deref(),
668 project_dir.as_deref(),
669 )
670}
671
672pub fn normalize_command_for_dispatch(command: &str) -> Option<String> {
696 match resolve_dispatch_target(command) {
697 DispatchTarget::ForceGeneric => None,
700 DispatchTarget::Command(resolved) | DispatchTarget::Pipeline(resolved) => {
701 if resolved == command.trim_start() {
702 None
703 } else {
704 Some(resolved)
705 }
706 }
707 }
708}
709
710pub(crate) fn plain_command_for_structured_output(command: &str) -> Option<String> {
713 match resolve_dispatch_target(command) {
714 DispatchTarget::Command(resolved) => Some(resolved),
715 DispatchTarget::Pipeline(_) | DispatchTarget::ForceGeneric => None,
716 }
717}
718
719enum DispatchTarget {
722 Command(String),
725 Pipeline(String),
729 ForceGeneric,
733}
734
735fn resolve_dispatch_target(command: &str) -> DispatchTarget {
736 let decommented = strip_top_level_comment(command);
741 let peeled = peel_shell_prefixes(&decommented);
742 let base = peeled
743 .as_deref()
744 .unwrap_or_else(|| decommented.trim_start());
745 match split_top_level_pipe(base) {
746 PipeSplit::LastStage(last) => DispatchTarget::Pipeline(last),
747 PipeSplit::Unsafe => DispatchTarget::ForceGeneric,
748 PipeSplit::None => DispatchTarget::Command(base.to_string()),
749 }
750}
751
752fn strip_top_level_comment(command: &str) -> String {
760 let bytes = command.as_bytes();
761 let mut result = String::with_capacity(command.len());
762 let mut seg_start = 0usize;
763 let mut in_single = false;
764 let mut in_double = false;
765 let mut in_backtick = false;
766 let mut paren_depth: u32 = 0;
767 let mut escaped = false;
768 let mut prev = b' '; let mut i = 0;
771 while i < bytes.len() {
772 let ch = bytes[i];
773 if escaped {
774 escaped = false;
775 prev = ch;
776 i += 1;
777 continue;
778 }
779 if in_single {
780 if ch == b'\'' {
781 in_single = false;
782 }
783 prev = ch;
784 i += 1;
785 continue;
786 }
787 if in_backtick {
788 if ch == b'\\' {
789 escaped = true;
790 } else if ch == b'`' {
791 in_backtick = false;
792 }
793 prev = ch;
794 i += 1;
795 continue;
796 }
797 if ch == b'\\' {
798 escaped = true;
799 prev = ch;
800 i += 1;
801 continue;
802 }
803 if ch == b'`' {
804 in_backtick = true;
805 prev = ch;
806 i += 1;
807 continue;
808 }
809 if ch == b'$' && bytes.get(i + 1) == Some(&b'(') {
810 paren_depth += 1;
811 prev = b'(';
812 i += 2;
813 continue;
814 }
815 if in_double {
816 if ch == b'"' {
817 in_double = false;
818 }
819 prev = ch;
820 i += 1;
821 continue;
822 }
823 if ch == b'#'
824 && paren_depth == 0
825 && matches!(prev, b' ' | b'\t' | b'\n' | b';' | b'&' | b'|' | b'(')
826 {
827 result.push_str(&command[seg_start..i]);
828 while i < bytes.len() && bytes[i] != b'\n' {
829 i += 1;
830 }
831 seg_start = i; prev = b'\n';
833 continue;
834 }
835 match ch {
836 b'\'' => in_single = true,
837 b'"' => in_double = true,
838 b'<' | b'>' if bytes.get(i + 1) == Some(&b'(') => {
839 paren_depth += 1;
840 prev = b'(';
841 i += 2;
842 continue;
843 }
844 b'(' => paren_depth += 1,
845 b')' => paren_depth = paren_depth.saturating_sub(1),
846 _ => {}
847 }
848 prev = ch;
849 i += 1;
850 }
851 result.push_str(&command[seg_start..]);
852 result
853}
854
855fn peel_shell_prefixes(command: &str) -> Option<String> {
859 let trimmed = command.trim_start();
860 if trimmed.is_empty() {
861 return None;
862 }
863
864 let (open_paren, after_paren) = if let Some(rest) = trimmed.strip_prefix('(') {
866 (true, rest.trim_start())
867 } else {
868 (false, trimmed)
869 };
870
871 let mut current = after_paren.to_string();
872 let mut changed = open_paren;
873
874 loop {
876 if let Some(stripped) = strip_leading_assignment_prefix(¤t) {
880 current = stripped;
881 changed = true;
882 continue;
883 }
884
885 let head: String = current.split_whitespace().next().unwrap_or("").to_string();
886
887 if head == "cd" {
889 if let Some(stripped) = strip_cd_prefix(¤t) {
892 current = stripped;
893 changed = true;
894 continue;
895 }
896 }
897
898 if head == "env" {
900 if let Some(stripped) = strip_env_prefix(¤t) {
901 current = stripped;
902 changed = true;
903 continue;
904 }
905 }
906
907 if head == "timeout" {
909 if let Some(stripped) = strip_timeout_prefix(¤t) {
910 current = stripped;
911 changed = true;
912 continue;
913 }
914 }
915
916 if head == "nohup" {
918 if let Some(rest) = current.strip_prefix("nohup").and_then(|s| {
919 let trimmed = s.trim_start();
920 if trimmed.is_empty() {
921 None
922 } else {
923 Some(trimmed.to_string())
924 }
925 }) {
926 current = rest;
927 changed = true;
928 continue;
929 }
930 }
931
932 break;
933 }
934
935 if changed {
936 Some(current)
937 } else {
938 None
939 }
940}
941
942pub fn is_shell_boundary(token: &str) -> bool {
949 matches!(token, "|" | "|&" | ";" | "&" | "&&" | "||" | "&>" | "&>>") || is_redirect_token(token)
950}
951
952fn is_redirect_token(token: &str) -> bool {
956 let rest = token.trim_start_matches(|c: char| c.is_ascii_digit());
957 rest.starts_with('>') || rest.starts_with('<') || rest.starts_with("&>")
958}
959
960#[derive(Debug, PartialEq, Eq)]
962enum PipeSplit {
963 None,
965 LastStage(String),
967 Unsafe,
972}
973
974fn split_top_level_pipe(command: &str) -> PipeSplit {
988 let bytes = command.as_bytes();
989 let mut in_single = false;
990 let mut in_double = false;
991 let mut in_backtick = false;
992 let mut paren_depth: u32 = 0;
993 let mut escaped = false;
994 let mut saw_unmatched_close = false;
995 let mut saw_top_pipe = false;
996 let mut saw_top_separator = false;
997 let mut last_pipe_end: Option<usize> = None;
998
999 let mut i = 0;
1000 while i < bytes.len() {
1001 let ch = bytes[i];
1002
1003 if escaped {
1004 escaped = false;
1005 i += 1;
1006 continue;
1007 }
1008 if in_single {
1009 if ch == b'\'' {
1010 in_single = false;
1011 }
1012 i += 1;
1013 continue;
1014 }
1015 if in_backtick {
1016 if ch == b'\\' {
1019 escaped = true;
1020 } else if ch == b'`' {
1021 in_backtick = false;
1022 }
1023 i += 1;
1024 continue;
1025 }
1026 if ch == b'\\' {
1027 escaped = true;
1028 i += 1;
1029 continue;
1030 }
1031 if ch == b'`' {
1032 in_backtick = true;
1033 i += 1;
1034 continue;
1035 }
1036 if ch == b'$' && bytes.get(i + 1) == Some(&b'(') {
1038 paren_depth += 1;
1039 i += 2;
1040 continue;
1041 }
1042 if in_double {
1043 if ch == b'"' {
1044 in_double = false;
1045 }
1046 i += 1;
1047 continue;
1048 }
1049
1050 let prev_raw = if i > 0 { bytes[i - 1] } else { b' ' };
1054
1055 match ch {
1056 b'\'' => in_single = true,
1057 b'"' => in_double = true,
1058 b'<' | b'>' if bytes.get(i + 1) == Some(&b'(') => {
1060 paren_depth += 1;
1061 i += 2;
1062 continue;
1063 }
1064 b'(' => paren_depth += 1,
1065 b')' => {
1066 if paren_depth == 0 {
1067 saw_unmatched_close = true;
1068 } else {
1069 paren_depth -= 1;
1070 }
1071 }
1072 b'|' if paren_depth == 0 => {
1073 if bytes.get(i + 1) == Some(&b'|') {
1074 saw_top_separator = true; i += 2;
1076 continue;
1077 }
1078 saw_top_pipe = true;
1079 if bytes.get(i + 1) == Some(&b'&') {
1080 last_pipe_end = Some(i + 2); i += 2;
1082 continue;
1083 }
1084 last_pipe_end = Some(i + 1);
1085 }
1086 b'&' if paren_depth == 0 => {
1087 if bytes.get(i + 1) == Some(&b'&') {
1088 saw_top_separator = true; i += 2;
1090 continue;
1091 }
1092 if bytes.get(i + 1) != Some(&b'>') && prev_raw != b'>' {
1095 saw_top_separator = true;
1096 }
1097 }
1098 b';' if paren_depth == 0 => saw_top_separator = true,
1099 b'\n' if paren_depth == 0 => saw_top_separator = true,
1100 _ => {}
1101 }
1102 i += 1;
1103 }
1104
1105 let imbalance =
1106 in_single || in_double || in_backtick || escaped || paren_depth != 0 || saw_unmatched_close;
1107
1108 if saw_top_pipe {
1109 if imbalance || saw_top_separator {
1111 return PipeSplit::Unsafe;
1112 }
1113 match last_pipe_end {
1114 Some(end) => {
1115 let last_stage = command[end..].trim();
1116 if last_stage.is_empty() {
1117 PipeSplit::Unsafe } else {
1119 PipeSplit::LastStage(last_stage.to_string())
1120 }
1121 }
1122 None => PipeSplit::Unsafe,
1123 }
1124 } else if imbalance && command.contains('|') {
1125 PipeSplit::Unsafe
1127 } else {
1128 PipeSplit::None
1129 }
1130}
1131
1132fn strip_cd_prefix(command: &str) -> Option<String> {
1133 let bytes = command.as_bytes();
1135 let mut in_single = false;
1136 let mut in_double = false;
1137 let mut i = 0;
1138 while i < bytes.len() {
1139 let ch = bytes[i] as char;
1140 if !in_double && ch == '\'' {
1141 in_single = !in_single;
1142 } else if !in_single && ch == '"' {
1143 in_double = !in_double;
1144 } else if !in_single && !in_double {
1145 if ch == '&' && i + 1 < bytes.len() && bytes[i + 1] as char == '&' {
1146 let rest = command[i + 2..].trim_start();
1147 if rest.is_empty() {
1148 return None;
1149 }
1150 return Some(rest.to_string());
1151 }
1152 if ch == ';' {
1153 let rest = command[i + 1..].trim_start();
1154 if rest.is_empty() {
1155 return None;
1156 }
1157 return Some(rest.to_string());
1158 }
1159 }
1160 i += 1;
1161 }
1162 None
1163}
1164
1165fn strip_env_prefix(command: &str) -> Option<String> {
1166 let rest = command.strip_prefix("env")?.trim_start();
1168 strip_leading_assignment_prefix(rest)
1169}
1170
1171fn strip_leading_assignment_prefix(command: &str) -> Option<String> {
1172 let mut index = 0usize;
1173 let mut consumed_assignment = false;
1174
1175 loop {
1176 index = skip_whitespace(command, index);
1177 if index >= command.len() {
1178 break;
1179 }
1180
1181 let word_end = shell_word_end(command, index)?;
1182 if word_end == index {
1183 break;
1184 }
1185
1186 let word = &command[index..word_end];
1187 if !is_env_assignment(word) {
1188 break;
1189 }
1190
1191 consumed_assignment = true;
1192 index = word_end;
1193 }
1194
1195 if !consumed_assignment {
1196 return None;
1197 }
1198
1199 let after = command[index..].trim_start();
1200 if after.is_empty() {
1201 None
1202 } else {
1203 Some(after.to_string())
1204 }
1205}
1206
1207fn skip_whitespace(input: &str, mut index: usize) -> usize {
1208 while index < input.len() {
1209 let Some(ch) = input[index..].chars().next() else {
1210 break;
1211 };
1212 if !ch.is_whitespace() {
1213 break;
1214 }
1215 index += ch.len_utf8();
1216 }
1217 index
1218}
1219
1220fn shell_word_end(command: &str, start: usize) -> Option<usize> {
1221 let mut in_single = false;
1222 let mut in_double = false;
1223 let mut escaped = false;
1224
1225 for (offset, ch) in command[start..].char_indices() {
1226 let index = start + offset;
1227
1228 if escaped {
1229 escaped = false;
1230 continue;
1231 }
1232
1233 if ch == '\\' && !in_single {
1234 escaped = true;
1235 continue;
1236 }
1237
1238 if ch == '\'' && !in_double {
1239 in_single = !in_single;
1240 continue;
1241 }
1242
1243 if ch == '"' && !in_single {
1244 in_double = !in_double;
1245 continue;
1246 }
1247
1248 if !in_single && !in_double && (ch.is_whitespace() || matches!(ch, ';' | '&' | '|')) {
1249 return Some(index);
1250 }
1251 }
1252
1253 if in_single || in_double || escaped {
1254 None
1255 } else {
1256 Some(command.len())
1257 }
1258}
1259
1260fn is_env_assignment(token: &str) -> bool {
1261 if token.starts_with('-') {
1262 return false;
1263 }
1264 let Some((name, _value)) = token.split_once('=') else {
1265 return false;
1266 };
1267 let mut chars = name.chars();
1268 let Some(first) = chars.next() else {
1269 return false;
1270 };
1271 (first.is_ascii_alphabetic() || first == '_')
1272 && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1273}
1274
1275fn strip_timeout_prefix(command: &str) -> Option<String> {
1276 let rest = command.strip_prefix("timeout")?.trim_start();
1277 let mut iter = rest.splitn(2, char::is_whitespace);
1279 let duration = iter.next()?;
1280 let after = iter.next()?.trim_start();
1281 if after.is_empty() || !looks_like_duration(duration) {
1282 return None;
1283 }
1284 Some(after.to_string())
1285}
1286
1287fn looks_like_duration(token: &str) -> bool {
1288 if token.is_empty() {
1289 return false;
1290 }
1291 let mut chars = token.chars().peekable();
1292 let mut saw_digit = false;
1293 while let Some(&ch) = chars.peek() {
1294 if ch.is_ascii_digit() {
1295 saw_digit = true;
1296 chars.next();
1297 } else {
1298 break;
1299 }
1300 }
1301 if !saw_digit {
1302 return false;
1303 }
1304 match chars.next() {
1305 None => true,
1306 Some(unit) => matches!(unit, 's' | 'm' | 'h' | 'd') && chars.next().is_none(),
1307 }
1308}
1309
1310pub fn user_filter_dir(storage_dir: &Path, harness: Harness) -> PathBuf {
1313 storage_dir.join(harness.storage_segment()).join("filters")
1314}
1315
1316fn legacy_user_filter_dir(storage_dir: &Path) -> PathBuf {
1317 storage_dir.join("filters")
1318}
1319
1320pub(crate) fn repair_legacy_user_filter_dir(storage_dir: &Path, harness: Harness) {
1324 let legacy_dir = legacy_user_filter_dir(storage_dir);
1325 if !legacy_dir.exists() {
1326 return;
1327 }
1328
1329 let entries = match fs::read_dir(&legacy_dir) {
1330 Ok(entries) => entries.filter_map(Result::ok).collect::<Vec<_>>(),
1331 Err(_) => return,
1332 };
1333 if entries.is_empty() {
1334 let _ = fs::remove_dir(&legacy_dir);
1335 return;
1336 }
1337
1338 let harness_dir = user_filter_dir(storage_dir, harness);
1339 if fs::create_dir_all(&harness_dir).is_err() {
1340 return;
1341 }
1342
1343 for entry in entries {
1344 let target = harness_dir.join(entry.file_name());
1345 if target.exists() {
1346 continue;
1347 }
1348 let _ = fs::rename(entry.path(), target);
1349 }
1350
1351 if fs::read_dir(&legacy_dir)
1352 .map(|mut entries| entries.next().is_none())
1353 .unwrap_or(false)
1354 {
1355 let _ = fs::remove_dir(&legacy_dir);
1356 }
1357}
1358
1359pub fn project_filter_dir(project_root: &Path) -> PathBuf {
1363 project_root.join(".cortexkit").join("aft").join("filters")
1364}
1365
1366#[cfg(test)]
1367mod tests {
1368 use super::*;
1369
1370 #[test]
1371 fn user_and_project_filter_dir_helpers() {
1372 let storage = Path::new("/tmp/aft-storage");
1373 assert_eq!(
1374 user_filter_dir(storage, Harness::Opencode),
1375 Path::new("/tmp/aft-storage/opencode/filters")
1376 );
1377
1378 let project = Path::new("/repo");
1379 assert_eq!(
1380 project_filter_dir(project),
1381 Path::new("/repo/.cortexkit/aft/filters")
1382 );
1383 }
1384
1385 #[test]
1386 fn repair_legacy_user_filter_dir_moves_root_filters_without_overwrite() {
1387 let temp = tempfile::tempdir().unwrap();
1388 let storage = temp.path();
1389 fs::create_dir_all(storage.join("filters")).unwrap();
1390 fs::create_dir_all(storage.join("opencode/filters")).unwrap();
1391 fs::write(storage.join("filters/root-only.toml"), "root").unwrap();
1392 fs::write(storage.join("filters/collides.toml"), "root").unwrap();
1393 fs::write(storage.join("opencode/filters/collides.toml"), "harness").unwrap();
1394
1395 repair_legacy_user_filter_dir(storage, Harness::Opencode);
1396
1397 assert_eq!(
1398 fs::read_to_string(storage.join("opencode/filters/root-only.toml")).unwrap(),
1399 "root"
1400 );
1401 assert_eq!(
1402 fs::read_to_string(storage.join("opencode/filters/collides.toml")).unwrap(),
1403 "harness"
1404 );
1405 assert_eq!(
1406 fs::read_to_string(storage.join("filters/collides.toml")).unwrap(),
1407 "root"
1408 );
1409 assert!(!storage.join("filters/root-only.toml").exists());
1410 }
1411}
1412
1413#[cfg(test)]
1414mod output_probe_tests {
1415 use super::*;
1416 use serde::Deserialize;
1417
1418 #[derive(Deserialize)]
1419 struct CorpusEntry {
1420 file: String,
1421 }
1422
1423 #[derive(Debug, PartialEq, Eq)]
1424 struct MatchPhaseResult {
1425 selected: Option<usize>,
1426 output: CompressionResult,
1427 }
1428
1429 fn legacy_match_phase(output: &str) -> MatchPhaseResult {
1430 let compressors = compressors_in_dispatch_order();
1431 let selected = [Specificity::Specific, Specificity::PackageManager]
1432 .into_iter()
1433 .find_map(|specificity| {
1434 compressors
1435 .iter()
1436 .enumerate()
1437 .filter(|(_, compressor)| compressor.specificity() == specificity)
1438 .find_map(|(index, compressor)| {
1439 compressor.matches_output(output).then_some(index)
1440 })
1441 });
1442 match_phase_result(&compressors, selected, output)
1443 }
1444
1445 fn probe_match_phase(output: &str) -> MatchPhaseResult {
1446 let compressors = compressors_in_dispatch_order();
1447 let probe = OutputProbe::new(output);
1448 let selected = [Specificity::Specific, Specificity::PackageManager]
1449 .into_iter()
1450 .find_map(|specificity| {
1451 compressors
1452 .iter()
1453 .enumerate()
1454 .filter(|(_, compressor)| compressor.specificity() == specificity)
1455 .find_map(|(index, compressor)| {
1456 compressor.matches_output_probe(&probe).then_some(index)
1457 })
1458 });
1459 match_phase_result(&compressors, selected, output)
1460 }
1461
1462 fn match_phase_result(
1463 compressors: &[&dyn Compressor],
1464 selected: Option<usize>,
1465 output: &str,
1466 ) -> MatchPhaseResult {
1467 let output = selected.map_or_else(
1468 || GenericCompressor.compress_with_exit_code("", output, None),
1469 |index| compressors[index].compress_output_match_with_exit_code(output, None),
1470 );
1471 MatchPhaseResult { selected, output }
1472 }
1473
1474 fn repository_root() -> PathBuf {
1475 Path::new(env!("CARGO_MANIFEST_DIR"))
1476 .parent()
1477 .and_then(Path::parent)
1478 .expect("aft crate should be nested under the repository root")
1479 .to_path_buf()
1480 }
1481
1482 fn benchmark_corpus() -> Vec<(String, String)> {
1483 let fixtures = repository_root().join("benchmarks/compression-tokens/fixtures");
1484 let manifest_text = fs::read_to_string(fixtures.join("manifest.json"))
1485 .expect("compression corpus manifest should be readable");
1486 let manifest: Vec<CorpusEntry> =
1487 serde_json::from_str(&manifest_text).expect("compression corpus manifest should parse");
1488 manifest
1489 .into_iter()
1490 .map(|entry| {
1491 let output = fs::read_to_string(fixtures.join(&entry.file))
1492 .unwrap_or_else(|error| panic!("failed to read {}: {error}", entry.file));
1493 (format!("compression corpus {}", entry.file), output)
1494 })
1495 .collect()
1496 }
1497
1498 fn integration_filter_inputs() -> Vec<(String, String)> {
1499 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1500 .join("tests/integration/fixtures/compress_filters");
1501 let mut paths = Vec::new();
1502 collect_named_files(&root, "input.txt", &mut paths);
1503 paths.sort();
1504 paths
1505 .into_iter()
1506 .map(|path| {
1507 let output = fs::read_to_string(&path)
1508 .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
1509 (format!("filter fixture {}", path.display()), output)
1510 })
1511 .collect()
1512 }
1513
1514 fn collect_named_files(root: &Path, wanted_name: &str, paths: &mut Vec<PathBuf>) {
1515 let entries = fs::read_dir(root)
1516 .unwrap_or_else(|error| panic!("failed to read {}: {error}", root.display()));
1517 for entry in entries {
1518 let path = entry
1519 .expect("fixture directory entry should be readable")
1520 .path();
1521 if path.is_dir() {
1522 collect_named_files(&path, wanted_name, paths);
1523 } else if path.file_name().is_some_and(|name| name == wanted_name) {
1524 paths.push(path);
1525 }
1526 }
1527 }
1528
1529 fn hostile_outputs() -> Vec<(String, String)> {
1530 let huge_json_array = format!(
1531 "[{}]",
1532 (0..20_000)
1533 .map(|index| format!(r#"{{"index":{index},"value":"payload"}}"#))
1534 .collect::<Vec<_>>()
1535 .join(",")
1536 );
1537 let ansi_heavy = (0..2_000)
1538 .map(|index| format!("\u{1b}[31mordinary output {index}\u{1b}[0m"))
1539 .collect::<Vec<_>>()
1540 .join("\n");
1541 vec![
1542 (
1543 "valid non-tool JSON object".to_string(),
1544 r#" {"payload":{"nested":[1,2,3]},"ok":true}"#.to_string(),
1545 ),
1546 ("huge JSON array".to_string(), huge_json_array),
1547 (
1548 "almost JSON".to_string(),
1549 r#"{"payload":[1,2,3],"unterminated":true"#.to_string(),
1550 ),
1551 ("empty output".to_string(), String::new()),
1552 ("ANSI-heavy output".to_string(), ansi_heavy),
1553 ]
1554 }
1555
1556 #[test]
1557 fn shared_probe_preserves_match_selection_and_output_across_corpora() {
1558 let cases = benchmark_corpus()
1559 .into_iter()
1560 .chain(integration_filter_inputs())
1561 .chain(hostile_outputs());
1562
1563 for (label, raw_output) in cases {
1564 let output = strip_ansi(&raw_output);
1565 let legacy = legacy_match_phase(&output);
1566 let probed = probe_match_phase(&output);
1567 assert_eq!(legacy, probed, "output-shape dispatch changed for {label}");
1568 }
1569 }
1570
1571 #[cfg(debug_assertions)]
1572 #[test]
1573 fn unmatched_json_object_is_parsed_once_across_match_phase() {
1574 let output = r#"{"payload":{"nested":[1,2,3]},"ok":true}"#;
1575 reset_output_probe_json_parse_count();
1576
1577 let result = probe_match_phase(output);
1578
1579 assert_eq!(result.selected, None);
1580 assert_eq!(output_probe_json_parse_count(), 1);
1581 }
1582}
1583
1584#[cfg(test)]
1585mod dispatch_specificity_tests {
1586 use super::*;
1587 use crate::compress::toml_filter::FilterRegistry;
1588
1589 fn empty_registry() -> FilterRegistry {
1590 FilterRegistry::default()
1591 }
1592
1593 fn dispatch(cmd: &str, output: &str) -> String {
1598 compress_with_registry(cmd, output, &empty_registry()).text
1599 }
1600
1601 #[test]
1602 fn generic_dispatch_does_not_classify_error_or_warning_words() {
1603 let result = compress_with_registry(
1604 "unknown-tool",
1605 "error: this is just a log line\nwarning: this too",
1606 &empty_registry(),
1607 );
1608
1609 assert!(result.dropped_by_class.is_empty());
1610 assert!(!result.had_inner_drop);
1611 assert!(result.text.contains("error: this is just a log line"));
1612 }
1613
1614 #[test]
1615 fn bun_run_vitest_routes_to_vitest_not_generic() {
1616 let output = "Test Files 1 passed (1)\n Tests 4 passed (4)\n Start at 10:00:00\n Duration 120ms\n";
1621 let compressed = dispatch("bun run vitest", output);
1622 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1624 }
1625
1626 #[test]
1627 fn npm_test_routes_to_vitest_when_output_is_vitest_shaped() {
1628 let output = "RERUN src/foo.test.ts x1\nFAIL src/foo.test.ts\nTest Files 1 failed (1)\nDuration 120ms\n";
1631 let compressed = dispatch("npm test", output);
1632 assert!(compressed.contains("FAIL src/foo.test.ts"));
1633 assert!(compressed.contains("Duration 120ms"));
1634 assert!(!compressed.contains("RERUN"));
1635 }
1636
1637 #[test]
1638 fn bun_run_vitest_token_match_wins_over_bun_head_match() {
1639 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";
1642 let compressed = dispatch("bun run vitest run", output);
1643 assert!(compressed.contains("Test Files") || compressed.contains("PASS"));
1645 }
1646
1647 #[test]
1648 fn bunx_jest_routes_to_vitest_module() {
1649 let output = "PASS src/foo.test.js (1.2s)\nTest Suites: 1 passed, 1 total\nTests: 3 passed, 3 total\n";
1650 let compressed = dispatch("bunx jest --json", output);
1651 assert!(compressed.contains("Tests:") && compressed.contains("Test Suites"));
1652 }
1653
1654 #[test]
1655 fn pnpm_run_vitest_routes_to_vitest() {
1656 let output = "Test Files 1 passed (1)\n Tests 10 passed (10)\n";
1657 let compressed = dispatch("pnpm run vitest", output);
1658 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1659 }
1660
1661 #[test]
1662 fn npx_eslint_routes_to_eslint_not_generic() {
1663 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";
1664 let compressed = dispatch("npx eslint .", output);
1665 assert!(compressed.contains("no-unused-vars") || compressed.contains("✖"));
1667 }
1668
1669 #[test]
1670 fn npm_run_lint_without_linter_output_shape_falls_back() {
1671 let output = "> my-project@1.0.0 lint\n> eslint .\n\nAll good.\n";
1674 let compressed = dispatch("npm run lint", output);
1675 assert!(compressed.contains("All good."));
1676 }
1677
1678 #[test]
1679 fn bun_test_still_routes_to_bun_test_compressor() {
1680 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";
1688 let compressed = dispatch("bun test", output);
1689 assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
1690 }
1691
1692 #[test]
1693 fn bunx_vitest_routes_to_vitest() {
1694 let output = "Test Files 1 passed (1)\n Tests 3 passed (3)\n";
1695 let compressed = dispatch("bunx vitest run", output);
1696 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1697 }
1698
1699 #[test]
1700 fn cargo_test_still_routes_to_cargo() {
1701 let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
1704 let compressed = dispatch("cargo test", output);
1705 assert!(compressed.contains("failed") || compressed.contains("FAILED"));
1707 }
1708
1709 #[test]
1710 fn top_level_piped_cargo_test_uses_generic_output() {
1711 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
1712
1713 let compressed = compress_with_registry("cargo test | cat", output, &empty_registry());
1714
1715 assert!(
1716 compressed.text.contains("test ok_test ... ok"),
1717 "piped cargo output must stay generic/raw, got: {}",
1718 compressed.text
1719 );
1720 }
1721
1722 #[test]
1723 fn non_piped_cargo_test_still_uses_cargo_compressor() {
1724 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
1725
1726 let compressed = compress_with_registry("cargo test", output, &empty_registry());
1727
1728 assert!(compressed.text.contains("running 1 test"));
1729 assert!(compressed.text.contains("test result: ok"));
1730 assert!(
1731 !compressed.text.contains("test ok_test ... ok"),
1732 "non-piped cargo test should keep using the cargo compressor, got: {}",
1733 compressed.text
1734 );
1735 }
1736
1737 #[test]
1738 fn git_status_still_routes_to_git() {
1739 let output =
1741 "On branch main\nYour branch is up to date.\n\nnothing to commit, working tree clean\n";
1742 let compressed = dispatch("git status", output);
1743 assert!(compressed.contains("branch") || compressed.contains("clean"));
1744 }
1745
1746 #[test]
1747 fn pnpm_install_still_routes_to_pnpm() {
1748 let output = "Progress: resolved 100, downloaded 50, added 50\nAdded 50 packages\n";
1750 let compressed = dispatch("pnpm install", output);
1751 assert!(compressed.contains("Added") || compressed.contains("Progress"));
1753 }
1754}
1755
1756#[cfg(test)]
1757mod exit_code_safety_tests {
1758 use super::*;
1759 use crate::compress::toml_filter::{build_registry, FilterRegistry};
1760
1761 fn empty_registry() -> FilterRegistry {
1762 FilterRegistry::default()
1763 }
1764
1765 #[test]
1766 fn go_build_failure_signal_preserved_even_when_exit_zero_masks_failure() {
1767 let output = "go: go.mod file not found in current directory or any parent directory; see 'go help modules'\n";
1768
1769 let failed =
1770 compress_with_registry_exit_code("go build ./...", output, Some(1), &empty_registry());
1771 assert!(!failed.text.contains("go build: ok"));
1772 assert!(failed.text.contains("go.mod file not found"));
1773
1774 let masked =
1775 compress_with_registry_exit_code("go build ./...", output, Some(0), &empty_registry());
1776 assert!(!masked.text.contains("go build: ok"));
1777 assert!(masked.text.contains("go.mod file not found"));
1778 }
1779
1780 #[test]
1781 fn playwright_nonzero_crash_does_not_become_passed_summary() {
1782 let output = r#"Running 4 tests using 2 workers
1783
1784 ✓ 1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
1785 ✓ 2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
1786 ✓ 3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
1787 ✓ 4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
1788
1789 4 passed (6.3s)
1790Error: browserType.launch: Target page, context or browser has been closed
1791"#;
1792
1793 let failed = compress_with_registry_exit_code(
1794 "npx playwright test",
1795 output,
1796 Some(1),
1797 &empty_registry(),
1798 );
1799 assert!(!failed.text.starts_with("playwright: 4 tests passed"));
1800 assert!(failed.text.contains("browserType.launch"));
1801 }
1802
1803 #[test]
1804 fn cargo_test_compile_error_nonzero_preserves_error_code_diagnostic() {
1805 let output = r#" Compiling demo v0.1.0 (/tmp/demo)
1806error[E0432]: unresolved import `crate::missing`
1807 --> src/lib.rs:1:5
1808 |
18091 | use crate::missing;
1810 | ^^^^^^^^^^^^^^ no `missing` in the root
1811
1812error: could not compile `demo` (lib test) due to 1 previous error
1813"#;
1814
1815 let failed =
1816 compress_with_registry_exit_code("cargo test", output, Some(101), &empty_registry());
1817 assert!(failed.text.contains("error[E0432]"));
1818 assert!(failed.text.contains("unresolved import"));
1819 assert!(failed.text.contains("error: could not compile"));
1820 }
1821
1822 #[test]
1823 fn chained_mypy_success_then_later_failure_uses_failure_preserving_output() {
1824 let output = "Success: no issues found in 1 source file\nError: node process exploded\n";
1825
1826 let failed = compress_with_registry_exit_code(
1827 "mypy src && node fail.js",
1828 output,
1829 Some(1),
1830 &empty_registry(),
1831 );
1832 assert_ne!(failed.text, "mypy: clean");
1833 assert!(failed.text.contains("Error: node process exploded"));
1834 }
1835
1836 #[test]
1837 fn toml_shortcircuit_is_skipped_for_nonzero_exit() {
1838 let registry = build_registry(
1839 &[(
1840 "wget",
1841 r#"[filter]
1842matches = ["wget"]
1843
1844[shortcircuit]
1845when = '(?s).*'
1846replacement = "wget: ok"
1847"#,
1848 )],
1849 None,
1850 None,
1851 );
1852 let output = "Connecting to example.invalid\nerror: connection refused\n";
1853
1854 let failed = compress_with_registry_exit_code(
1855 "wget https://example.invalid",
1856 output,
1857 Some(1),
1858 ®istry,
1859 );
1860 assert_ne!(failed.text, "wget: ok");
1861 assert!(failed.text.contains("error: connection refused"));
1862 }
1863
1864 #[test]
1865 fn unknown_exit_code_keeps_byte_identical_legacy_compressor_output() {
1866 let output =
1867 "Success: no issues found in 1 source file\nError: later chained command failed\n";
1868
1869 let legacy = compress_with_registry_exit_code(
1870 "mypy src && node fail.js",
1871 output,
1872 None,
1873 &empty_registry(),
1874 );
1875 assert_eq!(legacy.text, "mypy: clean");
1876 }
1877
1878 #[test]
1879 fn killed_exit_sentinel_rejects_clean_legacy_summary() {
1880 let output = "Success: no issues found in 1 source file
1881Error: later chained command failed
1882";
1883
1884 let killed = compress_with_registry_exit_code(
1885 "mypy src && node fail.js",
1886 output,
1887 Some(137),
1888 &empty_registry(),
1889 );
1890 assert_ne!(killed.text, "mypy: clean");
1891 assert!(killed.text.contains("Error: later chained command failed"));
1892 }
1893
1894 #[test]
1895 fn nonzero_clean_eslint_json_summary_falls_back_to_raw_output() {
1896 let output =
1897 r#"[{"filePath":"/repo/src/main.ts","messages":[],"errorCount":0,"warningCount":0}]"#;
1898
1899 let failed = compress_with_registry_exit_code(
1900 "eslint -f json .",
1901 output,
1902 Some(1),
1903 &empty_registry(),
1904 );
1905
1906 assert_ne!(failed.text, "eslint: no issues");
1907 assert!(failed.text.contains(r#""messages":[]"#));
1908 }
1909
1910 #[test]
1911 fn nonzero_appends_distinct_missing_raw_failure_lines() {
1912 let raw = "Error: first failure
1913progress
1914Error: second failure
1915";
1916 let compressed = CompressionResult::new("Error: first failure");
1917
1918 let preserved = failure_preserving_result("tool", raw, compressed, Some(1));
1919
1920 assert!(preserved.text.contains("Error: first failure"));
1921 assert!(preserved.text.contains("Error: second failure"));
1922 assert!(preserved
1923 .text
1924 .contains("[raw failure lines preserved by AFT]"));
1925 }
1926
1927 #[test]
1928 fn nonzero_cargo_failure_class_cap_falls_back_to_all_failures() {
1929 let mut output = String::from(
1930 "running 40 tests
1931
1932failures:
1933
1934",
1935 );
1936 for index in 0..40 {
1937 output.push_str(&format!(
1938 "---- case_{index} stdout ----
1939thread 'case_{index}' panicked at src/lib.rs:{index}:1
1940
1941"
1942 ));
1943 }
1944 output.push_str(
1945 "failures:
1946",
1947 );
1948 for index in 0..40 {
1949 output.push_str(&format!(
1950 " case_{index}
1951"
1952 ));
1953 }
1954 output.push_str(
1955 "
1956test result: FAILED. 0 passed; 40 failed; 0 ignored; 0 measured; 0 filtered out
1957",
1958 );
1959
1960 let failed =
1961 compress_with_registry_exit_code("cargo test", &output, Some(101), &empty_registry());
1962
1963 assert!(failed.text.contains("---- case_0 stdout ----"));
1964 assert!(failed.text.contains("---- case_39 stdout ----"));
1965 assert!(failed.dropped_by_class.is_empty());
1966 }
1967
1968 #[test]
1969 fn toml_shortcircuit_is_skipped_for_unknown_exit_when_failure_signal_exists() {
1970 let registry = build_registry(
1971 &[(
1972 "make",
1973 r#"[filter]
1974matches = ["make"]
1975
1976[shortcircuit]
1977when = '(?s).*'
1978replacement = "make: ok"
1979"#,
1980 )],
1981 None,
1982 None,
1983 );
1984 let output = "build step
1985ERROR: compiler crashed
1986";
1987
1988 let failed = compress_with_registry_exit_code("make", output, None, ®istry);
1989
1990 assert_ne!(failed.text, "make: ok");
1991 assert!(failed.text.contains("ERROR: compiler crashed"));
1992 }
1993
1994 #[test]
1995 fn successful_exit_still_gets_concise_success_summary() {
1996 let output = r#"Running 4 tests using 2 workers
1997
1998 ✓ 1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
1999 ✓ 2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
2000 ✓ 3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
2001 ✓ 4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
2002
2003 4 passed (6.3s)
2004"#;
2005
2006 let successful =
2007 compress_with_registry_exit_code("playwright test", output, Some(0), &empty_registry());
2008 assert_eq!(successful.text, "playwright: 4 tests passed (6.3s)");
2009 }
2010}
2011
2012#[cfg(test)]
2013mod normalize_command_tests {
2014 use super::*;
2015
2016 #[test]
2017 fn passes_bare_commands_unchanged() {
2018 assert_eq!(normalize_command_for_dispatch("bun test"), None);
2019 assert_eq!(normalize_command_for_dispatch("cargo build"), None);
2020 assert_eq!(normalize_command_for_dispatch("git status"), None);
2021 }
2022
2023 #[test]
2024 fn strips_cd_and_amp_prefix() {
2025 assert_eq!(
2026 normalize_command_for_dispatch("cd /repo && bun test").as_deref(),
2027 Some("bun test")
2028 );
2029 assert_eq!(
2030 normalize_command_for_dispatch("cd /repo/packages/aft && cargo test --release")
2031 .as_deref(),
2032 Some("cargo test --release")
2033 );
2034 }
2035
2036 #[test]
2037 fn strips_cd_and_semicolon_prefix() {
2038 assert_eq!(
2039 normalize_command_for_dispatch("cd /repo; bun test").as_deref(),
2040 Some("bun test")
2041 );
2042 }
2043
2044 #[test]
2045 fn strips_cd_with_quoted_path() {
2046 assert_eq!(
2047 normalize_command_for_dispatch("cd \"/path with space\" && npm install").as_deref(),
2048 Some("npm install")
2049 );
2050 }
2051
2052 #[test]
2053 fn strips_env_assignments() {
2054 assert_eq!(
2055 normalize_command_for_dispatch("env FOO=bar npm install").as_deref(),
2056 Some("npm install")
2057 );
2058 assert_eq!(
2059 normalize_command_for_dispatch("env FOO=bar BAZ=qux RUST_LOG=info cargo test")
2060 .as_deref(),
2061 Some("cargo test")
2062 );
2063 }
2064
2065 #[test]
2066 fn strips_bare_assignment_prefixes() {
2067 assert_eq!(
2068 normalize_command_for_dispatch("NODE_ENV=production npm install").as_deref(),
2069 Some("npm install")
2070 );
2071 assert_eq!(
2072 normalize_command_for_dispatch("FOO=1 BAR=2 cargo test").as_deref(),
2073 Some("cargo test")
2074 );
2075 assert_eq!(
2076 normalize_command_for_dispatch("RUSTFLAGS='-C debug' cargo build").as_deref(),
2077 Some("cargo build")
2078 );
2079 }
2080
2081 #[test]
2082 fn does_not_strip_later_assignment_arguments() {
2083 assert_eq!(normalize_command_for_dispatch("npm install foo=bar"), None);
2084 }
2085
2086 #[test]
2087 fn env_without_assignments_returns_none() {
2088 assert_eq!(
2090 normalize_command_for_dispatch("env npm install").as_deref(),
2091 None
2092 );
2093 }
2094
2095 #[test]
2096 fn strips_timeout_prefix() {
2097 assert_eq!(
2098 normalize_command_for_dispatch("timeout 30 cargo test").as_deref(),
2099 Some("cargo test")
2100 );
2101 assert_eq!(
2102 normalize_command_for_dispatch("timeout 5m bun test").as_deref(),
2103 Some("bun test")
2104 );
2105 }
2106
2107 #[test]
2108 fn strips_nohup_prefix() {
2109 assert_eq!(
2110 normalize_command_for_dispatch("nohup ./long-running-script.sh").as_deref(),
2111 Some("./long-running-script.sh")
2112 );
2113 }
2114
2115 #[test]
2116 fn strips_paren_then_cd_and_amp() {
2117 assert_eq!(
2118 normalize_command_for_dispatch("(cd /repo && bun test").as_deref(),
2119 Some("bun test")
2120 );
2121 }
2122
2123 #[test]
2124 fn chains_multiple_prefixes() {
2125 assert_eq!(
2127 normalize_command_for_dispatch("env FOO=bar timeout 30 cargo test").as_deref(),
2128 Some("cargo test")
2129 );
2130 assert_eq!(
2132 normalize_command_for_dispatch("cd /repo && env FOO=bar npm install").as_deref(),
2133 Some("npm install")
2134 );
2135 }
2136
2137 fn empty_registry() -> FilterRegistry {
2140 FilterRegistry::default()
2141 }
2142
2143 #[test]
2144 fn cd_prefix_bun_test_still_routes_to_bun_test() {
2145 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";
2146 let compressed = compress_with_registry("cd /repo && bun test", output, &empty_registry());
2147 assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
2152 }
2153
2154 #[test]
2155 fn cd_prefix_cargo_test_still_routes_to_cargo() {
2156 let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
2157 let compressed =
2158 compress_with_registry("cd /repo && cargo test", output, &empty_registry());
2159 assert!(compressed.contains("FAILED") || compressed.contains("failed"));
2160 }
2161
2162 #[test]
2163 fn env_prefix_npm_install_still_routes_to_npm() {
2164 let output = "added 50 packages, and audited 100 packages in 3s\n";
2165 let compressed = compress_with_registry(
2166 "env NODE_ENV=production npm install",
2167 output,
2168 &empty_registry(),
2169 );
2170 assert!(compressed.contains("added") || compressed.contains("audited"));
2172 }
2173
2174 #[test]
2175 fn bare_assignment_prefix_npm_install_routes_to_npm() {
2176 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";
2177 let compressed =
2178 compress_with_registry("NODE_ENV=production npm install", output, &empty_registry());
2179 assert!(!compressed.contains("npm http fetch"));
2180 assert!(compressed.contains("audited 100 packages"));
2181 }
2182
2183 #[test]
2184 fn bare_assignment_prefix_cargo_test_routes_to_cargo() {
2185 let output = "running 1 test\ntest foo ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n";
2186 let compressed =
2187 compress_with_registry("FOO=1 BAR=2 cargo test", output, &empty_registry());
2188 assert!(compressed.contains("running 1 test"));
2189 assert!(compressed.contains("test result: ok"));
2190 assert!(!compressed.contains("test foo ... ok"));
2191 }
2192
2193 #[test]
2194 fn quoted_assignment_prefix_cargo_build_routes_to_cargo() {
2195 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";
2196 let compressed = compress_with_registry(
2197 "RUSTFLAGS='-C debug' cargo build",
2198 output,
2199 &empty_registry(),
2200 );
2201 assert!(!compressed.contains("Compiling foo"));
2202 assert!(compressed.contains("warning: unused variable"));
2203 assert!(compressed.contains("Finished `dev` profile"));
2204 }
2205
2206 #[test]
2207 fn timeout_prefix_cargo_build_still_routes_to_cargo() {
2208 let output =
2209 " Compiling foo v0.1.0\n Finished `dev` profile [unoptimized] target(s) in 5s\n";
2210 let compressed =
2211 compress_with_registry("timeout 30 cargo build", output, &empty_registry());
2212 assert!(compressed.contains("Compiling") || compressed.contains("Finished"));
2214 }
2215
2216 #[test]
2217 fn normalize_splits_pipe_and_takes_last_stage() {
2218 assert_eq!(
2219 normalize_command_for_dispatch("git log | grep fix").as_deref(),
2220 Some("grep fix")
2221 );
2222 }
2223
2224 #[test]
2225 fn normalize_cd_prefix_then_pipe_takes_last_stage() {
2226 assert_eq!(
2227 normalize_command_for_dispatch("cd /repo && git log | grep fix").as_deref(),
2228 Some("grep fix")
2229 );
2230 }
2231
2232 #[test]
2233 fn normalize_no_pipe_returns_none() {
2234 assert_eq!(normalize_command_for_dispatch("git log"), None);
2235 }
2236
2237 #[test]
2238 fn normalize_quoted_pipe_not_split() {
2239 assert_eq!(
2240 normalize_command_for_dispatch("grep \"a|b\" file.txt"),
2241 None
2242 );
2243 }
2244
2245 #[test]
2246 fn normalize_balanced_command_substitution_splits_top_level_pipe() {
2247 assert_eq!(
2252 normalize_command_for_dispatch("echo $(cmd | cmd) | grep x").as_deref(),
2253 Some("grep x")
2254 );
2255 }
2256
2257 #[test]
2258 fn normalize_inner_pipe_in_substitution_without_top_level_pipe_is_none() {
2259 assert_eq!(
2261 normalize_command_for_dispatch("echo $(cargo test | cat)"),
2262 None
2263 );
2264 }
2265
2266 #[test]
2267 fn normalize_double_pipe_not_split() {
2268 assert_eq!(normalize_command_for_dispatch("git log || echo fail"), None);
2269 }
2270
2271 #[test]
2272 fn normalize_multi_pipe_returns_last_stage() {
2273 assert_eq!(
2274 normalize_command_for_dispatch("git log | grep fix | head -5").as_deref(),
2275 Some("head -5")
2276 );
2277 }
2278
2279 #[test]
2280 fn normalize_process_substitution_splits_top_level_pipe() {
2281 assert_eq!(
2283 normalize_command_for_dispatch("cat <(echo a | cat) | grep x").as_deref(),
2284 Some("grep x")
2285 );
2286 }
2287
2288 #[test]
2289 fn normalize_pipe_ampersand_splits_last_stage() {
2290 assert_eq!(
2292 normalize_command_for_dispatch("cargo test |& grep FAIL").as_deref(),
2293 Some("grep FAIL")
2294 );
2295 }
2296
2297 #[test]
2298 fn piped_cargo_test_grep_preserves_failed() {
2299 let grep_output = "test foo ... FAILED\n";
2300 let compressed =
2301 compress_with_registry("cargo test | grep FAIL", grep_output, &empty_registry());
2302 assert!(
2303 compressed.text.contains("FAILED"),
2304 "grep-filtered FAILED must survive, got: {}",
2305 compressed.text
2306 );
2307 }
2308
2309 #[test]
2310 fn unsafe_piped_command_forces_generic_and_preserves_output() {
2311 let grep_output = "test foo ... FAILED\n";
2315 let compressed =
2316 compress_with_registry("cargo test | grep \"FAIL", grep_output, &empty_registry());
2317 assert!(
2318 compressed.text.contains("FAILED"),
2319 "unsafe pipe must not drop output, got: {}",
2320 compressed.text
2321 );
2322 }
2323
2324 #[test]
2325 fn split_top_level_pipe_variants() {
2326 assert_eq!(split_top_level_pipe("git log"), PipeSplit::None);
2327 assert_eq!(
2328 split_top_level_pipe("git log | grep fix"),
2329 PipeSplit::LastStage("grep fix".to_string())
2330 );
2331 assert_eq!(split_top_level_pipe("a || b"), PipeSplit::None);
2333 assert_eq!(split_top_level_pipe("(a | b)"), PipeSplit::None);
2335 assert_eq!(split_top_level_pipe("echo $(a | b)"), PipeSplit::None);
2337 assert_eq!(split_top_level_pipe("a | grep \"x"), PipeSplit::Unsafe);
2339 assert_eq!(split_top_level_pipe("$(a | b | grep x"), PipeSplit::Unsafe);
2341 assert_eq!(split_top_level_pipe("cargo test |"), PipeSplit::Unsafe);
2346 assert_eq!(split_top_level_pipe("cargo test |&"), PipeSplit::Unsafe);
2347 assert_eq!(
2349 split_top_level_pipe("true | cargo test --quiet ; printf X"),
2350 PipeSplit::Unsafe
2351 );
2352 assert_eq!(
2353 split_top_level_pipe("true | cargo test && echo done"),
2354 PipeSplit::Unsafe
2355 );
2356 assert_eq!(
2358 split_top_level_pipe("echo ) | cargo test"),
2359 PipeSplit::Unsafe
2360 );
2361 assert_eq!(split_top_level_pipe("a | b & c"), PipeSplit::Unsafe);
2363 assert_eq!(
2364 split_top_level_pipe("cargo test 2>&1 | grep FAIL"),
2365 PipeSplit::LastStage("grep FAIL".to_string())
2366 );
2367 }
2368
2369 #[test]
2370 fn strip_top_level_comment_removes_only_real_comments() {
2371 assert_eq!(
2372 strip_top_level_comment("printf keep # | cargo test"),
2373 "printf keep "
2374 );
2375 assert_eq!(
2376 strip_top_level_comment("printf keep # cargo test"),
2377 "printf keep "
2378 );
2379 assert_eq!(
2381 strip_top_level_comment("curl http://x/y#frag"),
2382 "curl http://x/y#frag"
2383 );
2384 assert_eq!(
2386 strip_top_level_comment("grep \"# not a comment\" f"),
2387 "grep \"# not a comment\" f"
2388 );
2389 assert_eq!(
2390 strip_top_level_comment("echo '# literal'"),
2391 "echo '# literal'"
2392 );
2393 assert_eq!(
2395 strip_top_level_comment("git log | grep fix"),
2396 "git log | grep fix"
2397 );
2398 }
2399
2400 #[test]
2401 fn commented_command_does_not_misdispatch_and_preserves_output() {
2402 for cmd in ["printf keep # | cargo test", "printf keep # cargo test"] {
2405 let compressed = compress_with_registry(cmd, "keep\n", &empty_registry());
2406 assert!(
2407 compressed.text.contains("keep"),
2408 "comment must not drop output for {cmd:?}, got: {}",
2409 compressed.text
2410 );
2411 }
2412 }
2413
2414 #[test]
2415 fn pipe_with_trailing_command_chain_preserves_sentinel() {
2416 let compressed = compress_with_registry(
2419 "true | cargo test --quiet ; printf SENTINEL",
2420 "SENTINEL\n",
2421 &empty_registry(),
2422 );
2423 assert!(
2424 compressed.text.contains("SENTINEL"),
2425 "trailing-chain output must survive, got: {}",
2426 compressed.text
2427 );
2428 }
2429
2430 #[test]
2431 fn is_shell_boundary_covers_redirects_and_operators() {
2432 for tok in [
2433 "|",
2434 "|&",
2435 ";",
2436 "&",
2437 "&&",
2438 "||",
2439 ">",
2440 ">>",
2441 "<",
2442 "<<",
2443 "<<<",
2444 "&>",
2445 "&>>",
2446 "2>",
2447 "2>>",
2448 "2>&1",
2449 "1>&2",
2450 ">/dev/null",
2451 "2>/dev/null",
2452 ] {
2453 assert!(is_shell_boundary(tok), "{tok} should be a boundary");
2454 }
2455 for tok in ["test", "log", "build", "--release", "-v", "file.txt"] {
2456 assert!(!is_shell_boundary(tok), "{tok} must not be a boundary");
2457 }
2458 }
2459}