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