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 _ => Passthrough(R::UnknownShim),
93 };
94 wrap(mode)
95}
96
97fn first_positional(args: &[String]) -> Option<(usize, &str)> {
99 args.iter()
100 .enumerate()
101 .find(|(_, a)| !a.starts_with('-'))
102 .map(|(i, a)| (i, a.as_str()))
103}
104
105fn classify_js(shim: &str, args: &[String]) -> ExecMode {
106 if has_watch_flag(args) {
107 return Passthrough(R::Interactive);
108 }
109 let Some((idx, sub)) = first_positional(args) else {
110 return Passthrough(R::UnsupportedSubcommand);
111 };
112
113 let (script, key_tokens): (&str, Vec<String>) = if sub == "run" {
114 match args[idx + 1..].iter().find(|a| !a.starts_with('-')) {
116 Some(s) => (s.as_str(), vec!["run".to_string(), s.clone()]),
117 None => return Passthrough(R::UnsupportedSubcommand),
118 }
119 } else {
120 (sub, vec![sub.to_string()])
121 };
122
123 if is_known_interactive(shim, Some(script)) {
124 return Passthrough(R::Interactive);
125 }
126 if JS_SCRIPTS.contains(&script) {
127 Optimize {
128 family: Family::Validation,
129 command_key: build_key(&format!("validation:{shim}"), &key_tokens),
130 }
131 } else {
132 Passthrough(R::UnsupportedSubcommand)
133 }
134}
135
136fn classify_tsc(args: &[String]) -> ExecMode {
137 if has_watch_flag(args) || args.iter().any(|a| a == "-w" || a == "--watch") {
138 return Passthrough(R::Interactive);
139 }
140 Optimize {
141 family: Family::Validation,
142 command_key: build_key("validation:tsc", &drop_noise(args)),
143 }
144}
145
146fn classify_eslint(args: &[String]) -> ExecMode {
147 if args.iter().any(|a| a == "--fix" || a == "--fix-dry-run") {
148 return Passthrough(R::SideEffecting);
149 }
150 if has_watch_flag(args) {
151 return Passthrough(R::Interactive);
152 }
153 Optimize {
154 family: Family::Validation,
155 command_key: build_key("validation:eslint", &drop_noise(args)),
156 }
157}
158
159fn classify_pytest(args: &[String]) -> ExecMode {
160 Optimize {
161 family: Family::Validation,
162 command_key: build_key("validation:pytest", &drop_noise(args)),
163 }
164}
165
166fn classify_cargo(args: &[String]) -> ExecMode {
167 let rest: &[String] = match args.first() {
169 Some(first) if first.starts_with('+') => &args[1..],
170 _ => args,
171 };
172 match first_positional(rest) {
173 Some((idx, "test")) => Optimize {
174 family: Family::Validation,
175 command_key: build_key("validation:cargo:test", &drop_noise(&rest[idx + 1..])),
176 },
177 _ => Passthrough(R::UnsupportedSubcommand),
178 }
179}
180
181fn classify_go(args: &[String]) -> ExecMode {
182 match first_positional(args) {
183 Some((idx, "test")) => Optimize {
184 family: Family::Validation,
185 command_key: build_key("validation:go:test", &drop_noise(&args[idx + 1..])),
186 },
187 _ => Passthrough(R::UnsupportedSubcommand),
188 }
189}
190
191fn classify_search(shim: &str, args: &[String]) -> ExecMode {
192 Optimize {
194 family: Family::Search,
195 command_key: build_key(&format!("search:{shim}"), &drop_noise(args)),
196 }
197}
198
199fn classify_find(args: &[String]) -> ExecMode {
200 if args.iter().any(|a| FIND_DANGEROUS.contains(&a.as_str())) {
201 return Passthrough(R::SideEffecting);
202 }
203 Optimize {
204 family: Family::Tree,
205 command_key: build_key("tree:find", &drop_noise(args)),
206 }
207}
208
209fn classify_listing(shim: &str, args: &[String]) -> ExecMode {
210 Optimize {
211 family: Family::Tree,
212 command_key: build_key(&format!("tree:{shim}"), &drop_noise(args)),
213 }
214}
215
216fn git_subcommand(args: &[String]) -> Option<(usize, &str)> {
219 const TAKES_VALUE: &[&str] = &[
220 "-C",
221 "-c",
222 "--git-dir",
223 "--work-tree",
224 "--namespace",
225 "--exec-path",
226 "--super-prefix",
227 ];
228 let mut i = 0;
229 while i < args.len() {
230 let a = &args[i];
231 if a == "--" {
232 i += 1;
233 continue;
234 }
235 if a.starts_with('-') {
236 if TAKES_VALUE.contains(&a.as_str()) {
237 i += 2; } else {
239 i += 1; }
241 continue;
242 }
243 return Some((i, a.as_str()));
244 }
245 None
246}
247
248fn classify_git(args: &[String]) -> ExecMode {
249 match git_subcommand(args) {
250 Some((idx, sub)) if GIT_READONLY.contains(&sub) => {
251 if is_machine_readable(&args[idx + 1..]) {
256 Passthrough(R::MachineReadable)
257 } else {
258 Optimize {
259 family: Family::GitReadonly,
260 command_key: build_key(&format!("git:{sub}"), &drop_noise(&args[idx + 1..])),
261 }
262 }
263 }
264 Some((_, sub)) if GIT_MUTATING.contains(&sub) => Passthrough(R::MutatingGit),
265 _ => Passthrough(R::UnsupportedSubcommand),
267 }
268}
269
270fn is_machine_readable(args: &[String]) -> bool {
277 args.iter().any(|a| {
278 matches!(
279 a.as_str(),
280 "-z" | "-s"
282 | "--short"
283 | "--name-only"
284 | "--name-status"
285 | "--numstat"
286 | "--raw"
287 | "--no-ext-diff"
288 ) || a.starts_with("--porcelain") || a.starts_with("--format") || a.starts_with("--pretty=format") || a.contains("@{u") })
293}
294
295fn docker_positional(args: &[String], from: usize) -> Option<(usize, &str)> {
297 const TAKES_VALUE: &[&str] = &[
298 "-H",
299 "--host",
300 "--context",
301 "--config",
302 "--log-level",
303 "-l",
304 "--tlscacert",
305 "--tlscert",
306 "--tlskey",
307 ];
308 let mut i = from;
309 while i < args.len() {
310 let a = &args[i];
311 if a.starts_with('-') {
312 if TAKES_VALUE.contains(&a.as_str()) {
313 i += 2;
314 } else {
315 i += 1;
316 }
317 continue;
318 }
319 return Some((i, a.as_str()));
320 }
321 None
322}
323
324fn classify_docker(args: &[String]) -> ExecMode {
325 match docker_positional(args, 0) {
326 Some((idx, "logs")) => Optimize {
327 family: Family::Logs,
328 command_key: build_key("logs:docker:logs", &drop_noise(&args[idx + 1..])),
329 },
330 Some((idx, "compose")) => match docker_positional(args, idx + 1) {
331 Some((jdx, "logs")) => Optimize {
332 family: Family::Logs,
333 command_key: build_key("logs:docker-compose:logs", &drop_noise(&args[jdx + 1..])),
334 },
335 _ => Passthrough(R::DangerousDocker),
336 },
337 _ => Passthrough(R::DangerousDocker),
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 fn cfg() -> Config {
346 Config::default()
347 }
348
349 fn a(parts: &[&str]) -> Vec<String> {
350 parts.iter().map(|s| s.to_string()).collect()
351 }
352
353 fn mode(shim: &str, args: &[&str]) -> ExecMode {
354 classify(shim, &a(args), &cfg(), false, false, false).mode
355 }
356
357 fn is_opt(m: &ExecMode) -> bool {
358 matches!(m, Optimize { .. })
359 }
360
361 fn key(m: &ExecMode) -> String {
362 match m {
363 Optimize { command_key, .. } => command_key.clone(),
364 _ => String::new(),
365 }
366 }
367
368 #[test]
369 fn js_validation_optimized() {
370 assert!(is_opt(&mode("pnpm", &["test"])));
371 assert!(is_opt(&mode("pnpm", &["run", "test"])));
372 assert!(is_opt(&mode("npm", &["run", "lint"])));
373 assert!(is_opt(&mode("yarn", &["build"])));
374 assert!(is_opt(&mode("bun", &["test"])));
375 assert_eq!(key(&mode("pnpm", &["test"])), "validation:pnpm:test");
376 assert_eq!(
377 key(&mode("pnpm", &["run", "typecheck"])),
378 "validation:pnpm:run:typecheck"
379 );
380 }
381
382 #[test]
383 fn js_side_effect_commands_passthrough() {
384 assert!(!is_opt(&mode("pnpm", &["install"])));
385 assert!(!is_opt(&mode("npm", &["publish"])));
386 assert!(!is_opt(&mode("yarn", &["add", "react"])));
387 assert!(!is_opt(&mode("bun", &["add", "zod"])));
388 }
389
390 #[test]
391 fn watch_mode_passthrough() {
392 assert!(matches!(
393 mode("pnpm", &["test", "--watch"]),
394 Passthrough(R::Interactive)
395 ));
396 assert!(matches!(
397 mode("tsc", &["--watch"]),
398 Passthrough(R::Interactive)
399 ));
400 }
401
402 #[test]
403 fn git_readonly_optimized_mutating_passthrough() {
404 assert!(is_opt(&mode("git", &["diff"])));
405 assert!(is_opt(&mode("git", &["status"])));
406 assert!(is_opt(&mode("git", &["log"])));
407 assert!(is_opt(&mode("git", &["show"])));
408 assert_eq!(key(&mode("git", &["diff"])), "git:diff:default");
409
410 for sub in GIT_MUTATING {
411 assert!(
412 matches!(mode("git", &[sub]), Passthrough(R::MutatingGit)),
413 "git {sub} must pass through"
414 );
415 }
416 }
417
418 #[test]
419 fn git_machine_forms_passthrough_human_forms_optimize() {
420 for args in [
422 &["status", "--porcelain"][..],
423 &["status", "--porcelain=v2", "-z"][..],
424 &["status", "-s"][..],
425 &["status", "--short"][..],
426 &["diff", "--no-ext-diff", "--ignore-submodules"][..],
427 &["diff", "--name-only"][..],
428 &["diff", "--numstat"][..],
429 &["diff", "--name-status", "--diff-filter=ACM"][..],
430 &["log", "--oneline", "..@{upstream}"][..],
431 &["log", "-1", "--format=%H"][..],
432 &["log", "--pretty=format:%h %s"][..],
433 ] {
434 assert!(
435 matches!(mode("git", args), Passthrough(R::MachineReadable)),
436 "git {args:?} must pass through (machine-readable)"
437 );
438 }
439 assert!(is_opt(&mode("git", &["status"])));
441 assert!(is_opt(&mode("git", &["diff"])));
442 assert!(is_opt(&mode("git", &["diff", "HEAD~1"])));
443 assert!(is_opt(&mode("git", &["diff", "--stat"])));
444 assert!(is_opt(&mode("git", &["log", "--oneline", "-5"])));
445 assert!(is_opt(&mode("git", &["log", "--graph"])));
446 assert!(is_opt(&mode("git", &["-C", "/tmp/r", "status"])));
447 }
448
449 #[test]
450 fn git_global_options_before_subcommand() {
451 assert!(is_opt(&mode("git", &["-C", "/tmp/repo", "diff"])));
452 assert!(matches!(
453 mode("git", &["-C", "/tmp/repo", "commit"]),
454 Passthrough(R::MutatingGit)
455 ));
456 assert!(matches!(
457 mode("git", &["-c", "user.name=x", "push"]),
458 Passthrough(R::MutatingGit)
459 ));
460 }
461
462 #[test]
463 fn docker_logs_optimized_run_passthrough() {
464 assert!(is_opt(&mode("docker", &["logs", "api"])));
465 assert!(is_opt(&mode("docker", &["compose", "logs", "api"])));
466 assert!(matches!(
467 mode("docker", &["run", "img"]),
468 Passthrough(R::DangerousDocker)
469 ));
470 assert!(matches!(
471 mode("docker", &["compose", "up"]),
472 Passthrough(R::DangerousDocker)
473 ));
474 }
475
476 #[test]
477 fn find_dangerous_passthrough() {
478 assert!(is_opt(&mode("find", &[".", "-name", "*.ts"])));
479 assert!(matches!(
480 mode("find", &[".", "-delete"]),
481 Passthrough(R::SideEffecting)
482 ));
483 assert!(matches!(
484 mode("find", &[".", "-exec", "rm", "{}", ";"]),
485 Passthrough(R::SideEffecting)
486 ));
487 }
488
489 #[test]
490 fn eslint_fix_passthrough() {
491 assert!(is_opt(&mode("eslint", &["."])));
492 assert!(matches!(
493 mode("eslint", &[".", "--fix"]),
494 Passthrough(R::SideEffecting)
495 ));
496 }
497
498 #[test]
499 fn cargo_go_only_test_optimized() {
500 assert!(is_opt(&mode("cargo", &["test"])));
501 assert!(is_opt(&mode("cargo", &["+nightly", "test"])));
502 assert!(!is_opt(&mode("cargo", &["build"])));
503 assert!(is_opt(&mode("go", &["test", "./..."])));
504 assert!(!is_opt(&mode("go", &["build"])));
505 }
506
507 #[test]
508 fn noise_flags_excluded_from_key() {
509 assert_eq!(
510 key(&mode("rg", &["--color=always", "createSession", "src"])),
511 "search:rg:createSession:src"
512 );
513 }
514
515 #[test]
516 fn disabled_and_config_excluded() {
517 let disabled = classify("pnpm", &a(&["test"]), &cfg(), true, false, false).mode;
518 assert!(matches!(disabled, Passthrough(R::Disabled)));
519
520 let mut c = cfg();
521 c.intercept.git = false;
522 let excluded = classify("git", &a(&["diff"]), &c, false, false, false).mode;
523 assert!(matches!(excluded, Passthrough(R::ConfigExcluded)));
524 }
525}