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, HashSet};
68use std::fs;
69use std::path::{Path, PathBuf};
70use std::sync::{Arc, RwLock};
71use toml_filter::{apply_filter_with_exit_code_prestripped, 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 pub input_line_count: usize,
172}
173
174impl CompressionResult {
175 pub fn new(text: impl Into<String>) -> Self {
176 let text = text.into();
177 let input_line_count = text.lines().count();
178 Self {
179 text,
180 dropped_by_class: BTreeMap::new(),
181 had_inner_drop: false,
182 offset_hint_eligible: true,
183 offset_start_line: None,
184 input_line_count,
185 }
186 }
187
188 pub fn with_class_drops(
189 text: impl Into<String>,
190 dropped_by_class: BTreeMap<DropClass, usize>,
191 ) -> Self {
192 let text = text.into();
193 let input_line_count = text.lines().count();
194 let had_inner_drop = !dropped_by_class.is_empty();
195 Self {
196 text,
197 dropped_by_class,
198 had_inner_drop,
199 offset_hint_eligible: !had_inner_drop,
200 offset_start_line: None,
201 input_line_count,
202 }
203 }
204
205 pub fn with_inner_drop(text: impl Into<String>, offset_hint_eligible: bool) -> Self {
206 let text = text.into();
207 let input_line_count = text.lines().count();
208 Self {
209 text,
210 dropped_by_class: BTreeMap::new(),
211 had_inner_drop: true,
212 offset_hint_eligible,
213 offset_start_line: None,
214 input_line_count,
215 }
216 }
217
218 pub fn with_prefix_drop(text: impl Into<String>, offset_start_line: usize) -> Self {
219 let text = text.into();
220 let input_line_count = text.lines().count();
221 Self {
222 text,
223 dropped_by_class: BTreeMap::new(),
224 had_inner_drop: true,
225 offset_hint_eligible: true,
226 offset_start_line: Some(offset_start_line),
227 input_line_count,
228 }
229 }
230
231 pub fn with_input_line_count(mut self, count: usize) -> Self {
232 self.input_line_count = count;
233 self
234 }
235
236 pub fn input_line_count(&self) -> usize {
237 self.input_line_count
238 }
239
240 pub fn has_semantic_drops(&self) -> bool {
241 !self.dropped_by_class.is_empty()
242 }
243
244 pub fn has_any_drop(&self) -> bool {
245 self.had_inner_drop || self.has_semantic_drops()
246 }
247
248 pub fn map_text<F>(mut self, f: F) -> Self
249 where
250 F: FnOnce(&str) -> String,
251 {
252 self.text = f(&self.text);
253 self
254 }
255}
256
257impl std::fmt::Display for CompressionResult {
258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 f.write_str(&self.text)
260 }
261}
262
263impl std::ops::Deref for CompressionResult {
264 type Target = str;
265
266 fn deref(&self) -> &Self::Target {
267 &self.text
268 }
269}
270
271impl PartialEq<&str> for CompressionResult {
272 fn eq(&self, other: &&str) -> bool {
273 self.text == *other
274 }
275}
276
277impl PartialEq<String> for CompressionResult {
278 fn eq(&self, other: &String) -> bool {
279 self.text == *other
280 }
281}
282
283impl From<String> for CompressionResult {
284 fn from(text: String) -> Self {
285 Self::new(text)
286 }
287}
288
289impl From<&str> for CompressionResult {
290 fn from(text: &str) -> Self {
291 Self::new(text)
292 }
293}
294
295pub trait Compressor: Send + Sync {
298 fn matches(&self, command: &str) -> bool;
301
302 fn compress(&self, command: &str, output: &str) -> CompressionResult {
304 self.compress_with_exit_code(command, output, None)
305 }
306
307 fn compress_with_exit_code(
309 &self,
310 command: &str,
311 output: &str,
312 exit_code: Option<i32>,
313 ) -> CompressionResult;
314
315 fn specificity(&self) -> Specificity {
316 Specificity::Specific
317 }
318
319 fn matches_output(&self, _output: &str) -> bool {
324 false
325 }
326
327 fn matches_output_probe(&self, probe: &OutputProbe<'_>) -> bool {
330 self.matches_output(probe.output())
331 }
332
333 fn compress_output_match(&self, output: &str) -> CompressionResult {
335 self.compress_output_match_with_exit_code(output, None)
336 }
337
338 fn compress_output_match_with_exit_code(
341 &self,
342 output: &str,
343 exit_code: Option<i32>,
344 ) -> CompressionResult {
345 self.compress_with_exit_code("", output, exit_code)
346 }
347}
348pub fn compress(command: &str, output: String, ctx: &AppContext) -> CompressionResult {
354 compress_with_exit_code(command, output, None, ctx)
355}
356
357pub fn compress_with_exit_code(
358 command: &str,
359 output: String,
360 exit_code: Option<i32>,
361 ctx: &AppContext,
362) -> CompressionResult {
363 if !ctx.config().experimental_bash_compress {
364 let count = output.lines().count();
365 return CompressionResult::new(output).with_input_line_count(count);
366 }
367 let registry_handle = ctx.shared_filter_registry();
368 let guard = match registry_handle.read() {
369 Ok(g) => g,
370 Err(poisoned) => poisoned.into_inner(),
371 };
372 compress_with_registry_exit_code(command, &output, exit_code, &guard)
373}
374
375pub fn compress_with_registry(
381 command: &str,
382 output: &str,
383 registry: &FilterRegistry,
384) -> CompressionResult {
385 compress_with_registry_exit_code(command, output, None, registry)
386}
387
388pub fn compress_with_registry_exit_code(
389 command: &str,
390 output: &str,
391 exit_code: Option<i32>,
392 registry: &FilterRegistry,
393) -> CompressionResult {
394 let input_line_count = output.lines().count();
395 let mut result = compress_with_registry_exit_code_inner(command, output, exit_code, registry);
396 result.input_line_count = input_line_count;
397 result
398}
399
400fn compress_with_registry_exit_code_inner(
401 command: &str,
402 output: &str,
403 exit_code: Option<i32>,
404 registry: &FilterRegistry,
405) -> CompressionResult {
406 let stripped_for_generic = strip_ansi(output);
407
408 let dispatch_owned = match resolve_dispatch_target(command) {
415 DispatchTarget::Pipeline(_) | DispatchTarget::ForceGeneric => {
416 return GenericCompressor::compress_stripped_output(&stripped_for_generic).into();
417 }
418 DispatchTarget::Command(cmd) => cmd,
419 };
420 let dispatch_cmd = dispatch_owned.as_str();
421
422 let compressors = compressors_in_dispatch_order();
423
424 for compressor in compressors
426 .iter()
427 .filter(|c| c.specificity() == Specificity::Specific)
428 {
429 if compressor.matches(dispatch_cmd) {
430 let result =
431 compressor.compress_with_exit_code(dispatch_cmd, &stripped_for_generic, exit_code);
432 return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
433 }
434 }
435
436 let output_probe = OutputProbe::new(&stripped_for_generic);
442 for specificity in [Specificity::Specific, Specificity::PackageManager] {
443 for compressor in compressors
444 .iter()
445 .filter(|c| c.specificity() == specificity)
446 {
447 if compressor.matches_output_probe(&output_probe) {
448 let result = compressor
449 .compress_output_match_with_exit_code(&stripped_for_generic, exit_code);
450 return failure_preserving_result(
451 command,
452 &stripped_for_generic,
453 result,
454 exit_code,
455 );
456 }
457 }
458 }
459
460 for compressor in compressors
462 .iter()
463 .filter(|c| c.specificity() == Specificity::PackageManager)
464 {
465 if compressor.matches(dispatch_cmd) {
466 let result =
467 compressor.compress_with_exit_code(dispatch_cmd, &stripped_for_generic, exit_code);
468 return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
469 }
470 }
471
472 if let Some(filter) = registry.lookup(dispatch_cmd) {
475 let result = apply_filter_with_exit_code_prestripped(
476 filter,
477 output,
478 &stripped_for_generic,
479 exit_code,
480 );
481 return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
482 }
483
484 GenericCompressor::compress_stripped_output(&stripped_for_generic).into()
486}
487
488fn compressors_in_dispatch_order() -> [&'static dyn Compressor; 20] {
489 [
490 &GitCompressor,
491 &CargoCompressor,
492 &TscCompressor,
493 &NpmCompressor,
494 &BunCompressor,
495 &PnpmCompressor,
496 &PytestCompressor,
497 &EslintCompressor,
498 &VitestCompressor,
499 &BiomeCompressor,
500 &PrettierCompressor,
501 &RuffCompressor,
502 &MypyCompressor,
503 &GoCompressor,
504 &GolangciLintCompressor,
505 &PlaywrightCompressor,
506 &NextCompressor,
507 &LsCompressor,
508 &FindCompressor,
509 &TreeCompressor,
510 ]
511}
512
513fn failure_preserving_result(
514 command: &str,
515 stripped_raw_output: &str,
516 result: CompressionResult,
517 exit_code: Option<i32>,
518) -> CompressionResult {
519 if !matches!(exit_code, Some(code) if code != 0) {
520 return result;
521 }
522
523 if dropped_failure_or_error_blocks(&result)
524 || !text_has_failure_signal(&result.text)
525 || result_looks_successful(&result.text)
526 {
527 return GenericCompressor.compress_with_exit_code(command, stripped_raw_output, exit_code);
528 }
529
530 let missing = missing_raw_failure_signal_lines(stripped_raw_output, &result.text);
531 if missing.is_empty() {
532 result
533 } else {
534 append_missing_failure_lines(result, &missing)
535 }
536}
537
538fn dropped_failure_or_error_blocks(result: &CompressionResult) -> bool {
539 [DropClass::Error, DropClass::Failure]
540 .into_iter()
541 .any(|class| result.dropped_by_class.get(&class).copied().unwrap_or(0) > 0)
542}
543
544fn append_missing_failure_lines(
545 mut result: CompressionResult,
546 missing_failure_lines: &[String],
547) -> CompressionResult {
548 let mut text = result.text.trim_end().to_string();
549 if !text.is_empty() {
550 text.push('\n');
551 }
552 text.push_str("[raw failure lines preserved by AFT]\n");
553 text.push_str(&missing_failure_lines.join("\n"));
554 result.text = text;
555 result
556}
557
558pub(crate) fn missing_raw_failure_signal_lines(
559 raw_output: &str,
560 compressed_text: &str,
561) -> Vec<String> {
562 let compressed_lower = compressed_text.to_ascii_lowercase();
563 let compressed_lines: HashSet<&str> = compressed_text
564 .lines()
565 .zip(compressed_lower.lines())
566 .filter_map(|(line, lower)| {
567 let trimmed = line.trim();
568 (!trimmed.is_empty() && line_has_failure_signal_lower(trimmed, lower.trim()))
569 .then_some(trimmed)
570 })
571 .collect();
572 let raw_lower = raw_output.to_ascii_lowercase();
573 let mut seen = HashSet::new();
574 let mut missing = Vec::new();
575
576 for (line, lower) in raw_output.lines().zip(raw_lower.lines()) {
577 let trimmed = line.trim();
578 if trimmed.is_empty() || !line_has_failure_signal_lower(trimmed, lower.trim()) {
579 continue;
580 }
581 if compressed_lines.contains(trimmed) || !seen.insert(trimmed) {
582 continue;
583 }
584 missing.push(trimmed.to_string());
585 }
586
587 missing
588}
589
590fn result_looks_successful(text: &str) -> bool {
591 let lower = text.to_ascii_lowercase();
592 lower.contains("clean")
593 || lower.contains(" ok")
594 || lower.contains(":ok")
595 || lower.contains(": ok")
596 || lower.contains("passed")
597 || lower.contains("succeeded")
598 || lower.contains("no errors")
599 || lower.contains("0 errors")
600 || lower.contains("no issues")
601 || lower.contains("no diagnostics")
602 || lower.contains("all checks passed")
603 || lower.contains("formatted")
604 || lower.contains("0 fail")
605 || lower.contains("found 0")
606 || lower.contains("up to date")
607 || lower.contains("up-to-date")
608}
609
610pub(crate) fn text_has_failure_signal(text: &str) -> bool {
611 let lower = text.to_ascii_lowercase();
612 text.lines()
613 .zip(lower.lines())
614 .any(|(line, lower)| line_has_failure_signal_lower(line.trim(), lower.trim()))
615}
616
617fn line_has_failure_signal_lower(line: &str, lower: &str) -> bool {
618 line.contains("error[")
619 || lower.contains("error:")
620 || line.contains("Error")
621 || line.contains("ERROR")
622 || lower.contains("internalerror")
623 || lower.contains("traceback")
624 || lower.contains("exception")
625 || lower.contains("no module named")
626 || lower.contains("undefined reference")
627 || lower.contains("linker command failed")
628 || lower.contains("undefined:")
629 || lower.contains("expected declaration")
630 || lower.contains("collect2: error")
631 || lower.contains("ld: error")
632 || lower.contains("fatal error")
633 || line.contains("FAILED")
634 || line.contains("FAIL")
635 || contains_nonzero_failure_word_lower(lower)
636 || lower.contains("panic")
637 || lower.contains("cannot find")
638 || lower.contains("not found")
639 || lower.contains("no such")
640}
641
642fn contains_nonzero_failure_word_lower(lower: &str) -> bool {
643 for (index, _) in lower.match_indices("fail") {
644 let tail = &lower[index + "fail".len()..];
645 for suffix in ["ures", "ure", "ed", ""] {
646 let Some(after_suffix) = tail.strip_prefix(suffix) else {
647 continue;
648 };
649 let before_is_word = lower[..index].chars().next_back().is_some_and(is_word_char);
650 let after_is_word = after_suffix.chars().next().is_some_and(is_word_char);
651 if before_is_word || after_is_word {
652 continue;
653 }
654
655 let prefix = lower[..index].trim_end();
656 let digits_start = prefix
657 .char_indices()
658 .rev()
659 .take_while(|(_, ch)| ch.is_ascii_digit())
660 .last()
661 .map(|(idx, _)| idx);
662 let Some(digits_start) = digits_start else {
663 return true;
664 };
665 let digits = &prefix[digits_start..];
666 if digits.parse::<usize>().ok() != Some(0) {
667 return true;
668 }
669 }
670 }
671 false
672}
673
674fn is_word_char(ch: char) -> bool {
675 ch.is_ascii_alphanumeric() || ch == '_'
676}
677
678pub fn build_registry_for_context(ctx: &AppContext) -> FilterRegistry {
687 let harness = ctx.harness.lock().clone().unwrap_or(Harness::Opencode);
688 let config = ctx.config();
689 let storage_dir = config.storage_dir.clone();
690 let project_root = config.project_root.clone();
691 drop(config);
692
693 let user_dir = storage_dir.as_ref().map(|dir| {
694 repair_legacy_user_filter_dir(dir, harness.clone());
695 user_filter_dir(dir, harness)
696 });
697 let project_dir = match (project_root.as_ref(), storage_dir.as_ref()) {
698 (Some(root), Some(storage)) => {
699 if trust::is_project_trusted(Some(storage), root) {
700 Some(project_filter_dir(root))
701 } else {
702 None
703 }
704 }
705 _ => None,
706 };
707
708 toml_filter::build_registry(
709 builtin_filters::ALL,
710 user_dir.as_deref(),
711 project_dir.as_deref(),
712 )
713}
714
715pub fn normalize_command_for_dispatch(command: &str) -> Option<String> {
739 match resolve_dispatch_target(command) {
740 DispatchTarget::ForceGeneric => None,
743 DispatchTarget::Command(resolved) | DispatchTarget::Pipeline(resolved) => {
744 if resolved == command.trim_start() {
745 None
746 } else {
747 Some(resolved)
748 }
749 }
750 }
751}
752
753pub(crate) fn plain_command_for_structured_output(command: &str) -> Option<String> {
756 match resolve_dispatch_target(command) {
757 DispatchTarget::Command(resolved) => Some(resolved),
758 DispatchTarget::Pipeline(_) | DispatchTarget::ForceGeneric => None,
759 }
760}
761
762enum DispatchTarget {
765 Command(String),
768 Pipeline(String),
772 ForceGeneric,
776}
777
778fn resolve_dispatch_target(command: &str) -> DispatchTarget {
779 let decommented = strip_top_level_comment(command);
784 let peeled = peel_shell_prefixes(&decommented);
785 let base = peeled
786 .as_deref()
787 .unwrap_or_else(|| decommented.trim_start());
788 match split_top_level_pipe(base) {
789 PipeSplit::LastStage(last) => DispatchTarget::Pipeline(last),
790 PipeSplit::Unsafe => DispatchTarget::ForceGeneric,
791 PipeSplit::None => DispatchTarget::Command(base.to_string()),
792 }
793}
794
795fn strip_top_level_comment(command: &str) -> String {
803 let bytes = command.as_bytes();
804 let mut result = String::with_capacity(command.len());
805 let mut seg_start = 0usize;
806 let mut in_single = false;
807 let mut in_double = false;
808 let mut in_backtick = false;
809 let mut paren_depth: u32 = 0;
810 let mut escaped = false;
811 let mut prev = b' '; let mut i = 0;
814 while i < bytes.len() {
815 let ch = bytes[i];
816 if escaped {
817 escaped = false;
818 prev = ch;
819 i += 1;
820 continue;
821 }
822 if in_single {
823 if ch == b'\'' {
824 in_single = false;
825 }
826 prev = ch;
827 i += 1;
828 continue;
829 }
830 if in_backtick {
831 if ch == b'\\' {
832 escaped = true;
833 } else if ch == b'`' {
834 in_backtick = false;
835 }
836 prev = ch;
837 i += 1;
838 continue;
839 }
840 if ch == b'\\' {
841 escaped = true;
842 prev = ch;
843 i += 1;
844 continue;
845 }
846 if ch == b'`' {
847 in_backtick = true;
848 prev = ch;
849 i += 1;
850 continue;
851 }
852 if ch == b'$' && bytes.get(i + 1) == Some(&b'(') {
853 paren_depth += 1;
854 prev = b'(';
855 i += 2;
856 continue;
857 }
858 if in_double {
859 if ch == b'"' {
860 in_double = false;
861 }
862 prev = ch;
863 i += 1;
864 continue;
865 }
866 if ch == b'#'
867 && paren_depth == 0
868 && matches!(prev, b' ' | b'\t' | b'\n' | b';' | b'&' | b'|' | b'(')
869 {
870 result.push_str(&command[seg_start..i]);
871 while i < bytes.len() && bytes[i] != b'\n' {
872 i += 1;
873 }
874 seg_start = i; prev = b'\n';
876 continue;
877 }
878 match ch {
879 b'\'' => in_single = true,
880 b'"' => in_double = true,
881 b'<' | b'>' if bytes.get(i + 1) == Some(&b'(') => {
882 paren_depth += 1;
883 prev = b'(';
884 i += 2;
885 continue;
886 }
887 b'(' => paren_depth += 1,
888 b')' => paren_depth = paren_depth.saturating_sub(1),
889 _ => {}
890 }
891 prev = ch;
892 i += 1;
893 }
894 result.push_str(&command[seg_start..]);
895 result
896}
897
898fn peel_shell_prefixes(command: &str) -> Option<String> {
902 let trimmed = command.trim_start();
903 if trimmed.is_empty() {
904 return None;
905 }
906
907 let (open_paren, after_paren) = if let Some(rest) = trimmed.strip_prefix('(') {
909 (true, rest.trim_start())
910 } else {
911 (false, trimmed)
912 };
913
914 let mut current = after_paren.to_string();
915 let mut changed = open_paren;
916
917 loop {
919 if let Some(stripped) = strip_leading_assignment_prefix(¤t) {
923 current = stripped;
924 changed = true;
925 continue;
926 }
927
928 let head: String = current.split_whitespace().next().unwrap_or("").to_string();
929
930 if head == "cd" {
932 if let Some(stripped) = strip_cd_prefix(¤t) {
935 current = stripped;
936 changed = true;
937 continue;
938 }
939 }
940
941 if head == "env" {
943 if let Some(stripped) = strip_env_prefix(¤t) {
944 current = stripped;
945 changed = true;
946 continue;
947 }
948 }
949
950 if head == "timeout" {
952 if let Some(stripped) = strip_timeout_prefix(¤t) {
953 current = stripped;
954 changed = true;
955 continue;
956 }
957 }
958
959 if head == "nohup" {
961 if let Some(rest) = current.strip_prefix("nohup").and_then(|s| {
962 let trimmed = s.trim_start();
963 if trimmed.is_empty() {
964 None
965 } else {
966 Some(trimmed.to_string())
967 }
968 }) {
969 current = rest;
970 changed = true;
971 continue;
972 }
973 }
974
975 break;
976 }
977
978 if changed {
979 Some(current)
980 } else {
981 None
982 }
983}
984
985pub fn is_shell_boundary(token: &str) -> bool {
992 matches!(token, "|" | "|&" | ";" | "&" | "&&" | "||" | "&>" | "&>>") || is_redirect_token(token)
993}
994
995fn is_redirect_token(token: &str) -> bool {
999 let rest = token.trim_start_matches(|c: char| c.is_ascii_digit());
1000 rest.starts_with('>') || rest.starts_with('<') || rest.starts_with("&>")
1001}
1002
1003#[derive(Debug, PartialEq, Eq)]
1005enum PipeSplit {
1006 None,
1009 LastStage(String),
1011 Unsafe,
1018}
1019
1020#[cfg(unix)]
1024#[derive(Debug, Clone, PartialEq, Eq)]
1025pub(crate) struct PipelineInfo {
1026 pub(crate) segments: Vec<PipelineSegment>,
1027}
1028
1029#[cfg(unix)]
1030#[derive(Debug, Clone, PartialEq, Eq)]
1031pub(crate) struct PipelineSegment {
1032 pub(crate) label: String,
1033}
1034
1035#[derive(Debug, Default)]
1036struct PipelineScan {
1037 stages: Vec<String>,
1038 saw_top_pipe: bool,
1039 saw_top_separator: bool,
1040 saw_unmatched_close: bool,
1041 in_single: bool,
1042 in_double: bool,
1043 in_backtick: bool,
1044 paren_depth: u32,
1045 escaped: bool,
1046}
1047
1048#[cfg(unix)]
1054pub(crate) fn single_top_level_pipeline(command: &str) -> Option<PipelineInfo> {
1055 if strip_top_level_comment(command) != command {
1056 return None;
1057 }
1058
1059 let scan = scan_top_level_pipe(command);
1060 let stages = clean_pipeline_stages(scan)?;
1061 if stages.len() < 2 {
1062 return None;
1063 }
1064
1065 Some(PipelineInfo {
1066 segments: stages
1067 .iter()
1068 .map(|stage| PipelineSegment {
1069 label: pipeline_segment_label(stage),
1070 })
1071 .collect(),
1072 })
1073}
1074
1075#[cfg(unix)]
1076fn pipeline_segment_label(stage: &str) -> String {
1077 let stage = strip_leading_assignment_prefix(stage).unwrap_or_else(|| stage.trim().to_string());
1078 let start = skip_whitespace(&stage, 0);
1079 let Some(first_end) = shell_word_end(&stage, start) else {
1080 return stage;
1081 };
1082 if first_end == stage.len() {
1083 return stage[start..first_end].to_string();
1084 }
1085
1086 let first_word = &stage[start..first_end];
1087 if first_word != "git" {
1088 return first_word.to_string();
1089 }
1090
1091 let second_start = skip_whitespace(&stage, first_end);
1094 let second_end = shell_word_end(&stage, second_start).unwrap_or(second_start);
1095 if second_end > second_start {
1096 format!("{} {}", first_word, &stage[second_start..second_end])
1097 } else {
1098 first_word.to_string()
1099 }
1100}
1101
1102fn split_top_level_pipe(command: &str) -> PipeSplit {
1119 let scan = scan_top_level_pipe(command);
1120 if scan.saw_top_pipe {
1121 if let Some(stages) = clean_pipeline_stages(scan) {
1122 return PipeSplit::LastStage(stages.last().cloned().unwrap_or_default());
1123 }
1124 return PipeSplit::Unsafe;
1125 }
1126
1127 if (scan.imbalance() && command.contains('|')) || scan.saw_top_separator {
1128 PipeSplit::Unsafe
1129 } else {
1130 PipeSplit::None
1131 }
1132}
1133
1134fn clean_pipeline_stages(scan: PipelineScan) -> Option<Vec<String>> {
1135 if !scan.saw_top_pipe || scan.imbalance() || scan.saw_top_separator {
1136 return None;
1137 }
1138 if scan.stages.iter().any(|stage| stage.trim().is_empty()) {
1139 return None;
1140 }
1141 Some(scan.stages)
1142}
1143
1144impl PipelineScan {
1145 fn imbalance(&self) -> bool {
1146 self.in_single
1147 || self.in_double
1148 || self.in_backtick
1149 || self.escaped
1150 || self.paren_depth != 0
1151 || self.saw_unmatched_close
1152 }
1153}
1154
1155fn scan_top_level_pipe(command: &str) -> PipelineScan {
1156 let bytes = command.as_bytes();
1157 let mut scan = PipelineScan::default();
1158 let mut stage_start = 0usize;
1159 let mut i = 0;
1160
1161 while i < bytes.len() {
1162 let ch = bytes[i];
1163
1164 if scan.escaped {
1165 scan.escaped = false;
1166 i += 1;
1167 continue;
1168 }
1169 if scan.in_single {
1170 if ch == b'\'' {
1171 scan.in_single = false;
1172 }
1173 i += 1;
1174 continue;
1175 }
1176 if scan.in_backtick {
1177 if ch == b'\\' {
1180 scan.escaped = true;
1181 } else if ch == b'`' {
1182 scan.in_backtick = false;
1183 }
1184 i += 1;
1185 continue;
1186 }
1187 if ch == b'\\' {
1188 scan.escaped = true;
1189 i += 1;
1190 continue;
1191 }
1192 if ch == b'`' {
1193 scan.in_backtick = true;
1194 i += 1;
1195 continue;
1196 }
1197 if ch == b'$' && bytes.get(i + 1) == Some(&b'(') {
1199 scan.paren_depth += 1;
1200 i += 2;
1201 continue;
1202 }
1203 if scan.in_double {
1204 if ch == b'"' {
1205 scan.in_double = false;
1206 }
1207 i += 1;
1208 continue;
1209 }
1210
1211 let prev_raw = if i > 0 { bytes[i - 1] } else { b' ' };
1215
1216 match ch {
1217 b'\'' => scan.in_single = true,
1218 b'"' => scan.in_double = true,
1219 b'<' | b'>' if bytes.get(i + 1) == Some(&b'(') => {
1221 scan.paren_depth += 1;
1222 i += 2;
1223 continue;
1224 }
1225 b'(' => scan.paren_depth += 1,
1226 b')' => {
1227 if scan.paren_depth == 0 {
1228 scan.saw_unmatched_close = true;
1229 } else {
1230 scan.paren_depth -= 1;
1231 }
1232 }
1233 b'|' if scan.paren_depth == 0 => {
1234 if bytes.get(i + 1) == Some(&b'|') {
1235 scan.saw_top_separator = true; i += 2;
1237 continue;
1238 }
1239 scan.saw_top_pipe = true;
1240 scan.stages.push(command[stage_start..i].trim().to_string());
1241 let delimiter_len = if bytes.get(i + 1) == Some(&b'&') {
1242 2 } else {
1244 1
1245 };
1246 stage_start = i + delimiter_len;
1247 i += delimiter_len;
1248 continue;
1249 }
1250 b'&' if scan.paren_depth == 0 => {
1251 if bytes.get(i + 1) == Some(&b'&') {
1252 scan.saw_top_separator = true; i += 2;
1254 continue;
1255 }
1256 if bytes.get(i + 1) != Some(&b'>') && prev_raw != b'>' {
1259 scan.saw_top_separator = true;
1260 }
1261 }
1262 b';' | b'\n' if scan.paren_depth == 0 => scan.saw_top_separator = true,
1263 _ => {}
1264 }
1265 i += 1;
1266 }
1267
1268 if scan.saw_top_pipe {
1269 scan.stages.push(command[stage_start..].trim().to_string());
1270 }
1271 scan
1272}
1273
1274fn strip_cd_prefix(command: &str) -> Option<String> {
1275 let bytes = command.as_bytes();
1277 let mut in_single = false;
1278 let mut in_double = false;
1279 let mut i = 0;
1280 while i < bytes.len() {
1281 let ch = bytes[i] as char;
1282 if !in_double && ch == '\'' {
1283 in_single = !in_single;
1284 } else if !in_single && ch == '"' {
1285 in_double = !in_double;
1286 } else if !in_single && !in_double {
1287 if ch == '&' && i + 1 < bytes.len() && bytes[i + 1] as char == '&' {
1288 let rest = command[i + 2..].trim_start();
1289 if rest.is_empty() {
1290 return None;
1291 }
1292 return Some(rest.to_string());
1293 }
1294 if ch == ';' {
1295 let rest = command[i + 1..].trim_start();
1296 if rest.is_empty() {
1297 return None;
1298 }
1299 return Some(rest.to_string());
1300 }
1301 }
1302 i += 1;
1303 }
1304 None
1305}
1306
1307fn strip_env_prefix(command: &str) -> Option<String> {
1308 let rest = command.strip_prefix("env")?.trim_start();
1310 strip_leading_assignment_prefix(rest)
1311}
1312
1313fn strip_leading_assignment_prefix(command: &str) -> Option<String> {
1314 let mut index = 0usize;
1315 let mut consumed_assignment = false;
1316
1317 loop {
1318 index = skip_whitespace(command, index);
1319 if index >= command.len() {
1320 break;
1321 }
1322
1323 let word_end = shell_word_end(command, index)?;
1324 if word_end == index {
1325 break;
1326 }
1327
1328 let word = &command[index..word_end];
1329 if !is_env_assignment(word) {
1330 break;
1331 }
1332
1333 consumed_assignment = true;
1334 index = word_end;
1335 }
1336
1337 if !consumed_assignment {
1338 return None;
1339 }
1340
1341 let after = command[index..].trim_start();
1342 if after.is_empty() {
1343 None
1344 } else {
1345 Some(after.to_string())
1346 }
1347}
1348
1349fn skip_whitespace(input: &str, mut index: usize) -> usize {
1350 while index < input.len() {
1351 let Some(ch) = input[index..].chars().next() else {
1352 break;
1353 };
1354 if !ch.is_whitespace() {
1355 break;
1356 }
1357 index += ch.len_utf8();
1358 }
1359 index
1360}
1361
1362fn shell_word_end(command: &str, start: usize) -> Option<usize> {
1363 let mut in_single = false;
1364 let mut in_double = false;
1365 let mut escaped = false;
1366
1367 for (offset, ch) in command[start..].char_indices() {
1368 let index = start + offset;
1369
1370 if escaped {
1371 escaped = false;
1372 continue;
1373 }
1374
1375 if ch == '\\' && !in_single {
1376 escaped = true;
1377 continue;
1378 }
1379
1380 if ch == '\'' && !in_double {
1381 in_single = !in_single;
1382 continue;
1383 }
1384
1385 if ch == '"' && !in_single {
1386 in_double = !in_double;
1387 continue;
1388 }
1389
1390 if !in_single && !in_double && (ch.is_whitespace() || matches!(ch, ';' | '&' | '|')) {
1391 return Some(index);
1392 }
1393 }
1394
1395 if in_single || in_double || escaped {
1396 None
1397 } else {
1398 Some(command.len())
1399 }
1400}
1401
1402fn is_env_assignment(token: &str) -> bool {
1403 if token.starts_with('-') {
1404 return false;
1405 }
1406 let Some((name, _value)) = token.split_once('=') else {
1407 return false;
1408 };
1409 let mut chars = name.chars();
1410 let Some(first) = chars.next() else {
1411 return false;
1412 };
1413 (first.is_ascii_alphabetic() || first == '_')
1414 && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1415}
1416
1417fn strip_timeout_prefix(command: &str) -> Option<String> {
1418 let rest = command.strip_prefix("timeout")?.trim_start();
1419 let mut iter = rest.splitn(2, char::is_whitespace);
1421 let duration = iter.next()?;
1422 let after = iter.next()?.trim_start();
1423 if after.is_empty() || !looks_like_duration(duration) {
1424 return None;
1425 }
1426 Some(after.to_string())
1427}
1428
1429fn looks_like_duration(token: &str) -> bool {
1430 if token.is_empty() {
1431 return false;
1432 }
1433 let mut chars = token.chars().peekable();
1434 let mut saw_digit = false;
1435 while let Some(&ch) = chars.peek() {
1436 if ch.is_ascii_digit() {
1437 saw_digit = true;
1438 chars.next();
1439 } else {
1440 break;
1441 }
1442 }
1443 if !saw_digit {
1444 return false;
1445 }
1446 match chars.next() {
1447 None => true,
1448 Some(unit) => matches!(unit, 's' | 'm' | 'h' | 'd') && chars.next().is_none(),
1449 }
1450}
1451
1452pub fn user_filter_dir(storage_dir: &Path, harness: Harness) -> PathBuf {
1455 storage_dir.join(harness.storage_segment()).join("filters")
1456}
1457
1458fn legacy_user_filter_dir(storage_dir: &Path) -> PathBuf {
1459 storage_dir.join("filters")
1460}
1461
1462pub(crate) fn repair_legacy_user_filter_dir(storage_dir: &Path, harness: Harness) {
1466 let legacy_dir = legacy_user_filter_dir(storage_dir);
1467 if !legacy_dir.exists() {
1468 return;
1469 }
1470
1471 let entries = match fs::read_dir(&legacy_dir) {
1472 Ok(entries) => entries.filter_map(Result::ok).collect::<Vec<_>>(),
1473 Err(_) => return,
1474 };
1475 if entries.is_empty() {
1476 let _ = fs::remove_dir(&legacy_dir);
1477 return;
1478 }
1479
1480 let harness_dir = user_filter_dir(storage_dir, harness);
1481 if fs::create_dir_all(&harness_dir).is_err() {
1482 return;
1483 }
1484
1485 for entry in entries {
1486 let target = harness_dir.join(entry.file_name());
1487 if target.exists() {
1488 continue;
1489 }
1490 let _ = fs::rename(entry.path(), target);
1491 }
1492
1493 if fs::read_dir(&legacy_dir)
1494 .map(|mut entries| entries.next().is_none())
1495 .unwrap_or(false)
1496 {
1497 let _ = fs::remove_dir(&legacy_dir);
1498 }
1499}
1500
1501pub fn project_filter_dir(project_root: &Path) -> PathBuf {
1505 project_root.join(".cortexkit").join("aft").join("filters")
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510 use super::*;
1511
1512 #[cfg(unix)]
1513 #[test]
1514 fn single_pipeline_scanner_returns_segment_labels() {
1515 let pipeline = single_top_level_pipeline("git rebase upstream/main | tail -3").unwrap();
1516 assert_eq!(
1517 pipeline
1518 .segments
1519 .iter()
1520 .map(|segment| segment.label.as_str())
1521 .collect::<Vec<_>>(),
1522 ["git rebase", "tail"]
1523 );
1524 }
1525
1526 #[cfg(unix)]
1527 #[test]
1528 fn single_pipeline_scanner_rejects_unsafe_shapes() {
1529 for command in [
1530 "false | tail -1; true | cat",
1531 "false | tail -1 &",
1532 "false | tail -1 # trailing comment",
1533 "cat <<EOF | tail -1\nbody\nEOF",
1534 ] {
1535 assert!(
1536 single_top_level_pipeline(command).is_none(),
1537 "scanner should reject {command:?}"
1538 );
1539 }
1540 }
1541
1542 #[cfg(unix)]
1543 #[test]
1544 fn single_pipeline_scanner_accepts_pipe_without_top_level_newline() {
1545 let pipeline = single_top_level_pipeline("false | tail -1").unwrap();
1546 assert_eq!(pipeline.segments.len(), 2);
1547 }
1548
1549 #[test]
1550 fn dispatch_strips_ansi_once_for_generic_and_toml_tiers() {
1551 let registry = toml_filter::build_registry(builtin_filters::ALL, None, None);
1552 for (command, output) in [
1553 ("custom-runner", "\u{1b}[32mline\u{1b}[0m\n"),
1554 (
1555 "docker build .",
1556 "\u{1b}[32m#1 DONE 0.1s\u{1b}[0m\nretained line\n",
1557 ),
1558 ] {
1559 generic::reset_ansi_strip_count();
1560 let result = compress_with_registry(command, output, ®istry);
1561 assert!(!result.is_empty());
1562 assert_eq!(generic::ansi_strip_count(), 1, "command: {command}");
1563 }
1564 }
1565
1566 #[test]
1567 fn user_and_project_filter_dir_helpers() {
1568 let storage = Path::new("/tmp/aft-storage");
1569 assert_eq!(
1570 user_filter_dir(storage, Harness::Opencode),
1571 Path::new("/tmp/aft-storage/opencode/filters")
1572 );
1573
1574 let project = Path::new("/repo");
1575 assert_eq!(
1576 project_filter_dir(project),
1577 Path::new("/repo/.cortexkit/aft/filters")
1578 );
1579 }
1580
1581 #[test]
1582 fn repair_legacy_user_filter_dir_moves_root_filters_without_overwrite() {
1583 let temp = tempfile::tempdir().unwrap();
1584 let storage = temp.path();
1585 fs::create_dir_all(storage.join("filters")).unwrap();
1586 fs::create_dir_all(storage.join("opencode/filters")).unwrap();
1587 fs::write(storage.join("filters/root-only.toml"), "root").unwrap();
1588 fs::write(storage.join("filters/collides.toml"), "root").unwrap();
1589 fs::write(storage.join("opencode/filters/collides.toml"), "harness").unwrap();
1590
1591 repair_legacy_user_filter_dir(storage, Harness::Opencode);
1592
1593 assert_eq!(
1594 fs::read_to_string(storage.join("opencode/filters/root-only.toml")).unwrap(),
1595 "root"
1596 );
1597 assert_eq!(
1598 fs::read_to_string(storage.join("opencode/filters/collides.toml")).unwrap(),
1599 "harness"
1600 );
1601 assert_eq!(
1602 fs::read_to_string(storage.join("filters/collides.toml")).unwrap(),
1603 "root"
1604 );
1605 assert!(!storage.join("filters/root-only.toml").exists());
1606 }
1607}
1608
1609#[cfg(test)]
1610mod output_probe_tests {
1611 use super::*;
1612 use serde::Deserialize;
1613
1614 #[derive(Deserialize)]
1615 struct CorpusEntry {
1616 file: String,
1617 }
1618
1619 #[derive(Debug, PartialEq, Eq)]
1620 struct MatchPhaseResult {
1621 selected: Option<usize>,
1622 output: CompressionResult,
1623 }
1624
1625 fn legacy_match_phase(output: &str) -> MatchPhaseResult {
1626 let compressors = compressors_in_dispatch_order();
1627 let selected = [Specificity::Specific, Specificity::PackageManager]
1628 .into_iter()
1629 .find_map(|specificity| {
1630 compressors
1631 .iter()
1632 .enumerate()
1633 .filter(|(_, compressor)| compressor.specificity() == specificity)
1634 .find_map(|(index, compressor)| {
1635 compressor.matches_output(output).then_some(index)
1636 })
1637 });
1638 match_phase_result(&compressors, selected, output)
1639 }
1640
1641 fn probe_match_phase(output: &str) -> MatchPhaseResult {
1642 let compressors = compressors_in_dispatch_order();
1643 let probe = OutputProbe::new(output);
1644 let selected = [Specificity::Specific, Specificity::PackageManager]
1645 .into_iter()
1646 .find_map(|specificity| {
1647 compressors
1648 .iter()
1649 .enumerate()
1650 .filter(|(_, compressor)| compressor.specificity() == specificity)
1651 .find_map(|(index, compressor)| {
1652 compressor.matches_output_probe(&probe).then_some(index)
1653 })
1654 });
1655 match_phase_result(&compressors, selected, output)
1656 }
1657
1658 fn match_phase_result(
1659 compressors: &[&dyn Compressor],
1660 selected: Option<usize>,
1661 output: &str,
1662 ) -> MatchPhaseResult {
1663 let output = selected.map_or_else(
1664 || GenericCompressor.compress_with_exit_code("", output, None),
1665 |index| compressors[index].compress_output_match_with_exit_code(output, None),
1666 );
1667 MatchPhaseResult { selected, output }
1668 }
1669
1670 fn repository_root() -> PathBuf {
1671 Path::new(env!("CARGO_MANIFEST_DIR"))
1672 .parent()
1673 .and_then(Path::parent)
1674 .expect("aft crate should be nested under the repository root")
1675 .to_path_buf()
1676 }
1677
1678 fn benchmark_corpus() -> Vec<(String, String)> {
1679 let fixtures = repository_root().join("benchmarks/compression-tokens/fixtures");
1680 let manifest_text = fs::read_to_string(fixtures.join("manifest.json"))
1681 .expect("compression corpus manifest should be readable");
1682 let manifest: Vec<CorpusEntry> =
1683 serde_json::from_str(&manifest_text).expect("compression corpus manifest should parse");
1684 manifest
1685 .into_iter()
1686 .map(|entry| {
1687 let output = fs::read_to_string(fixtures.join(&entry.file))
1688 .unwrap_or_else(|error| panic!("failed to read {}: {error}", entry.file));
1689 (format!("compression corpus {}", entry.file), output)
1690 })
1691 .collect()
1692 }
1693
1694 fn integration_filter_inputs() -> Vec<(String, String)> {
1695 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1696 .join("tests/integration/fixtures/compress_filters");
1697 let mut paths = Vec::new();
1698 collect_named_files(&root, "input.txt", &mut paths);
1699 paths.sort();
1700 paths
1701 .into_iter()
1702 .map(|path| {
1703 let output = fs::read_to_string(&path)
1704 .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
1705 (format!("filter fixture {}", path.display()), output)
1706 })
1707 .collect()
1708 }
1709
1710 fn collect_named_files(root: &Path, wanted_name: &str, paths: &mut Vec<PathBuf>) {
1711 let entries = fs::read_dir(root)
1712 .unwrap_or_else(|error| panic!("failed to read {}: {error}", root.display()));
1713 for entry in entries {
1714 let path = entry
1715 .expect("fixture directory entry should be readable")
1716 .path();
1717 if path.is_dir() {
1718 collect_named_files(&path, wanted_name, paths);
1719 } else if path.file_name().is_some_and(|name| name == wanted_name) {
1720 paths.push(path);
1721 }
1722 }
1723 }
1724
1725 fn hostile_outputs() -> Vec<(String, String)> {
1726 let huge_json_array = format!(
1727 "[{}]",
1728 (0..20_000)
1729 .map(|index| format!(r#"{{"index":{index},"value":"payload"}}"#))
1730 .collect::<Vec<_>>()
1731 .join(",")
1732 );
1733 let ansi_heavy = (0..2_000)
1734 .map(|index| format!("\u{1b}[31mordinary output {index}\u{1b}[0m"))
1735 .collect::<Vec<_>>()
1736 .join("\n");
1737 vec![
1738 (
1739 "valid non-tool JSON object".to_string(),
1740 r#" {"payload":{"nested":[1,2,3]},"ok":true}"#.to_string(),
1741 ),
1742 ("huge JSON array".to_string(), huge_json_array),
1743 (
1744 "almost JSON".to_string(),
1745 r#"{"payload":[1,2,3],"unterminated":true"#.to_string(),
1746 ),
1747 ("empty output".to_string(), String::new()),
1748 ("ANSI-heavy output".to_string(), ansi_heavy),
1749 ]
1750 }
1751
1752 #[test]
1753 fn shared_probe_preserves_match_selection_and_output_across_corpora() {
1754 let cases = benchmark_corpus()
1755 .into_iter()
1756 .chain(integration_filter_inputs())
1757 .chain(hostile_outputs());
1758
1759 for (label, raw_output) in cases {
1760 let output = strip_ansi(&raw_output);
1761 let legacy = legacy_match_phase(&output);
1762 let probed = probe_match_phase(&output);
1763 assert_eq!(legacy, probed, "output-shape dispatch changed for {label}");
1764 }
1765 }
1766
1767 #[cfg(debug_assertions)]
1768 #[test]
1769 fn unmatched_json_object_is_parsed_once_across_match_phase() {
1770 let output = r#"{"payload":{"nested":[1,2,3]},"ok":true}"#;
1771 reset_output_probe_json_parse_count();
1772
1773 let result = probe_match_phase(output);
1774
1775 assert_eq!(result.selected, None);
1776 assert_eq!(output_probe_json_parse_count(), 1);
1777 }
1778}
1779
1780#[cfg(test)]
1781mod dispatch_specificity_tests {
1782 use super::*;
1783 use crate::compress::toml_filter::FilterRegistry;
1784
1785 fn empty_registry() -> FilterRegistry {
1786 FilterRegistry::default()
1787 }
1788
1789 fn dispatch(cmd: &str, output: &str) -> String {
1794 compress_with_registry(cmd, output, &empty_registry()).text
1795 }
1796
1797 #[test]
1798 fn generic_dispatch_does_not_classify_error_or_warning_words() {
1799 let result = compress_with_registry(
1800 "unknown-tool",
1801 "error: this is just a log line\nwarning: this too",
1802 &empty_registry(),
1803 );
1804
1805 assert!(result.dropped_by_class.is_empty());
1806 assert!(!result.had_inner_drop);
1807 assert!(result.text.contains("error: this is just a log line"));
1808 }
1809
1810 #[test]
1811 fn bun_run_vitest_routes_to_vitest_not_generic() {
1812 let output = "Test Files 1 passed (1)\n Tests 4 passed (4)\n Start at 10:00:00\n Duration 120ms\n";
1817 let compressed = dispatch("bun run vitest", output);
1818 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1820 }
1821
1822 #[test]
1823 fn npm_test_routes_to_vitest_when_output_is_vitest_shaped() {
1824 let output = "RERUN src/foo.test.ts x1\nFAIL src/foo.test.ts\nTest Files 1 failed (1)\nDuration 120ms\n";
1827 let compressed = dispatch("npm test", output);
1828 assert!(compressed.contains("FAIL src/foo.test.ts"));
1829 assert!(compressed.contains("Duration 120ms"));
1830 assert!(!compressed.contains("RERUN"));
1831 }
1832
1833 #[test]
1834 fn bun_run_vitest_token_match_wins_over_bun_head_match() {
1835 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";
1838 let compressed = dispatch("bun run vitest run", output);
1839 assert!(compressed.contains("Test Files") || compressed.contains("PASS"));
1841 }
1842
1843 #[test]
1844 fn bunx_jest_routes_to_vitest_module() {
1845 let output = "PASS src/foo.test.js (1.2s)\nTest Suites: 1 passed, 1 total\nTests: 3 passed, 3 total\n";
1846 let compressed = dispatch("bunx jest --json", output);
1847 assert!(compressed.contains("Tests:") && compressed.contains("Test Suites"));
1848 }
1849
1850 #[test]
1851 fn pnpm_run_vitest_routes_to_vitest() {
1852 let output = "Test Files 1 passed (1)\n Tests 10 passed (10)\n";
1853 let compressed = dispatch("pnpm run vitest", output);
1854 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1855 }
1856
1857 #[test]
1858 fn npx_eslint_routes_to_eslint_not_generic() {
1859 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";
1860 let compressed = dispatch("npx eslint .", output);
1861 assert!(compressed.contains("no-unused-vars") || compressed.contains("✖"));
1863 }
1864
1865 #[test]
1866 fn npm_run_lint_without_linter_output_shape_falls_back() {
1867 let output = "> my-project@1.0.0 lint\n> eslint .\n\nAll good.\n";
1870 let compressed = dispatch("npm run lint", output);
1871 assert!(compressed.contains("All good."));
1872 }
1873
1874 #[test]
1875 fn bun_test_still_routes_to_bun_test_compressor() {
1876 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";
1884 let compressed = dispatch("bun test", output);
1885 assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
1886 }
1887
1888 #[test]
1889 fn bunx_vitest_routes_to_vitest() {
1890 let output = "Test Files 1 passed (1)\n Tests 3 passed (3)\n";
1891 let compressed = dispatch("bunx vitest run", output);
1892 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1893 }
1894
1895 #[test]
1896 fn cargo_test_still_routes_to_cargo() {
1897 let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
1900 let compressed = dispatch("cargo test", output);
1901 assert!(compressed.contains("failed") || compressed.contains("FAILED"));
1903 }
1904
1905 #[test]
1906 fn top_level_piped_cargo_test_uses_generic_output() {
1907 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
1908
1909 let compressed = compress_with_registry("cargo test | cat", output, &empty_registry());
1910
1911 assert!(
1912 compressed.text.contains("test ok_test ... ok"),
1913 "piped cargo output must stay generic/raw, got: {}",
1914 compressed.text
1915 );
1916 }
1917
1918 #[test]
1919 fn non_piped_cargo_test_still_uses_cargo_compressor() {
1920 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
1921
1922 let compressed = compress_with_registry("cargo test", output, &empty_registry());
1923
1924 assert!(compressed.text.contains("running 1 test"));
1925 assert!(compressed.text.contains("test result: ok"));
1926 assert!(
1927 !compressed.text.contains("test ok_test ... ok"),
1928 "non-piped cargo test should keep using the cargo compressor, got: {}",
1929 compressed.text
1930 );
1931 }
1932
1933 #[test]
1934 fn git_status_still_routes_to_git() {
1935 let output =
1937 "On branch main\nYour branch is up to date.\n\nnothing to commit, working tree clean\n";
1938 let compressed = dispatch("git status", output);
1939 assert!(compressed.contains("branch") || compressed.contains("clean"));
1940 }
1941
1942 #[test]
1943 fn pnpm_install_still_routes_to_pnpm() {
1944 let output = "Progress: resolved 100, downloaded 50, added 50\nAdded 50 packages\n";
1946 let compressed = dispatch("pnpm install", output);
1947 assert!(compressed.contains("Added") || compressed.contains("Progress"));
1949 }
1950}
1951
1952#[cfg(test)]
1953mod exit_code_safety_tests {
1954 use super::*;
1955 use crate::compress::toml_filter::{build_registry, FilterRegistry};
1956
1957 fn empty_registry() -> FilterRegistry {
1958 FilterRegistry::default()
1959 }
1960
1961 #[test]
1962 fn go_build_failure_signal_preserved_even_when_exit_zero_masks_failure() {
1963 let output = "go: go.mod file not found in current directory or any parent directory; see 'go help modules'\n";
1964
1965 let failed =
1966 compress_with_registry_exit_code("go build ./...", output, Some(1), &empty_registry());
1967 assert!(!failed.text.contains("go build: ok"));
1968 assert!(failed.text.contains("go.mod file not found"));
1969
1970 let masked =
1971 compress_with_registry_exit_code("go build ./...", output, Some(0), &empty_registry());
1972 assert!(!masked.text.contains("go build: ok"));
1973 assert!(masked.text.contains("go.mod file not found"));
1974 }
1975
1976 #[test]
1977 fn playwright_nonzero_crash_does_not_become_passed_summary() {
1978 let output = r#"Running 4 tests using 2 workers
1979
1980 ✓ 1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
1981 ✓ 2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
1982 ✓ 3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
1983 ✓ 4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
1984
1985 4 passed (6.3s)
1986Error: browserType.launch: Target page, context or browser has been closed
1987"#;
1988
1989 let failed = compress_with_registry_exit_code(
1990 "npx playwright test",
1991 output,
1992 Some(1),
1993 &empty_registry(),
1994 );
1995 assert!(!failed.text.starts_with("playwright: 4 tests passed"));
1996 assert!(failed.text.contains("browserType.launch"));
1997 }
1998
1999 #[test]
2000 fn cargo_test_compile_error_nonzero_preserves_error_code_diagnostic() {
2001 let output = r#" Compiling demo v0.1.0 (/tmp/demo)
2002error[E0432]: unresolved import `crate::missing`
2003 --> src/lib.rs:1:5
2004 |
20051 | use crate::missing;
2006 | ^^^^^^^^^^^^^^ no `missing` in the root
2007
2008error: could not compile `demo` (lib test) due to 1 previous error
2009"#;
2010
2011 let failed =
2012 compress_with_registry_exit_code("cargo test", output, Some(101), &empty_registry());
2013 assert!(failed.text.contains("error[E0432]"));
2014 assert!(failed.text.contains("unresolved import"));
2015 assert!(failed.text.contains("error: could not compile"));
2016 }
2017
2018 #[test]
2019 fn chained_mypy_success_then_later_failure_uses_failure_preserving_output() {
2020 let output = "Success: no issues found in 1 source file\nError: node process exploded\n";
2021
2022 let failed = compress_with_registry_exit_code(
2023 "mypy src && node fail.js",
2024 output,
2025 Some(1),
2026 &empty_registry(),
2027 );
2028 assert_ne!(failed.text, "mypy: clean");
2029 assert!(failed.text.contains("Error: node process exploded"));
2030 }
2031
2032 #[test]
2033 fn toml_shortcircuit_is_skipped_for_nonzero_exit() {
2034 let registry = build_registry(
2035 &[(
2036 "wget",
2037 r#"[filter]
2038matches = ["wget"]
2039
2040[shortcircuit]
2041when = '(?s).*'
2042replacement = "wget: ok"
2043"#,
2044 )],
2045 None,
2046 None,
2047 );
2048 let output = "Connecting to example.invalid\nerror: connection refused\n";
2049
2050 let failed = compress_with_registry_exit_code(
2051 "wget https://example.invalid",
2052 output,
2053 Some(1),
2054 ®istry,
2055 );
2056 assert_ne!(failed.text, "wget: ok");
2057 assert!(failed.text.contains("error: connection refused"));
2058 }
2059
2060 #[test]
2061 fn unknown_exit_code_keeps_byte_identical_legacy_compressor_output() {
2062 let output = "Success: no issues found in 1 source file\n";
2065
2066 let legacy = compress_with_registry_exit_code("mypy src", output, None, &empty_registry());
2067 assert_eq!(legacy.text, "mypy: clean");
2068 }
2069
2070 #[test]
2071 fn killed_exit_sentinel_rejects_clean_legacy_summary() {
2072 let output = "Success: no issues found in 1 source file
2073Error: later chained command failed
2074";
2075
2076 let killed = compress_with_registry_exit_code(
2077 "mypy src && node fail.js",
2078 output,
2079 Some(137),
2080 &empty_registry(),
2081 );
2082 assert_ne!(killed.text, "mypy: clean");
2083 assert!(killed.text.contains("Error: later chained command failed"));
2084 }
2085
2086 #[test]
2087 fn nonzero_clean_eslint_json_summary_falls_back_to_raw_output() {
2088 let output =
2089 r#"[{"filePath":"/repo/src/main.ts","messages":[],"errorCount":0,"warningCount":0}]"#;
2090
2091 let failed = compress_with_registry_exit_code(
2092 "eslint -f json .",
2093 output,
2094 Some(1),
2095 &empty_registry(),
2096 );
2097
2098 assert_ne!(failed.text, "eslint: no issues");
2099 assert!(failed.text.contains(r#""messages":[]"#));
2100 }
2101
2102 #[test]
2103 fn nonzero_appends_distinct_missing_raw_failure_lines() {
2104 let raw = "Error: first failure
2105progress
2106Error: second failure
2107";
2108 let compressed = CompressionResult::new("Error: first failure");
2109
2110 let preserved = failure_preserving_result("tool", raw, compressed, Some(1));
2111
2112 assert!(preserved.text.contains("Error: first failure"));
2113 assert!(preserved.text.contains("Error: second failure"));
2114 assert!(preserved
2115 .text
2116 .contains("[raw failure lines preserved by AFT]"));
2117 }
2118
2119 #[test]
2120 fn nonzero_cargo_failure_class_cap_falls_back_to_all_failures() {
2121 let mut output = String::from(
2122 "running 40 tests
2123
2124failures:
2125
2126",
2127 );
2128 for index in 0..40 {
2129 output.push_str(&format!(
2130 "---- case_{index} stdout ----
2131thread 'case_{index}' panicked at src/lib.rs:{index}:1
2132
2133"
2134 ));
2135 }
2136 output.push_str(
2137 "failures:
2138",
2139 );
2140 for index in 0..40 {
2141 output.push_str(&format!(
2142 " case_{index}
2143"
2144 ));
2145 }
2146 output.push_str(
2147 "
2148test result: FAILED. 0 passed; 40 failed; 0 ignored; 0 measured; 0 filtered out
2149",
2150 );
2151
2152 let failed =
2153 compress_with_registry_exit_code("cargo test", &output, Some(101), &empty_registry());
2154
2155 assert!(failed.text.contains("---- case_0 stdout ----"));
2156 assert!(failed.text.contains("---- case_39 stdout ----"));
2157 assert!(failed.dropped_by_class.is_empty());
2158 }
2159
2160 #[test]
2161 fn toml_shortcircuit_is_skipped_for_unknown_exit_when_failure_signal_exists() {
2162 let registry = build_registry(
2163 &[(
2164 "make",
2165 r#"[filter]
2166matches = ["make"]
2167
2168[shortcircuit]
2169when = '(?s).*'
2170replacement = "make: ok"
2171"#,
2172 )],
2173 None,
2174 None,
2175 );
2176 let output = "build step
2177ERROR: compiler crashed
2178";
2179
2180 let failed = compress_with_registry_exit_code("make", output, None, ®istry);
2181
2182 assert_ne!(failed.text, "make: ok");
2183 assert!(failed.text.contains("ERROR: compiler crashed"));
2184 }
2185
2186 fn frozen_contains_nonzero_failure_word(line: &str, word: &str) -> bool {
2188 let lower = line.to_ascii_lowercase();
2189 for (index, _) in lower.match_indices(word) {
2190 let end = index + word.len();
2191 let before_is_word = lower[..index].chars().next_back().is_some_and(is_word_char);
2192 let after_is_word = lower[end..].chars().next().is_some_and(is_word_char);
2193 if before_is_word || after_is_word {
2194 continue;
2195 }
2196
2197 let prefix = lower[..index].trim_end();
2198 let digits_start = prefix
2199 .char_indices()
2200 .rev()
2201 .take_while(|(_, ch)| ch.is_ascii_digit())
2202 .last()
2203 .map(|(idx, _)| idx);
2204 let Some(digits_start) = digits_start else {
2205 return true;
2206 };
2207 let digits = &prefix[digits_start..];
2208 if digits.parse::<usize>().ok() != Some(0) {
2209 return true;
2210 }
2211 }
2212 false
2213 }
2214
2215 fn frozen_line_has_failure_signal(line: &str) -> bool {
2216 let lower = line.to_ascii_lowercase();
2217 line.contains("error[")
2218 || lower.contains("error:")
2219 || line.contains("Error")
2220 || line.contains("ERROR")
2221 || lower.contains("internalerror")
2222 || lower.contains("traceback")
2223 || lower.contains("exception")
2224 || lower.contains("no module named")
2225 || lower.contains("undefined reference")
2226 || lower.contains("linker command failed")
2227 || lower.contains("undefined:")
2228 || lower.contains("expected declaration")
2229 || lower.contains("collect2: error")
2230 || lower.contains("ld: error")
2231 || lower.contains("fatal error")
2232 || line.contains("FAILED")
2233 || line.contains("FAIL")
2234 || frozen_contains_nonzero_failure_word(line, "fail")
2235 || frozen_contains_nonzero_failure_word(line, "failed")
2236 || frozen_contains_nonzero_failure_word(line, "failure")
2237 || frozen_contains_nonzero_failure_word(line, "failures")
2238 || lower.contains("panic")
2239 || lower.contains("cannot find")
2240 || lower.contains("not found")
2241 || lower.contains("no such")
2242 }
2243
2244 fn frozen_missing_failure_lines_reference(
2245 raw_output: &str,
2246 compressed_text: &str,
2247 ) -> Vec<String> {
2248 let compressed_lines: std::collections::BTreeSet<String> = compressed_text
2249 .lines()
2250 .map(str::trim)
2251 .filter(|line| !line.is_empty())
2252 .map(ToString::to_string)
2253 .collect();
2254 let mut seen = std::collections::BTreeSet::new();
2255 let mut missing = Vec::new();
2256
2257 for line in raw_output.lines() {
2258 let trimmed = line.trim();
2259 if trimmed.is_empty() || !frozen_line_has_failure_signal(trimmed) {
2260 continue;
2261 }
2262 if compressed_lines.contains(trimmed) || !seen.insert(trimmed.to_string()) {
2263 continue;
2264 }
2265 missing.push(trimmed.to_string());
2266 }
2267
2268 missing
2269 }
2270
2271 #[test]
2272 fn failure_line_index_is_byte_equivalent_to_frozen_reference() {
2273 let signals = [
2274 "Error: ordinary failure",
2275 "ERROR: uppercase failure",
2276 "error[E0432]: unresolved import",
2277 "eRrOr: mixed case",
2278 "0 fail",
2279 "00 failed",
2280 "1 failed",
2281 "0 failures",
2282 "01 failure",
2283 "2 failures",
2284 "failed_extra",
2285 "prefix_failure_suffix",
2286 "failures!",
2287 "thread panicked at src/lib.rs:1",
2288 "undefined reference to `main`",
2289 "例外ではない unicode status",
2290 ];
2291 for signal in signals {
2292 assert_eq!(
2293 line_has_failure_signal_lower(signal, &signal.to_ascii_lowercase()),
2294 frozen_line_has_failure_signal(signal),
2295 "classifier diverged for {signal:?}"
2296 );
2297 }
2298 for prefix in ["", "0 ", "00 ", "1 ", "01 ", "pre", "_", "2 errors and "] {
2299 for word in [
2300 "fail",
2301 "failed",
2302 "failure",
2303 "failures",
2304 "failing",
2305 "failuresx",
2306 ] {
2307 for suffix in ["", "!", "_tail", "x", " then continue"] {
2308 let line = format!("{prefix}{word}{suffix}");
2309 assert_eq!(
2310 line_has_failure_signal_lower(&line, &line.to_ascii_lowercase()),
2311 frozen_line_has_failure_signal(&line),
2312 "classifier diverged for {line:?}"
2313 );
2314 }
2315 }
2316 }
2317
2318 let mut raw = String::new();
2319 let mut compressed = String::new();
2320 for (index, signal) in signals.iter().enumerate() {
2321 raw.push_str("ordinary progress\n");
2322 raw.push_str(" ");
2323 raw.push_str(signal);
2324 raw.push_str(" \n");
2325 if index % 2 == 0 {
2326 compressed.push_str(signal);
2327 compressed.push('\n');
2328 }
2329 if index % 3 == 0 {
2330 raw.push_str(signal);
2331 raw.push('\n');
2332 }
2333 }
2334
2335 let expected = frozen_missing_failure_lines_reference(&raw, &compressed);
2336 let actual = missing_raw_failure_signal_lines(&raw, &compressed);
2337 assert_eq!(actual, expected);
2338
2339 let render_raw = "Error: first failure\nprogress\nError: second failure\n";
2340 let render_compressed = "Error: first failure";
2341 let render_missing = frozen_missing_failure_lines_reference(render_raw, render_compressed);
2342 let expected_text = format!(
2343 "{render_compressed}\n[raw failure lines preserved by AFT]\n{}",
2344 render_missing.join("\n")
2345 );
2346 let actual_result = failure_preserving_result(
2347 "cargo test",
2348 render_raw,
2349 CompressionResult::new(render_compressed),
2350 Some(101),
2351 );
2352 assert_eq!(actual_result.text.as_bytes(), expected_text.as_bytes());
2353 }
2354
2355 #[test]
2356 fn failure_line_index_allocations_do_not_scale_with_progress_lines() {
2357 use std::fmt::Write as _;
2358
2359 let mut output = String::new();
2360 for index in 0..5_000 {
2361 writeln!(
2362 output,
2363 "worker-{index:05}: processed /workspace/src/file-{index:05}.rs"
2364 )
2365 .expect("writing to String cannot fail");
2366 }
2367 output.push_str("ERROR: linker command failed for target app\n");
2368
2369 let (missing, allocations) =
2370 crate::test_allocations::count(|| missing_raw_failure_signal_lines(&output, &output));
2371
2372 assert!(missing.is_empty());
2373 assert!(
2374 allocations <= 8,
2375 "failure-line membership should borrow progress lines; got {allocations} allocations"
2376 );
2377 }
2378
2379 #[test]
2380 fn successful_exit_still_gets_concise_success_summary() {
2381 let output = r#"Running 4 tests using 2 workers
2382
2383 ✓ 1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
2384 ✓ 2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
2385 ✓ 3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
2386 ✓ 4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
2387
2388 4 passed (6.3s)
2389"#;
2390
2391 let successful =
2392 compress_with_registry_exit_code("playwright test", output, Some(0), &empty_registry());
2393 assert_eq!(successful.text, "playwright: 4 tests passed (6.3s)");
2394 }
2395}
2396
2397#[cfg(test)]
2398mod normalize_command_tests {
2399 use super::*;
2400
2401 #[test]
2402 fn passes_bare_commands_unchanged() {
2403 assert_eq!(normalize_command_for_dispatch("bun test"), None);
2404 assert_eq!(normalize_command_for_dispatch("cargo build"), None);
2405 assert_eq!(normalize_command_for_dispatch("git status"), None);
2406 }
2407
2408 #[test]
2409 fn strips_cd_and_amp_prefix() {
2410 assert_eq!(
2411 normalize_command_for_dispatch("cd /repo && bun test").as_deref(),
2412 Some("bun test")
2413 );
2414 assert_eq!(
2415 normalize_command_for_dispatch("cd /repo/packages/aft && cargo test --release")
2416 .as_deref(),
2417 Some("cargo test --release")
2418 );
2419 }
2420
2421 #[test]
2422 fn strips_cd_and_semicolon_prefix() {
2423 assert_eq!(
2424 normalize_command_for_dispatch("cd /repo; bun test").as_deref(),
2425 Some("bun test")
2426 );
2427 }
2428
2429 #[test]
2430 fn strips_cd_with_quoted_path() {
2431 assert_eq!(
2432 normalize_command_for_dispatch("cd \"/path with space\" && npm install").as_deref(),
2433 Some("npm install")
2434 );
2435 }
2436
2437 #[test]
2438 fn strips_env_assignments() {
2439 assert_eq!(
2440 normalize_command_for_dispatch("env FOO=bar npm install").as_deref(),
2441 Some("npm install")
2442 );
2443 assert_eq!(
2444 normalize_command_for_dispatch("env FOO=bar BAZ=qux RUST_LOG=info cargo test")
2445 .as_deref(),
2446 Some("cargo test")
2447 );
2448 }
2449
2450 #[test]
2451 fn strips_bare_assignment_prefixes() {
2452 assert_eq!(
2453 normalize_command_for_dispatch("NODE_ENV=production npm install").as_deref(),
2454 Some("npm install")
2455 );
2456 assert_eq!(
2457 normalize_command_for_dispatch("FOO=1 BAR=2 cargo test").as_deref(),
2458 Some("cargo test")
2459 );
2460 assert_eq!(
2461 normalize_command_for_dispatch("RUSTFLAGS='-C debug' cargo build").as_deref(),
2462 Some("cargo build")
2463 );
2464 }
2465
2466 #[test]
2467 fn does_not_strip_later_assignment_arguments() {
2468 assert_eq!(normalize_command_for_dispatch("npm install foo=bar"), None);
2469 }
2470
2471 #[test]
2472 fn env_without_assignments_returns_none() {
2473 assert_eq!(
2475 normalize_command_for_dispatch("env npm install").as_deref(),
2476 None
2477 );
2478 }
2479
2480 #[test]
2481 fn strips_timeout_prefix() {
2482 assert_eq!(
2483 normalize_command_for_dispatch("timeout 30 cargo test").as_deref(),
2484 Some("cargo test")
2485 );
2486 assert_eq!(
2487 normalize_command_for_dispatch("timeout 5m bun test").as_deref(),
2488 Some("bun test")
2489 );
2490 }
2491
2492 #[test]
2493 fn strips_nohup_prefix() {
2494 assert_eq!(
2495 normalize_command_for_dispatch("nohup ./long-running-script.sh").as_deref(),
2496 Some("./long-running-script.sh")
2497 );
2498 }
2499
2500 #[test]
2501 fn strips_paren_then_cd_and_amp() {
2502 assert_eq!(
2503 normalize_command_for_dispatch("(cd /repo && bun test").as_deref(),
2504 Some("bun test")
2505 );
2506 }
2507
2508 #[test]
2509 fn chains_multiple_prefixes() {
2510 assert_eq!(
2512 normalize_command_for_dispatch("env FOO=bar timeout 30 cargo test").as_deref(),
2513 Some("cargo test")
2514 );
2515 assert_eq!(
2517 normalize_command_for_dispatch("cd /repo && env FOO=bar npm install").as_deref(),
2518 Some("npm install")
2519 );
2520 }
2521
2522 fn empty_registry() -> FilterRegistry {
2525 FilterRegistry::default()
2526 }
2527
2528 #[test]
2529 fn cd_prefix_bun_test_still_routes_to_bun_test() {
2530 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";
2531 let compressed = compress_with_registry("cd /repo && bun test", output, &empty_registry());
2532 assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
2537 }
2538
2539 #[test]
2540 fn cd_prefix_cargo_test_still_routes_to_cargo() {
2541 let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
2542 let compressed =
2543 compress_with_registry("cd /repo && cargo test", output, &empty_registry());
2544 assert!(compressed.contains("FAILED") || compressed.contains("failed"));
2545 }
2546
2547 #[test]
2548 fn env_prefix_npm_install_still_routes_to_npm() {
2549 let output = "added 50 packages, and audited 100 packages in 3s\n";
2550 let compressed = compress_with_registry(
2551 "env NODE_ENV=production npm install",
2552 output,
2553 &empty_registry(),
2554 );
2555 assert!(compressed.contains("added") || compressed.contains("audited"));
2557 }
2558
2559 #[test]
2560 fn bare_assignment_prefix_npm_install_routes_to_npm() {
2561 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";
2562 let compressed =
2563 compress_with_registry("NODE_ENV=production npm install", output, &empty_registry());
2564 assert!(!compressed.contains("npm http fetch"));
2565 assert!(compressed.contains("audited 100 packages"));
2566 }
2567
2568 #[test]
2569 fn bare_assignment_prefix_cargo_test_routes_to_cargo() {
2570 let output = "running 1 test\ntest foo ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n";
2571 let compressed =
2572 compress_with_registry("FOO=1 BAR=2 cargo test", output, &empty_registry());
2573 assert!(compressed.contains("running 1 test"));
2574 assert!(compressed.contains("test result: ok"));
2575 assert!(!compressed.contains("test foo ... ok"));
2576 }
2577
2578 #[test]
2579 fn quoted_assignment_prefix_cargo_build_routes_to_cargo() {
2580 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";
2581 let compressed = compress_with_registry(
2582 "RUSTFLAGS='-C debug' cargo build",
2583 output,
2584 &empty_registry(),
2585 );
2586 assert!(!compressed.contains("Compiling foo"));
2587 assert!(compressed.contains("warning: unused variable"));
2588 assert!(compressed.contains("Finished `dev` profile"));
2589 }
2590
2591 #[test]
2592 fn timeout_prefix_cargo_build_still_routes_to_cargo() {
2593 let output =
2594 " Compiling foo v0.1.0\n Finished `dev` profile [unoptimized] target(s) in 5s\n";
2595 let compressed =
2596 compress_with_registry("timeout 30 cargo build", output, &empty_registry());
2597 assert!(compressed.contains("Compiling") || compressed.contains("Finished"));
2599 }
2600
2601 #[test]
2602 fn normalize_splits_pipe_and_takes_last_stage() {
2603 assert_eq!(
2604 normalize_command_for_dispatch("git log | grep fix").as_deref(),
2605 Some("grep fix")
2606 );
2607 }
2608
2609 #[test]
2610 fn normalize_cd_prefix_then_pipe_takes_last_stage() {
2611 assert_eq!(
2612 normalize_command_for_dispatch("cd /repo && git log | grep fix").as_deref(),
2613 Some("grep fix")
2614 );
2615 }
2616
2617 #[test]
2618 fn normalize_no_pipe_returns_none() {
2619 assert_eq!(normalize_command_for_dispatch("git log"), None);
2620 }
2621
2622 #[test]
2623 fn normalize_quoted_pipe_not_split() {
2624 assert_eq!(
2625 normalize_command_for_dispatch("grep \"a|b\" file.txt"),
2626 None
2627 );
2628 }
2629
2630 #[test]
2631 fn normalize_balanced_command_substitution_splits_top_level_pipe() {
2632 assert_eq!(
2637 normalize_command_for_dispatch("echo $(cmd | cmd) | grep x").as_deref(),
2638 Some("grep x")
2639 );
2640 }
2641
2642 #[test]
2643 fn normalize_inner_pipe_in_substitution_without_top_level_pipe_is_none() {
2644 assert_eq!(
2646 normalize_command_for_dispatch("echo $(cargo test | cat)"),
2647 None
2648 );
2649 }
2650
2651 #[test]
2652 fn normalize_double_pipe_not_split() {
2653 assert_eq!(normalize_command_for_dispatch("git log || echo fail"), None);
2654 }
2655
2656 #[test]
2657 fn normalize_multi_pipe_returns_last_stage() {
2658 assert_eq!(
2659 normalize_command_for_dispatch("git log | grep fix | head -5").as_deref(),
2660 Some("head -5")
2661 );
2662 }
2663
2664 #[test]
2665 fn normalize_process_substitution_splits_top_level_pipe() {
2666 assert_eq!(
2668 normalize_command_for_dispatch("cat <(echo a | cat) | grep x").as_deref(),
2669 Some("grep x")
2670 );
2671 }
2672
2673 #[test]
2674 fn normalize_pipe_ampersand_splits_last_stage() {
2675 assert_eq!(
2677 normalize_command_for_dispatch("cargo test |& grep FAIL").as_deref(),
2678 Some("grep FAIL")
2679 );
2680 }
2681
2682 #[test]
2683 fn piped_cargo_test_grep_preserves_failed() {
2684 let grep_output = "test foo ... FAILED\n";
2685 let compressed =
2686 compress_with_registry("cargo test | grep FAIL", grep_output, &empty_registry());
2687 assert!(
2688 compressed.text.contains("FAILED"),
2689 "grep-filtered FAILED must survive, got: {}",
2690 compressed.text
2691 );
2692 }
2693
2694 #[test]
2695 fn unsafe_piped_command_forces_generic_and_preserves_output() {
2696 let grep_output = "test foo ... FAILED\n";
2700 let compressed =
2701 compress_with_registry("cargo test | grep \"FAIL", grep_output, &empty_registry());
2702 assert!(
2703 compressed.text.contains("FAILED"),
2704 "unsafe pipe must not drop output, got: {}",
2705 compressed.text
2706 );
2707 }
2708
2709 #[test]
2710 fn split_top_level_pipe_variants() {
2711 assert_eq!(split_top_level_pipe("git log"), PipeSplit::None);
2712 assert_eq!(
2713 split_top_level_pipe("git log | grep fix"),
2714 PipeSplit::LastStage("grep fix".to_string())
2715 );
2716 assert_eq!(split_top_level_pipe("a || b"), PipeSplit::Unsafe);
2719 assert_eq!(split_top_level_pipe("(a | b)"), PipeSplit::None);
2721 assert_eq!(split_top_level_pipe("echo $(a | b)"), PipeSplit::None);
2723 assert_eq!(split_top_level_pipe("a | grep \"x"), PipeSplit::Unsafe);
2725 assert_eq!(split_top_level_pipe("$(a | b | grep x"), PipeSplit::Unsafe);
2727 assert_eq!(split_top_level_pipe("cargo test |"), PipeSplit::Unsafe);
2732 assert_eq!(split_top_level_pipe("cargo test |&"), PipeSplit::Unsafe);
2733 assert_eq!(
2735 split_top_level_pipe("true | cargo test --quiet ; printf X"),
2736 PipeSplit::Unsafe
2737 );
2738 assert_eq!(
2739 split_top_level_pipe("true | cargo test && echo done"),
2740 PipeSplit::Unsafe
2741 );
2742 assert_eq!(
2744 split_top_level_pipe("echo ) | cargo test"),
2745 PipeSplit::Unsafe
2746 );
2747 assert_eq!(split_top_level_pipe("a | b & c"), PipeSplit::Unsafe);
2749 assert_eq!(
2750 split_top_level_pipe("cargo test 2>&1 | grep FAIL"),
2751 PipeSplit::LastStage("grep FAIL".to_string())
2752 );
2753 assert_eq!(
2757 split_top_level_pipe("cargo test ; printf SENTINEL"),
2758 PipeSplit::Unsafe
2759 );
2760 assert_eq!(
2761 split_top_level_pipe("cargo test && echo done"),
2762 PipeSplit::Unsafe
2763 );
2764 assert_eq!(
2765 split_top_level_pipe("cargo test\necho done"),
2766 PipeSplit::Unsafe
2767 );
2768 assert_eq!(split_top_level_pipe("cargo test 2>&1"), PipeSplit::None);
2771 assert_eq!(
2772 split_top_level_pipe("cargo test &> /dev/null"),
2773 PipeSplit::None
2774 );
2775 }
2776
2777 #[test]
2778 fn strip_top_level_comment_removes_only_real_comments() {
2779 assert_eq!(
2780 strip_top_level_comment("printf keep # | cargo test"),
2781 "printf keep "
2782 );
2783 assert_eq!(
2784 strip_top_level_comment("printf keep # cargo test"),
2785 "printf keep "
2786 );
2787 assert_eq!(
2789 strip_top_level_comment("curl http://x/y#frag"),
2790 "curl http://x/y#frag"
2791 );
2792 assert_eq!(
2794 strip_top_level_comment("grep \"# not a comment\" f"),
2795 "grep \"# not a comment\" f"
2796 );
2797 assert_eq!(
2798 strip_top_level_comment("echo '# literal'"),
2799 "echo '# literal'"
2800 );
2801 assert_eq!(
2803 strip_top_level_comment("git log | grep fix"),
2804 "git log | grep fix"
2805 );
2806 }
2807
2808 #[test]
2809 fn commented_command_does_not_misdispatch_and_preserves_output() {
2810 for cmd in ["printf keep # | cargo test", "printf keep # cargo test"] {
2813 let compressed = compress_with_registry(cmd, "keep\n", &empty_registry());
2814 assert!(
2815 compressed.text.contains("keep"),
2816 "comment must not drop output for {cmd:?}, got: {}",
2817 compressed.text
2818 );
2819 }
2820 }
2821
2822 #[test]
2823 fn pipe_with_trailing_command_chain_preserves_sentinel() {
2824 let compressed = compress_with_registry(
2827 "true | cargo test --quiet ; printf SENTINEL",
2828 "SENTINEL\n",
2829 &empty_registry(),
2830 );
2831 assert!(
2832 compressed.text.contains("SENTINEL"),
2833 "trailing-chain output must survive, got: {}",
2834 compressed.text
2835 );
2836 }
2837
2838 #[test]
2842 fn separator_list_forces_generic_and_preserves_sentinel() {
2843 let cargo_noise =
2848 "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
2849 let transcript = format!("{cargo_noise}SENTINEL\n");
2850 let compressed = compress_with_registry(
2851 "cargo test ; printf SENTINEL",
2852 &transcript,
2853 &empty_registry(),
2854 );
2855 assert!(
2856 compressed.text.contains("SENTINEL"),
2857 "separator-list output must survive generic compression, got: {}",
2858 compressed.text
2859 );
2860 }
2861
2862 #[test]
2863 fn cd_prefix_peel_leaves_no_residual_separator_for_cargo() {
2864 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
2868 let compressed = compress_with_registry("cd /x && cargo test", output, &empty_registry());
2869 assert!(
2871 !compressed.text.contains("test ok_test ... ok"),
2872 "cd-peeled cargo test must still use the cargo compressor, got: {}",
2873 compressed.text
2874 );
2875 assert!(compressed.text.contains("test result: ok"));
2876 }
2877
2878 #[test]
2879 fn clean_single_cargo_test_dispatch_byte_identical() {
2880 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
2884 let compressed = compress_with_registry("cargo test", output, &empty_registry());
2885 assert!(compressed.text.contains("running 1 test"));
2886 assert!(compressed.text.contains("test result: ok"));
2887 assert!(
2888 !compressed.text.contains("test ok_test ... ok"),
2889 "clean cargo test must keep using the cargo compressor, got: {}",
2890 compressed.text
2891 );
2892 }
2893
2894 #[test]
2895 fn is_shell_boundary_covers_redirects_and_operators() {
2896 for tok in [
2897 "|",
2898 "|&",
2899 ";",
2900 "&",
2901 "&&",
2902 "||",
2903 ">",
2904 ">>",
2905 "<",
2906 "<<",
2907 "<<<",
2908 "&>",
2909 "&>>",
2910 "2>",
2911 "2>>",
2912 "2>&1",
2913 "1>&2",
2914 ">/dev/null",
2915 "2>/dev/null",
2916 ] {
2917 assert!(is_shell_boundary(tok), "{tok} should be a boundary");
2918 }
2919 for tok in ["test", "log", "build", "--release", "-v", "file.txt"] {
2920 assert!(!is_shell_boundary(tok), "{tok} must not be a boundary");
2921 }
2922 }
2923}