1use super::command_key::{build_key, command_original, drop_noise};
9use super::interactive::{has_watch_flag, is_known_interactive};
10use super::{ExecMode, Family, PassthroughReason};
11use crate::config::Config;
12
13use ExecMode::{Optimize, Passthrough};
14use PassthroughReason as R;
15
16pub struct Classified {
17 pub mode: ExecMode,
18 pub command_original: String,
19}
20
21const GIT_READONLY: &[&str] = &["status", "diff", "log", "show"];
23const GIT_MUTATING: &[&str] = &[
25 "add",
26 "am",
27 "apply",
28 "branch",
29 "checkout",
30 "cherry-pick",
31 "clean",
32 "clone",
33 "commit",
34 "fetch",
35 "merge",
36 "mv",
37 "pull",
38 "push",
39 "rebase",
40 "reset",
41 "restore",
42 "revert",
43 "rm",
44 "stash",
45 "switch",
46 "tag",
47 "worktree",
48];
49const JS_SCRIPTS: &[&str] = &["test", "lint", "typecheck", "build"];
51const JS_SCRIPT_PREFIXES: &[&str] = &["test:", "lint:", "build:", "typecheck:"];
54const FIND_DANGEROUS: &[&str] = &[
56 "-exec", "-execdir", "-delete", "-ok", "-okdir", "-fprint", "-fprintf", "-fls", "-fprint0",
57];
58
59pub fn classify(
60 shim: &str,
61 args: &[String],
62 cfg: &Config,
63 disabled: bool,
64 repo_disabled: bool,
65 _stdin_is_tty: bool,
66) -> Classified {
67 let command_original = command_original(shim, args);
68 let wrap = |mode| Classified {
69 mode,
70 command_original: command_original.clone(),
71 };
72
73 if disabled {
74 return wrap(Passthrough(R::Disabled));
75 }
76 if repo_disabled {
77 return wrap(Passthrough(R::RepoDisabled));
78 }
79 if !cfg.intercept.is_enabled(shim) {
80 return wrap(Passthrough(R::ConfigExcluded));
81 }
82
83 let mode = match shim {
84 "npm" | "pnpm" | "yarn" | "bun" => classify_js(shim, args),
85 "tsc" => classify_tsc(args),
86 "eslint" => classify_eslint(args),
87 "vitest" => classify_vitest(args),
88 "jest" => classify_jest(args),
89 "pytest" => classify_pytest(args),
90 "cargo" => classify_cargo(args),
91 "go" => classify_go(args),
92 "rg" | "grep" => classify_search(shim, args),
93 "find" => classify_find(args),
94 "ls" | "tree" => classify_listing(shim, args),
95 "git" => classify_git(args),
96 "docker" => classify_docker(args),
97 _ if cfg.intercept.is_extra(shim) => classify_extra(shim, args),
100 _ => Passthrough(R::UnknownShim),
101 };
102 wrap(mode)
103}
104
105fn classify_extra(shim: &str, args: &[String]) -> ExecMode {
109 if has_watch_flag(args) || is_known_interactive(shim, first_positional(args).map(|(_, s)| s)) {
110 return Passthrough(R::Interactive);
111 }
112 Optimize {
113 family: Family::Validation,
114 command_key: build_key(&format!("validation:{shim}"), &drop_noise(args)),
115 }
116}
117
118fn first_positional(args: &[String]) -> Option<(usize, &str)> {
120 args.iter()
121 .enumerate()
122 .find(|(_, a)| !a.starts_with('-'))
123 .map(|(i, a)| (i, a.as_str()))
124}
125
126fn classify_js(shim: &str, args: &[String]) -> ExecMode {
127 if has_watch_flag(args) {
128 return Passthrough(R::Interactive);
129 }
130 let Some((idx, sub)) = first_positional(args) else {
131 return Passthrough(R::UnsupportedSubcommand);
132 };
133
134 let (script, key_tokens): (&str, Vec<String>) = if sub == "run" {
135 match args[idx + 1..].iter().find(|a| !a.starts_with('-')) {
137 Some(s) => (s.as_str(), vec!["run".to_string(), s.clone()]),
138 None => return Passthrough(R::UnsupportedSubcommand),
139 }
140 } else {
141 (sub, vec![sub.to_string()])
142 };
143
144 if is_known_interactive(shim, Some(script)) {
145 return Passthrough(R::Interactive);
146 }
147 if js_script_whitelisted(script) {
148 Optimize {
149 family: Family::Validation,
150 command_key: build_key(&format!("validation:{shim}"), &key_tokens),
151 }
152 } else {
153 Passthrough(R::UnsupportedSubcommand)
154 }
155}
156
157fn js_script_whitelisted(script: &str) -> bool {
162 if JS_SCRIPTS.contains(&script) {
163 return true;
164 }
165 JS_SCRIPT_PREFIXES.iter().any(|p| script.starts_with(p))
166 && !script.split(':').any(|seg| {
167 seg == "fix"
168 || seg == "dev"
169 || seg == "serve"
170 || seg == "start"
171 || seg.starts_with("watch")
172 })
173}
174
175fn classify_tsc(args: &[String]) -> ExecMode {
176 if has_watch_flag(args) || args.iter().any(|a| a == "-w" || a == "--watch") {
177 return Passthrough(R::Interactive);
178 }
179 Optimize {
180 family: Family::Validation,
181 command_key: build_key("validation:tsc", &drop_noise(args)),
182 }
183}
184
185fn classify_eslint(args: &[String]) -> ExecMode {
186 if args.iter().any(|a| a == "--fix" || a == "--fix-dry-run") {
187 return Passthrough(R::SideEffecting);
188 }
189 if has_watch_flag(args) {
190 return Passthrough(R::Interactive);
191 }
192 Optimize {
193 family: Family::Validation,
194 command_key: build_key("validation:eslint", &drop_noise(args)),
195 }
196}
197
198fn classify_pytest(args: &[String]) -> ExecMode {
199 Optimize {
200 family: Family::Validation,
201 command_key: build_key("validation:pytest", &drop_noise(args)),
202 }
203}
204
205fn classify_vitest(args: &[String]) -> ExecMode {
208 if has_watch_flag(args) {
209 return Passthrough(R::Interactive);
210 }
211 if args.iter().any(|a| a == "-u" || a == "--update") {
213 return Passthrough(R::SideEffecting);
214 }
215 let explicit_run =
216 matches!(first_positional(args), Some((_, "run"))) || args.iter().any(|a| a == "--run");
217 if explicit_run {
218 Optimize {
219 family: Family::Validation,
220 command_key: build_key("validation:vitest", &drop_noise(args)),
221 }
222 } else {
223 Passthrough(R::Interactive)
225 }
226}
227
228fn classify_jest(args: &[String]) -> ExecMode {
230 if has_watch_flag(args) {
231 return Passthrough(R::Interactive);
232 }
233 if args.iter().any(|a| a == "-u" || a == "--updateSnapshot") {
234 return Passthrough(R::SideEffecting);
235 }
236 Optimize {
237 family: Family::Validation,
238 command_key: build_key("validation:jest", &drop_noise(args)),
239 }
240}
241
242fn classify_cargo(args: &[String]) -> ExecMode {
243 let rest: &[String] = match args.first() {
245 Some(first) if first.starts_with('+') => &args[1..],
246 _ => args,
247 };
248 match first_positional(rest) {
249 Some((idx, "test")) => Optimize {
250 family: Family::Validation,
251 command_key: build_key("validation:cargo:test", &drop_noise(&rest[idx + 1..])),
252 },
253 Some((idx, "check")) => Optimize {
254 family: Family::Validation,
255 command_key: build_key("validation:cargo:check", &drop_noise(&rest[idx + 1..])),
256 },
257 Some((idx, "clippy")) => {
258 if rest.iter().any(|a| a == "--fix") {
260 Passthrough(R::SideEffecting)
261 } else {
262 Optimize {
263 family: Family::Validation,
264 command_key: build_key(
265 "validation:cargo:clippy",
266 &drop_noise(&rest[idx + 1..]),
267 ),
268 }
269 }
270 }
271 _ => Passthrough(R::UnsupportedSubcommand),
272 }
273}
274
275fn classify_go(args: &[String]) -> ExecMode {
276 match first_positional(args) {
277 Some((idx, "test")) => Optimize {
278 family: Family::Validation,
279 command_key: build_key("validation:go:test", &drop_noise(&args[idx + 1..])),
280 },
281 _ => Passthrough(R::UnsupportedSubcommand),
282 }
283}
284
285fn classify_search(shim: &str, args: &[String]) -> ExecMode {
286 Optimize {
288 family: Family::Search,
289 command_key: build_key(&format!("search:{shim}"), &drop_noise(args)),
290 }
291}
292
293fn classify_find(args: &[String]) -> ExecMode {
294 if args.iter().any(|a| FIND_DANGEROUS.contains(&a.as_str())) {
295 return Passthrough(R::SideEffecting);
296 }
297 Optimize {
298 family: Family::Tree,
299 command_key: build_key("tree:find", &drop_noise(args)),
300 }
301}
302
303fn classify_listing(shim: &str, args: &[String]) -> ExecMode {
304 Optimize {
305 family: Family::Tree,
306 command_key: build_key(&format!("tree:{shim}"), &drop_noise(args)),
307 }
308}
309
310fn git_subcommand(args: &[String]) -> Option<(usize, &str)> {
313 const TAKES_VALUE: &[&str] = &[
314 "-C",
315 "-c",
316 "--git-dir",
317 "--work-tree",
318 "--namespace",
319 "--exec-path",
320 "--super-prefix",
321 ];
322 let mut i = 0;
323 while i < args.len() {
324 let a = &args[i];
325 if a == "--" {
326 i += 1;
327 continue;
328 }
329 if a.starts_with('-') {
330 if TAKES_VALUE.contains(&a.as_str()) {
331 i += 2; } else {
333 i += 1; }
335 continue;
336 }
337 return Some((i, a.as_str()));
338 }
339 None
340}
341
342fn classify_git(args: &[String]) -> ExecMode {
343 match git_subcommand(args) {
344 Some((idx, sub)) if GIT_READONLY.contains(&sub) => {
345 if is_machine_readable(&args[idx + 1..]) {
350 Passthrough(R::MachineReadable)
351 } else {
352 Optimize {
353 family: Family::GitReadonly,
354 command_key: build_key(&format!("git:{sub}"), &drop_noise(&args[idx + 1..])),
355 }
356 }
357 }
358 Some((_, sub)) if GIT_MUTATING.contains(&sub) => Passthrough(R::MutatingGit),
359 _ => Passthrough(R::UnsupportedSubcommand),
361 }
362}
363
364fn is_machine_readable(args: &[String]) -> bool {
371 args.iter().any(|a| {
372 matches!(
373 a.as_str(),
374 "-z" | "-s"
376 | "--short"
377 | "--name-only"
378 | "--name-status"
379 | "--numstat"
380 | "--raw"
381 | "--no-ext-diff"
382 ) || a.starts_with("--porcelain") || a.starts_with("--format") || a.starts_with("--pretty=format") || a.contains("@{u") })
387}
388
389fn docker_positional(args: &[String], from: usize) -> Option<(usize, &str)> {
391 const TAKES_VALUE: &[&str] = &[
392 "-H",
393 "--host",
394 "--context",
395 "--config",
396 "--log-level",
397 "-l",
398 "--tlscacert",
399 "--tlscert",
400 "--tlskey",
401 ];
402 let mut i = from;
403 while i < args.len() {
404 let a = &args[i];
405 if a.starts_with('-') {
406 if TAKES_VALUE.contains(&a.as_str()) {
407 i += 2;
408 } else {
409 i += 1;
410 }
411 continue;
412 }
413 return Some((i, a.as_str()));
414 }
415 None
416}
417
418fn classify_docker(args: &[String]) -> ExecMode {
419 match docker_positional(args, 0) {
420 Some((idx, "logs")) => Optimize {
421 family: Family::Logs,
422 command_key: build_key("logs:docker:logs", &drop_noise(&args[idx + 1..])),
423 },
424 Some((idx, "compose")) => match docker_positional(args, idx + 1) {
425 Some((jdx, "logs")) => Optimize {
426 family: Family::Logs,
427 command_key: build_key("logs:docker-compose:logs", &drop_noise(&args[jdx + 1..])),
428 },
429 _ => Passthrough(R::DangerousDocker),
430 },
431 _ => Passthrough(R::DangerousDocker),
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 fn cfg() -> Config {
440 Config::default()
441 }
442
443 fn a(parts: &[&str]) -> Vec<String> {
444 parts.iter().map(|s| s.to_string()).collect()
445 }
446
447 fn mode(shim: &str, args: &[&str]) -> ExecMode {
448 classify(shim, &a(args), &cfg(), false, false, false).mode
449 }
450
451 fn is_opt(m: &ExecMode) -> bool {
452 matches!(m, Optimize { .. })
453 }
454
455 fn key(m: &ExecMode) -> String {
456 match m {
457 Optimize { command_key, .. } => command_key.clone(),
458 _ => String::new(),
459 }
460 }
461
462 #[test]
463 fn js_validation_optimized() {
464 assert!(is_opt(&mode("pnpm", &["test"])));
465 assert!(is_opt(&mode("pnpm", &["run", "test"])));
466 assert!(is_opt(&mode("npm", &["run", "lint"])));
467 assert!(is_opt(&mode("yarn", &["build"])));
468 assert!(is_opt(&mode("bun", &["test"])));
469 assert_eq!(key(&mode("pnpm", &["test"])), "validation:pnpm:test");
470 assert_eq!(
471 key(&mode("pnpm", &["run", "typecheck"])),
472 "validation:pnpm:run:typecheck"
473 );
474 }
475
476 #[test]
477 fn js_side_effect_commands_passthrough() {
478 assert!(!is_opt(&mode("pnpm", &["install"])));
479 assert!(!is_opt(&mode("npm", &["publish"])));
480 assert!(!is_opt(&mode("yarn", &["add", "react"])));
481 assert!(!is_opt(&mode("bun", &["add", "zod"])));
482 }
483
484 #[test]
485 fn watch_mode_passthrough() {
486 assert!(matches!(
487 mode("pnpm", &["test", "--watch"]),
488 Passthrough(R::Interactive)
489 ));
490 assert!(matches!(
491 mode("tsc", &["--watch"]),
492 Passthrough(R::Interactive)
493 ));
494 }
495
496 #[test]
497 fn git_readonly_optimized_mutating_passthrough() {
498 assert!(is_opt(&mode("git", &["diff"])));
499 assert!(is_opt(&mode("git", &["status"])));
500 assert!(is_opt(&mode("git", &["log"])));
501 assert!(is_opt(&mode("git", &["show"])));
502 assert_eq!(key(&mode("git", &["diff"])), "git:diff:default");
503
504 for sub in GIT_MUTATING {
505 assert!(
506 matches!(mode("git", &[sub]), Passthrough(R::MutatingGit)),
507 "git {sub} must pass through"
508 );
509 }
510 }
511
512 #[test]
513 fn extra_commands_classify_as_generic_validation() {
514 let mut cfg = Config::default();
515 cfg.intercept.extra = vec!["mytool".to_string()];
516
517 let c = classify(
519 "mytool",
520 &["run".to_string(), "--color=always".to_string()],
521 &cfg,
522 false,
523 false,
524 false,
525 );
526 match &c.mode {
527 Optimize {
528 family,
529 command_key,
530 } => {
531 assert_eq!(*family, Family::Validation);
532 assert_eq!(command_key, "validation:mytool:run");
533 }
534 other => panic!("expected Optimize, got {other:?}"),
535 }
536
537 let c = classify(
539 "mytool",
540 &["--watch".to_string()],
541 &cfg,
542 false,
543 false,
544 false,
545 );
546 assert!(matches!(c.mode, Passthrough(R::Interactive)));
547
548 let c = classify("randomtool", &[], &cfg, false, false, false);
550 assert!(matches!(
551 c.mode,
552 Passthrough(R::ConfigExcluded | R::UnknownShim)
553 ));
554
555 cfg.intercept.extra.push("git".to_string());
557 let c = classify("git", &["commit".to_string()], &cfg, false, false, false);
558 assert!(matches!(c.mode, Passthrough(R::MutatingGit)));
559 }
560
561 #[test]
562 fn git_machine_forms_passthrough_human_forms_optimize() {
563 for args in [
565 &["status", "--porcelain"][..],
566 &["status", "--porcelain=v2", "-z"][..],
567 &["status", "-s"][..],
568 &["status", "--short"][..],
569 &["diff", "--no-ext-diff", "--ignore-submodules"][..],
570 &["diff", "--name-only"][..],
571 &["diff", "--numstat"][..],
572 &["diff", "--name-status", "--diff-filter=ACM"][..],
573 &["log", "--oneline", "..@{upstream}"][..],
574 &["log", "-1", "--format=%H"][..],
575 &["log", "--pretty=format:%h %s"][..],
576 ] {
577 assert!(
578 matches!(mode("git", args), Passthrough(R::MachineReadable)),
579 "git {args:?} must pass through (machine-readable)"
580 );
581 }
582 assert!(is_opt(&mode("git", &["status"])));
584 assert!(is_opt(&mode("git", &["diff"])));
585 assert!(is_opt(&mode("git", &["diff", "HEAD~1"])));
586 assert!(is_opt(&mode("git", &["diff", "--stat"])));
587 assert!(is_opt(&mode("git", &["log", "--oneline", "-5"])));
588 assert!(is_opt(&mode("git", &["log", "--graph"])));
589 assert!(is_opt(&mode("git", &["-C", "/tmp/r", "status"])));
590 }
591
592 #[test]
593 fn git_global_options_before_subcommand() {
594 assert!(is_opt(&mode("git", &["-C", "/tmp/repo", "diff"])));
595 assert!(matches!(
596 mode("git", &["-C", "/tmp/repo", "commit"]),
597 Passthrough(R::MutatingGit)
598 ));
599 assert!(matches!(
600 mode("git", &["-c", "user.name=x", "push"]),
601 Passthrough(R::MutatingGit)
602 ));
603 }
604
605 #[test]
606 fn docker_logs_optimized_run_passthrough() {
607 assert!(is_opt(&mode("docker", &["logs", "api"])));
608 assert!(is_opt(&mode("docker", &["compose", "logs", "api"])));
609 assert!(matches!(
610 mode("docker", &["run", "img"]),
611 Passthrough(R::DangerousDocker)
612 ));
613 assert!(matches!(
614 mode("docker", &["compose", "up"]),
615 Passthrough(R::DangerousDocker)
616 ));
617 }
618
619 #[test]
620 fn find_dangerous_passthrough() {
621 assert!(is_opt(&mode("find", &[".", "-name", "*.ts"])));
622 assert!(matches!(
623 mode("find", &[".", "-delete"]),
624 Passthrough(R::SideEffecting)
625 ));
626 assert!(matches!(
627 mode("find", &[".", "-exec", "rm", "{}", ";"]),
628 Passthrough(R::SideEffecting)
629 ));
630 }
631
632 #[test]
633 fn eslint_fix_passthrough() {
634 assert!(is_opt(&mode("eslint", &["."])));
635 assert!(matches!(
636 mode("eslint", &[".", "--fix"]),
637 Passthrough(R::SideEffecting)
638 ));
639 }
640
641 #[test]
642 fn cargo_go_validation_subcommands() {
643 assert!(is_opt(&mode("cargo", &["test"])));
644 assert!(is_opt(&mode("cargo", &["+nightly", "test"])));
645 assert!(is_opt(&mode("cargo", &["check"])));
646 assert!(is_opt(&mode("cargo", &["check", "--all-targets"])));
647 assert!(is_opt(&mode("cargo", &["clippy"])));
648 assert!(is_opt(&mode("cargo", &["clippy", "--", "-D", "warnings"])));
649 assert_eq!(
650 key(&mode("cargo", &["check"])),
651 "validation:cargo:check:default"
652 );
653 assert!(!is_opt(&mode("cargo", &["build"])));
654 assert!(!is_opt(&mode("cargo", &["publish"])));
655 assert!(matches!(
657 mode("cargo", &["clippy", "--fix"]),
658 Passthrough(R::SideEffecting)
659 ));
660 assert!(is_opt(&mode("go", &["test", "./..."])));
661 assert!(!is_opt(&mode("go", &["build"])));
662 }
663
664 #[test]
665 fn vitest_only_explicit_run_optimized() {
666 assert!(is_opt(&mode("vitest", &["run"])));
667 assert!(is_opt(&mode("vitest", &["run", "src/session"])));
668 assert!(is_opt(&mode("vitest", &["related", "--run", "src/a.ts"])));
669 assert_eq!(key(&mode("vitest", &["run"])), "validation:vitest:run");
670 assert!(matches!(mode("vitest", &[]), Passthrough(R::Interactive)));
672 assert!(matches!(
673 mode("vitest", &["watch"]),
674 Passthrough(R::Interactive)
675 ));
676 assert!(matches!(
677 mode("vitest", &["run", "--watch"]),
678 Passthrough(R::Interactive)
679 ));
680 assert!(matches!(
682 mode("vitest", &["run", "-u"]),
683 Passthrough(R::SideEffecting)
684 ));
685 }
686
687 #[test]
688 fn jest_optimized_except_watch_and_snapshot_updates() {
689 assert!(is_opt(&mode("jest", &[])));
690 assert!(is_opt(&mode("jest", &["src/session"])));
691 assert_eq!(key(&mode("jest", &[])), "validation:jest:default");
692 assert!(matches!(
693 mode("jest", &["--watch"]),
694 Passthrough(R::Interactive)
695 ));
696 assert!(matches!(
697 mode("jest", &["--watchAll"]),
698 Passthrough(R::Interactive)
699 ));
700 assert!(matches!(
701 mode("jest", &["-u"]),
702 Passthrough(R::SideEffecting)
703 ));
704 assert!(matches!(
705 mode("jest", &["--updateSnapshot"]),
706 Passthrough(R::SideEffecting)
707 ));
708 }
709
710 #[test]
711 fn js_script_prefixes_optimized_dangerous_variants_passthrough() {
712 assert!(is_opt(&mode("pnpm", &["test:unit"])));
714 assert!(is_opt(&mode("pnpm", &["run", "test:e2e"])));
715 assert!(is_opt(&mode("npm", &["run", "lint:js"])));
716 assert!(is_opt(&mode("yarn", &["build:prod"])));
717 assert!(is_opt(&mode("pnpm", &["run", "typecheck:strict"])));
718 assert!(is_opt(&mode("pnpm", &["run", "test:fixtures"])));
720 assert!(!is_opt(&mode("pnpm", &["run", "test:watch"])));
722 assert!(!is_opt(&mode("pnpm", &["run", "lint:fix"])));
723 assert!(!is_opt(&mode("pnpm", &["run", "build:dev"])));
724 assert!(!is_opt(&mode("npm", &["run", "build:serve"])));
725 assert!(!is_opt(&mode("pnpm", &["run", "test:watch-unit"])));
726 assert!(!is_opt(&mode("pnpm", &["run", "deploy:prod"])));
728 }
729
730 #[test]
731 fn noise_flags_excluded_from_key() {
732 assert_eq!(
733 key(&mode("rg", &["--color=always", "createSession", "src"])),
734 "search:rg:createSession:src"
735 );
736 }
737
738 #[test]
739 fn disabled_and_config_excluded() {
740 let disabled = classify("pnpm", &a(&["test"]), &cfg(), true, false, false).mode;
741 assert!(matches!(disabled, Passthrough(R::Disabled)));
742
743 let mut c = cfg();
744 c.intercept.git = false;
745 let excluded = classify("git", &a(&["diff"]), &c, false, false, false).mode;
746 assert!(matches!(excluded, Passthrough(R::ConfigExcluded)));
747 }
748}