1pub mod biome;
21pub mod builtin_filters;
22pub mod bun;
23pub mod caps;
24pub mod cargo;
25pub mod eslint;
26pub mod find;
27pub mod generic;
28pub mod git;
29pub mod go;
30pub mod listing_fold;
31pub mod ls;
32pub mod mypy;
33pub mod next;
34pub mod npm;
35pub mod playwright;
36pub mod pnpm;
37pub mod prettier;
38pub mod pytest;
39pub mod ruff;
40pub mod toml_filter;
41pub mod tree;
42pub mod trust;
43pub mod tsc;
44pub mod vitest;
45
46use crate::context::AppContext;
47use crate::harness::Harness;
48use biome::BiomeCompressor;
49use bun::BunCompressor;
50use caps::DropClass;
51use cargo::CargoCompressor;
52use eslint::EslintCompressor;
53use find::FindCompressor;
54use generic::{strip_ansi, GenericCompressor};
55use git::GitCompressor;
56use go::{GoCompressor, GolangciLintCompressor};
57use ls::LsCompressor;
58use mypy::MypyCompressor;
59use next::NextCompressor;
60use npm::NpmCompressor;
61use playwright::PlaywrightCompressor;
62use pnpm::PnpmCompressor;
63use prettier::PrettierCompressor;
64use pytest::PytestCompressor;
65use ruff::RuffCompressor;
66use std::cell::OnceCell;
67use std::collections::{BTreeMap, BTreeSet};
68use std::fs;
69use std::path::{Path, PathBuf};
70use std::sync::{Arc, RwLock};
71use toml_filter::{apply_filter_with_exit_code_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_lines: BTreeSet<String> = compressed_text
528 .lines()
529 .map(str::trim)
530 .filter(|line| !line.is_empty())
531 .map(ToString::to_string)
532 .collect();
533 let mut seen = BTreeSet::new();
534 let mut missing = Vec::new();
535
536 for line in raw_output.lines() {
537 let trimmed = line.trim();
538 if trimmed.is_empty() || !line_has_failure_signal(trimmed) {
539 continue;
540 }
541 if compressed_lines.contains(trimmed) || !seen.insert(trimmed.to_string()) {
542 continue;
543 }
544 missing.push(trimmed.to_string());
545 }
546
547 missing
548}
549
550fn result_looks_successful(text: &str) -> bool {
551 let lower = text.to_ascii_lowercase();
552 lower.contains("clean")
553 || lower.contains(" ok")
554 || lower.contains(":ok")
555 || lower.contains(": ok")
556 || lower.contains("passed")
557 || lower.contains("succeeded")
558 || lower.contains("no errors")
559 || lower.contains("0 errors")
560 || lower.contains("no issues")
561 || lower.contains("no diagnostics")
562 || lower.contains("all checks passed")
563 || lower.contains("formatted")
564 || lower.contains("0 fail")
565 || lower.contains("found 0")
566 || lower.contains("up to date")
567 || lower.contains("up-to-date")
568}
569
570pub(crate) fn text_has_failure_signal(text: &str) -> bool {
571 text.lines()
572 .any(|line| line_has_failure_signal(line.trim()))
573}
574
575fn line_has_failure_signal(line: &str) -> bool {
576 let lower = line.to_ascii_lowercase();
577 line.contains("error[")
578 || lower.contains("error:")
579 || line.contains("Error")
580 || line.contains("ERROR")
581 || lower.contains("internalerror")
582 || lower.contains("traceback")
583 || lower.contains("exception")
584 || lower.contains("no module named")
585 || lower.contains("undefined reference")
586 || lower.contains("linker command failed")
587 || lower.contains("undefined:")
588 || lower.contains("expected declaration")
589 || lower.contains("collect2: error")
590 || lower.contains("ld: error")
591 || lower.contains("fatal error")
592 || line.contains("FAILED")
593 || line.contains("FAIL")
594 || contains_nonzero_failure_word(line, "fail")
595 || contains_nonzero_failure_word(line, "failed")
596 || contains_nonzero_failure_word(line, "failure")
597 || contains_nonzero_failure_word(line, "failures")
598 || lower.contains("panic")
599 || lower.contains("cannot find")
600 || lower.contains("not found")
601 || lower.contains("no such")
602}
603
604fn contains_nonzero_failure_word(line: &str, word: &str) -> bool {
605 let lower = line.to_ascii_lowercase();
606 for (index, _) in lower.match_indices(word) {
607 let end = index + word.len();
608 let before_is_word = lower[..index].chars().next_back().is_some_and(is_word_char);
609 let after_is_word = lower[end..].chars().next().is_some_and(is_word_char);
610 if before_is_word || after_is_word {
611 continue;
612 }
613
614 let prefix = lower[..index].trim_end();
615 let digits_start = prefix
616 .char_indices()
617 .rev()
618 .take_while(|(_, ch)| ch.is_ascii_digit())
619 .last()
620 .map(|(idx, _)| idx);
621 let Some(digits_start) = digits_start else {
622 return true;
623 };
624 let digits = &prefix[digits_start..];
625 if digits.parse::<usize>().ok() != Some(0) {
626 return true;
627 }
628 }
629 false
630}
631
632fn is_word_char(ch: char) -> bool {
633 ch.is_ascii_alphanumeric() || ch == '_'
634}
635
636pub fn build_registry_for_context(ctx: &AppContext) -> FilterRegistry {
645 let harness = ctx.harness.lock().clone().unwrap_or(Harness::Opencode);
646 let config = ctx.config();
647 let storage_dir = config.storage_dir.clone();
648 let project_root = config.project_root.clone();
649 drop(config);
650
651 let user_dir = storage_dir.as_ref().map(|dir| {
652 repair_legacy_user_filter_dir(dir, harness.clone());
653 user_filter_dir(dir, harness)
654 });
655 let project_dir = match (project_root.as_ref(), storage_dir.as_ref()) {
656 (Some(root), Some(storage)) => {
657 if trust::is_project_trusted(Some(storage), root) {
658 Some(project_filter_dir(root))
659 } else {
660 None
661 }
662 }
663 _ => None,
664 };
665
666 toml_filter::build_registry(
667 builtin_filters::ALL,
668 user_dir.as_deref(),
669 project_dir.as_deref(),
670 )
671}
672
673pub fn normalize_command_for_dispatch(command: &str) -> Option<String> {
697 match resolve_dispatch_target(command) {
698 DispatchTarget::ForceGeneric => None,
701 DispatchTarget::Command(resolved) | DispatchTarget::Pipeline(resolved) => {
702 if resolved == command.trim_start() {
703 None
704 } else {
705 Some(resolved)
706 }
707 }
708 }
709}
710
711pub(crate) fn plain_command_for_structured_output(command: &str) -> Option<String> {
714 match resolve_dispatch_target(command) {
715 DispatchTarget::Command(resolved) => Some(resolved),
716 DispatchTarget::Pipeline(_) | DispatchTarget::ForceGeneric => None,
717 }
718}
719
720enum DispatchTarget {
723 Command(String),
726 Pipeline(String),
730 ForceGeneric,
734}
735
736fn resolve_dispatch_target(command: &str) -> DispatchTarget {
737 let decommented = strip_top_level_comment(command);
742 let peeled = peel_shell_prefixes(&decommented);
743 let base = peeled
744 .as_deref()
745 .unwrap_or_else(|| decommented.trim_start());
746 match split_top_level_pipe(base) {
747 PipeSplit::LastStage(last) => DispatchTarget::Pipeline(last),
748 PipeSplit::Unsafe => DispatchTarget::ForceGeneric,
749 PipeSplit::None => DispatchTarget::Command(base.to_string()),
750 }
751}
752
753fn strip_top_level_comment(command: &str) -> String {
761 let bytes = command.as_bytes();
762 let mut result = String::with_capacity(command.len());
763 let mut seg_start = 0usize;
764 let mut in_single = false;
765 let mut in_double = false;
766 let mut in_backtick = false;
767 let mut paren_depth: u32 = 0;
768 let mut escaped = false;
769 let mut prev = b' '; let mut i = 0;
772 while i < bytes.len() {
773 let ch = bytes[i];
774 if escaped {
775 escaped = false;
776 prev = ch;
777 i += 1;
778 continue;
779 }
780 if in_single {
781 if ch == b'\'' {
782 in_single = false;
783 }
784 prev = ch;
785 i += 1;
786 continue;
787 }
788 if in_backtick {
789 if ch == b'\\' {
790 escaped = true;
791 } else if ch == b'`' {
792 in_backtick = false;
793 }
794 prev = ch;
795 i += 1;
796 continue;
797 }
798 if ch == b'\\' {
799 escaped = true;
800 prev = ch;
801 i += 1;
802 continue;
803 }
804 if ch == b'`' {
805 in_backtick = true;
806 prev = ch;
807 i += 1;
808 continue;
809 }
810 if ch == b'$' && bytes.get(i + 1) == Some(&b'(') {
811 paren_depth += 1;
812 prev = b'(';
813 i += 2;
814 continue;
815 }
816 if in_double {
817 if ch == b'"' {
818 in_double = false;
819 }
820 prev = ch;
821 i += 1;
822 continue;
823 }
824 if ch == b'#'
825 && paren_depth == 0
826 && matches!(prev, b' ' | b'\t' | b'\n' | b';' | b'&' | b'|' | b'(')
827 {
828 result.push_str(&command[seg_start..i]);
829 while i < bytes.len() && bytes[i] != b'\n' {
830 i += 1;
831 }
832 seg_start = i; prev = b'\n';
834 continue;
835 }
836 match ch {
837 b'\'' => in_single = true,
838 b'"' => in_double = true,
839 b'<' | b'>' if bytes.get(i + 1) == Some(&b'(') => {
840 paren_depth += 1;
841 prev = b'(';
842 i += 2;
843 continue;
844 }
845 b'(' => paren_depth += 1,
846 b')' => paren_depth = paren_depth.saturating_sub(1),
847 _ => {}
848 }
849 prev = ch;
850 i += 1;
851 }
852 result.push_str(&command[seg_start..]);
853 result
854}
855
856fn peel_shell_prefixes(command: &str) -> Option<String> {
860 let trimmed = command.trim_start();
861 if trimmed.is_empty() {
862 return None;
863 }
864
865 let (open_paren, after_paren) = if let Some(rest) = trimmed.strip_prefix('(') {
867 (true, rest.trim_start())
868 } else {
869 (false, trimmed)
870 };
871
872 let mut current = after_paren.to_string();
873 let mut changed = open_paren;
874
875 loop {
877 if let Some(stripped) = strip_leading_assignment_prefix(¤t) {
881 current = stripped;
882 changed = true;
883 continue;
884 }
885
886 let head: String = current.split_whitespace().next().unwrap_or("").to_string();
887
888 if head == "cd" {
890 if let Some(stripped) = strip_cd_prefix(¤t) {
893 current = stripped;
894 changed = true;
895 continue;
896 }
897 }
898
899 if head == "env" {
901 if let Some(stripped) = strip_env_prefix(¤t) {
902 current = stripped;
903 changed = true;
904 continue;
905 }
906 }
907
908 if head == "timeout" {
910 if let Some(stripped) = strip_timeout_prefix(¤t) {
911 current = stripped;
912 changed = true;
913 continue;
914 }
915 }
916
917 if head == "nohup" {
919 if let Some(rest) = current.strip_prefix("nohup").and_then(|s| {
920 let trimmed = s.trim_start();
921 if trimmed.is_empty() {
922 None
923 } else {
924 Some(trimmed.to_string())
925 }
926 }) {
927 current = rest;
928 changed = true;
929 continue;
930 }
931 }
932
933 break;
934 }
935
936 if changed {
937 Some(current)
938 } else {
939 None
940 }
941}
942
943pub fn is_shell_boundary(token: &str) -> bool {
950 matches!(token, "|" | "|&" | ";" | "&" | "&&" | "||" | "&>" | "&>>") || is_redirect_token(token)
951}
952
953fn is_redirect_token(token: &str) -> bool {
957 let rest = token.trim_start_matches(|c: char| c.is_ascii_digit());
958 rest.starts_with('>') || rest.starts_with('<') || rest.starts_with("&>")
959}
960
961#[derive(Debug, PartialEq, Eq)]
963enum PipeSplit {
964 None,
967 LastStage(String),
969 Unsafe,
976}
977
978#[cfg(unix)]
982#[derive(Debug, Clone, PartialEq, Eq)]
983pub(crate) struct PipelineInfo {
984 pub(crate) segments: Vec<PipelineSegment>,
985}
986
987#[cfg(unix)]
988#[derive(Debug, Clone, PartialEq, Eq)]
989pub(crate) struct PipelineSegment {
990 pub(crate) label: String,
991}
992
993#[derive(Debug, Default)]
994struct PipelineScan {
995 stages: Vec<String>,
996 saw_top_pipe: bool,
997 saw_top_separator: bool,
998 saw_unmatched_close: bool,
999 in_single: bool,
1000 in_double: bool,
1001 in_backtick: bool,
1002 paren_depth: u32,
1003 escaped: bool,
1004}
1005
1006#[cfg(unix)]
1012pub(crate) fn single_top_level_pipeline(command: &str) -> Option<PipelineInfo> {
1013 if strip_top_level_comment(command) != command {
1014 return None;
1015 }
1016
1017 let scan = scan_top_level_pipe(command);
1018 let stages = clean_pipeline_stages(scan)?;
1019 if stages.len() < 2 {
1020 return None;
1021 }
1022
1023 Some(PipelineInfo {
1024 segments: stages
1025 .iter()
1026 .map(|stage| PipelineSegment {
1027 label: pipeline_segment_label(stage),
1028 })
1029 .collect(),
1030 })
1031}
1032
1033#[cfg(unix)]
1034fn pipeline_segment_label(stage: &str) -> String {
1035 let stage = strip_leading_assignment_prefix(stage).unwrap_or_else(|| stage.trim().to_string());
1036 let start = skip_whitespace(&stage, 0);
1037 let Some(first_end) = shell_word_end(&stage, start) else {
1038 return stage;
1039 };
1040 if first_end == stage.len() {
1041 return stage[start..first_end].to_string();
1042 }
1043
1044 let first_word = &stage[start..first_end];
1045 if first_word != "git" {
1046 return first_word.to_string();
1047 }
1048
1049 let second_start = skip_whitespace(&stage, first_end);
1052 let second_end = shell_word_end(&stage, second_start).unwrap_or(second_start);
1053 if second_end > second_start {
1054 format!("{} {}", first_word, &stage[second_start..second_end])
1055 } else {
1056 first_word.to_string()
1057 }
1058}
1059
1060fn split_top_level_pipe(command: &str) -> PipeSplit {
1077 let scan = scan_top_level_pipe(command);
1078 if scan.saw_top_pipe {
1079 if let Some(stages) = clean_pipeline_stages(scan) {
1080 return PipeSplit::LastStage(stages.last().cloned().unwrap_or_default());
1081 }
1082 return PipeSplit::Unsafe;
1083 }
1084
1085 if (scan.imbalance() && command.contains('|')) || scan.saw_top_separator {
1086 PipeSplit::Unsafe
1087 } else {
1088 PipeSplit::None
1089 }
1090}
1091
1092fn clean_pipeline_stages(scan: PipelineScan) -> Option<Vec<String>> {
1093 if !scan.saw_top_pipe || scan.imbalance() || scan.saw_top_separator {
1094 return None;
1095 }
1096 if scan.stages.iter().any(|stage| stage.trim().is_empty()) {
1097 return None;
1098 }
1099 Some(scan.stages)
1100}
1101
1102impl PipelineScan {
1103 fn imbalance(&self) -> bool {
1104 self.in_single
1105 || self.in_double
1106 || self.in_backtick
1107 || self.escaped
1108 || self.paren_depth != 0
1109 || self.saw_unmatched_close
1110 }
1111}
1112
1113fn scan_top_level_pipe(command: &str) -> PipelineScan {
1114 let bytes = command.as_bytes();
1115 let mut scan = PipelineScan::default();
1116 let mut stage_start = 0usize;
1117 let mut i = 0;
1118
1119 while i < bytes.len() {
1120 let ch = bytes[i];
1121
1122 if scan.escaped {
1123 scan.escaped = false;
1124 i += 1;
1125 continue;
1126 }
1127 if scan.in_single {
1128 if ch == b'\'' {
1129 scan.in_single = false;
1130 }
1131 i += 1;
1132 continue;
1133 }
1134 if scan.in_backtick {
1135 if ch == b'\\' {
1138 scan.escaped = true;
1139 } else if ch == b'`' {
1140 scan.in_backtick = false;
1141 }
1142 i += 1;
1143 continue;
1144 }
1145 if ch == b'\\' {
1146 scan.escaped = true;
1147 i += 1;
1148 continue;
1149 }
1150 if ch == b'`' {
1151 scan.in_backtick = true;
1152 i += 1;
1153 continue;
1154 }
1155 if ch == b'$' && bytes.get(i + 1) == Some(&b'(') {
1157 scan.paren_depth += 1;
1158 i += 2;
1159 continue;
1160 }
1161 if scan.in_double {
1162 if ch == b'"' {
1163 scan.in_double = false;
1164 }
1165 i += 1;
1166 continue;
1167 }
1168
1169 let prev_raw = if i > 0 { bytes[i - 1] } else { b' ' };
1173
1174 match ch {
1175 b'\'' => scan.in_single = true,
1176 b'"' => scan.in_double = true,
1177 b'<' | b'>' if bytes.get(i + 1) == Some(&b'(') => {
1179 scan.paren_depth += 1;
1180 i += 2;
1181 continue;
1182 }
1183 b'(' => scan.paren_depth += 1,
1184 b')' => {
1185 if scan.paren_depth == 0 {
1186 scan.saw_unmatched_close = true;
1187 } else {
1188 scan.paren_depth -= 1;
1189 }
1190 }
1191 b'|' if scan.paren_depth == 0 => {
1192 if bytes.get(i + 1) == Some(&b'|') {
1193 scan.saw_top_separator = true; i += 2;
1195 continue;
1196 }
1197 scan.saw_top_pipe = true;
1198 scan.stages.push(command[stage_start..i].trim().to_string());
1199 let delimiter_len = if bytes.get(i + 1) == Some(&b'&') {
1200 2 } else {
1202 1
1203 };
1204 stage_start = i + delimiter_len;
1205 i += delimiter_len;
1206 continue;
1207 }
1208 b'&' if scan.paren_depth == 0 => {
1209 if bytes.get(i + 1) == Some(&b'&') {
1210 scan.saw_top_separator = true; i += 2;
1212 continue;
1213 }
1214 if bytes.get(i + 1) != Some(&b'>') && prev_raw != b'>' {
1217 scan.saw_top_separator = true;
1218 }
1219 }
1220 b';' | b'\n' if scan.paren_depth == 0 => scan.saw_top_separator = true,
1221 _ => {}
1222 }
1223 i += 1;
1224 }
1225
1226 if scan.saw_top_pipe {
1227 scan.stages.push(command[stage_start..].trim().to_string());
1228 }
1229 scan
1230}
1231
1232fn strip_cd_prefix(command: &str) -> Option<String> {
1233 let bytes = command.as_bytes();
1235 let mut in_single = false;
1236 let mut in_double = false;
1237 let mut i = 0;
1238 while i < bytes.len() {
1239 let ch = bytes[i] as char;
1240 if !in_double && ch == '\'' {
1241 in_single = !in_single;
1242 } else if !in_single && ch == '"' {
1243 in_double = !in_double;
1244 } else if !in_single && !in_double {
1245 if ch == '&' && i + 1 < bytes.len() && bytes[i + 1] as char == '&' {
1246 let rest = command[i + 2..].trim_start();
1247 if rest.is_empty() {
1248 return None;
1249 }
1250 return Some(rest.to_string());
1251 }
1252 if ch == ';' {
1253 let rest = command[i + 1..].trim_start();
1254 if rest.is_empty() {
1255 return None;
1256 }
1257 return Some(rest.to_string());
1258 }
1259 }
1260 i += 1;
1261 }
1262 None
1263}
1264
1265fn strip_env_prefix(command: &str) -> Option<String> {
1266 let rest = command.strip_prefix("env")?.trim_start();
1268 strip_leading_assignment_prefix(rest)
1269}
1270
1271fn strip_leading_assignment_prefix(command: &str) -> Option<String> {
1272 let mut index = 0usize;
1273 let mut consumed_assignment = false;
1274
1275 loop {
1276 index = skip_whitespace(command, index);
1277 if index >= command.len() {
1278 break;
1279 }
1280
1281 let word_end = shell_word_end(command, index)?;
1282 if word_end == index {
1283 break;
1284 }
1285
1286 let word = &command[index..word_end];
1287 if !is_env_assignment(word) {
1288 break;
1289 }
1290
1291 consumed_assignment = true;
1292 index = word_end;
1293 }
1294
1295 if !consumed_assignment {
1296 return None;
1297 }
1298
1299 let after = command[index..].trim_start();
1300 if after.is_empty() {
1301 None
1302 } else {
1303 Some(after.to_string())
1304 }
1305}
1306
1307fn skip_whitespace(input: &str, mut index: usize) -> usize {
1308 while index < input.len() {
1309 let Some(ch) = input[index..].chars().next() else {
1310 break;
1311 };
1312 if !ch.is_whitespace() {
1313 break;
1314 }
1315 index += ch.len_utf8();
1316 }
1317 index
1318}
1319
1320fn shell_word_end(command: &str, start: usize) -> Option<usize> {
1321 let mut in_single = false;
1322 let mut in_double = false;
1323 let mut escaped = false;
1324
1325 for (offset, ch) in command[start..].char_indices() {
1326 let index = start + offset;
1327
1328 if escaped {
1329 escaped = false;
1330 continue;
1331 }
1332
1333 if ch == '\\' && !in_single {
1334 escaped = true;
1335 continue;
1336 }
1337
1338 if ch == '\'' && !in_double {
1339 in_single = !in_single;
1340 continue;
1341 }
1342
1343 if ch == '"' && !in_single {
1344 in_double = !in_double;
1345 continue;
1346 }
1347
1348 if !in_single && !in_double && (ch.is_whitespace() || matches!(ch, ';' | '&' | '|')) {
1349 return Some(index);
1350 }
1351 }
1352
1353 if in_single || in_double || escaped {
1354 None
1355 } else {
1356 Some(command.len())
1357 }
1358}
1359
1360fn is_env_assignment(token: &str) -> bool {
1361 if token.starts_with('-') {
1362 return false;
1363 }
1364 let Some((name, _value)) = token.split_once('=') else {
1365 return false;
1366 };
1367 let mut chars = name.chars();
1368 let Some(first) = chars.next() else {
1369 return false;
1370 };
1371 (first.is_ascii_alphabetic() || first == '_')
1372 && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1373}
1374
1375fn strip_timeout_prefix(command: &str) -> Option<String> {
1376 let rest = command.strip_prefix("timeout")?.trim_start();
1377 let mut iter = rest.splitn(2, char::is_whitespace);
1379 let duration = iter.next()?;
1380 let after = iter.next()?.trim_start();
1381 if after.is_empty() || !looks_like_duration(duration) {
1382 return None;
1383 }
1384 Some(after.to_string())
1385}
1386
1387fn looks_like_duration(token: &str) -> bool {
1388 if token.is_empty() {
1389 return false;
1390 }
1391 let mut chars = token.chars().peekable();
1392 let mut saw_digit = false;
1393 while let Some(&ch) = chars.peek() {
1394 if ch.is_ascii_digit() {
1395 saw_digit = true;
1396 chars.next();
1397 } else {
1398 break;
1399 }
1400 }
1401 if !saw_digit {
1402 return false;
1403 }
1404 match chars.next() {
1405 None => true,
1406 Some(unit) => matches!(unit, 's' | 'm' | 'h' | 'd') && chars.next().is_none(),
1407 }
1408}
1409
1410pub fn user_filter_dir(storage_dir: &Path, harness: Harness) -> PathBuf {
1413 storage_dir.join(harness.storage_segment()).join("filters")
1414}
1415
1416fn legacy_user_filter_dir(storage_dir: &Path) -> PathBuf {
1417 storage_dir.join("filters")
1418}
1419
1420pub(crate) fn repair_legacy_user_filter_dir(storage_dir: &Path, harness: Harness) {
1424 let legacy_dir = legacy_user_filter_dir(storage_dir);
1425 if !legacy_dir.exists() {
1426 return;
1427 }
1428
1429 let entries = match fs::read_dir(&legacy_dir) {
1430 Ok(entries) => entries.filter_map(Result::ok).collect::<Vec<_>>(),
1431 Err(_) => return,
1432 };
1433 if entries.is_empty() {
1434 let _ = fs::remove_dir(&legacy_dir);
1435 return;
1436 }
1437
1438 let harness_dir = user_filter_dir(storage_dir, harness);
1439 if fs::create_dir_all(&harness_dir).is_err() {
1440 return;
1441 }
1442
1443 for entry in entries {
1444 let target = harness_dir.join(entry.file_name());
1445 if target.exists() {
1446 continue;
1447 }
1448 let _ = fs::rename(entry.path(), target);
1449 }
1450
1451 if fs::read_dir(&legacy_dir)
1452 .map(|mut entries| entries.next().is_none())
1453 .unwrap_or(false)
1454 {
1455 let _ = fs::remove_dir(&legacy_dir);
1456 }
1457}
1458
1459pub fn project_filter_dir(project_root: &Path) -> PathBuf {
1463 project_root.join(".cortexkit").join("aft").join("filters")
1464}
1465
1466#[cfg(test)]
1467mod tests {
1468 use super::*;
1469
1470 #[cfg(unix)]
1471 #[test]
1472 fn single_pipeline_scanner_returns_segment_labels() {
1473 let pipeline = single_top_level_pipeline("git rebase upstream/main | tail -3").unwrap();
1474 assert_eq!(
1475 pipeline
1476 .segments
1477 .iter()
1478 .map(|segment| segment.label.as_str())
1479 .collect::<Vec<_>>(),
1480 ["git rebase", "tail"]
1481 );
1482 }
1483
1484 #[cfg(unix)]
1485 #[test]
1486 fn single_pipeline_scanner_rejects_unsafe_shapes() {
1487 for command in [
1488 "false | tail -1; true | cat",
1489 "false | tail -1 &",
1490 "false | tail -1 # trailing comment",
1491 "cat <<EOF | tail -1\nbody\nEOF",
1492 ] {
1493 assert!(
1494 single_top_level_pipeline(command).is_none(),
1495 "scanner should reject {command:?}"
1496 );
1497 }
1498 }
1499
1500 #[cfg(unix)]
1501 #[test]
1502 fn single_pipeline_scanner_accepts_pipe_without_top_level_newline() {
1503 let pipeline = single_top_level_pipeline("false | tail -1").unwrap();
1504 assert_eq!(pipeline.segments.len(), 2);
1505 }
1506
1507 #[test]
1508 fn dispatch_strips_ansi_once_for_generic_and_toml_tiers() {
1509 let registry = toml_filter::build_registry(builtin_filters::ALL, None, None);
1510 for (command, output) in [
1511 ("custom-runner", "\u{1b}[32mline\u{1b}[0m\n"),
1512 (
1513 "docker build .",
1514 "\u{1b}[32m#1 DONE 0.1s\u{1b}[0m\nretained line\n",
1515 ),
1516 ] {
1517 generic::reset_ansi_strip_count();
1518 let result = compress_with_registry(command, output, ®istry);
1519 assert!(!result.is_empty());
1520 assert_eq!(generic::ansi_strip_count(), 1, "command: {command}");
1521 }
1522 }
1523
1524 #[test]
1525 fn user_and_project_filter_dir_helpers() {
1526 let storage = Path::new("/tmp/aft-storage");
1527 assert_eq!(
1528 user_filter_dir(storage, Harness::Opencode),
1529 Path::new("/tmp/aft-storage/opencode/filters")
1530 );
1531
1532 let project = Path::new("/repo");
1533 assert_eq!(
1534 project_filter_dir(project),
1535 Path::new("/repo/.cortexkit/aft/filters")
1536 );
1537 }
1538
1539 #[test]
1540 fn repair_legacy_user_filter_dir_moves_root_filters_without_overwrite() {
1541 let temp = tempfile::tempdir().unwrap();
1542 let storage = temp.path();
1543 fs::create_dir_all(storage.join("filters")).unwrap();
1544 fs::create_dir_all(storage.join("opencode/filters")).unwrap();
1545 fs::write(storage.join("filters/root-only.toml"), "root").unwrap();
1546 fs::write(storage.join("filters/collides.toml"), "root").unwrap();
1547 fs::write(storage.join("opencode/filters/collides.toml"), "harness").unwrap();
1548
1549 repair_legacy_user_filter_dir(storage, Harness::Opencode);
1550
1551 assert_eq!(
1552 fs::read_to_string(storage.join("opencode/filters/root-only.toml")).unwrap(),
1553 "root"
1554 );
1555 assert_eq!(
1556 fs::read_to_string(storage.join("opencode/filters/collides.toml")).unwrap(),
1557 "harness"
1558 );
1559 assert_eq!(
1560 fs::read_to_string(storage.join("filters/collides.toml")).unwrap(),
1561 "root"
1562 );
1563 assert!(!storage.join("filters/root-only.toml").exists());
1564 }
1565}
1566
1567#[cfg(test)]
1568mod output_probe_tests {
1569 use super::*;
1570 use serde::Deserialize;
1571
1572 #[derive(Deserialize)]
1573 struct CorpusEntry {
1574 file: String,
1575 }
1576
1577 #[derive(Debug, PartialEq, Eq)]
1578 struct MatchPhaseResult {
1579 selected: Option<usize>,
1580 output: CompressionResult,
1581 }
1582
1583 fn legacy_match_phase(output: &str) -> MatchPhaseResult {
1584 let compressors = compressors_in_dispatch_order();
1585 let selected = [Specificity::Specific, Specificity::PackageManager]
1586 .into_iter()
1587 .find_map(|specificity| {
1588 compressors
1589 .iter()
1590 .enumerate()
1591 .filter(|(_, compressor)| compressor.specificity() == specificity)
1592 .find_map(|(index, compressor)| {
1593 compressor.matches_output(output).then_some(index)
1594 })
1595 });
1596 match_phase_result(&compressors, selected, output)
1597 }
1598
1599 fn probe_match_phase(output: &str) -> MatchPhaseResult {
1600 let compressors = compressors_in_dispatch_order();
1601 let probe = OutputProbe::new(output);
1602 let selected = [Specificity::Specific, Specificity::PackageManager]
1603 .into_iter()
1604 .find_map(|specificity| {
1605 compressors
1606 .iter()
1607 .enumerate()
1608 .filter(|(_, compressor)| compressor.specificity() == specificity)
1609 .find_map(|(index, compressor)| {
1610 compressor.matches_output_probe(&probe).then_some(index)
1611 })
1612 });
1613 match_phase_result(&compressors, selected, output)
1614 }
1615
1616 fn match_phase_result(
1617 compressors: &[&dyn Compressor],
1618 selected: Option<usize>,
1619 output: &str,
1620 ) -> MatchPhaseResult {
1621 let output = selected.map_or_else(
1622 || GenericCompressor.compress_with_exit_code("", output, None),
1623 |index| compressors[index].compress_output_match_with_exit_code(output, None),
1624 );
1625 MatchPhaseResult { selected, output }
1626 }
1627
1628 fn repository_root() -> PathBuf {
1629 Path::new(env!("CARGO_MANIFEST_DIR"))
1630 .parent()
1631 .and_then(Path::parent)
1632 .expect("aft crate should be nested under the repository root")
1633 .to_path_buf()
1634 }
1635
1636 fn benchmark_corpus() -> Vec<(String, String)> {
1637 let fixtures = repository_root().join("benchmarks/compression-tokens/fixtures");
1638 let manifest_text = fs::read_to_string(fixtures.join("manifest.json"))
1639 .expect("compression corpus manifest should be readable");
1640 let manifest: Vec<CorpusEntry> =
1641 serde_json::from_str(&manifest_text).expect("compression corpus manifest should parse");
1642 manifest
1643 .into_iter()
1644 .map(|entry| {
1645 let output = fs::read_to_string(fixtures.join(&entry.file))
1646 .unwrap_or_else(|error| panic!("failed to read {}: {error}", entry.file));
1647 (format!("compression corpus {}", entry.file), output)
1648 })
1649 .collect()
1650 }
1651
1652 fn integration_filter_inputs() -> Vec<(String, String)> {
1653 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1654 .join("tests/integration/fixtures/compress_filters");
1655 let mut paths = Vec::new();
1656 collect_named_files(&root, "input.txt", &mut paths);
1657 paths.sort();
1658 paths
1659 .into_iter()
1660 .map(|path| {
1661 let output = fs::read_to_string(&path)
1662 .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
1663 (format!("filter fixture {}", path.display()), output)
1664 })
1665 .collect()
1666 }
1667
1668 fn collect_named_files(root: &Path, wanted_name: &str, paths: &mut Vec<PathBuf>) {
1669 let entries = fs::read_dir(root)
1670 .unwrap_or_else(|error| panic!("failed to read {}: {error}", root.display()));
1671 for entry in entries {
1672 let path = entry
1673 .expect("fixture directory entry should be readable")
1674 .path();
1675 if path.is_dir() {
1676 collect_named_files(&path, wanted_name, paths);
1677 } else if path.file_name().is_some_and(|name| name == wanted_name) {
1678 paths.push(path);
1679 }
1680 }
1681 }
1682
1683 fn hostile_outputs() -> Vec<(String, String)> {
1684 let huge_json_array = format!(
1685 "[{}]",
1686 (0..20_000)
1687 .map(|index| format!(r#"{{"index":{index},"value":"payload"}}"#))
1688 .collect::<Vec<_>>()
1689 .join(",")
1690 );
1691 let ansi_heavy = (0..2_000)
1692 .map(|index| format!("\u{1b}[31mordinary output {index}\u{1b}[0m"))
1693 .collect::<Vec<_>>()
1694 .join("\n");
1695 vec![
1696 (
1697 "valid non-tool JSON object".to_string(),
1698 r#" {"payload":{"nested":[1,2,3]},"ok":true}"#.to_string(),
1699 ),
1700 ("huge JSON array".to_string(), huge_json_array),
1701 (
1702 "almost JSON".to_string(),
1703 r#"{"payload":[1,2,3],"unterminated":true"#.to_string(),
1704 ),
1705 ("empty output".to_string(), String::new()),
1706 ("ANSI-heavy output".to_string(), ansi_heavy),
1707 ]
1708 }
1709
1710 #[test]
1711 fn shared_probe_preserves_match_selection_and_output_across_corpora() {
1712 let cases = benchmark_corpus()
1713 .into_iter()
1714 .chain(integration_filter_inputs())
1715 .chain(hostile_outputs());
1716
1717 for (label, raw_output) in cases {
1718 let output = strip_ansi(&raw_output);
1719 let legacy = legacy_match_phase(&output);
1720 let probed = probe_match_phase(&output);
1721 assert_eq!(legacy, probed, "output-shape dispatch changed for {label}");
1722 }
1723 }
1724
1725 #[cfg(debug_assertions)]
1726 #[test]
1727 fn unmatched_json_object_is_parsed_once_across_match_phase() {
1728 let output = r#"{"payload":{"nested":[1,2,3]},"ok":true}"#;
1729 reset_output_probe_json_parse_count();
1730
1731 let result = probe_match_phase(output);
1732
1733 assert_eq!(result.selected, None);
1734 assert_eq!(output_probe_json_parse_count(), 1);
1735 }
1736}
1737
1738#[cfg(test)]
1739mod dispatch_specificity_tests {
1740 use super::*;
1741 use crate::compress::toml_filter::FilterRegistry;
1742
1743 fn empty_registry() -> FilterRegistry {
1744 FilterRegistry::default()
1745 }
1746
1747 fn dispatch(cmd: &str, output: &str) -> String {
1752 compress_with_registry(cmd, output, &empty_registry()).text
1753 }
1754
1755 #[test]
1756 fn generic_dispatch_does_not_classify_error_or_warning_words() {
1757 let result = compress_with_registry(
1758 "unknown-tool",
1759 "error: this is just a log line\nwarning: this too",
1760 &empty_registry(),
1761 );
1762
1763 assert!(result.dropped_by_class.is_empty());
1764 assert!(!result.had_inner_drop);
1765 assert!(result.text.contains("error: this is just a log line"));
1766 }
1767
1768 #[test]
1769 fn bun_run_vitest_routes_to_vitest_not_generic() {
1770 let output = "Test Files 1 passed (1)\n Tests 4 passed (4)\n Start at 10:00:00\n Duration 120ms\n";
1775 let compressed = dispatch("bun run vitest", output);
1776 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1778 }
1779
1780 #[test]
1781 fn npm_test_routes_to_vitest_when_output_is_vitest_shaped() {
1782 let output = "RERUN src/foo.test.ts x1\nFAIL src/foo.test.ts\nTest Files 1 failed (1)\nDuration 120ms\n";
1785 let compressed = dispatch("npm test", output);
1786 assert!(compressed.contains("FAIL src/foo.test.ts"));
1787 assert!(compressed.contains("Duration 120ms"));
1788 assert!(!compressed.contains("RERUN"));
1789 }
1790
1791 #[test]
1792 fn bun_run_vitest_token_match_wins_over_bun_head_match() {
1793 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";
1796 let compressed = dispatch("bun run vitest run", output);
1797 assert!(compressed.contains("Test Files") || compressed.contains("PASS"));
1799 }
1800
1801 #[test]
1802 fn bunx_jest_routes_to_vitest_module() {
1803 let output = "PASS src/foo.test.js (1.2s)\nTest Suites: 1 passed, 1 total\nTests: 3 passed, 3 total\n";
1804 let compressed = dispatch("bunx jest --json", output);
1805 assert!(compressed.contains("Tests:") && compressed.contains("Test Suites"));
1806 }
1807
1808 #[test]
1809 fn pnpm_run_vitest_routes_to_vitest() {
1810 let output = "Test Files 1 passed (1)\n Tests 10 passed (10)\n";
1811 let compressed = dispatch("pnpm run vitest", output);
1812 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1813 }
1814
1815 #[test]
1816 fn npx_eslint_routes_to_eslint_not_generic() {
1817 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";
1818 let compressed = dispatch("npx eslint .", output);
1819 assert!(compressed.contains("no-unused-vars") || compressed.contains("✖"));
1821 }
1822
1823 #[test]
1824 fn npm_run_lint_without_linter_output_shape_falls_back() {
1825 let output = "> my-project@1.0.0 lint\n> eslint .\n\nAll good.\n";
1828 let compressed = dispatch("npm run lint", output);
1829 assert!(compressed.contains("All good."));
1830 }
1831
1832 #[test]
1833 fn bun_test_still_routes_to_bun_test_compressor() {
1834 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";
1842 let compressed = dispatch("bun test", output);
1843 assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
1844 }
1845
1846 #[test]
1847 fn bunx_vitest_routes_to_vitest() {
1848 let output = "Test Files 1 passed (1)\n Tests 3 passed (3)\n";
1849 let compressed = dispatch("bunx vitest run", output);
1850 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1851 }
1852
1853 #[test]
1854 fn cargo_test_still_routes_to_cargo() {
1855 let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
1858 let compressed = dispatch("cargo test", output);
1859 assert!(compressed.contains("failed") || compressed.contains("FAILED"));
1861 }
1862
1863 #[test]
1864 fn top_level_piped_cargo_test_uses_generic_output() {
1865 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
1866
1867 let compressed = compress_with_registry("cargo test | cat", output, &empty_registry());
1868
1869 assert!(
1870 compressed.text.contains("test ok_test ... ok"),
1871 "piped cargo output must stay generic/raw, got: {}",
1872 compressed.text
1873 );
1874 }
1875
1876 #[test]
1877 fn non_piped_cargo_test_still_uses_cargo_compressor() {
1878 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
1879
1880 let compressed = compress_with_registry("cargo test", output, &empty_registry());
1881
1882 assert!(compressed.text.contains("running 1 test"));
1883 assert!(compressed.text.contains("test result: ok"));
1884 assert!(
1885 !compressed.text.contains("test ok_test ... ok"),
1886 "non-piped cargo test should keep using the cargo compressor, got: {}",
1887 compressed.text
1888 );
1889 }
1890
1891 #[test]
1892 fn git_status_still_routes_to_git() {
1893 let output =
1895 "On branch main\nYour branch is up to date.\n\nnothing to commit, working tree clean\n";
1896 let compressed = dispatch("git status", output);
1897 assert!(compressed.contains("branch") || compressed.contains("clean"));
1898 }
1899
1900 #[test]
1901 fn pnpm_install_still_routes_to_pnpm() {
1902 let output = "Progress: resolved 100, downloaded 50, added 50\nAdded 50 packages\n";
1904 let compressed = dispatch("pnpm install", output);
1905 assert!(compressed.contains("Added") || compressed.contains("Progress"));
1907 }
1908}
1909
1910#[cfg(test)]
1911mod exit_code_safety_tests {
1912 use super::*;
1913 use crate::compress::toml_filter::{build_registry, FilterRegistry};
1914
1915 fn empty_registry() -> FilterRegistry {
1916 FilterRegistry::default()
1917 }
1918
1919 #[test]
1920 fn go_build_failure_signal_preserved_even_when_exit_zero_masks_failure() {
1921 let output = "go: go.mod file not found in current directory or any parent directory; see 'go help modules'\n";
1922
1923 let failed =
1924 compress_with_registry_exit_code("go build ./...", output, Some(1), &empty_registry());
1925 assert!(!failed.text.contains("go build: ok"));
1926 assert!(failed.text.contains("go.mod file not found"));
1927
1928 let masked =
1929 compress_with_registry_exit_code("go build ./...", output, Some(0), &empty_registry());
1930 assert!(!masked.text.contains("go build: ok"));
1931 assert!(masked.text.contains("go.mod file not found"));
1932 }
1933
1934 #[test]
1935 fn playwright_nonzero_crash_does_not_become_passed_summary() {
1936 let output = r#"Running 4 tests using 2 workers
1937
1938 ✓ 1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
1939 ✓ 2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
1940 ✓ 3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
1941 ✓ 4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
1942
1943 4 passed (6.3s)
1944Error: browserType.launch: Target page, context or browser has been closed
1945"#;
1946
1947 let failed = compress_with_registry_exit_code(
1948 "npx playwright test",
1949 output,
1950 Some(1),
1951 &empty_registry(),
1952 );
1953 assert!(!failed.text.starts_with("playwright: 4 tests passed"));
1954 assert!(failed.text.contains("browserType.launch"));
1955 }
1956
1957 #[test]
1958 fn cargo_test_compile_error_nonzero_preserves_error_code_diagnostic() {
1959 let output = r#" Compiling demo v0.1.0 (/tmp/demo)
1960error[E0432]: unresolved import `crate::missing`
1961 --> src/lib.rs:1:5
1962 |
19631 | use crate::missing;
1964 | ^^^^^^^^^^^^^^ no `missing` in the root
1965
1966error: could not compile `demo` (lib test) due to 1 previous error
1967"#;
1968
1969 let failed =
1970 compress_with_registry_exit_code("cargo test", output, Some(101), &empty_registry());
1971 assert!(failed.text.contains("error[E0432]"));
1972 assert!(failed.text.contains("unresolved import"));
1973 assert!(failed.text.contains("error: could not compile"));
1974 }
1975
1976 #[test]
1977 fn chained_mypy_success_then_later_failure_uses_failure_preserving_output() {
1978 let output = "Success: no issues found in 1 source file\nError: node process exploded\n";
1979
1980 let failed = compress_with_registry_exit_code(
1981 "mypy src && node fail.js",
1982 output,
1983 Some(1),
1984 &empty_registry(),
1985 );
1986 assert_ne!(failed.text, "mypy: clean");
1987 assert!(failed.text.contains("Error: node process exploded"));
1988 }
1989
1990 #[test]
1991 fn toml_shortcircuit_is_skipped_for_nonzero_exit() {
1992 let registry = build_registry(
1993 &[(
1994 "wget",
1995 r#"[filter]
1996matches = ["wget"]
1997
1998[shortcircuit]
1999when = '(?s).*'
2000replacement = "wget: ok"
2001"#,
2002 )],
2003 None,
2004 None,
2005 );
2006 let output = "Connecting to example.invalid\nerror: connection refused\n";
2007
2008 let failed = compress_with_registry_exit_code(
2009 "wget https://example.invalid",
2010 output,
2011 Some(1),
2012 ®istry,
2013 );
2014 assert_ne!(failed.text, "wget: ok");
2015 assert!(failed.text.contains("error: connection refused"));
2016 }
2017
2018 #[test]
2019 fn unknown_exit_code_keeps_byte_identical_legacy_compressor_output() {
2020 let output = "Success: no issues found in 1 source file\n";
2023
2024 let legacy = compress_with_registry_exit_code("mypy src", output, None, &empty_registry());
2025 assert_eq!(legacy.text, "mypy: clean");
2026 }
2027
2028 #[test]
2029 fn killed_exit_sentinel_rejects_clean_legacy_summary() {
2030 let output = "Success: no issues found in 1 source file
2031Error: later chained command failed
2032";
2033
2034 let killed = compress_with_registry_exit_code(
2035 "mypy src && node fail.js",
2036 output,
2037 Some(137),
2038 &empty_registry(),
2039 );
2040 assert_ne!(killed.text, "mypy: clean");
2041 assert!(killed.text.contains("Error: later chained command failed"));
2042 }
2043
2044 #[test]
2045 fn nonzero_clean_eslint_json_summary_falls_back_to_raw_output() {
2046 let output =
2047 r#"[{"filePath":"/repo/src/main.ts","messages":[],"errorCount":0,"warningCount":0}]"#;
2048
2049 let failed = compress_with_registry_exit_code(
2050 "eslint -f json .",
2051 output,
2052 Some(1),
2053 &empty_registry(),
2054 );
2055
2056 assert_ne!(failed.text, "eslint: no issues");
2057 assert!(failed.text.contains(r#""messages":[]"#));
2058 }
2059
2060 #[test]
2061 fn nonzero_appends_distinct_missing_raw_failure_lines() {
2062 let raw = "Error: first failure
2063progress
2064Error: second failure
2065";
2066 let compressed = CompressionResult::new("Error: first failure");
2067
2068 let preserved = failure_preserving_result("tool", raw, compressed, Some(1));
2069
2070 assert!(preserved.text.contains("Error: first failure"));
2071 assert!(preserved.text.contains("Error: second failure"));
2072 assert!(preserved
2073 .text
2074 .contains("[raw failure lines preserved by AFT]"));
2075 }
2076
2077 #[test]
2078 fn nonzero_cargo_failure_class_cap_falls_back_to_all_failures() {
2079 let mut output = String::from(
2080 "running 40 tests
2081
2082failures:
2083
2084",
2085 );
2086 for index in 0..40 {
2087 output.push_str(&format!(
2088 "---- case_{index} stdout ----
2089thread 'case_{index}' panicked at src/lib.rs:{index}:1
2090
2091"
2092 ));
2093 }
2094 output.push_str(
2095 "failures:
2096",
2097 );
2098 for index in 0..40 {
2099 output.push_str(&format!(
2100 " case_{index}
2101"
2102 ));
2103 }
2104 output.push_str(
2105 "
2106test result: FAILED. 0 passed; 40 failed; 0 ignored; 0 measured; 0 filtered out
2107",
2108 );
2109
2110 let failed =
2111 compress_with_registry_exit_code("cargo test", &output, Some(101), &empty_registry());
2112
2113 assert!(failed.text.contains("---- case_0 stdout ----"));
2114 assert!(failed.text.contains("---- case_39 stdout ----"));
2115 assert!(failed.dropped_by_class.is_empty());
2116 }
2117
2118 #[test]
2119 fn toml_shortcircuit_is_skipped_for_unknown_exit_when_failure_signal_exists() {
2120 let registry = build_registry(
2121 &[(
2122 "make",
2123 r#"[filter]
2124matches = ["make"]
2125
2126[shortcircuit]
2127when = '(?s).*'
2128replacement = "make: ok"
2129"#,
2130 )],
2131 None,
2132 None,
2133 );
2134 let output = "build step
2135ERROR: compiler crashed
2136";
2137
2138 let failed = compress_with_registry_exit_code("make", output, None, ®istry);
2139
2140 assert_ne!(failed.text, "make: ok");
2141 assert!(failed.text.contains("ERROR: compiler crashed"));
2142 }
2143
2144 #[test]
2145 fn successful_exit_still_gets_concise_success_summary() {
2146 let output = r#"Running 4 tests using 2 workers
2147
2148 ✓ 1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
2149 ✓ 2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
2150 ✓ 3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
2151 ✓ 4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
2152
2153 4 passed (6.3s)
2154"#;
2155
2156 let successful =
2157 compress_with_registry_exit_code("playwright test", output, Some(0), &empty_registry());
2158 assert_eq!(successful.text, "playwright: 4 tests passed (6.3s)");
2159 }
2160}
2161
2162#[cfg(test)]
2163mod normalize_command_tests {
2164 use super::*;
2165
2166 #[test]
2167 fn passes_bare_commands_unchanged() {
2168 assert_eq!(normalize_command_for_dispatch("bun test"), None);
2169 assert_eq!(normalize_command_for_dispatch("cargo build"), None);
2170 assert_eq!(normalize_command_for_dispatch("git status"), None);
2171 }
2172
2173 #[test]
2174 fn strips_cd_and_amp_prefix() {
2175 assert_eq!(
2176 normalize_command_for_dispatch("cd /repo && bun test").as_deref(),
2177 Some("bun test")
2178 );
2179 assert_eq!(
2180 normalize_command_for_dispatch("cd /repo/packages/aft && cargo test --release")
2181 .as_deref(),
2182 Some("cargo test --release")
2183 );
2184 }
2185
2186 #[test]
2187 fn strips_cd_and_semicolon_prefix() {
2188 assert_eq!(
2189 normalize_command_for_dispatch("cd /repo; bun test").as_deref(),
2190 Some("bun test")
2191 );
2192 }
2193
2194 #[test]
2195 fn strips_cd_with_quoted_path() {
2196 assert_eq!(
2197 normalize_command_for_dispatch("cd \"/path with space\" && npm install").as_deref(),
2198 Some("npm install")
2199 );
2200 }
2201
2202 #[test]
2203 fn strips_env_assignments() {
2204 assert_eq!(
2205 normalize_command_for_dispatch("env FOO=bar npm install").as_deref(),
2206 Some("npm install")
2207 );
2208 assert_eq!(
2209 normalize_command_for_dispatch("env FOO=bar BAZ=qux RUST_LOG=info cargo test")
2210 .as_deref(),
2211 Some("cargo test")
2212 );
2213 }
2214
2215 #[test]
2216 fn strips_bare_assignment_prefixes() {
2217 assert_eq!(
2218 normalize_command_for_dispatch("NODE_ENV=production npm install").as_deref(),
2219 Some("npm install")
2220 );
2221 assert_eq!(
2222 normalize_command_for_dispatch("FOO=1 BAR=2 cargo test").as_deref(),
2223 Some("cargo test")
2224 );
2225 assert_eq!(
2226 normalize_command_for_dispatch("RUSTFLAGS='-C debug' cargo build").as_deref(),
2227 Some("cargo build")
2228 );
2229 }
2230
2231 #[test]
2232 fn does_not_strip_later_assignment_arguments() {
2233 assert_eq!(normalize_command_for_dispatch("npm install foo=bar"), None);
2234 }
2235
2236 #[test]
2237 fn env_without_assignments_returns_none() {
2238 assert_eq!(
2240 normalize_command_for_dispatch("env npm install").as_deref(),
2241 None
2242 );
2243 }
2244
2245 #[test]
2246 fn strips_timeout_prefix() {
2247 assert_eq!(
2248 normalize_command_for_dispatch("timeout 30 cargo test").as_deref(),
2249 Some("cargo test")
2250 );
2251 assert_eq!(
2252 normalize_command_for_dispatch("timeout 5m bun test").as_deref(),
2253 Some("bun test")
2254 );
2255 }
2256
2257 #[test]
2258 fn strips_nohup_prefix() {
2259 assert_eq!(
2260 normalize_command_for_dispatch("nohup ./long-running-script.sh").as_deref(),
2261 Some("./long-running-script.sh")
2262 );
2263 }
2264
2265 #[test]
2266 fn strips_paren_then_cd_and_amp() {
2267 assert_eq!(
2268 normalize_command_for_dispatch("(cd /repo && bun test").as_deref(),
2269 Some("bun test")
2270 );
2271 }
2272
2273 #[test]
2274 fn chains_multiple_prefixes() {
2275 assert_eq!(
2277 normalize_command_for_dispatch("env FOO=bar timeout 30 cargo test").as_deref(),
2278 Some("cargo test")
2279 );
2280 assert_eq!(
2282 normalize_command_for_dispatch("cd /repo && env FOO=bar npm install").as_deref(),
2283 Some("npm install")
2284 );
2285 }
2286
2287 fn empty_registry() -> FilterRegistry {
2290 FilterRegistry::default()
2291 }
2292
2293 #[test]
2294 fn cd_prefix_bun_test_still_routes_to_bun_test() {
2295 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";
2296 let compressed = compress_with_registry("cd /repo && bun test", output, &empty_registry());
2297 assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
2302 }
2303
2304 #[test]
2305 fn cd_prefix_cargo_test_still_routes_to_cargo() {
2306 let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
2307 let compressed =
2308 compress_with_registry("cd /repo && cargo test", output, &empty_registry());
2309 assert!(compressed.contains("FAILED") || compressed.contains("failed"));
2310 }
2311
2312 #[test]
2313 fn env_prefix_npm_install_still_routes_to_npm() {
2314 let output = "added 50 packages, and audited 100 packages in 3s\n";
2315 let compressed = compress_with_registry(
2316 "env NODE_ENV=production npm install",
2317 output,
2318 &empty_registry(),
2319 );
2320 assert!(compressed.contains("added") || compressed.contains("audited"));
2322 }
2323
2324 #[test]
2325 fn bare_assignment_prefix_npm_install_routes_to_npm() {
2326 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";
2327 let compressed =
2328 compress_with_registry("NODE_ENV=production npm install", output, &empty_registry());
2329 assert!(!compressed.contains("npm http fetch"));
2330 assert!(compressed.contains("audited 100 packages"));
2331 }
2332
2333 #[test]
2334 fn bare_assignment_prefix_cargo_test_routes_to_cargo() {
2335 let output = "running 1 test\ntest foo ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n";
2336 let compressed =
2337 compress_with_registry("FOO=1 BAR=2 cargo test", output, &empty_registry());
2338 assert!(compressed.contains("running 1 test"));
2339 assert!(compressed.contains("test result: ok"));
2340 assert!(!compressed.contains("test foo ... ok"));
2341 }
2342
2343 #[test]
2344 fn quoted_assignment_prefix_cargo_build_routes_to_cargo() {
2345 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";
2346 let compressed = compress_with_registry(
2347 "RUSTFLAGS='-C debug' cargo build",
2348 output,
2349 &empty_registry(),
2350 );
2351 assert!(!compressed.contains("Compiling foo"));
2352 assert!(compressed.contains("warning: unused variable"));
2353 assert!(compressed.contains("Finished `dev` profile"));
2354 }
2355
2356 #[test]
2357 fn timeout_prefix_cargo_build_still_routes_to_cargo() {
2358 let output =
2359 " Compiling foo v0.1.0\n Finished `dev` profile [unoptimized] target(s) in 5s\n";
2360 let compressed =
2361 compress_with_registry("timeout 30 cargo build", output, &empty_registry());
2362 assert!(compressed.contains("Compiling") || compressed.contains("Finished"));
2364 }
2365
2366 #[test]
2367 fn normalize_splits_pipe_and_takes_last_stage() {
2368 assert_eq!(
2369 normalize_command_for_dispatch("git log | grep fix").as_deref(),
2370 Some("grep fix")
2371 );
2372 }
2373
2374 #[test]
2375 fn normalize_cd_prefix_then_pipe_takes_last_stage() {
2376 assert_eq!(
2377 normalize_command_for_dispatch("cd /repo && git log | grep fix").as_deref(),
2378 Some("grep fix")
2379 );
2380 }
2381
2382 #[test]
2383 fn normalize_no_pipe_returns_none() {
2384 assert_eq!(normalize_command_for_dispatch("git log"), None);
2385 }
2386
2387 #[test]
2388 fn normalize_quoted_pipe_not_split() {
2389 assert_eq!(
2390 normalize_command_for_dispatch("grep \"a|b\" file.txt"),
2391 None
2392 );
2393 }
2394
2395 #[test]
2396 fn normalize_balanced_command_substitution_splits_top_level_pipe() {
2397 assert_eq!(
2402 normalize_command_for_dispatch("echo $(cmd | cmd) | grep x").as_deref(),
2403 Some("grep x")
2404 );
2405 }
2406
2407 #[test]
2408 fn normalize_inner_pipe_in_substitution_without_top_level_pipe_is_none() {
2409 assert_eq!(
2411 normalize_command_for_dispatch("echo $(cargo test | cat)"),
2412 None
2413 );
2414 }
2415
2416 #[test]
2417 fn normalize_double_pipe_not_split() {
2418 assert_eq!(normalize_command_for_dispatch("git log || echo fail"), None);
2419 }
2420
2421 #[test]
2422 fn normalize_multi_pipe_returns_last_stage() {
2423 assert_eq!(
2424 normalize_command_for_dispatch("git log | grep fix | head -5").as_deref(),
2425 Some("head -5")
2426 );
2427 }
2428
2429 #[test]
2430 fn normalize_process_substitution_splits_top_level_pipe() {
2431 assert_eq!(
2433 normalize_command_for_dispatch("cat <(echo a | cat) | grep x").as_deref(),
2434 Some("grep x")
2435 );
2436 }
2437
2438 #[test]
2439 fn normalize_pipe_ampersand_splits_last_stage() {
2440 assert_eq!(
2442 normalize_command_for_dispatch("cargo test |& grep FAIL").as_deref(),
2443 Some("grep FAIL")
2444 );
2445 }
2446
2447 #[test]
2448 fn piped_cargo_test_grep_preserves_failed() {
2449 let grep_output = "test foo ... FAILED\n";
2450 let compressed =
2451 compress_with_registry("cargo test | grep FAIL", grep_output, &empty_registry());
2452 assert!(
2453 compressed.text.contains("FAILED"),
2454 "grep-filtered FAILED must survive, got: {}",
2455 compressed.text
2456 );
2457 }
2458
2459 #[test]
2460 fn unsafe_piped_command_forces_generic_and_preserves_output() {
2461 let grep_output = "test foo ... FAILED\n";
2465 let compressed =
2466 compress_with_registry("cargo test | grep \"FAIL", grep_output, &empty_registry());
2467 assert!(
2468 compressed.text.contains("FAILED"),
2469 "unsafe pipe must not drop output, got: {}",
2470 compressed.text
2471 );
2472 }
2473
2474 #[test]
2475 fn split_top_level_pipe_variants() {
2476 assert_eq!(split_top_level_pipe("git log"), PipeSplit::None);
2477 assert_eq!(
2478 split_top_level_pipe("git log | grep fix"),
2479 PipeSplit::LastStage("grep fix".to_string())
2480 );
2481 assert_eq!(split_top_level_pipe("a || b"), PipeSplit::Unsafe);
2484 assert_eq!(split_top_level_pipe("(a | b)"), PipeSplit::None);
2486 assert_eq!(split_top_level_pipe("echo $(a | b)"), PipeSplit::None);
2488 assert_eq!(split_top_level_pipe("a | grep \"x"), PipeSplit::Unsafe);
2490 assert_eq!(split_top_level_pipe("$(a | b | grep x"), PipeSplit::Unsafe);
2492 assert_eq!(split_top_level_pipe("cargo test |"), PipeSplit::Unsafe);
2497 assert_eq!(split_top_level_pipe("cargo test |&"), PipeSplit::Unsafe);
2498 assert_eq!(
2500 split_top_level_pipe("true | cargo test --quiet ; printf X"),
2501 PipeSplit::Unsafe
2502 );
2503 assert_eq!(
2504 split_top_level_pipe("true | cargo test && echo done"),
2505 PipeSplit::Unsafe
2506 );
2507 assert_eq!(
2509 split_top_level_pipe("echo ) | cargo test"),
2510 PipeSplit::Unsafe
2511 );
2512 assert_eq!(split_top_level_pipe("a | b & c"), PipeSplit::Unsafe);
2514 assert_eq!(
2515 split_top_level_pipe("cargo test 2>&1 | grep FAIL"),
2516 PipeSplit::LastStage("grep FAIL".to_string())
2517 );
2518 assert_eq!(
2522 split_top_level_pipe("cargo test ; printf SENTINEL"),
2523 PipeSplit::Unsafe
2524 );
2525 assert_eq!(
2526 split_top_level_pipe("cargo test && echo done"),
2527 PipeSplit::Unsafe
2528 );
2529 assert_eq!(
2530 split_top_level_pipe("cargo test\necho done"),
2531 PipeSplit::Unsafe
2532 );
2533 assert_eq!(split_top_level_pipe("cargo test 2>&1"), PipeSplit::None);
2536 assert_eq!(
2537 split_top_level_pipe("cargo test &> /dev/null"),
2538 PipeSplit::None
2539 );
2540 }
2541
2542 #[test]
2543 fn strip_top_level_comment_removes_only_real_comments() {
2544 assert_eq!(
2545 strip_top_level_comment("printf keep # | cargo test"),
2546 "printf keep "
2547 );
2548 assert_eq!(
2549 strip_top_level_comment("printf keep # cargo test"),
2550 "printf keep "
2551 );
2552 assert_eq!(
2554 strip_top_level_comment("curl http://x/y#frag"),
2555 "curl http://x/y#frag"
2556 );
2557 assert_eq!(
2559 strip_top_level_comment("grep \"# not a comment\" f"),
2560 "grep \"# not a comment\" f"
2561 );
2562 assert_eq!(
2563 strip_top_level_comment("echo '# literal'"),
2564 "echo '# literal'"
2565 );
2566 assert_eq!(
2568 strip_top_level_comment("git log | grep fix"),
2569 "git log | grep fix"
2570 );
2571 }
2572
2573 #[test]
2574 fn commented_command_does_not_misdispatch_and_preserves_output() {
2575 for cmd in ["printf keep # | cargo test", "printf keep # cargo test"] {
2578 let compressed = compress_with_registry(cmd, "keep\n", &empty_registry());
2579 assert!(
2580 compressed.text.contains("keep"),
2581 "comment must not drop output for {cmd:?}, got: {}",
2582 compressed.text
2583 );
2584 }
2585 }
2586
2587 #[test]
2588 fn pipe_with_trailing_command_chain_preserves_sentinel() {
2589 let compressed = compress_with_registry(
2592 "true | cargo test --quiet ; printf SENTINEL",
2593 "SENTINEL\n",
2594 &empty_registry(),
2595 );
2596 assert!(
2597 compressed.text.contains("SENTINEL"),
2598 "trailing-chain output must survive, got: {}",
2599 compressed.text
2600 );
2601 }
2602
2603 #[test]
2607 fn separator_list_forces_generic_and_preserves_sentinel() {
2608 let cargo_noise =
2613 "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
2614 let transcript = format!("{cargo_noise}SENTINEL\n");
2615 let compressed = compress_with_registry(
2616 "cargo test ; printf SENTINEL",
2617 &transcript,
2618 &empty_registry(),
2619 );
2620 assert!(
2621 compressed.text.contains("SENTINEL"),
2622 "separator-list output must survive generic compression, got: {}",
2623 compressed.text
2624 );
2625 }
2626
2627 #[test]
2628 fn cd_prefix_peel_leaves_no_residual_separator_for_cargo() {
2629 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
2633 let compressed = compress_with_registry("cd /x && cargo test", output, &empty_registry());
2634 assert!(
2636 !compressed.text.contains("test ok_test ... ok"),
2637 "cd-peeled cargo test must still use the cargo compressor, got: {}",
2638 compressed.text
2639 );
2640 assert!(compressed.text.contains("test result: ok"));
2641 }
2642
2643 #[test]
2644 fn clean_single_cargo_test_dispatch_byte_identical() {
2645 let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
2649 let compressed = compress_with_registry("cargo test", output, &empty_registry());
2650 assert!(compressed.text.contains("running 1 test"));
2651 assert!(compressed.text.contains("test result: ok"));
2652 assert!(
2653 !compressed.text.contains("test ok_test ... ok"),
2654 "clean cargo test must keep using the cargo compressor, got: {}",
2655 compressed.text
2656 );
2657 }
2658
2659 #[test]
2660 fn is_shell_boundary_covers_redirects_and_operators() {
2661 for tok in [
2662 "|",
2663 "|&",
2664 ";",
2665 "&",
2666 "&&",
2667 "||",
2668 ">",
2669 ">>",
2670 "<",
2671 "<<",
2672 "<<<",
2673 "&>",
2674 "&>>",
2675 "2>",
2676 "2>>",
2677 "2>&1",
2678 "1>&2",
2679 ">/dev/null",
2680 "2>/dev/null",
2681 ] {
2682 assert!(is_shell_boundary(tok), "{tok} should be a boundary");
2683 }
2684 for tok in ["test", "log", "build", "--release", "-v", "file.txt"] {
2685 assert!(!is_shell_boundary(tok), "{tok} must not be a boundary");
2686 }
2687 }
2688}