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 FIND_DANGEROUS: &[&str] = &[
53 "-exec", "-execdir", "-delete", "-ok", "-okdir", "-fprint", "-fprintf", "-fls", "-fprint0",
54];
55
56pub fn classify(
57 shim: &str,
58 args: &[String],
59 cfg: &Config,
60 disabled: bool,
61 repo_disabled: bool,
62 _stdin_is_tty: bool,
63) -> Classified {
64 let command_original = command_original(shim, args);
65 let wrap = |mode| Classified {
66 mode,
67 command_original: command_original.clone(),
68 };
69
70 if disabled {
71 return wrap(Passthrough(R::Disabled));
72 }
73 if repo_disabled {
74 return wrap(Passthrough(R::RepoDisabled));
75 }
76 if !cfg.intercept.is_enabled(shim) {
77 return wrap(Passthrough(R::ConfigExcluded));
78 }
79
80 let mode = match shim {
81 "npm" | "pnpm" | "yarn" | "bun" => classify_js(shim, args),
82 "tsc" => classify_tsc(args),
83 "eslint" => classify_eslint(args),
84 "pytest" => classify_pytest(args),
85 "cargo" => classify_cargo(args),
86 "go" => classify_go(args),
87 "rg" | "grep" => classify_search(shim, args),
88 "find" => classify_find(args),
89 "ls" | "tree" => classify_listing(shim, args),
90 "git" => classify_git(args),
91 "docker" => classify_docker(args),
92 _ if cfg.intercept.is_extra(shim) => classify_extra(shim, args),
95 _ => Passthrough(R::UnknownShim),
96 };
97 wrap(mode)
98}
99
100fn classify_extra(shim: &str, args: &[String]) -> ExecMode {
104 if has_watch_flag(args) || is_known_interactive(shim, first_positional(args).map(|(_, s)| s)) {
105 return Passthrough(R::Interactive);
106 }
107 Optimize {
108 family: Family::Validation,
109 command_key: build_key(&format!("validation:{shim}"), &drop_noise(args)),
110 }
111}
112
113fn first_positional(args: &[String]) -> Option<(usize, &str)> {
115 args.iter()
116 .enumerate()
117 .find(|(_, a)| !a.starts_with('-'))
118 .map(|(i, a)| (i, a.as_str()))
119}
120
121fn classify_js(shim: &str, args: &[String]) -> ExecMode {
122 if has_watch_flag(args) {
123 return Passthrough(R::Interactive);
124 }
125 let Some((idx, sub)) = first_positional(args) else {
126 return Passthrough(R::UnsupportedSubcommand);
127 };
128
129 let (script, key_tokens): (&str, Vec<String>) = if sub == "run" {
130 match args[idx + 1..].iter().find(|a| !a.starts_with('-')) {
132 Some(s) => (s.as_str(), vec!["run".to_string(), s.clone()]),
133 None => return Passthrough(R::UnsupportedSubcommand),
134 }
135 } else {
136 (sub, vec![sub.to_string()])
137 };
138
139 if is_known_interactive(shim, Some(script)) {
140 return Passthrough(R::Interactive);
141 }
142 if JS_SCRIPTS.contains(&script) {
143 Optimize {
144 family: Family::Validation,
145 command_key: build_key(&format!("validation:{shim}"), &key_tokens),
146 }
147 } else {
148 Passthrough(R::UnsupportedSubcommand)
149 }
150}
151
152fn classify_tsc(args: &[String]) -> ExecMode {
153 if has_watch_flag(args) || args.iter().any(|a| a == "-w" || a == "--watch") {
154 return Passthrough(R::Interactive);
155 }
156 Optimize {
157 family: Family::Validation,
158 command_key: build_key("validation:tsc", &drop_noise(args)),
159 }
160}
161
162fn classify_eslint(args: &[String]) -> ExecMode {
163 if args.iter().any(|a| a == "--fix" || a == "--fix-dry-run") {
164 return Passthrough(R::SideEffecting);
165 }
166 if has_watch_flag(args) {
167 return Passthrough(R::Interactive);
168 }
169 Optimize {
170 family: Family::Validation,
171 command_key: build_key("validation:eslint", &drop_noise(args)),
172 }
173}
174
175fn classify_pytest(args: &[String]) -> ExecMode {
176 Optimize {
177 family: Family::Validation,
178 command_key: build_key("validation:pytest", &drop_noise(args)),
179 }
180}
181
182fn classify_cargo(args: &[String]) -> ExecMode {
183 let rest: &[String] = match args.first() {
185 Some(first) if first.starts_with('+') => &args[1..],
186 _ => args,
187 };
188 match first_positional(rest) {
189 Some((idx, "test")) => Optimize {
190 family: Family::Validation,
191 command_key: build_key("validation:cargo:test", &drop_noise(&rest[idx + 1..])),
192 },
193 _ => Passthrough(R::UnsupportedSubcommand),
194 }
195}
196
197fn classify_go(args: &[String]) -> ExecMode {
198 match first_positional(args) {
199 Some((idx, "test")) => Optimize {
200 family: Family::Validation,
201 command_key: build_key("validation:go:test", &drop_noise(&args[idx + 1..])),
202 },
203 _ => Passthrough(R::UnsupportedSubcommand),
204 }
205}
206
207fn classify_search(shim: &str, args: &[String]) -> ExecMode {
208 Optimize {
210 family: Family::Search,
211 command_key: build_key(&format!("search:{shim}"), &drop_noise(args)),
212 }
213}
214
215fn classify_find(args: &[String]) -> ExecMode {
216 if args.iter().any(|a| FIND_DANGEROUS.contains(&a.as_str())) {
217 return Passthrough(R::SideEffecting);
218 }
219 Optimize {
220 family: Family::Tree,
221 command_key: build_key("tree:find", &drop_noise(args)),
222 }
223}
224
225fn classify_listing(shim: &str, args: &[String]) -> ExecMode {
226 Optimize {
227 family: Family::Tree,
228 command_key: build_key(&format!("tree:{shim}"), &drop_noise(args)),
229 }
230}
231
232fn git_subcommand(args: &[String]) -> Option<(usize, &str)> {
235 const TAKES_VALUE: &[&str] = &[
236 "-C",
237 "-c",
238 "--git-dir",
239 "--work-tree",
240 "--namespace",
241 "--exec-path",
242 "--super-prefix",
243 ];
244 let mut i = 0;
245 while i < args.len() {
246 let a = &args[i];
247 if a == "--" {
248 i += 1;
249 continue;
250 }
251 if a.starts_with('-') {
252 if TAKES_VALUE.contains(&a.as_str()) {
253 i += 2; } else {
255 i += 1; }
257 continue;
258 }
259 return Some((i, a.as_str()));
260 }
261 None
262}
263
264fn classify_git(args: &[String]) -> ExecMode {
265 match git_subcommand(args) {
266 Some((idx, sub)) if GIT_READONLY.contains(&sub) => {
267 if is_machine_readable(&args[idx + 1..]) {
272 Passthrough(R::MachineReadable)
273 } else {
274 Optimize {
275 family: Family::GitReadonly,
276 command_key: build_key(&format!("git:{sub}"), &drop_noise(&args[idx + 1..])),
277 }
278 }
279 }
280 Some((_, sub)) if GIT_MUTATING.contains(&sub) => Passthrough(R::MutatingGit),
281 _ => Passthrough(R::UnsupportedSubcommand),
283 }
284}
285
286fn is_machine_readable(args: &[String]) -> bool {
293 args.iter().any(|a| {
294 matches!(
295 a.as_str(),
296 "-z" | "-s"
298 | "--short"
299 | "--name-only"
300 | "--name-status"
301 | "--numstat"
302 | "--raw"
303 | "--no-ext-diff"
304 ) || a.starts_with("--porcelain") || a.starts_with("--format") || a.starts_with("--pretty=format") || a.contains("@{u") })
309}
310
311fn docker_positional(args: &[String], from: usize) -> Option<(usize, &str)> {
313 const TAKES_VALUE: &[&str] = &[
314 "-H",
315 "--host",
316 "--context",
317 "--config",
318 "--log-level",
319 "-l",
320 "--tlscacert",
321 "--tlscert",
322 "--tlskey",
323 ];
324 let mut i = from;
325 while i < args.len() {
326 let a = &args[i];
327 if a.starts_with('-') {
328 if TAKES_VALUE.contains(&a.as_str()) {
329 i += 2;
330 } else {
331 i += 1;
332 }
333 continue;
334 }
335 return Some((i, a.as_str()));
336 }
337 None
338}
339
340fn classify_docker(args: &[String]) -> ExecMode {
341 match docker_positional(args, 0) {
342 Some((idx, "logs")) => Optimize {
343 family: Family::Logs,
344 command_key: build_key("logs:docker:logs", &drop_noise(&args[idx + 1..])),
345 },
346 Some((idx, "compose")) => match docker_positional(args, idx + 1) {
347 Some((jdx, "logs")) => Optimize {
348 family: Family::Logs,
349 command_key: build_key("logs:docker-compose:logs", &drop_noise(&args[jdx + 1..])),
350 },
351 _ => Passthrough(R::DangerousDocker),
352 },
353 _ => Passthrough(R::DangerousDocker),
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 fn cfg() -> Config {
362 Config::default()
363 }
364
365 fn a(parts: &[&str]) -> Vec<String> {
366 parts.iter().map(|s| s.to_string()).collect()
367 }
368
369 fn mode(shim: &str, args: &[&str]) -> ExecMode {
370 classify(shim, &a(args), &cfg(), false, false, false).mode
371 }
372
373 fn is_opt(m: &ExecMode) -> bool {
374 matches!(m, Optimize { .. })
375 }
376
377 fn key(m: &ExecMode) -> String {
378 match m {
379 Optimize { command_key, .. } => command_key.clone(),
380 _ => String::new(),
381 }
382 }
383
384 #[test]
385 fn js_validation_optimized() {
386 assert!(is_opt(&mode("pnpm", &["test"])));
387 assert!(is_opt(&mode("pnpm", &["run", "test"])));
388 assert!(is_opt(&mode("npm", &["run", "lint"])));
389 assert!(is_opt(&mode("yarn", &["build"])));
390 assert!(is_opt(&mode("bun", &["test"])));
391 assert_eq!(key(&mode("pnpm", &["test"])), "validation:pnpm:test");
392 assert_eq!(
393 key(&mode("pnpm", &["run", "typecheck"])),
394 "validation:pnpm:run:typecheck"
395 );
396 }
397
398 #[test]
399 fn js_side_effect_commands_passthrough() {
400 assert!(!is_opt(&mode("pnpm", &["install"])));
401 assert!(!is_opt(&mode("npm", &["publish"])));
402 assert!(!is_opt(&mode("yarn", &["add", "react"])));
403 assert!(!is_opt(&mode("bun", &["add", "zod"])));
404 }
405
406 #[test]
407 fn watch_mode_passthrough() {
408 assert!(matches!(
409 mode("pnpm", &["test", "--watch"]),
410 Passthrough(R::Interactive)
411 ));
412 assert!(matches!(
413 mode("tsc", &["--watch"]),
414 Passthrough(R::Interactive)
415 ));
416 }
417
418 #[test]
419 fn git_readonly_optimized_mutating_passthrough() {
420 assert!(is_opt(&mode("git", &["diff"])));
421 assert!(is_opt(&mode("git", &["status"])));
422 assert!(is_opt(&mode("git", &["log"])));
423 assert!(is_opt(&mode("git", &["show"])));
424 assert_eq!(key(&mode("git", &["diff"])), "git:diff:default");
425
426 for sub in GIT_MUTATING {
427 assert!(
428 matches!(mode("git", &[sub]), Passthrough(R::MutatingGit)),
429 "git {sub} must pass through"
430 );
431 }
432 }
433
434 #[test]
435 fn extra_commands_classify_as_generic_validation() {
436 let mut cfg = Config::default();
437 cfg.intercept.extra = vec!["vitest".to_string()];
438
439 let c = classify(
441 "vitest",
442 &["run".to_string(), "--color=always".to_string()],
443 &cfg,
444 false,
445 false,
446 false,
447 );
448 match &c.mode {
449 Optimize {
450 family,
451 command_key,
452 } => {
453 assert_eq!(*family, Family::Validation);
454 assert_eq!(command_key, "validation:vitest:run");
455 }
456 other => panic!("expected Optimize, got {other:?}"),
457 }
458
459 let c = classify(
461 "vitest",
462 &["--watch".to_string()],
463 &cfg,
464 false,
465 false,
466 false,
467 );
468 assert!(matches!(c.mode, Passthrough(R::Interactive)));
469
470 let c = classify("randomtool", &[], &cfg, false, false, false);
472 assert!(matches!(
473 c.mode,
474 Passthrough(R::ConfigExcluded | R::UnknownShim)
475 ));
476
477 cfg.intercept.extra.push("git".to_string());
479 let c = classify("git", &["commit".to_string()], &cfg, false, false, false);
480 assert!(matches!(c.mode, Passthrough(R::MutatingGit)));
481 }
482
483 #[test]
484 fn git_machine_forms_passthrough_human_forms_optimize() {
485 for args in [
487 &["status", "--porcelain"][..],
488 &["status", "--porcelain=v2", "-z"][..],
489 &["status", "-s"][..],
490 &["status", "--short"][..],
491 &["diff", "--no-ext-diff", "--ignore-submodules"][..],
492 &["diff", "--name-only"][..],
493 &["diff", "--numstat"][..],
494 &["diff", "--name-status", "--diff-filter=ACM"][..],
495 &["log", "--oneline", "..@{upstream}"][..],
496 &["log", "-1", "--format=%H"][..],
497 &["log", "--pretty=format:%h %s"][..],
498 ] {
499 assert!(
500 matches!(mode("git", args), Passthrough(R::MachineReadable)),
501 "git {args:?} must pass through (machine-readable)"
502 );
503 }
504 assert!(is_opt(&mode("git", &["status"])));
506 assert!(is_opt(&mode("git", &["diff"])));
507 assert!(is_opt(&mode("git", &["diff", "HEAD~1"])));
508 assert!(is_opt(&mode("git", &["diff", "--stat"])));
509 assert!(is_opt(&mode("git", &["log", "--oneline", "-5"])));
510 assert!(is_opt(&mode("git", &["log", "--graph"])));
511 assert!(is_opt(&mode("git", &["-C", "/tmp/r", "status"])));
512 }
513
514 #[test]
515 fn git_global_options_before_subcommand() {
516 assert!(is_opt(&mode("git", &["-C", "/tmp/repo", "diff"])));
517 assert!(matches!(
518 mode("git", &["-C", "/tmp/repo", "commit"]),
519 Passthrough(R::MutatingGit)
520 ));
521 assert!(matches!(
522 mode("git", &["-c", "user.name=x", "push"]),
523 Passthrough(R::MutatingGit)
524 ));
525 }
526
527 #[test]
528 fn docker_logs_optimized_run_passthrough() {
529 assert!(is_opt(&mode("docker", &["logs", "api"])));
530 assert!(is_opt(&mode("docker", &["compose", "logs", "api"])));
531 assert!(matches!(
532 mode("docker", &["run", "img"]),
533 Passthrough(R::DangerousDocker)
534 ));
535 assert!(matches!(
536 mode("docker", &["compose", "up"]),
537 Passthrough(R::DangerousDocker)
538 ));
539 }
540
541 #[test]
542 fn find_dangerous_passthrough() {
543 assert!(is_opt(&mode("find", &[".", "-name", "*.ts"])));
544 assert!(matches!(
545 mode("find", &[".", "-delete"]),
546 Passthrough(R::SideEffecting)
547 ));
548 assert!(matches!(
549 mode("find", &[".", "-exec", "rm", "{}", ";"]),
550 Passthrough(R::SideEffecting)
551 ));
552 }
553
554 #[test]
555 fn eslint_fix_passthrough() {
556 assert!(is_opt(&mode("eslint", &["."])));
557 assert!(matches!(
558 mode("eslint", &[".", "--fix"]),
559 Passthrough(R::SideEffecting)
560 ));
561 }
562
563 #[test]
564 fn cargo_go_only_test_optimized() {
565 assert!(is_opt(&mode("cargo", &["test"])));
566 assert!(is_opt(&mode("cargo", &["+nightly", "test"])));
567 assert!(!is_opt(&mode("cargo", &["build"])));
568 assert!(is_opt(&mode("go", &["test", "./..."])));
569 assert!(!is_opt(&mode("go", &["build"])));
570 }
571
572 #[test]
573 fn noise_flags_excluded_from_key() {
574 assert_eq!(
575 key(&mode("rg", &["--color=always", "createSession", "src"])),
576 "search:rg:createSession:src"
577 );
578 }
579
580 #[test]
581 fn disabled_and_config_excluded() {
582 let disabled = classify("pnpm", &a(&["test"]), &cfg(), true, false, false).mode;
583 assert!(matches!(disabled, Passthrough(R::Disabled)));
584
585 let mut c = cfg();
586 c.intercept.git = false;
587 let excluded = classify("git", &a(&["diff"]), &c, false, false, false).mode;
588 assert!(matches!(excluded, Passthrough(R::ConfigExcluded)));
589 }
590}