1pub mod biome;
19pub mod builtin_filters;
20pub mod bun;
21pub mod cargo;
22pub mod eslint;
23pub mod generic;
24pub mod git;
25pub mod go;
26pub mod mypy;
27pub mod next;
28pub mod npm;
29pub mod playwright;
30pub mod pnpm;
31pub mod prettier;
32pub mod pytest;
33pub mod ruff;
34pub mod toml_filter;
35pub mod trust;
36pub mod tsc;
37pub mod vitest;
38
39use crate::context::AppContext;
40use biome::BiomeCompressor;
41use bun::BunCompressor;
42use cargo::CargoCompressor;
43use eslint::EslintCompressor;
44use generic::{strip_ansi, GenericCompressor};
45use git::GitCompressor;
46use go::{GoCompressor, GolangciLintCompressor};
47use mypy::MypyCompressor;
48use next::NextCompressor;
49use npm::NpmCompressor;
50use playwright::PlaywrightCompressor;
51use pnpm::PnpmCompressor;
52use prettier::PrettierCompressor;
53use pytest::PytestCompressor;
54use ruff::RuffCompressor;
55use std::path::{Path, PathBuf};
56use std::sync::{Arc, RwLock};
57use toml_filter::{apply_filter, FilterRegistry};
58use tsc::TscCompressor;
59use vitest::VitestCompressor;
60
61pub type SharedFilterRegistry = Arc<RwLock<FilterRegistry>>;
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum Specificity {
84 Specific,
85 PackageManager,
86}
87
88pub trait Compressor {
91 fn matches(&self, command: &str) -> bool;
94
95 fn compress(&self, command: &str, output: &str) -> String;
97
98 fn specificity(&self) -> Specificity {
99 Specificity::Specific
100 }
101}
102
103pub fn compress(command: &str, output: String, ctx: &AppContext) -> String {
109 if !ctx.config().experimental_bash_compress {
110 return output;
111 }
112 let registry_handle = ctx.shared_filter_registry();
113 let guard = match registry_handle.read() {
114 Ok(g) => g,
115 Err(poisoned) => poisoned.into_inner(),
116 };
117 compress_with_registry(command, &output, &guard)
118}
119
120pub fn compress_with_registry(command: &str, output: &str, registry: &FilterRegistry) -> String {
126 let stripped_for_generic = strip_ansi(output);
127
128 let normalized = normalize_command_for_dispatch(command);
134 let dispatch_cmd = normalized.as_deref().unwrap_or(command);
135
136 let compressors: [&dyn Compressor; 17] = [
137 &GitCompressor,
138 &CargoCompressor,
139 &TscCompressor,
140 &NpmCompressor,
141 &BunCompressor,
142 &PnpmCompressor,
143 &PytestCompressor,
144 &EslintCompressor,
145 &VitestCompressor,
146 &BiomeCompressor,
147 &PrettierCompressor,
148 &RuffCompressor,
149 &MypyCompressor,
150 &GoCompressor,
151 &GolangciLintCompressor,
152 &PlaywrightCompressor,
153 &NextCompressor,
154 ];
155
156 for compressor in compressors
158 .iter()
159 .filter(|c| c.specificity() == Specificity::Specific)
160 {
161 if compressor.matches(dispatch_cmd) {
162 return compressor.compress(dispatch_cmd, &stripped_for_generic);
163 }
164 }
165
166 for compressor in compressors
168 .iter()
169 .filter(|c| c.specificity() == Specificity::PackageManager)
170 {
171 if compressor.matches(dispatch_cmd) {
172 return compressor.compress(dispatch_cmd, &stripped_for_generic);
173 }
174 }
175
176 if let Some(filter) = registry.lookup(dispatch_cmd) {
179 return apply_filter(filter, output);
180 }
181
182 GenericCompressor.compress(command, &stripped_for_generic)
184}
185
186pub fn build_registry_for_context(ctx: &AppContext) -> FilterRegistry {
195 let config = ctx.config();
196 let storage_dir = config.storage_dir.clone();
197 let project_root = config.project_root.clone();
198 drop(config);
199
200 let user_dir = storage_dir.as_ref().map(|d| d.join("filters"));
201 let project_dir = match (project_root.as_ref(), storage_dir.as_ref()) {
202 (Some(root), Some(storage)) => {
203 if trust::is_project_trusted(Some(storage), root) {
204 Some(root.join(".aft").join("filters"))
205 } else {
206 None
207 }
208 }
209 _ => None,
210 };
211
212 toml_filter::build_registry(
213 builtin_filters::ALL,
214 user_dir.as_deref(),
215 project_dir.as_deref(),
216 )
217}
218
219pub fn normalize_command_for_dispatch(command: &str) -> Option<String> {
242 let trimmed = command.trim_start();
243 if trimmed.is_empty() {
244 return None;
245 }
246
247 let (open_paren, after_paren) = if let Some(rest) = trimmed.strip_prefix('(') {
249 (true, rest.trim_start())
250 } else {
251 (false, trimmed)
252 };
253
254 let mut current = after_paren.to_string();
255 let mut changed = open_paren;
256
257 loop {
259 let head: String = current.split_whitespace().next().unwrap_or("").to_string();
260
261 if head == "cd" {
263 if let Some(stripped) = strip_cd_prefix(¤t) {
266 current = stripped;
267 changed = true;
268 continue;
269 }
270 }
271
272 if head == "env" {
274 if let Some(stripped) = strip_env_prefix(¤t) {
275 current = stripped;
276 changed = true;
277 continue;
278 }
279 }
280
281 if head == "timeout" {
283 if let Some(stripped) = strip_timeout_prefix(¤t) {
284 current = stripped;
285 changed = true;
286 continue;
287 }
288 }
289
290 if head == "nohup" {
292 if let Some(rest) = current.strip_prefix("nohup").and_then(|s| {
293 let trimmed = s.trim_start();
294 if trimmed.is_empty() {
295 None
296 } else {
297 Some(trimmed.to_string())
298 }
299 }) {
300 current = rest;
301 changed = true;
302 continue;
303 }
304 }
305
306 break;
307 }
308
309 if changed {
310 Some(current)
311 } else {
312 None
313 }
314}
315
316fn strip_cd_prefix(command: &str) -> Option<String> {
317 let bytes = command.as_bytes();
319 let mut in_single = false;
320 let mut in_double = false;
321 let mut i = 0;
322 while i < bytes.len() {
323 let ch = bytes[i] as char;
324 if !in_double && ch == '\'' {
325 in_single = !in_single;
326 } else if !in_single && ch == '"' {
327 in_double = !in_double;
328 } else if !in_single && !in_double {
329 if ch == '&' && i + 1 < bytes.len() && bytes[i + 1] as char == '&' {
330 let rest = command[i + 2..].trim_start();
331 if rest.is_empty() {
332 return None;
333 }
334 return Some(rest.to_string());
335 }
336 if ch == ';' {
337 let rest = command[i + 1..].trim_start();
338 if rest.is_empty() {
339 return None;
340 }
341 return Some(rest.to_string());
342 }
343 }
344 i += 1;
345 }
346 None
347}
348
349fn strip_env_prefix(command: &str) -> Option<String> {
350 let rest = command.strip_prefix("env")?.trim_start();
353 let mut tokens = rest.split_whitespace().peekable();
354 let mut consumed = 0usize;
355 while let Some(&token) = tokens.peek() {
356 if !is_env_assignment(token) {
357 break;
358 }
359 consumed += token.len();
360 tokens.next();
362 }
363 if consumed == 0 {
364 return None;
365 }
366 let mut idx = 0usize;
368 let mut consumed_now = 0usize;
369 let bytes = rest.as_bytes();
370 while consumed_now < consumed && idx < bytes.len() {
371 while idx < bytes.len() && (bytes[idx] as char).is_whitespace() {
373 idx += 1;
374 }
375 let token_start = idx;
377 while idx < bytes.len() && !(bytes[idx] as char).is_whitespace() {
378 idx += 1;
379 }
380 consumed_now += idx - token_start;
381 }
382 let after = rest[idx..].trim_start();
383 if after.is_empty() {
384 None
385 } else {
386 Some(after.to_string())
387 }
388}
389
390fn is_env_assignment(token: &str) -> bool {
391 if token.starts_with('-') {
392 return false;
393 }
394 let Some((name, _value)) = token.split_once('=') else {
395 return false;
396 };
397 !name.is_empty()
398 && name
399 .chars()
400 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
401}
402
403fn strip_timeout_prefix(command: &str) -> Option<String> {
404 let rest = command.strip_prefix("timeout")?.trim_start();
405 let mut iter = rest.splitn(2, char::is_whitespace);
407 let duration = iter.next()?;
408 let after = iter.next()?.trim_start();
409 if after.is_empty() || !looks_like_duration(duration) {
410 return None;
411 }
412 Some(after.to_string())
413}
414
415fn looks_like_duration(token: &str) -> bool {
416 if token.is_empty() {
417 return false;
418 }
419 let mut chars = token.chars().peekable();
420 let mut saw_digit = false;
421 while let Some(&ch) = chars.peek() {
422 if ch.is_ascii_digit() {
423 saw_digit = true;
424 chars.next();
425 } else {
426 break;
427 }
428 }
429 if !saw_digit {
430 return false;
431 }
432 match chars.next() {
433 None => true,
434 Some(unit) => matches!(unit, 's' | 'm' | 'h' | 'd') && chars.next().is_none(),
435 }
436}
437
438pub fn user_filter_dir(storage_dir: &Path) -> PathBuf {
441 storage_dir.join("filters")
442}
443
444pub fn project_filter_dir(project_root: &Path) -> PathBuf {
448 project_root.join(".aft").join("filters")
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn user_and_project_filter_dir_helpers() {
457 let storage = Path::new("/tmp/aft-storage");
458 assert_eq!(
459 user_filter_dir(storage),
460 Path::new("/tmp/aft-storage/filters")
461 );
462
463 let project = Path::new("/repo");
464 assert_eq!(project_filter_dir(project), Path::new("/repo/.aft/filters"));
465 }
466}
467
468#[cfg(test)]
469mod dispatch_specificity_tests {
470 use super::*;
471 use crate::compress::toml_filter::FilterRegistry;
472
473 fn empty_registry() -> FilterRegistry {
474 FilterRegistry::default()
475 }
476
477 fn dispatch(cmd: &str, output: &str) -> String {
482 compress_with_registry(cmd, output, &empty_registry())
483 }
484
485 #[test]
486 fn bun_run_vitest_routes_to_vitest_not_generic() {
487 let output = "Test Files 1 passed (1)\n Tests 4 passed (4)\n Start at 10:00:00\n Duration 120ms\n";
492 let compressed = dispatch("bun run vitest", output);
493 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
495 }
496
497 #[test]
498 fn npm_test_routes_to_vitest_when_output_is_vitest_shaped() {
499 let output = "added 100 packages, removed 2 packages\n";
506 let _compressed = dispatch("npm test", output);
507 }
511
512 #[test]
513 fn bun_run_vitest_token_match_wins_over_bun_head_match() {
514 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";
517 let compressed = dispatch("bun run vitest run", output);
518 assert!(compressed.contains("Test Files") || compressed.contains("PASS"));
520 }
521
522 #[test]
523 fn bunx_jest_routes_to_vitest_module() {
524 let output = "PASS src/foo.test.js (1.2s)\nTest Suites: 1 passed, 1 total\nTests: 3 passed, 3 total\n";
525 let compressed = dispatch("bunx jest --json", output);
526 assert!(compressed.contains("Tests:") && compressed.contains("Test Suites"));
527 }
528
529 #[test]
530 fn pnpm_run_vitest_routes_to_vitest() {
531 let output = "Test Files 1 passed (1)\n Tests 10 passed (10)\n";
532 let compressed = dispatch("pnpm run vitest", output);
533 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
534 }
535
536 #[test]
537 fn npx_eslint_routes_to_eslint_not_generic() {
538 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";
539 let compressed = dispatch("npx eslint .", output);
540 assert!(compressed.contains("no-unused-vars") || compressed.contains("✖"));
542 }
543
544 #[test]
545 fn npm_run_lint_with_eslint_token_routes_to_eslint() {
546 let output = "> my-project@1.0.0 lint\n> eslint .\n\nAll good.\n";
551 let _compressed = dispatch("npm run lint", output);
552 }
556
557 #[test]
558 fn bun_test_still_routes_to_bun_test_compressor() {
559 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";
567 let compressed = dispatch("bun test", output);
568 assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
569 }
570
571 #[test]
572 fn bunx_vitest_routes_to_vitest() {
573 let output = "Test Files 1 passed (1)\n Tests 3 passed (3)\n";
574 let compressed = dispatch("bunx vitest run", output);
575 assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
576 }
577
578 #[test]
579 fn cargo_test_still_routes_to_cargo() {
580 let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
583 let compressed = dispatch("cargo test", output);
584 assert!(compressed.contains("failed") || compressed.contains("FAILED"));
586 }
587
588 #[test]
589 fn git_status_still_routes_to_git() {
590 let output =
592 "On branch main\nYour branch is up to date.\n\nnothing to commit, working tree clean\n";
593 let compressed = dispatch("git status", output);
594 assert!(compressed.contains("branch") || compressed.contains("clean"));
595 }
596
597 #[test]
598 fn pnpm_install_still_routes_to_pnpm() {
599 let output = "Progress: resolved 100, downloaded 50, added 50\nAdded 50 packages\n";
601 let compressed = dispatch("pnpm install", output);
602 assert!(compressed.contains("Added") || compressed.contains("Progress"));
604 }
605}
606
607#[cfg(test)]
608mod normalize_command_tests {
609 use super::*;
610
611 #[test]
612 fn passes_bare_commands_unchanged() {
613 assert_eq!(normalize_command_for_dispatch("bun test"), None);
614 assert_eq!(normalize_command_for_dispatch("cargo build"), None);
615 assert_eq!(normalize_command_for_dispatch("git status"), None);
616 }
617
618 #[test]
619 fn strips_cd_and_amp_prefix() {
620 assert_eq!(
621 normalize_command_for_dispatch("cd /repo && bun test").as_deref(),
622 Some("bun test")
623 );
624 assert_eq!(
625 normalize_command_for_dispatch("cd /repo/packages/aft && cargo test --release")
626 .as_deref(),
627 Some("cargo test --release")
628 );
629 }
630
631 #[test]
632 fn strips_cd_and_semicolon_prefix() {
633 assert_eq!(
634 normalize_command_for_dispatch("cd /repo; bun test").as_deref(),
635 Some("bun test")
636 );
637 }
638
639 #[test]
640 fn strips_cd_with_quoted_path() {
641 assert_eq!(
642 normalize_command_for_dispatch("cd \"/path with space\" && npm install").as_deref(),
643 Some("npm install")
644 );
645 }
646
647 #[test]
648 fn strips_env_assignments() {
649 assert_eq!(
650 normalize_command_for_dispatch("env FOO=bar npm install").as_deref(),
651 Some("npm install")
652 );
653 assert_eq!(
654 normalize_command_for_dispatch("env FOO=bar BAZ=qux RUST_LOG=info cargo test")
655 .as_deref(),
656 Some("cargo test")
657 );
658 }
659
660 #[test]
661 fn env_without_assignments_returns_none() {
662 assert_eq!(
664 normalize_command_for_dispatch("env npm install").as_deref(),
665 None
666 );
667 }
668
669 #[test]
670 fn strips_timeout_prefix() {
671 assert_eq!(
672 normalize_command_for_dispatch("timeout 30 cargo test").as_deref(),
673 Some("cargo test")
674 );
675 assert_eq!(
676 normalize_command_for_dispatch("timeout 5m bun test").as_deref(),
677 Some("bun test")
678 );
679 }
680
681 #[test]
682 fn strips_nohup_prefix() {
683 assert_eq!(
684 normalize_command_for_dispatch("nohup ./long-running-script.sh").as_deref(),
685 Some("./long-running-script.sh")
686 );
687 }
688
689 #[test]
690 fn strips_paren_then_cd_and_amp() {
691 assert_eq!(
692 normalize_command_for_dispatch("(cd /repo && bun test").as_deref(),
693 Some("bun test")
694 );
695 }
696
697 #[test]
698 fn chains_multiple_prefixes() {
699 assert_eq!(
701 normalize_command_for_dispatch("env FOO=bar timeout 30 cargo test").as_deref(),
702 Some("cargo test")
703 );
704 assert_eq!(
706 normalize_command_for_dispatch("cd /repo && env FOO=bar npm install").as_deref(),
707 Some("npm install")
708 );
709 }
710
711 fn empty_registry() -> FilterRegistry {
714 FilterRegistry::default()
715 }
716
717 #[test]
718 fn cd_prefix_bun_test_still_routes_to_bun_test() {
719 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";
720 let compressed = compress_with_registry("cd /repo && bun test", output, &empty_registry());
721 assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
726 }
727
728 #[test]
729 fn cd_prefix_cargo_test_still_routes_to_cargo() {
730 let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
731 let compressed =
732 compress_with_registry("cd /repo && cargo test", output, &empty_registry());
733 assert!(compressed.contains("FAILED") || compressed.contains("failed"));
734 }
735
736 #[test]
737 fn env_prefix_npm_install_still_routes_to_npm() {
738 let output = "added 50 packages, and audited 100 packages in 3s\n";
739 let compressed = compress_with_registry(
740 "env NODE_ENV=production npm install",
741 output,
742 &empty_registry(),
743 );
744 assert!(compressed.contains("added") || compressed.contains("audited"));
746 }
747
748 #[test]
749 fn timeout_prefix_cargo_build_still_routes_to_cargo() {
750 let output =
751 " Compiling foo v0.1.0\n Finished `dev` profile [unoptimized] target(s) in 5s\n";
752 let compressed =
753 compress_with_registry("timeout 30 cargo build", output, &empty_registry());
754 assert!(compressed.contains("Compiling") || compressed.contains("Finished"));
756 }
757}