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        // User-elected `[intercept] extra` commands: generic validation
93        // treatment (builtins above always take precedence).
94        _ if cfg.intercept.is_extra(shim) => classify_extra(shim, args),
95        _ => Passthrough(R::UnknownShim),
96    };
97    wrap(mode)
98}
99
100/// A user-added command from `[intercept] extra`. Reduced generically as a
101/// validation-style command; the standard guards still apply (watch-mode
102/// passthrough here, plus the min-token floor and agent gating downstream).
103fn 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
113/// First token not starting with `-` (the subcommand), with its index.
114fn 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        // `pnpm run <script>` — the script is the next positional.
131        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    // Skip a leading `+toolchain` selector.
184    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    // Unparseable machine formats degrade to generic later; still safe to capture.
209    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
232/// The git subcommand, skipping global options (`git -C p status`, `git -c k=v
233/// commit`, etc.). Returns `(index, subcommand)`.
234fn 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; // separate-form value follows
254            } else {
255                i += 1; // flag, or `--opt=value` single token
256            }
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            // Machine-readable forms (`--porcelain`, `-z`, `@{upstream}` ranges,
268            // `--no-ext-diff`) are run by shell prompts and IDE SCM, which PARSE
269            // the output — reducing it would corrupt them. Pass those through and
270            // only optimize the human-readable forms an agent actually reads.
271            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        // Unknown or no subcommand → safe passthrough.
282        _ => Passthrough(R::UnsupportedSubcommand),
283    }
284}
285
286/// True when a git read-only invocation emits a stable, machine-parseable
287/// format that a program (shell prompt, IDE SCM, git hook, or an agent's own
288/// `$(...)`/pipe/xargs) consumes. Reducing those corrupts the parser, so they
289/// pass through untouched. This is a block-list and so necessarily incomplete;
290/// we err toward passthrough (a missed optimization is harmless, a corrupted
291/// parse is not), and the min-token floor is the final backstop.
292fn is_machine_readable(args: &[String]) -> bool {
293    args.iter().any(|a| {
294        matches!(
295            a.as_str(),
296            // stable columnar / scripting formats
297            "-z" | "-s"
298                | "--short"
299                | "--name-only"
300                | "--name-status"
301                | "--numstat"
302                | "--raw"
303                | "--no-ext-diff"
304        ) || a.starts_with("--porcelain")      // --porcelain, =v1, =v2
305            || a.starts_with("--format")        // git log/show custom format
306            || a.starts_with("--pretty=format") // --pretty=format:%H (machine)
307            || a.contains("@{u") // @{u} / @{upstream} ahead-behind ranges
308    })
309}
310
311/// First positional for docker, skipping global options that take a value.
312fn 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        // Optimized with a validation key.
440        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        // Watch mode passes through.
460        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        // Not in extra -> excluded by the config gate (never optimized).
471        let c = classify("randomtool", &[], &cfg, false, false, false);
472        assert!(matches!(
473            c.mode,
474            Passthrough(R::ConfigExcluded | R::UnknownShim)
475        ));
476
477        // A builtin listed in extra keeps its specialized policy.
478        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        // Prompt / IDE SCM / scripting forms — parsed by programs, must pass through.
486        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        // Human/agent forms — still optimized.
505        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}