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,
966 LastStage(String),
968 Unsafe,
975}
976
977fn split_top_level_pipe(command: &str) -> PipeSplit {
995 let bytes = command.as_bytes();
996 let mut in_single = false;
997 let mut in_double = false;
998 let mut in_backtick = false;
999 let mut paren_depth: u32 = 0;
1000 let mut escaped = false;
1001 let mut saw_unmatched_close = false;
1002 let mut saw_top_pipe = false;
1003 let mut saw_top_separator = false;
1004 let mut last_pipe_end: Option<usize> = None;
1005
1006 let mut i = 0;
1007 while i < bytes.len() {
1008 let ch = bytes[i];
1009
1010 if escaped {
1011 escaped = false;
1012 i += 1;
1013 continue;
1014 }
1015 if in_single {
1016 if ch == b'\'' {
1017 in_single = false;
1018 }
1019 i += 1;
1020 continue;
1021 }
1022 if in_backtick {
1023 if ch == b'\\' {
1026 escaped = true;
1027 } else if ch == b'`' {
1028 in_backtick = false;
1029 }
1030 i += 1;
1031 continue;
1032 }
1033 if ch == b'\\' {
1034 escaped = true;
1035 i += 1;
1036 continue;
1037 }
1038 if ch == b'`' {
1039 in_backtick = true;
1040 i += 1;
1041 continue;
1042 }
1043 if ch == b'$' && bytes.get(i + 1) == Some(&b'(') {
1045 paren_depth += 1;
1046 i += 2;
1047 continue;
1048 }
1049 if in_double {
1050 if ch == b'"' {
1051 in_double = false;
1052 }
1053 i += 1;
1054 continue;
1055 }
1056
1057 let prev_raw = if i > 0 { bytes[i - 1] } else { b' ' };
1061
1062 match ch {
1063 b'\'' => in_single = true,
1064 b'"' => in_double = true,
1065 b'<' | b'>' if bytes.get(i + 1) == Some(&b'(') => {
1067 paren_depth += 1;
1068 i += 2;
1069 continue;
1070 }
1071 b'(' => paren_depth += 1,
1072 b')' => {
1073 if paren_depth == 0 {
1074 saw_unmatched_close = true;
1075 } else {
1076 paren_depth -= 1;
1077 }
1078 }
1079 b'|' if paren_depth == 0 => {
1080 if bytes.get(i + 1) == Some(&b'|') {
1081 saw_top_separator = true; i += 2;
1083 continue;
1084 }
1085 saw_top_pipe = true;
1086 if bytes.get(i + 1) == Some(&b'&') {
1087 last_pipe_end = Some(i + 2); i += 2;
1089 continue;
1090 }
1091 last_pipe_end = Some(i + 1);
1092 }
1093 b'&' if paren_depth == 0 => {
1094 if bytes.get(i + 1) == Some(&b'&') {
1095 saw_top_separator = true; i += 2;
1097 continue;
1098 }
1099 if bytes.get(i + 1) != Some(&b'>') && prev_raw != b'>' {
1102 saw_top_separator = true;
1103 }
1104 }
1105 b';' if paren_depth == 0 => saw_top_separator = true,
1106 b'\n' if paren_depth == 0 => saw_top_separator = true,
1107 _ => {}
1108 }
1109 i += 1;
1110 }
1111
1112 let imbalance =
1113 in_single || in_double || in_backtick || escaped || paren_depth != 0 || saw_unmatched_close;
1114
1115 if saw_top_pipe {
1116 if imbalance || saw_top_separator {
1118 return PipeSplit::Unsafe;
1119 }
1120 match last_pipe_end {
1121 Some(end) => {
1122 let last_stage = command[end..].trim();
1123 if last_stage.is_empty() {
1124 PipeSplit::Unsafe } else {
1126 PipeSplit::LastStage(last_stage.to_string())
1127 }
1128 }
1129 None => PipeSplit::Unsafe,
1130 }
1131 } else if imbalance && command.contains('|') {
1132 PipeSplit::Unsafe
1134 } else if saw_top_separator {
1135 PipeSplit::Unsafe
1140 } else {
1141 PipeSplit::None
1142 }
1143}
1144
1145fn strip_cd_prefix(command: &str) -> Option<String> {
1146 let bytes = command.as_bytes();
1148 let mut in_single = false;
1149 let mut in_double = false;
1150 let mut i = 0;
1151 while i < bytes.len() {
1152 let ch = bytes[i] as char;
1153 if !in_double && ch == '\'' {
1154 in_single = !in_single;
1155 } else if !in_single && ch == '"' {
1156 in_double = !in_double;
1157 } else if !in_single && !in_double {
1158 if ch == '&' && i + 1 < bytes.len() && bytes[i + 1] as char == '&' {
1159 let rest = command[i + 2..].trim_start();
1160 if rest.is_empty() {
1161 return None;
1162 }
1163 return Some(rest.to_string());
1164 }
1165 if ch == ';' {
1166 let rest = command[i + 1..].trim_start();
1167 if rest.is_empty() {
1168 return None;
1169 }
1170 return Some(rest.to_string());
1171 }
1172 }
1173 i += 1;
1174 }
1175 None
1176}
1177
1178fn strip_env_prefix(command: &str) -> Option<String> {
1179 let rest = command.strip_prefix("env")?.trim_start();
1181 strip_leading_assignment_prefix(rest)
1182}
1183
1184fn strip_leading_assignment_prefix(command: &str) -> Option<String> {
1185 let mut index = 0usize;
1186 let mut consumed_assignment = false;
1187
1188 loop {
1189 index = skip_whitespace(command, index);
1190 if index >= command.len() {
1191 break;
1192 }
1193
1194 let word_end = shell_word_end(command, index)?;
1195 if word_end == index {
1196 break;
1197 }
1198
1199 let word = &command[index..word_end];
1200 if !is_env_assignment(word) {
1201 break;
1202 }
1203
1204 consumed_assignment = true;
1205 index = word_end;
1206 }
1207
1208 if !consumed_assignment {
1209 return None;
1210 }
1211
1212 let after = command[index..].trim_start();
1213 if after.is_empty() {
1214 None
1215 } else {
1216 Some(after.to_string())
1217 }
1218}
1219
1220fn skip_whitespace(input: &str, mut index: usize) -> usize {
1221 while index < input.len() {
1222 let Some(ch) = input[index..].chars().next() else {
1223 break;
1224 };
1225 if !ch.is_whitespace() {
1226 break;
1227 }
1228 index += ch.len_utf8();
1229 }
1230 index
1231}
1232
1233fn shell_word_end(command: &str, start: usize) -> Option<usize> {
1234 let mut in_single = false;
1235 let mut in_double = false;
1236 let mut escaped = false;
1237
1238 for (offset, ch) in command[start..].char_indices() {
1239 let index = start + offset;
1240
1241 if escaped {
1242 escaped = false;
1243 continue;
1244 }
1245
1246 if ch == '\\' && !in_single {
1247 escaped = true;
1248 continue;
1249 }
1250
1251 if ch == '\'' && !in_double {
1252 in_single = !in_single;
1253 continue;
1254 }
1255
1256 if ch == '"' && !in_single {
1257 in_double = !in_double;
1258 continue;
1259 }
1260
1261 if !in_single && !in_double && (ch.is_whitespace() || matches!(ch, ';' | '&' | '|')) {
1262 return Some(index);
1263 }
1264 }
1265
1266 if in_single || in_double || escaped {
1267 None
1268 } else {
1269 Some(command.len())
1270 }
1271}
1272
1273fn is_env_assignment(token: &str) -> bool {
1274 if token.starts_with('-') {
1275 return false;
1276 }
1277 let Some((name, _value)) = token.split_once('=') else {
1278 return false;
1279 };
1280 let mut chars = name.chars();
1281 let Some(first) = chars.next() else {
1282 return false;
1283 };
1284 (first.is_ascii_alphabetic() || first == '_')
1285 && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1286}
1287
1288fn strip_timeout_prefix(command: &str) -> Option<String> {
1289 let rest = command.strip_prefix("timeout")?.trim_start();
1290 let mut iter = rest.splitn(2, char::is_whitespace);
1292 let duration = iter.next()?;
1293 let after = iter.next()?.trim_start();
1294 if after.is_empty() || !looks_like_duration(duration) {
1295 return None;
1296 }
1297 Some(after.to_string())
1298}
1299
1300fn looks_like_duration(token: &str) -> bool {
1301 if token.is_empty() {
1302 return false;
1303 }
1304 let mut chars = token.chars().peekable();
1305 let mut saw_digit = false;
1306 while let Some(&ch) = chars.peek() {
1307 if ch.is_ascii_digit() {
1308 saw_digit = true;
1309 chars.next();
1310 } else {
1311 break;
1312 }
1313 }
1314 if !saw_digit {
1315 return false;
1316 }
1317 match chars.next() {
1318 None => true,
1319 Some(unit) => matches!(unit, 's' | 'm' | 'h' | 'd') && chars.next().is_none(),
1320 }
1321}
1322
1323pub fn user_filter_dir(storage_dir: &Path, harness: Harness) -> PathBuf {
1326 storage_dir.join(harness.storage_segment()).join("filters")
1327}
1328
1329fn legacy_user_filter_dir(storage_dir: &Path) -> PathBuf {
1330 storage_dir.join("filters")
1331}
1332
1333pub(crate) fn repair_legacy_user_filter_dir(storage_dir: &Path, harness: Harness) {
1337 let legacy_dir = legacy_user_filter_dir(storage_dir);
1338 if !legacy_dir.exists() {
1339 return;
1340 }
1341
1342 let entries = match fs::read_dir(&legacy_dir) {
1343 Ok(entries) => entries.filter_map(Result::ok).collect::<Vec<_>>(),
1344 Err(_) => return,
1345 };
1346 if entries.is_empty() {
1347 let _ = fs::remove_dir(&legacy_dir);
1348 return;
1349 }
1350
1351 let harness_dir = user_filter_dir(storage_dir, harness);
1352 if fs::create_dir_all(&harness_dir).is_err() {
1353 return;
1354 }
1355
1356 for entry in entries {
1357 let target = harness_dir.join(entry.file_name());
1358 if target.exists() {
1359 continue;
1360 }
1361 let _ = fs::rename(entry.path(), target);
1362 }
1363
1364 if fs::read_dir(&legacy_dir)
1365 .map(|mut entries| entries.next().is_none())
1366 .unwrap_or(false)
1367 {
1368 let _ = fs::remove_dir(&legacy_dir);
1369 }
1370}
1371
1372pub fn project_filter_dir(project_root: &Path) -> PathBuf {
1376 project_root.join(".cortexkit").join("aft").join("filters")
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381 use super::*;
1382
1383 #[test]
1384 fn user_and_project_filter_dir_helpers() {
1385 let storage = Path::new("/tmp/aft-storage");
1386 assert_eq!(
1387 user_filter_dir(storage, Harness::Opencode),
1388 Path::new("/tmp/aft-storage/opencode/filters")
1389 );
1390
1391 let project = Path::new("/repo");
1392 assert_eq!(
1393 project_filter_dir(project),
1394 Path::new("/repo/.cortexkit/aft/filters")
1395 );
1396 }
1397
1398 #[test]
1399 fn repair_legacy_user_filter_dir_moves_root_filters_without_overwrite() {
1400 let temp = tempfile::tempdir().unwrap();
1401 let storage = temp.path();
1402 fs::create_dir_all(storage.join("filters")).unwrap();
1403 fs::create_dir_all(storage.join("opencode/filters")).unwrap();
1404 fs::write(storage.join("filters/root-only.toml"), "root").unwrap();
1405 fs::write(storage.join("filters/collides.toml"), "root").unwrap();
1406 fs::write(storage.join("opencode/filters/collides.toml"), "harness").unwrap();
1407
1408 repair_legacy_user_filter_dir(storage, Harness::Opencode);
1409
1410 assert_eq!(
1411 fs::read_to_string(storage.join("opencode/filters/root-only.toml")).unwrap(),
1412 "root"
1413 );
1414 assert_eq!(
1415 fs::read_to_string(storage.join("opencode/filters/collides.toml")).unwrap(),
1416 "harness"
1417 );
1418 assert_eq!(
1419 fs::read_to_string(storage.join("filters/collides.toml")).unwrap(),
1420 "root"
1421 );
1422 assert!(!storage.join("filters/root-only.toml").exists());
1423 }
1424}
1425
1426#[cfg(test)]
1427mod output_probe_tests {
1428 use super::*;
1429 use serde::Deserialize;
1430
1431 #[derive(Deserialize)]
1432 struct CorpusEntry {
1433 file: String,
1434 }
1435
1436 #[derive(Debug, PartialEq, Eq)]
1437 struct MatchPhaseResult {
1438 selected: Option<usize>,
1439 output: CompressionResult,
1440 }
1441
1442 fn legacy_match_phase(output: &str) -> MatchPhaseResult {
1443 let compressors = compressors_in_dispatch_order();
1444 let selected = [Specificity::Specific, Specificity::PackageManager]
1445 .into_iter()
1446 .find_map(|specificity| {
1447 compressors
1448 .iter()
1449 .enumerate()
1450 .filter(|(_, compressor)| compressor.specificity() == specificity)
1451 .find_map(|(index, compressor)| {
1452 compressor.matches_output(output).then_some(index)
1453 })
1454 });
1455 match_phase_result(&compressors, selected, output)
1456 }
1457
1458 fn probe_match_phase(output: &str) -> MatchPhaseResult {
1459 let compressors = compressors_in_dispatch_order();
1460 let probe = OutputProbe::new(output);
1461 let selected = [Specificity::Specific, Specificity::PackageManager]
1462 .into_iter()
1463 .find_map(|specificity| {
1464 compressors
1465 .iter()
1466 .enumerate()
1467 .filter(|(_, compressor)| compressor.specificity() == specificity)
1468 .find_map(|(index, compressor)| {
1469 compressor.matches_output_probe(&probe).then_some(index)
1470 })
1471 });
1472 match_phase_result(&compressors, selected, output)
1473 }
1474
1475 fn match_phase_result(
1476 compressors: &[&dyn Compressor],
1477 selected: Option<usize>,
1478 output: &str,
1479 ) -> MatchPhaseResult {
1480 let output = selected.map_or_else(
1481 || GenericCompressor.compress_with_exit_code("", output, None),
1482 |index| compressors[index].compress_output_match_with_exit_code(output, None),
1483 );
1484 MatchPhaseResult { selected, output }
1485 }
1486
1487 fn repository_root() -> PathBuf {
1488 Path::new(env!("CARGO_MANIFEST_DIR"))
1489 .parent()
1490 .and_then(Path::parent)
1491 .expect("aft crate should be nested under the repository root")
1492 .to_path_buf()
1493 }
1494
1495 fn benchmark_corpus() -> Vec<(String, String)> {
1496 let fixtures = repository_root().join("benchmarks/compression-tokens/fixtures");
1497 let manifest_text = fs::read_to_string(fixtures.join("manifest.json"))
1498 .expect("compression corpus manifest should be readable");
1499 let manifest: Vec<CorpusEntry> =
1500 serde_json::from_str(&manifest_text).expect("compression corpus manifest should parse");
1501 manifest
1502 .into_iter()
1503 .map(|entry| {
1504 let output = fs::read_to_string(fixtures.join(&entry.file))
1505 .unwrap_or_else(|error| panic!("failed to read {}: {error}", entry.file));
1506 (format!("compression corpus {}", entry.file), output)
1507 })
1508 .collect()
1509 }
1510
1511 fn integration_filter_inputs() -> Vec<(String, String)> {
1512 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1513 .join("tests/integration/fixtures/compress_filters");
1514 let mut paths = Vec::new();
1515 collect_named_files(&root, "input.txt", &mut paths);
1516 paths.sort();
1517 paths
1518 .into_iter()
1519 .map(|path| {
1520 let output = fs::read_to_string(&path)
1521 .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
1522 (format!("filter fixture {}", path.display()), output)
1523 })
1524 .collect()
1525 }
1526
1527 fn collect_named_files(root: &Path, wanted_name: &str, paths: &mut Vec<PathBuf>) {
1528 let entries = fs::read_dir(root)
1529 .unwrap_or_else(|error| panic!("failed to read {}: {error}", root.display()));
1530 for entry in entries {
1531 let path = entry
1532 .expect("fixture directory entry should be readable")
1533 .path();
1534 if path.is_dir() {
1535 collect_named_files(&path, wanted_name, paths);
1536 } else if path.file_name().is_some_and(|name| name == wanted_name) {
1537 paths.push(path);
1538 }
1539 }
1540 }
1541
1542 fn hostile_outputs() -> Vec<(String, String)> {
1543 let huge_json_array = format!(
1544 "[{}]",
1545 (0..20_000)
1546 .map(|index| format!(r#"{{"index":{index},"value":"payload"}}"#))
1547 .collect::<Vec<_>>()
1548 .join(",")
1549 );
1550 let ansi_heavy = (0..2_000)
1551 .map(|index| format!("\u{1b}[31mordinary output {index}\u{1b}[0m"))
1552 .collect::<Vec<_>>()
1553 .join("\n");
1554 vec![
1555 (
1556 "valid non-tool JSON object".to_string(),
1557 r#" {"payload":{"nested":[1,2,3]},"ok":true}"#.to_string(),
1558 ),
1559 ("huge JSON array".to_string(), huge_json_array),
1560 (
1561 "almost JSON".to_string(),
1562 r#"{"payload":[1,2,3],"unterminated":true"#.to_string(),
1563 ),
1564 ("empty output".to_string(), String::new()),
1565 ("ANSI-heavy output".to_string(), ansi_heavy),
1566 ]
1567 }
1568
1569 #[test]
1570 fn shared_probe_preserves_match_selection_and_output_across_corpora() {
1571 let cases = benchmark_corpus()
1572 .into_iter()
1573 .chain(integration_filter_inputs())
1574 .chain(hostile_outputs());
1575
1576 for (label, raw_output) in cases {
1577 let output = strip_ansi(&raw_output);
1578 let legacy = legacy_match_phase(&output);
1579 let probed = probe_match_phase(&output);
1580 assert_eq!(legacy, probed, "output-shape dispatch changed for {label}");
1581 }
1582 }
1583
1584 #[cfg(debug_assertions)]
1585 #[test]
1586 fn unmatched_json_object_is_parsed_once_across_match_phase() {
1587 let output = r#"{"payload":{"nested":[1,2,3]},"ok":true}"#;
1588 reset_output_probe_json_parse_count();
1589
1590 let result = probe_match_phase(output);
1591
1592 assert_eq!(result.selected, None);
1593 assert_eq!(output_probe_json_parse_count(), 1);
1594 }
1595}
1596
1597#[cfg(test)]
1598mod dispatch_specificity_tests {
1599 use super::*;
1600 use crate::compress::toml_filter::FilterRegistry;
1601
1602 fn empty_registry() -> FilterRegistry {
1603 FilterRegistry::default()
1604 }
1605
1606 fn dispatch(cmd: &str, output: &str) -> String {
1611 compress_with_registry(cmd, output, &empty_registry()).text
1612 }
1613
1614 #[test]
1615 fn generic_dispatch_does_not_classify_error_or_warning_words() {
1616 let result = compress_with_registry(
1617 "unknown-tool",
1618 "error: this is just a log line\nwarning: this too",
1619 &empty_registry(),
1620 );
1621
1622 assert!(result.dropped_by_class.is_empty());
1623 assert!(!result.had_inner_drop);
1624 assert!(result.text.contains("error: this is just a log line"));
1625 }
1626
1627 #[test]
1628 fn bun_run_vitest_routes_to_vitest_not_generic() {
1629 let output = "Test Files 1 passed (1)\n Tests 4 passed (4)\n Start at 10:00:00\n Duration 120ms\n";
1634 let compressed = dispatch("bun run vitest", output);
1635 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1637 }
1638
1639 #[test]
1640 fn npm_test_routes_to_vitest_when_output_is_vitest_shaped() {
1641 let output = "RERUN src/foo.test.ts x1\nFAIL src/foo.test.ts\nTest Files 1 failed (1)\nDuration 120ms\n";
1644 let compressed = dispatch("npm test", output);
1645 assert!(compressed.contains("FAIL src/foo.test.ts"));
1646 assert!(compressed.contains("Duration 120ms"));
1647 assert!(!compressed.contains("RERUN"));
1648 }
1649
1650 #[test]
1651 fn bun_run_vitest_token_match_wins_over_bun_head_match() {
1652 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";
1655 let compressed = dispatch("bun run vitest run", output);
1656 assert!(compressed.contains("Test Files") || compressed.contains("PASS"));
1658 }
1659
1660 #[test]
1661 fn bunx_jest_routes_to_vitest_module() {
1662 let output = "PASS src/foo.test.js (1.2s)\nTest Suites: 1 passed, 1 total\nTests: 3 passed, 3 total\n";
1663 let compressed = dispatch("bunx jest --json", output);
1664 assert!(compressed.contains("Tests:") && compressed.contains("Test Suites"));
1665 }
1666
1667 #[test]
1668 fn pnpm_run_vitest_routes_to_vitest() {
1669 let output = "Test Files 1 passed (1)\n Tests 10 passed (10)\n";
1670 let compressed = dispatch("pnpm run vitest", output);
1671 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1672 }
1673
1674 #[test]
1675 fn npx_eslint_routes_to_eslint_not_generic() {
1676 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";
1677 let compressed = dispatch("npx eslint .", output);
1678 assert!(compressed.contains("no-unused-vars") || compressed.contains("✖"));
1680 }
1681
1682 #[test]
1683 fn npm_run_lint_without_linter_output_shape_falls_back() {
1684 let output = "> my-project@1.0.0 lint\n> eslint .\n\nAll good.\n";
1687 let compressed = dispatch("npm run lint", output);
1688 assert!(compressed.contains("All good."));
1689 }
1690
1691 #[test]
1692 fn bun_test_still_routes_to_bun_test_compressor() {
1693 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";
1701 let compressed = dispatch("bun test", output);
1702 assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
1703 }
1704
1705 #[test]
1706 fn bunx_vitest_routes_to_vitest() {
1707 let output = "Test Files 1 passed (1)\n Tests 3 passed (3)\n";
1708 let compressed = dispatch("bunx vitest run", output);
1709 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1710 }
1711
1712 #[test]
1713 fn cargo_test_still_routes_to_cargo() {
1714 let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
1717 let compressed = dispatch("cargo test", output);
1718 assert!(compressed.contains("failed") || compressed.contains("FAILED"));
1720 }
1721
1722 #[test]
1723 fn top_level_piped_cargo_test_uses_generic_output() {
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 | cat", output, &empty_registry());
1727
1728 assert!(
1729 compressed.text.contains("test ok_test ... ok"),
1730 "piped cargo output must stay generic/raw, got: {}",
1731 compressed.text
1732 );
1733 }
1734
1735 #[test]
1736 fn non_piped_cargo_test_still_uses_cargo_compressor() {
1737 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
1738
1739 let compressed = compress_with_registry("cargo test", output, &empty_registry());
1740
1741 assert!(compressed.text.contains("running 1 test"));
1742 assert!(compressed.text.contains("test result: ok"));
1743 assert!(
1744 !compressed.text.contains("test ok_test ... ok"),
1745 "non-piped cargo test should keep using the cargo compressor, got: {}",
1746 compressed.text
1747 );
1748 }
1749
1750 #[test]
1751 fn git_status_still_routes_to_git() {
1752 let output =
1754 "On branch main\nYour branch is up to date.\n\nnothing to commit, working tree clean\n";
1755 let compressed = dispatch("git status", output);
1756 assert!(compressed.contains("branch") || compressed.contains("clean"));
1757 }
1758
1759 #[test]
1760 fn pnpm_install_still_routes_to_pnpm() {
1761 let output = "Progress: resolved 100, downloaded 50, added 50\nAdded 50 packages\n";
1763 let compressed = dispatch("pnpm install", output);
1764 assert!(compressed.contains("Added") || compressed.contains("Progress"));
1766 }
1767}
1768
1769#[cfg(test)]
1770mod exit_code_safety_tests {
1771 use super::*;
1772 use crate::compress::toml_filter::{build_registry, FilterRegistry};
1773
1774 fn empty_registry() -> FilterRegistry {
1775 FilterRegistry::default()
1776 }
1777
1778 #[test]
1779 fn go_build_failure_signal_preserved_even_when_exit_zero_masks_failure() {
1780 let output = "go: go.mod file not found in current directory or any parent directory; see 'go help modules'\n";
1781
1782 let failed =
1783 compress_with_registry_exit_code("go build ./...", output, Some(1), &empty_registry());
1784 assert!(!failed.text.contains("go build: ok"));
1785 assert!(failed.text.contains("go.mod file not found"));
1786
1787 let masked =
1788 compress_with_registry_exit_code("go build ./...", output, Some(0), &empty_registry());
1789 assert!(!masked.text.contains("go build: ok"));
1790 assert!(masked.text.contains("go.mod file not found"));
1791 }
1792
1793 #[test]
1794 fn playwright_nonzero_crash_does_not_become_passed_summary() {
1795 let output = r#"Running 4 tests using 2 workers
1796
1797 ✓ 1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
1798 ✓ 2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
1799 ✓ 3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
1800 ✓ 4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
1801
1802 4 passed (6.3s)
1803Error: browserType.launch: Target page, context or browser has been closed
1804"#;
1805
1806 let failed = compress_with_registry_exit_code(
1807 "npx playwright test",
1808 output,
1809 Some(1),
1810 &empty_registry(),
1811 );
1812 assert!(!failed.text.starts_with("playwright: 4 tests passed"));
1813 assert!(failed.text.contains("browserType.launch"));
1814 }
1815
1816 #[test]
1817 fn cargo_test_compile_error_nonzero_preserves_error_code_diagnostic() {
1818 let output = r#" Compiling demo v0.1.0 (/tmp/demo)
1819error[E0432]: unresolved import `crate::missing`
1820 --> src/lib.rs:1:5
1821 |
18221 | use crate::missing;
1823 | ^^^^^^^^^^^^^^ no `missing` in the root
1824
1825error: could not compile `demo` (lib test) due to 1 previous error
1826"#;
1827
1828 let failed =
1829 compress_with_registry_exit_code("cargo test", output, Some(101), &empty_registry());
1830 assert!(failed.text.contains("error[E0432]"));
1831 assert!(failed.text.contains("unresolved import"));
1832 assert!(failed.text.contains("error: could not compile"));
1833 }
1834
1835 #[test]
1836 fn chained_mypy_success_then_later_failure_uses_failure_preserving_output() {
1837 let output = "Success: no issues found in 1 source file\nError: node process exploded\n";
1838
1839 let failed = compress_with_registry_exit_code(
1840 "mypy src && node fail.js",
1841 output,
1842 Some(1),
1843 &empty_registry(),
1844 );
1845 assert_ne!(failed.text, "mypy: clean");
1846 assert!(failed.text.contains("Error: node process exploded"));
1847 }
1848
1849 #[test]
1850 fn toml_shortcircuit_is_skipped_for_nonzero_exit() {
1851 let registry = build_registry(
1852 &[(
1853 "wget",
1854 r#"[filter]
1855matches = ["wget"]
1856
1857[shortcircuit]
1858when = '(?s).*'
1859replacement = "wget: ok"
1860"#,
1861 )],
1862 None,
1863 None,
1864 );
1865 let output = "Connecting to example.invalid\nerror: connection refused\n";
1866
1867 let failed = compress_with_registry_exit_code(
1868 "wget https://example.invalid",
1869 output,
1870 Some(1),
1871 ®istry,
1872 );
1873 assert_ne!(failed.text, "wget: ok");
1874 assert!(failed.text.contains("error: connection refused"));
1875 }
1876
1877 #[test]
1878 fn unknown_exit_code_keeps_byte_identical_legacy_compressor_output() {
1879 let output = "Success: no issues found in 1 source file\n";
1882
1883 let legacy = compress_with_registry_exit_code("mypy src", output, None, &empty_registry());
1884 assert_eq!(legacy.text, "mypy: clean");
1885 }
1886
1887 #[test]
1888 fn killed_exit_sentinel_rejects_clean_legacy_summary() {
1889 let output = "Success: no issues found in 1 source file
1890Error: later chained command failed
1891";
1892
1893 let killed = compress_with_registry_exit_code(
1894 "mypy src && node fail.js",
1895 output,
1896 Some(137),
1897 &empty_registry(),
1898 );
1899 assert_ne!(killed.text, "mypy: clean");
1900 assert!(killed.text.contains("Error: later chained command failed"));
1901 }
1902
1903 #[test]
1904 fn nonzero_clean_eslint_json_summary_falls_back_to_raw_output() {
1905 let output =
1906 r#"[{"filePath":"/repo/src/main.ts","messages":[],"errorCount":0,"warningCount":0}]"#;
1907
1908 let failed = compress_with_registry_exit_code(
1909 "eslint -f json .",
1910 output,
1911 Some(1),
1912 &empty_registry(),
1913 );
1914
1915 assert_ne!(failed.text, "eslint: no issues");
1916 assert!(failed.text.contains(r#""messages":[]"#));
1917 }
1918
1919 #[test]
1920 fn nonzero_appends_distinct_missing_raw_failure_lines() {
1921 let raw = "Error: first failure
1922progress
1923Error: second failure
1924";
1925 let compressed = CompressionResult::new("Error: first failure");
1926
1927 let preserved = failure_preserving_result("tool", raw, compressed, Some(1));
1928
1929 assert!(preserved.text.contains("Error: first failure"));
1930 assert!(preserved.text.contains("Error: second failure"));
1931 assert!(preserved
1932 .text
1933 .contains("[raw failure lines preserved by AFT]"));
1934 }
1935
1936 #[test]
1937 fn nonzero_cargo_failure_class_cap_falls_back_to_all_failures() {
1938 let mut output = String::from(
1939 "running 40 tests
1940
1941failures:
1942
1943",
1944 );
1945 for index in 0..40 {
1946 output.push_str(&format!(
1947 "---- case_{index} stdout ----
1948thread 'case_{index}' panicked at src/lib.rs:{index}:1
1949
1950"
1951 ));
1952 }
1953 output.push_str(
1954 "failures:
1955",
1956 );
1957 for index in 0..40 {
1958 output.push_str(&format!(
1959 " case_{index}
1960"
1961 ));
1962 }
1963 output.push_str(
1964 "
1965test result: FAILED. 0 passed; 40 failed; 0 ignored; 0 measured; 0 filtered out
1966",
1967 );
1968
1969 let failed =
1970 compress_with_registry_exit_code("cargo test", &output, Some(101), &empty_registry());
1971
1972 assert!(failed.text.contains("---- case_0 stdout ----"));
1973 assert!(failed.text.contains("---- case_39 stdout ----"));
1974 assert!(failed.dropped_by_class.is_empty());
1975 }
1976
1977 #[test]
1978 fn toml_shortcircuit_is_skipped_for_unknown_exit_when_failure_signal_exists() {
1979 let registry = build_registry(
1980 &[(
1981 "make",
1982 r#"[filter]
1983matches = ["make"]
1984
1985[shortcircuit]
1986when = '(?s).*'
1987replacement = "make: ok"
1988"#,
1989 )],
1990 None,
1991 None,
1992 );
1993 let output = "build step
1994ERROR: compiler crashed
1995";
1996
1997 let failed = compress_with_registry_exit_code("make", output, None, ®istry);
1998
1999 assert_ne!(failed.text, "make: ok");
2000 assert!(failed.text.contains("ERROR: compiler crashed"));
2001 }
2002
2003 #[test]
2004 fn successful_exit_still_gets_concise_success_summary() {
2005 let output = r#"Running 4 tests using 2 workers
2006
2007 ✓ 1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
2008 ✓ 2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
2009 ✓ 3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
2010 ✓ 4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
2011
2012 4 passed (6.3s)
2013"#;
2014
2015 let successful =
2016 compress_with_registry_exit_code("playwright test", output, Some(0), &empty_registry());
2017 assert_eq!(successful.text, "playwright: 4 tests passed (6.3s)");
2018 }
2019}
2020
2021#[cfg(test)]
2022mod normalize_command_tests {
2023 use super::*;
2024
2025 #[test]
2026 fn passes_bare_commands_unchanged() {
2027 assert_eq!(normalize_command_for_dispatch("bun test"), None);
2028 assert_eq!(normalize_command_for_dispatch("cargo build"), None);
2029 assert_eq!(normalize_command_for_dispatch("git status"), None);
2030 }
2031
2032 #[test]
2033 fn strips_cd_and_amp_prefix() {
2034 assert_eq!(
2035 normalize_command_for_dispatch("cd /repo && bun test").as_deref(),
2036 Some("bun test")
2037 );
2038 assert_eq!(
2039 normalize_command_for_dispatch("cd /repo/packages/aft && cargo test --release")
2040 .as_deref(),
2041 Some("cargo test --release")
2042 );
2043 }
2044
2045 #[test]
2046 fn strips_cd_and_semicolon_prefix() {
2047 assert_eq!(
2048 normalize_command_for_dispatch("cd /repo; bun test").as_deref(),
2049 Some("bun test")
2050 );
2051 }
2052
2053 #[test]
2054 fn strips_cd_with_quoted_path() {
2055 assert_eq!(
2056 normalize_command_for_dispatch("cd \"/path with space\" && npm install").as_deref(),
2057 Some("npm install")
2058 );
2059 }
2060
2061 #[test]
2062 fn strips_env_assignments() {
2063 assert_eq!(
2064 normalize_command_for_dispatch("env FOO=bar npm install").as_deref(),
2065 Some("npm install")
2066 );
2067 assert_eq!(
2068 normalize_command_for_dispatch("env FOO=bar BAZ=qux RUST_LOG=info cargo test")
2069 .as_deref(),
2070 Some("cargo test")
2071 );
2072 }
2073
2074 #[test]
2075 fn strips_bare_assignment_prefixes() {
2076 assert_eq!(
2077 normalize_command_for_dispatch("NODE_ENV=production npm install").as_deref(),
2078 Some("npm install")
2079 );
2080 assert_eq!(
2081 normalize_command_for_dispatch("FOO=1 BAR=2 cargo test").as_deref(),
2082 Some("cargo test")
2083 );
2084 assert_eq!(
2085 normalize_command_for_dispatch("RUSTFLAGS='-C debug' cargo build").as_deref(),
2086 Some("cargo build")
2087 );
2088 }
2089
2090 #[test]
2091 fn does_not_strip_later_assignment_arguments() {
2092 assert_eq!(normalize_command_for_dispatch("npm install foo=bar"), None);
2093 }
2094
2095 #[test]
2096 fn env_without_assignments_returns_none() {
2097 assert_eq!(
2099 normalize_command_for_dispatch("env npm install").as_deref(),
2100 None
2101 );
2102 }
2103
2104 #[test]
2105 fn strips_timeout_prefix() {
2106 assert_eq!(
2107 normalize_command_for_dispatch("timeout 30 cargo test").as_deref(),
2108 Some("cargo test")
2109 );
2110 assert_eq!(
2111 normalize_command_for_dispatch("timeout 5m bun test").as_deref(),
2112 Some("bun test")
2113 );
2114 }
2115
2116 #[test]
2117 fn strips_nohup_prefix() {
2118 assert_eq!(
2119 normalize_command_for_dispatch("nohup ./long-running-script.sh").as_deref(),
2120 Some("./long-running-script.sh")
2121 );
2122 }
2123
2124 #[test]
2125 fn strips_paren_then_cd_and_amp() {
2126 assert_eq!(
2127 normalize_command_for_dispatch("(cd /repo && bun test").as_deref(),
2128 Some("bun test")
2129 );
2130 }
2131
2132 #[test]
2133 fn chains_multiple_prefixes() {
2134 assert_eq!(
2136 normalize_command_for_dispatch("env FOO=bar timeout 30 cargo test").as_deref(),
2137 Some("cargo test")
2138 );
2139 assert_eq!(
2141 normalize_command_for_dispatch("cd /repo && env FOO=bar npm install").as_deref(),
2142 Some("npm install")
2143 );
2144 }
2145
2146 fn empty_registry() -> FilterRegistry {
2149 FilterRegistry::default()
2150 }
2151
2152 #[test]
2153 fn cd_prefix_bun_test_still_routes_to_bun_test() {
2154 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";
2155 let compressed = compress_with_registry("cd /repo && bun test", output, &empty_registry());
2156 assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
2161 }
2162
2163 #[test]
2164 fn cd_prefix_cargo_test_still_routes_to_cargo() {
2165 let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
2166 let compressed =
2167 compress_with_registry("cd /repo && cargo test", output, &empty_registry());
2168 assert!(compressed.contains("FAILED") || compressed.contains("failed"));
2169 }
2170
2171 #[test]
2172 fn env_prefix_npm_install_still_routes_to_npm() {
2173 let output = "added 50 packages, and audited 100 packages in 3s\n";
2174 let compressed = compress_with_registry(
2175 "env NODE_ENV=production npm install",
2176 output,
2177 &empty_registry(),
2178 );
2179 assert!(compressed.contains("added") || compressed.contains("audited"));
2181 }
2182
2183 #[test]
2184 fn bare_assignment_prefix_npm_install_routes_to_npm() {
2185 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";
2186 let compressed =
2187 compress_with_registry("NODE_ENV=production npm install", output, &empty_registry());
2188 assert!(!compressed.contains("npm http fetch"));
2189 assert!(compressed.contains("audited 100 packages"));
2190 }
2191
2192 #[test]
2193 fn bare_assignment_prefix_cargo_test_routes_to_cargo() {
2194 let output = "running 1 test\ntest foo ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n";
2195 let compressed =
2196 compress_with_registry("FOO=1 BAR=2 cargo test", output, &empty_registry());
2197 assert!(compressed.contains("running 1 test"));
2198 assert!(compressed.contains("test result: ok"));
2199 assert!(!compressed.contains("test foo ... ok"));
2200 }
2201
2202 #[test]
2203 fn quoted_assignment_prefix_cargo_build_routes_to_cargo() {
2204 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";
2205 let compressed = compress_with_registry(
2206 "RUSTFLAGS='-C debug' cargo build",
2207 output,
2208 &empty_registry(),
2209 );
2210 assert!(!compressed.contains("Compiling foo"));
2211 assert!(compressed.contains("warning: unused variable"));
2212 assert!(compressed.contains("Finished `dev` profile"));
2213 }
2214
2215 #[test]
2216 fn timeout_prefix_cargo_build_still_routes_to_cargo() {
2217 let output =
2218 " Compiling foo v0.1.0\n Finished `dev` profile [unoptimized] target(s) in 5s\n";
2219 let compressed =
2220 compress_with_registry("timeout 30 cargo build", output, &empty_registry());
2221 assert!(compressed.contains("Compiling") || compressed.contains("Finished"));
2223 }
2224
2225 #[test]
2226 fn normalize_splits_pipe_and_takes_last_stage() {
2227 assert_eq!(
2228 normalize_command_for_dispatch("git log | grep fix").as_deref(),
2229 Some("grep fix")
2230 );
2231 }
2232
2233 #[test]
2234 fn normalize_cd_prefix_then_pipe_takes_last_stage() {
2235 assert_eq!(
2236 normalize_command_for_dispatch("cd /repo && git log | grep fix").as_deref(),
2237 Some("grep fix")
2238 );
2239 }
2240
2241 #[test]
2242 fn normalize_no_pipe_returns_none() {
2243 assert_eq!(normalize_command_for_dispatch("git log"), None);
2244 }
2245
2246 #[test]
2247 fn normalize_quoted_pipe_not_split() {
2248 assert_eq!(
2249 normalize_command_for_dispatch("grep \"a|b\" file.txt"),
2250 None
2251 );
2252 }
2253
2254 #[test]
2255 fn normalize_balanced_command_substitution_splits_top_level_pipe() {
2256 assert_eq!(
2261 normalize_command_for_dispatch("echo $(cmd | cmd) | grep x").as_deref(),
2262 Some("grep x")
2263 );
2264 }
2265
2266 #[test]
2267 fn normalize_inner_pipe_in_substitution_without_top_level_pipe_is_none() {
2268 assert_eq!(
2270 normalize_command_for_dispatch("echo $(cargo test | cat)"),
2271 None
2272 );
2273 }
2274
2275 #[test]
2276 fn normalize_double_pipe_not_split() {
2277 assert_eq!(normalize_command_for_dispatch("git log || echo fail"), None);
2278 }
2279
2280 #[test]
2281 fn normalize_multi_pipe_returns_last_stage() {
2282 assert_eq!(
2283 normalize_command_for_dispatch("git log | grep fix | head -5").as_deref(),
2284 Some("head -5")
2285 );
2286 }
2287
2288 #[test]
2289 fn normalize_process_substitution_splits_top_level_pipe() {
2290 assert_eq!(
2292 normalize_command_for_dispatch("cat <(echo a | cat) | grep x").as_deref(),
2293 Some("grep x")
2294 );
2295 }
2296
2297 #[test]
2298 fn normalize_pipe_ampersand_splits_last_stage() {
2299 assert_eq!(
2301 normalize_command_for_dispatch("cargo test |& grep FAIL").as_deref(),
2302 Some("grep FAIL")
2303 );
2304 }
2305
2306 #[test]
2307 fn piped_cargo_test_grep_preserves_failed() {
2308 let grep_output = "test foo ... FAILED\n";
2309 let compressed =
2310 compress_with_registry("cargo test | grep FAIL", grep_output, &empty_registry());
2311 assert!(
2312 compressed.text.contains("FAILED"),
2313 "grep-filtered FAILED must survive, got: {}",
2314 compressed.text
2315 );
2316 }
2317
2318 #[test]
2319 fn unsafe_piped_command_forces_generic_and_preserves_output() {
2320 let grep_output = "test foo ... FAILED\n";
2324 let compressed =
2325 compress_with_registry("cargo test | grep \"FAIL", grep_output, &empty_registry());
2326 assert!(
2327 compressed.text.contains("FAILED"),
2328 "unsafe pipe must not drop output, got: {}",
2329 compressed.text
2330 );
2331 }
2332
2333 #[test]
2334 fn split_top_level_pipe_variants() {
2335 assert_eq!(split_top_level_pipe("git log"), PipeSplit::None);
2336 assert_eq!(
2337 split_top_level_pipe("git log | grep fix"),
2338 PipeSplit::LastStage("grep fix".to_string())
2339 );
2340 assert_eq!(split_top_level_pipe("a || b"), PipeSplit::Unsafe);
2343 assert_eq!(split_top_level_pipe("(a | b)"), PipeSplit::None);
2345 assert_eq!(split_top_level_pipe("echo $(a | b)"), PipeSplit::None);
2347 assert_eq!(split_top_level_pipe("a | grep \"x"), PipeSplit::Unsafe);
2349 assert_eq!(split_top_level_pipe("$(a | b | grep x"), PipeSplit::Unsafe);
2351 assert_eq!(split_top_level_pipe("cargo test |"), PipeSplit::Unsafe);
2356 assert_eq!(split_top_level_pipe("cargo test |&"), PipeSplit::Unsafe);
2357 assert_eq!(
2359 split_top_level_pipe("true | cargo test --quiet ; printf X"),
2360 PipeSplit::Unsafe
2361 );
2362 assert_eq!(
2363 split_top_level_pipe("true | cargo test && echo done"),
2364 PipeSplit::Unsafe
2365 );
2366 assert_eq!(
2368 split_top_level_pipe("echo ) | cargo test"),
2369 PipeSplit::Unsafe
2370 );
2371 assert_eq!(split_top_level_pipe("a | b & c"), PipeSplit::Unsafe);
2373 assert_eq!(
2374 split_top_level_pipe("cargo test 2>&1 | grep FAIL"),
2375 PipeSplit::LastStage("grep FAIL".to_string())
2376 );
2377 assert_eq!(
2381 split_top_level_pipe("cargo test ; printf SENTINEL"),
2382 PipeSplit::Unsafe
2383 );
2384 assert_eq!(
2385 split_top_level_pipe("cargo test && echo done"),
2386 PipeSplit::Unsafe
2387 );
2388 assert_eq!(
2389 split_top_level_pipe("cargo test\necho done"),
2390 PipeSplit::Unsafe
2391 );
2392 assert_eq!(split_top_level_pipe("cargo test 2>&1"), PipeSplit::None);
2395 assert_eq!(
2396 split_top_level_pipe("cargo test &> /dev/null"),
2397 PipeSplit::None
2398 );
2399 }
2400
2401 #[test]
2402 fn strip_top_level_comment_removes_only_real_comments() {
2403 assert_eq!(
2404 strip_top_level_comment("printf keep # | cargo test"),
2405 "printf keep "
2406 );
2407 assert_eq!(
2408 strip_top_level_comment("printf keep # cargo test"),
2409 "printf keep "
2410 );
2411 assert_eq!(
2413 strip_top_level_comment("curl http://x/y#frag"),
2414 "curl http://x/y#frag"
2415 );
2416 assert_eq!(
2418 strip_top_level_comment("grep \"# not a comment\" f"),
2419 "grep \"# not a comment\" f"
2420 );
2421 assert_eq!(
2422 strip_top_level_comment("echo '# literal'"),
2423 "echo '# literal'"
2424 );
2425 assert_eq!(
2427 strip_top_level_comment("git log | grep fix"),
2428 "git log | grep fix"
2429 );
2430 }
2431
2432 #[test]
2433 fn commented_command_does_not_misdispatch_and_preserves_output() {
2434 for cmd in ["printf keep # | cargo test", "printf keep # cargo test"] {
2437 let compressed = compress_with_registry(cmd, "keep\n", &empty_registry());
2438 assert!(
2439 compressed.text.contains("keep"),
2440 "comment must not drop output for {cmd:?}, got: {}",
2441 compressed.text
2442 );
2443 }
2444 }
2445
2446 #[test]
2447 fn pipe_with_trailing_command_chain_preserves_sentinel() {
2448 let compressed = compress_with_registry(
2451 "true | cargo test --quiet ; printf SENTINEL",
2452 "SENTINEL\n",
2453 &empty_registry(),
2454 );
2455 assert!(
2456 compressed.text.contains("SENTINEL"),
2457 "trailing-chain output must survive, got: {}",
2458 compressed.text
2459 );
2460 }
2461
2462 #[test]
2466 fn separator_list_forces_generic_and_preserves_sentinel() {
2467 let cargo_noise =
2472 "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
2473 let transcript = format!("{cargo_noise}SENTINEL\n");
2474 let compressed = compress_with_registry(
2475 "cargo test ; printf SENTINEL",
2476 &transcript,
2477 &empty_registry(),
2478 );
2479 assert!(
2480 compressed.text.contains("SENTINEL"),
2481 "separator-list output must survive generic compression, got: {}",
2482 compressed.text
2483 );
2484 }
2485
2486 #[test]
2487 fn cd_prefix_peel_leaves_no_residual_separator_for_cargo() {
2488 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
2492 let compressed = compress_with_registry("cd /x && cargo test", output, &empty_registry());
2493 assert!(
2495 !compressed.text.contains("test ok_test ... ok"),
2496 "cd-peeled cargo test must still use the cargo compressor, got: {}",
2497 compressed.text
2498 );
2499 assert!(compressed.text.contains("test result: ok"));
2500 }
2501
2502 #[test]
2503 fn clean_single_cargo_test_dispatch_byte_identical() {
2504 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
2508 let compressed = compress_with_registry("cargo test", output, &empty_registry());
2509 assert!(compressed.text.contains("running 1 test"));
2510 assert!(compressed.text.contains("test result: ok"));
2511 assert!(
2512 !compressed.text.contains("test ok_test ... ok"),
2513 "clean cargo test must keep using the cargo compressor, got: {}",
2514 compressed.text
2515 );
2516 }
2517
2518 #[test]
2519 fn is_shell_boundary_covers_redirects_and_operators() {
2520 for tok in [
2521 "|",
2522 "|&",
2523 ";",
2524 "&",
2525 "&&",
2526 "||",
2527 ">",
2528 ">>",
2529 "<",
2530 "<<",
2531 "<<<",
2532 "&>",
2533 "&>>",
2534 "2>",
2535 "2>>",
2536 "2>&1",
2537 "1>&2",
2538 ">/dev/null",
2539 "2>/dev/null",
2540 ] {
2541 assert!(is_shell_boundary(tok), "{tok} should be a boundary");
2542 }
2543 for tok in ["test", "log", "build", "--release", "-v", "file.txt"] {
2544 assert!(!is_shell_boundary(tok), "{tok} must not be a boundary");
2545 }
2546 }
2547}