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