Skip to main content

dejavu/exec/
classify.rs

1//! Command classification and passthrough policy (spec §11).
2//!
3//! Default is passthrough. A command is optimized only when the shim is known,
4//! the family is supported, the args match a whitelist, it is not interactive,
5//! Dejavu is not disabled, and config allows it. Anything ambiguous — in
6//! particular any git subcommand not provably read-only — passes through.
7
8use 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
21/// Read-only git subcommands that may be optimized.
22const GIT_READONLY: &[&str] = &["status", "diff", "log", "show"];
23/// Git subcommands that must always pass through (spec §10.7).
24const 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];
49/// JS package-manager scripts we optimize.
50const JS_SCRIPTS: &[&str] = &["test", "lint", "typecheck", "build"];
51/// `find` primaries that cause side effects.
52const 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
97/// First token not starting with `-` (the subcommand), with its index.
98fn 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        // `pnpm run <script>` — the script is the next positional.
115        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    // Skip a leading `+toolchain` selector.
168    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    // Unparseable machine formats degrade to generic later; still safe to capture.
193    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
216/// The git subcommand, skipping global options (`git -C p status`, `git -c k=v
217/// commit`, etc.). Returns `(index, subcommand)`.
218fn 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; // separate-form value follows
238            } else {
239                i += 1; // flag, or `--opt=value` single token
240            }
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            // Machine-readable forms (`--porcelain`, `-z`, `@{upstream}` ranges,
252            // `--no-ext-diff`) are run by shell prompts and IDE SCM, which PARSE
253            // the output — reducing it would corrupt them. Pass those through and
254            // only optimize the human-readable forms an agent actually reads.
255            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        // Unknown or no subcommand → safe passthrough.
266        _ => Passthrough(R::UnsupportedSubcommand),
267    }
268}
269
270/// True when a git read-only invocation emits a stable, machine-parseable
271/// format that a program (shell prompt, IDE SCM, git hook, or an agent's own
272/// `$(...)`/pipe/xargs) consumes. Reducing those corrupts the parser, so they
273/// pass through untouched. This is a block-list and so necessarily incomplete;
274/// we err toward passthrough (a missed optimization is harmless, a corrupted
275/// parse is not), and the min-token floor is the final backstop.
276fn is_machine_readable(args: &[String]) -> bool {
277    args.iter().any(|a| {
278        matches!(
279            a.as_str(),
280            // stable columnar / scripting formats
281            "-z" | "-s"
282                | "--short"
283                | "--name-only"
284                | "--name-status"
285                | "--numstat"
286                | "--raw"
287                | "--no-ext-diff"
288        ) || a.starts_with("--porcelain")      // --porcelain, =v1, =v2
289            || a.starts_with("--format")        // git log/show custom format
290            || a.starts_with("--pretty=format") // --pretty=format:%H (machine)
291            || a.contains("@{u") // @{u} / @{upstream} ahead-behind ranges
292    })
293}
294
295/// First positional for docker, skipping global options that take a value.
296fn 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        // Prompt / IDE SCM / scripting forms — parsed by programs, must pass through.
421        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        // Human/agent forms — still optimized.
440        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}