Skip to main content

bash_ast/
tool_invocation.rs

1//! `ToolInvocation` extraction from a [`PeeledCommand`] (R295-F1).
2//!
3//! Extracts a structured [`ToolInvocation`] once the peeled command's
4//! basename is matched against a registered [`ToolLookup`]. The schema
5//! (global-flag specs, max subcommand depth) lives in [`ToolSchemaInfo`],
6//! which the full policy types in `agent_tools::tool_schema` embed.
7//!
8//! Dependency direction is intentionally one-way:
9//! - `bash-ast` (this crate) defines extraction + the lookup trait.
10//! - `agent-tools` defines policy (`ApprovalTier`, `ToolSchema`) and
11//!   implements `ToolLookup` on its `ToolRegistry`.
12//! No circular deps arise.
13//!
14//! @arch:see(.yah/docs/working/W086-git-subcommand-gate.md)
15//!
16//! @yah:ticket(R295-F7, "bash-ast: arg provenance + path scope + pipe-edge facts")
17//! @yah:assignee(agent:miravel)
18//! @yah:at(2026-05-25T23:42:54Z)
19//! @yah:status(review)
20//! @yah:parent(R295)
21//! @yah:next("Add pure fns over the existing tree: arg_provenance(&CommandArgument) -> Provenance (Literal from Word; Variable from SimpleExpansion/Expansion; CommandSub from CommandSubstitution; ProcessSub from ProcessSubstitution; Glob when a Word contains unquoted */?/[) and path_scope(literal, camp_root) -> PathScope (WithinCamp/OutsideCamp/System/Unresolvable via canonicalization against camp root). See addendum table in the design doc.")
22//! @yah:next("Add a pipe-edge stamping helper: when walking a Pipeline, expose each stage's upstream/downstream tool basename so the floor walker can populate CommandFact.pipe_into/pipe_from.")
23//! @yah:verify("cargo test -p bash-ast # provenance per node-kind + path_scope buckets (within/outside/system/unresolvable) + glob detection")
24//! @arch:see(.yah/docs/working/W086-git-subcommand-gate.md)
25//! @yah:handoff("Shipped. bash-ast/src/tool_invocation.rs grew three public families:\n(1) Provenance enum (Literal/Glob/Variable/ProcessSub/CommandSub) with Ord-derived severity order; arg_provenance(&CommandArgument)->Provenance pure fn; arg_fact(&CommandArgument, &Path)->ArgFact combines provenance + path_scope.\n(2) PathScope enum (WithinCamp/Unresolvable/OutsideCamp/System); path_scope(literal, camp_root) pure fn — no disk I/O, static `..`-escape analysis via escapes_camp().\n(3) pipeline_stage_basenames(&Pipeline)->Vec<Option<String>> pipe-edge helper — peels each stage via peel_simple_command_loose, returns lowercased tool basenames for all stages (None for subshells/control-flow stages).\nPrivate helpers: primary_provenance, string_parts_provenance (fold over StringPart variants), arg_raw_text/primary_raw_text/string_parts_text (display), is_system_path (SYSTEM_DIR_PREFIXES const), looks_like_path, escapes_camp, stmt_basename.\n38 new tests: 14 provenance, 16 path_scope, 5 pipeline_stage_basenames + 2 existing extraction tests untouched. cargo test -p bash-ast: 69/69 green, no warnings.")
26//! @yah:verify("cargo test -p bash-ast tool_invocation # 49/49 green")
27
28use std::path::Path;
29
30use serde::{Deserialize, Serialize};
31
32use crate::ast::{CommandArgument, Pipeline, PrimaryExpression, SimpleExpansionElement, Statement, StringPart};
33use crate::wrappers::{peel_simple_command_loose, PeeledCommand};
34
35// ---------- GlobalFlagSpec ----------
36
37/// Describes how a single global flag (one that appears between the tool name
38/// and the subcommand chain) should be consumed during extraction.
39///
40/// Global flags in subcommand-oriented tools precede the subcommand: for
41/// `git -C /tmp stash list`, `-C /tmp` are global flags consumed before
42/// `stash list`. This spec tells the extractor how many tokens to consume.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(tag = "kind", rename_all = "snake_case")]
45pub enum GlobalFlagSpec {
46    /// A bare flag that takes no following value.
47    /// E.g. `--bare`, `-p`, `--paginate`, `--no-pager`.
48    Bare { flag: String },
49    /// A flag that carries a value either via `=` (attached: `--git-dir=/tmp`)
50    /// or a separate next token (`--git-dir /tmp`, `-C /tmp`).
51    /// The extractor consumes one extra token when there is no `=`.
52    TakesValue { flag: String },
53}
54
55impl GlobalFlagSpec {
56    pub fn flag(&self) -> &str {
57        match self {
58            Self::Bare { flag } | Self::TakesValue { flag } => flag,
59        }
60    }
61}
62
63// ---------- ToolSchemaInfo ----------
64
65/// Minimal schema info needed for [`tool_invocation_of`].
66///
67/// This is the intersection of what extraction needs — the full policy
68/// (tier table, leaf-flag escalation) lives in `agent_tools::tool_schema`.
69/// Returned by [`ToolLookup::lookup`]; cheap to clone since schemas are
70/// small static data.
71#[derive(Debug, Clone)]
72pub struct ToolSchemaInfo {
73    /// Lowercase basename of the tool (e.g. `"git"`, `"gh"`).
74    pub tool: String,
75    /// Global flags to peel before the subcommand chain.
76    pub global_flags: Vec<GlobalFlagSpec>,
77    /// Maximum consecutive non-flag positionals to consume as the subcommand
78    /// chain. `1` for flat tools (cargo, npm); `2` for git (`stash list`),
79    /// docker (`container rm`); `3` for aws (`s3api put-object`).
80    pub max_depth: u8,
81}
82
83// ---------- ToolLookup ----------
84
85/// Abstraction over a tool-schema registry. [`tool_invocation_of`] calls
86/// this to resolve the basename of the peeled primary to a schema.
87///
88/// Implementors: `agent_tools::tool_schema::ToolRegistry` (production),
89/// and the `MockRegistry` in the tests below.
90pub trait ToolLookup {
91    /// Return the schema info for `basename` (already lowercase-stripped),
92    /// or `None` if the tool is not registered.
93    fn lookup(&self, basename: &str) -> Option<ToolSchemaInfo>;
94}
95
96// ---------- ToolInvocation ----------
97
98/// Structured representation of a single tool invocation extracted from a
99/// [`PeeledCommand`]. All string fields borrow from the source `PeeledCommand`
100/// to avoid heap allocation in the hot approval-gate path.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ToolInvocation<'a> {
103    /// Lowercase basename of the matched tool (e.g. `"git"`).
104    /// Borrows from [`PeeledCommand::primary`].
105    pub tool: &'a str,
106    /// The subcommand chain, up to `max_depth` non-flag positionals after the
107    /// global flags. E.g. `["stash", "list"]` for `git -C /tmp stash list`.
108    pub subcommand: Vec<&'a str>,
109    /// Non-flag positionals after the subcommand chain (the "rest" args like
110    /// branch names, file paths, refs). E.g. `["origin", "main"]` for
111    /// `git push origin main`.
112    pub rest: Vec<&'a str>,
113    /// Raw tokens stripped as global flags (including value tokens for
114    /// `TakesValue` specs). E.g. `["-C", "/tmp"]` for `git -C /tmp …`.
115    pub global_flags: Vec<&'a str>,
116    /// Flag tokens appearing after the subcommand chain, used by F2's
117    /// leaf-flag escalation (e.g. `"--hard"` in `git reset --hard HEAD`).
118    pub leaf_flags: Vec<&'a str>,
119}
120
121// ---------- tool_invocation_of ----------
122
123/// Extract a [`ToolInvocation`] from `peeled`, looking the primary up in
124/// `registry`. Returns `None` when the primary's basename is not registered.
125///
126/// `peeled` must have had wrappers stripped via [`crate::wrappers::peel_command`]
127/// or one of its siblings. Variable-primary commands (e.g. `$TOOL status`)
128/// cause `peel_command` to return `None`, so they never reach this function.
129pub fn tool_invocation_of<'a>(
130    peeled: &'a PeeledCommand,
131    registry: &impl ToolLookup,
132) -> Option<ToolInvocation<'a>> {
133    let basename = tool_basename(&peeled.primary);
134    let info = registry.lookup(basename)?;
135
136    let args = &peeled.args;
137    let mut i = 0usize;
138
139    // --- 1. Peel global flags ---
140    let mut global_flags: Vec<&'a str> = Vec::new();
141    while i < args.len() {
142        let arg: &'a str = args[i].as_str();
143        if !arg.starts_with('-') {
144            break; // first non-flag positional starts the subcommand chain
145        }
146        match find_global_flag(arg, &info.global_flags) {
147            Some(GlobalFlagSpec::Bare { .. }) => {
148                global_flags.push(arg);
149                i += 1;
150            }
151            Some(GlobalFlagSpec::TakesValue { flag }) => {
152                global_flags.push(arg);
153                i += 1;
154                // If arg is exactly the flag name (no attached `=value`), eat
155                // the next token as the value.
156                if arg == flag.as_str() {
157                    if let Some(value) = args.get(i) {
158                        global_flags.push(value.as_str());
159                        i += 1;
160                    }
161                }
162                // If arg.starts_with(flag + "="), the value is attached; already consumed.
163            }
164            None => break, // unrecognised flag — stop global peeling
165        }
166    }
167
168    // --- 2. Consume subcommand chain ---
169    let mut subcommand: Vec<&'a str> = Vec::new();
170    while i < args.len() && subcommand.len() < info.max_depth as usize {
171        let arg: &'a str = args[i].as_str();
172        if arg.starts_with('-') {
173            break; // flag interrupts the subcommand chain
174        }
175        subcommand.push(arg);
176        i += 1;
177    }
178
179    // --- 3. Split remaining into leaf_flags and rest ---
180    let mut leaf_flags: Vec<&'a str> = Vec::new();
181    let mut rest: Vec<&'a str> = Vec::new();
182    for arg in &args[i..] {
183        if arg.starts_with('-') {
184            leaf_flags.push(arg.as_str());
185        } else {
186            rest.push(arg.as_str());
187        }
188    }
189
190    Some(ToolInvocation {
191        tool: basename,
192        subcommand,
193        rest,
194        global_flags,
195        leaf_flags,
196    })
197}
198
199// ---------- Provenance ----------
200
201/// How statically bounded a command argument's value is.
202///
203/// Derived purely from the tree-sitter-bash AST node kind of the argument —
204/// no heuristics, no disk I/O. Controls whether [`path_scope`] is computable
205/// and feeds `every_arg`/`any_arg` quantifiers in the policy DSL (R295-F9).
206///
207/// Ordinal order is severity: `CommandSub` > `ProcessSub` > `Variable` > `Glob`
208/// > `Literal`. The `Ord` impl enables the fold in [`arg_provenance`].
209#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
210pub enum Provenance {
211    /// Fully static — value known at parse time (`Word`, `RawString`, `Number`, …).
212    Literal,
213    /// Unquoted `Word` containing `*`, `?`, or `[` — expands to an unknown set of paths.
214    Glob,
215    /// `$VAR`, `${…}`, `$((…))` — value unknown at gate time.
216    Variable,
217    /// `<(cmd)` process substitution — data flows from a subprocess file descriptor.
218    ProcessSub,
219    /// `$(cmd)` command substitution — injection point; result may contain arbitrary text.
220    CommandSub,
221}
222
223// ---------- PathScope ----------
224
225/// How far a literal-provenance argument reaches relative to `camp_root`.
226///
227/// Only computable when [`Provenance`] is `Literal`; [`arg_fact`] sets
228/// `ArgFact::scope` to `None` for all other provenances.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
230pub enum PathScope {
231    /// Resolves to a path at or under `camp_root` (safe for read operations).
232    WithinCamp,
233    /// Non-path argument (git ref, flag value, …) or relative with no static
234    /// anchor — cannot determine scope statically.
235    Unresolvable,
236    /// Absolute path outside `camp_root`, or relative path that escapes via `..`.
237    OutsideCamp,
238    /// Under a well-known OS system directory: `/etc`, `/usr`, `/bin`, `/sbin`,
239    /// `/lib`, `/var`, `/dev`, `/proc`, `/sys`, `/boot`, `/root`, `/run`.
240    System,
241}
242
243// ---------- ArgFact ----------
244
245/// Per-argument provenance and path scope derived from the raw AST node.
246///
247/// Built by [`arg_fact`]; consumed by the floor walker (R295-F8) when
248/// assembling `CommandFact.args`.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct ArgFact {
251    /// Best-effort text representation (for display and DSL `raw:` matching).
252    pub raw: String,
253    /// How statically bounded this argument is.
254    pub provenance: Provenance,
255    /// `Some(_)` iff `provenance == Literal`. `None` otherwise.
256    pub scope: Option<PathScope>,
257}
258
259// ---------- arg_provenance ----------
260
261/// Classify the provenance of a [`CommandArgument`] from the parse tree.
262///
263/// Pure — no disk I/O. Useful as a standalone predicate when `scope` is not
264/// needed; see [`arg_fact`] for the full `(provenance, scope)` pair.
265pub fn arg_provenance(arg: &CommandArgument) -> Provenance {
266    match arg {
267        CommandArgument::Primary(p) => primary_provenance(p),
268        CommandArgument::Concatenation(c) => {
269            c.parts.iter().map(primary_provenance).fold(Provenance::Literal, Ord::max)
270        }
271        CommandArgument::Regex(_) | CommandArgument::Operator { .. } => Provenance::Literal,
272    }
273}
274
275/// Build an [`ArgFact`] for a single command argument.
276///
277/// `camp_root` is used to compute [`PathScope`] for literal arguments;
278/// other provenances get `scope: None`.
279pub fn arg_fact(arg: &CommandArgument, camp_root: &Path) -> ArgFact {
280    let raw = arg_raw_text(arg);
281    let provenance = arg_provenance(arg);
282    let scope = if provenance == Provenance::Literal {
283        Some(path_scope(&raw, camp_root))
284    } else {
285        None
286    };
287    ArgFact { raw, provenance, scope }
288}
289
290// ---------- path_scope ----------
291
292/// Classify a literal argument string by how far it can reach relative to
293/// `camp_root`. Pure — no disk I/O; uses static path analysis only.
294///
295/// Call this only when `arg_provenance` returned `Literal`; the result is
296/// meaningless for variable/glob/substitution arguments.
297pub fn path_scope(literal: &str, camp_root: &Path) -> PathScope {
298    if literal.is_empty() {
299        return PathScope::Unresolvable;
300    }
301    let p = Path::new(literal);
302    if p.is_absolute() {
303        return if is_system_path(literal) {
304            PathScope::System
305        } else if p.starts_with(camp_root) {
306            PathScope::WithinCamp
307        } else {
308            PathScope::OutsideCamp
309        };
310    }
311    // Tilde-relative → home dir, which is outside any camp.
312    if literal.starts_with('~') {
313        return PathScope::OutsideCamp;
314    }
315    // Non-path-looking args (git refs, flag values, remote names, …).
316    if !looks_like_path(literal) {
317        return PathScope::Unresolvable;
318    }
319    // Relative path — check statically whether `..` components escape the camp.
320    if escapes_camp(p) {
321        PathScope::OutsideCamp
322    } else {
323        PathScope::WithinCamp
324    }
325}
326
327// ---------- pipeline_stage_basenames ----------
328
329/// For each stage of `pipeline`, resolve the lowercased tool basename after
330/// peeling wrappers. `None` for stages that are not simple commands or that
331/// cannot be peeled (subshells, control flow, …).
332///
333/// The floor walker (R295-F8) uses this to stamp `CommandFact::pipe_into` /
334/// `CommandFact::pipe_from` when building approval manifests.
335pub fn pipeline_stage_basenames(pipeline: &Pipeline) -> Vec<Option<String>> {
336    pipeline.stages.iter().map(stmt_basename).collect()
337}
338
339// ---------- private helpers ----------
340
341/// Return a sub-slice of `primary` that is the basename after the last `/`.
342/// `/usr/bin/git` → `"git"`, `"git"` → `"git"`.
343fn tool_basename(primary: &str) -> &str {
344    primary.rsplit('/').next().unwrap_or(primary)
345}
346
347/// Find the first `GlobalFlagSpec` in `specs` that matches `arg`.
348///
349/// Matches `arg` exactly for `Bare` specs. For `TakesValue` specs, also
350/// matches the `--flag=value` form (arg starts with flag immediately
351/// followed by `=`).
352fn find_global_flag<'s>(arg: &str, specs: &'s [GlobalFlagSpec]) -> Option<&'s GlobalFlagSpec> {
353    for spec in specs {
354        let flag = spec.flag();
355        if arg == flag {
356            return Some(spec);
357        }
358        if matches!(spec, GlobalFlagSpec::TakesValue { .. }) {
359            // --flag=value: flag must be an exact prefix before '='
360            if arg.starts_with(flag) && arg.as_bytes().get(flag.len()) == Some(&b'=') {
361                return Some(spec);
362            }
363        }
364    }
365    None
366}
367
368fn primary_provenance(p: &PrimaryExpression) -> Provenance {
369    match p {
370        PrimaryExpression::Word(w) => {
371            if w.text.contains(['*', '?', '[']) {
372                Provenance::Glob
373            } else {
374                Provenance::Literal
375            }
376        }
377        PrimaryExpression::RawString(_)
378        | PrimaryExpression::Number(_)
379        | PrimaryExpression::AnsiCString(_) => Provenance::Literal,
380        PrimaryExpression::StringNode(s) => string_parts_provenance(&s.parts),
381        PrimaryExpression::TranslatedString(t) => string_parts_provenance(&t.parts),
382        PrimaryExpression::SimpleExpansion(_)
383        | PrimaryExpression::Expansion(_)
384        | PrimaryExpression::ArithmeticExpansion(_) => Provenance::Variable,
385        PrimaryExpression::CommandSubstitution(_) => Provenance::CommandSub,
386        PrimaryExpression::ProcessSubstitution(_) => Provenance::ProcessSub,
387        // Brace expansion e.g. {a,b,c} — expands to a set of words.
388        PrimaryExpression::BraceExpression(_) => Provenance::Glob,
389    }
390}
391
392fn string_parts_provenance(parts: &[StringPart]) -> Provenance {
393    parts
394        .iter()
395        .map(|part| match part {
396            StringPart::Content(_) | StringPart::Raw { .. } => Provenance::Literal,
397            StringPart::SimpleExpansion(_)
398            | StringPart::Expansion(_)
399            | StringPart::ArithmeticExpansion(_) => Provenance::Variable,
400            StringPart::CommandSubstitution(_) => Provenance::CommandSub,
401        })
402        .fold(Provenance::Literal, Ord::max)
403}
404
405pub fn arg_raw_text(arg: &CommandArgument) -> String {
406    match arg {
407        CommandArgument::Primary(p) => primary_raw_text(p),
408        CommandArgument::Concatenation(c) => {
409            c.parts.iter().map(primary_raw_text).collect()
410        }
411        CommandArgument::Regex(r) => r.text.clone(),
412        CommandArgument::Operator { text, .. } => text.clone(),
413    }
414}
415
416fn primary_raw_text(p: &PrimaryExpression) -> String {
417    match p {
418        PrimaryExpression::Word(w) => w.text.clone(),
419        PrimaryExpression::RawString(r) => r.text.trim_matches('\'').to_owned(),
420        PrimaryExpression::Number(n) => n.text.clone(),
421        PrimaryExpression::AnsiCString(a) => a.text.clone(),
422        PrimaryExpression::BraceExpression(b) => b.text.clone(),
423        PrimaryExpression::StringNode(s) => string_parts_text(&s.parts),
424        PrimaryExpression::TranslatedString(t) => string_parts_text(&t.parts),
425        PrimaryExpression::SimpleExpansion(se) => match &se.element {
426            SimpleExpansionElement::VariableName(v) => format!("${}", v.text),
427            SimpleExpansionElement::SpecialVariableName(s) => format!("${}", s.text),
428        },
429        PrimaryExpression::Expansion(_) => "${…}".to_owned(),
430        PrimaryExpression::CommandSubstitution(_) => "$(…)".to_owned(),
431        PrimaryExpression::ProcessSubstitution(_) => "<(…)".to_owned(),
432        PrimaryExpression::ArithmeticExpansion(_) => "$((…))".to_owned(),
433    }
434}
435
436fn string_parts_text(parts: &[StringPart]) -> String {
437    parts
438        .iter()
439        .map(|part| match part {
440            StringPart::Content(c) => c.text.clone(),
441            StringPart::Raw { text, .. } => text.clone(),
442            StringPart::SimpleExpansion(se) => match &se.element {
443                SimpleExpansionElement::VariableName(v) => format!("${}", v.text),
444                SimpleExpansionElement::SpecialVariableName(s) => format!("${}", s.text),
445            },
446            StringPart::Expansion(_) => "${…}".to_owned(),
447            StringPart::CommandSubstitution(_) => "$(…)".to_owned(),
448            StringPart::ArithmeticExpansion(_) => "$((…))".to_owned(),
449        })
450        .collect()
451}
452
453const SYSTEM_DIR_PREFIXES: &[&str] = &[
454    "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64",
455    "/var", "/dev", "/proc", "/sys", "/boot", "/root", "/run", "/snap",
456];
457
458fn is_system_path(literal: &str) -> bool {
459    if literal == "/" {
460        return true;
461    }
462    SYSTEM_DIR_PREFIXES.iter().any(|dir| {
463        literal == *dir || literal.starts_with(&format!("{}/", dir))
464    })
465}
466
467fn looks_like_path(literal: &str) -> bool {
468    literal.contains('/') || literal.starts_with('.') || literal.starts_with('~')
469}
470
471/// Returns `true` if the relative path `p` escapes the camp root via `..`.
472///
473/// Simulates resolving `p` from the camp root by tracking directory depth.
474/// If depth ever goes negative, the path escapes.
475fn escapes_camp(p: &Path) -> bool {
476    let mut depth: i32 = 0;
477    for component in p.components() {
478        match component {
479            std::path::Component::ParentDir => {
480                depth -= 1;
481                if depth < 0 {
482                    return true;
483                }
484            }
485            std::path::Component::Normal(_) => depth += 1,
486            std::path::Component::CurDir | std::path::Component::RootDir => {}
487            std::path::Component::Prefix(_) => {}
488        }
489    }
490    false
491}
492
493fn stmt_basename(stmt: &Statement) -> Option<String> {
494    let cmd = match stmt {
495        Statement::Command(c) => c,
496        Statement::Redirected(r) => match r.body.as_deref() {
497            Some(Statement::Command(c)) => c,
498            _ => return None,
499        },
500        _ => return None,
501    };
502    peel_simple_command_loose(cmd).map(|p| tool_basename(&p.primary).to_lowercase())
503}
504
505// ---------- tests ----------
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::{parse_to_ast, wrappers::peel_command};
511
512    /// Minimal mock registry — enough peeling info for a fake tool named
513    /// "mock" with max_depth=2. No policy; that lives in agent-tools.
514    struct MockRegistry {
515        schemas: Vec<ToolSchemaInfo>,
516    }
517
518    impl ToolLookup for MockRegistry {
519        fn lookup(&self, basename: &str) -> Option<ToolSchemaInfo> {
520            let key = basename.to_lowercase();
521            self.schemas.iter().find(|s| s.tool == key).cloned()
522        }
523    }
524
525    fn mock_registry() -> MockRegistry {
526        MockRegistry {
527            schemas: vec![ToolSchemaInfo {
528                tool: "mock".to_string(),
529                global_flags: vec![
530                    GlobalFlagSpec::Bare { flag: "--bare".to_string() },
531                    GlobalFlagSpec::TakesValue { flag: "-X".to_string() },
532                    GlobalFlagSpec::TakesValue { flag: "--dir".to_string() },
533                ],
534                max_depth: 2,
535            }],
536        }
537    }
538
539    /// Parse + peel `src`, then extract via `registry`. Returns five owned
540    /// vecs (tool, subcommand, rest, global_flags, leaf_flags) so lifetime
541    /// issues with the locally-owned `PeeledCommand` don't escape the helper.
542    fn extract(
543        src: &str,
544        registry: &MockRegistry,
545    ) -> Option<(String, Vec<String>, Vec<String>, Vec<String>, Vec<String>)> {
546        let prog = parse_to_ast(src).ok()?;
547        let peeled = peel_command(&prog)?;
548        let inv = tool_invocation_of(&peeled, registry)?;
549        Some((
550            inv.tool.to_owned(),
551            inv.subcommand.iter().map(|s| s.to_string()).collect(),
552            inv.rest.iter().map(|s| s.to_string()).collect(),
553            inv.global_flags.iter().map(|s| s.to_string()).collect(),
554            inv.leaf_flags.iter().map(|s| s.to_string()).collect(),
555        ))
556    }
557
558    #[test]
559    fn bare_invocation() {
560        let reg = mock_registry();
561        let (tool, subcmd, rest, gflags, lflags) = extract("mock sub1", &reg).unwrap();
562        assert_eq!(tool, "mock");
563        assert_eq!(subcmd, ["sub1"]);
564        assert!(rest.is_empty());
565        assert!(gflags.is_empty());
566        assert!(lflags.is_empty());
567    }
568
569    #[test]
570    fn dash_flag_with_attached_value() {
571        // TakesValue with = form — one token consumed
572        let reg = mock_registry();
573        let (_, subcmd, _, gflags, _) = extract("mock --dir=/tmp sub1", &reg).unwrap();
574        assert_eq!(gflags, ["--dir=/tmp"]);
575        assert_eq!(subcmd, ["sub1"]);
576    }
577
578    #[test]
579    fn dash_dash_flag_equals_with_space() {
580        // TakesValue with separate token — two tokens consumed
581        let reg = mock_registry();
582        let (_, subcmd, _, gflags, _) = extract("mock --dir /tmp sub1", &reg).unwrap();
583        assert_eq!(gflags, ["--dir", "/tmp"]);
584        assert_eq!(subcmd, ["sub1"]);
585    }
586
587    #[test]
588    fn short_flag_takes_value() {
589        let reg = mock_registry();
590        let (_, subcmd, _, gflags, _) = extract("mock -X value sub1", &reg).unwrap();
591        assert_eq!(gflags, ["-X", "value"]);
592        assert_eq!(subcmd, ["sub1"]);
593    }
594
595    #[test]
596    fn bare_global_flag() {
597        let reg = mock_registry();
598        let (_, subcmd, _, gflags, _) = extract("mock --bare sub1", &reg).unwrap();
599        assert_eq!(gflags, ["--bare"]);
600        assert_eq!(subcmd, ["sub1"]);
601    }
602
603    #[test]
604    fn deepest_chain_the_mock_schema_declares() {
605        // max_depth=2 → consumes sub1 + sub2; extra positional goes to rest
606        let reg = mock_registry();
607        let (_, subcmd, rest, _, lflags) =
608            extract("mock sub1 sub2 extra --leaf-flag", &reg).unwrap();
609        assert_eq!(subcmd, ["sub1", "sub2"]);
610        assert_eq!(rest, ["extra"]);
611        assert_eq!(lflags, ["--leaf-flag"]);
612    }
613
614    #[test]
615    fn leaf_flags_after_subcommand() {
616        let reg = mock_registry();
617        let (_, subcmd, rest, _, lflags) = extract("mock sub1 --opt target", &reg).unwrap();
618        assert_eq!(subcmd, ["sub1"]);
619        assert_eq!(rest, ["target"]);
620        assert_eq!(lflags, ["--opt"]);
621    }
622
623    #[test]
624    fn absolute_path_primary() {
625        // /usr/bin/mock should match the "mock" schema
626        let reg = mock_registry();
627        let (tool, subcmd, _, _, _) = extract("/usr/bin/mock sub1", &reg).unwrap();
628        assert_eq!(tool, "mock");
629        assert_eq!(subcmd, ["sub1"]);
630    }
631
632    #[test]
633    fn unknown_primary_returns_none() {
634        let reg = mock_registry();
635        let prog = parse_to_ast("notregistered status").expect("parse");
636        let peeled = peel_command(&prog).expect("peel");
637        assert!(tool_invocation_of(&peeled, &reg).is_none());
638    }
639
640    #[test]
641    fn variable_primary_returns_none() {
642        // peel_command refuses variable primaries — so no PeeledCommand exists
643        // to feed into tool_invocation_of. This test exercises the composition.
644        let prog = parse_to_ast("$TOOL status").expect("parse");
645        assert!(
646            peel_command(&prog).is_none(),
647            "peel_command should return None for variable primary"
648        );
649    }
650
651    #[test]
652    fn global_flag_stops_at_unknown_flag() {
653        // An unrecognised flag stops global peeling. Since it starts with '-'
654        // it also blocks the subcommand chain (which only consumes non-flag
655        // positionals). Both the unknown flag and the trailing positional end
656        // up in the remaining bucket: flag → leaf_flags, positional → rest.
657        let reg = mock_registry();
658        let (_, subcmd, rest, gflags, lflags) =
659            extract("mock --unknown-flag sub1", &reg).unwrap();
660        assert!(gflags.is_empty());
661        assert!(subcmd.is_empty());
662        assert_eq!(lflags, ["--unknown-flag"]);
663        assert_eq!(rest, ["sub1"]);
664    }
665
666    // ===== arg_provenance tests =====
667
668    fn first_arg_provenance(src: &str) -> Provenance {
669        let prog = parse_to_ast(src).expect("parse");
670        match &prog.statements[0] {
671            crate::ast::Statement::Command(c) => arg_provenance(&c.arguments[0]),
672            _ => panic!("expected Command statement for: {src}"),
673        }
674    }
675
676    #[test]
677    fn provenance_word_is_literal() {
678        assert_eq!(first_arg_provenance("echo hello"), Provenance::Literal);
679    }
680
681    #[test]
682    fn provenance_number_is_literal() {
683        // `sleep 30` — the `30` is a Word node (not Number in the command context)
684        assert_eq!(first_arg_provenance("sleep 30"), Provenance::Literal);
685    }
686
687    #[test]
688    fn provenance_raw_string_is_literal() {
689        assert_eq!(first_arg_provenance("echo 'hello world'"), Provenance::Literal);
690    }
691
692    #[test]
693    fn provenance_glob_star() {
694        assert_eq!(first_arg_provenance("rm *.rs"), Provenance::Glob);
695    }
696
697    #[test]
698    fn provenance_glob_question_mark() {
699        assert_eq!(first_arg_provenance("ls file?.txt"), Provenance::Glob);
700    }
701
702    #[test]
703    fn provenance_glob_bracket() {
704        assert_eq!(first_arg_provenance("ls [abc].txt"), Provenance::Glob);
705    }
706
707    #[test]
708    fn provenance_simple_expansion_is_variable() {
709        assert_eq!(first_arg_provenance("echo $VAR"), Provenance::Variable);
710    }
711
712    #[test]
713    fn provenance_command_sub_is_command_sub() {
714        assert_eq!(first_arg_provenance("echo $(date)"), Provenance::CommandSub);
715    }
716
717    #[test]
718    fn provenance_process_sub_is_process_sub() {
719        // diff <(ls) <(ls /tmp) — first arg is a process substitution
720        assert_eq!(first_arg_provenance("diff <(ls) <(ls /tmp)"), Provenance::ProcessSub);
721    }
722
723    #[test]
724    fn provenance_double_quoted_all_literal() {
725        // "hello world" has only string_content parts → Literal
726        assert_eq!(first_arg_provenance(r#"echo "hello world""#), Provenance::Literal);
727    }
728
729    #[test]
730    fn provenance_double_quoted_with_var() {
731        // "hello $VAR" has a simple_expansion part → Variable
732        assert_eq!(first_arg_provenance(r#"echo "hello $VAR""#), Provenance::Variable);
733    }
734
735    #[test]
736    fn provenance_double_quoted_with_cmd_sub() {
737        // "ts=$(date)" — command substitution dominates
738        assert_eq!(first_arg_provenance(r#"echo "ts=$(date)""#), Provenance::CommandSub);
739    }
740
741    #[test]
742    fn provenance_ordering_cmd_sub_dominates_variable() {
743        // In a concatenation, CommandSub beats Variable
744        assert!(Provenance::CommandSub > Provenance::Variable);
745        assert!(Provenance::Variable > Provenance::Glob);
746        assert!(Provenance::Glob > Provenance::Literal);
747    }
748
749    #[test]
750    fn provenance_absolute_path_word_is_literal() {
751        assert_eq!(first_arg_provenance("cat /etc/hosts"), Provenance::Literal);
752    }
753
754    #[test]
755    fn provenance_relative_path_word_is_literal() {
756        assert_eq!(first_arg_provenance("rm ./build/output"), Provenance::Literal);
757    }
758
759    // ===== path_scope tests =====
760
761    fn camp() -> std::path::PathBuf {
762        std::path::PathBuf::from("/home/user/project")
763    }
764
765    #[test]
766    fn scope_absolute_within_camp() {
767        assert_eq!(
768            path_scope("/home/user/project/src/main.rs", &camp()),
769            PathScope::WithinCamp,
770        );
771    }
772
773    #[test]
774    fn scope_absolute_camp_root_itself() {
775        assert_eq!(path_scope("/home/user/project", &camp()), PathScope::WithinCamp);
776    }
777
778    #[test]
779    fn scope_absolute_outside_camp() {
780        assert_eq!(
781            path_scope("/home/user/other/file.rs", &camp()),
782            PathScope::OutsideCamp,
783        );
784    }
785
786    #[test]
787    fn scope_absolute_system_etc() {
788        assert_eq!(path_scope("/etc/hosts", &camp()), PathScope::System);
789    }
790
791    #[test]
792    fn scope_absolute_system_usr_bin() {
793        assert_eq!(path_scope("/usr/bin/python3", &camp()), PathScope::System);
794    }
795
796    #[test]
797    fn scope_absolute_system_root() {
798        assert_eq!(path_scope("/", &camp()), PathScope::System);
799    }
800
801    #[test]
802    fn scope_absolute_system_var() {
803        assert_eq!(path_scope("/var/log/syslog", &camp()), PathScope::System);
804    }
805
806    #[test]
807    fn scope_absolute_tmp_is_outside_not_system() {
808        // /tmp is not in our SYSTEM_DIR_PREFIXES list, so it's OutsideCamp.
809        assert_eq!(path_scope("/tmp/foo", &camp()), PathScope::OutsideCamp);
810    }
811
812    #[test]
813    fn scope_relative_no_escape() {
814        assert_eq!(path_scope("src/main.rs", &camp()), PathScope::WithinCamp);
815    }
816
817    #[test]
818    fn scope_relative_dot_slash() {
819        assert_eq!(path_scope("./build/output", &camp()), PathScope::WithinCamp);
820    }
821
822    #[test]
823    fn scope_relative_single_dotdot_escapes() {
824        // `../sibling` from camp_root goes outside
825        assert_eq!(path_scope("../sibling", &camp()), PathScope::OutsideCamp);
826    }
827
828    #[test]
829    fn scope_relative_dotdot_then_back_within() {
830        // `../project/foo` — goes up then back in — still escapes (depth < 0 at `..`)
831        assert_eq!(path_scope("../project/foo", &camp()), PathScope::OutsideCamp);
832    }
833
834    #[test]
835    fn scope_relative_descends_then_up_stays_within() {
836        // `src/../README.md` — up but never escapes root: depth 1 → 0 (not negative)
837        assert_eq!(path_scope("src/../README.md", &camp()), PathScope::WithinCamp);
838    }
839
840    #[test]
841    fn scope_tilde_is_outside() {
842        assert_eq!(path_scope("~/secrets", &camp()), PathScope::OutsideCamp);
843    }
844
845    #[test]
846    fn scope_git_ref_is_unresolvable() {
847        assert_eq!(path_scope("HEAD~1", &camp()), PathScope::Unresolvable);
848    }
849
850    #[test]
851    fn scope_remote_name_is_unresolvable() {
852        assert_eq!(path_scope("origin", &camp()), PathScope::Unresolvable);
853    }
854
855    #[test]
856    fn scope_branch_name_is_unresolvable() {
857        assert_eq!(path_scope("main", &camp()), PathScope::Unresolvable);
858    }
859
860    #[test]
861    fn scope_empty_is_unresolvable() {
862        assert_eq!(path_scope("", &camp()), PathScope::Unresolvable);
863    }
864
865    // ===== pipeline_stage_basenames tests =====
866
867    fn pipeline_basenames(src: &str) -> Vec<Option<String>> {
868        let prog = parse_to_ast(src).expect("parse");
869        match &prog.statements[0] {
870            crate::ast::Statement::Pipeline(p) => pipeline_stage_basenames(p),
871            _ => panic!("expected Pipeline statement for: {src}"),
872        }
873    }
874
875    #[test]
876    fn pipe_two_simple_stages() {
877        let basenames = pipeline_basenames("curl url | sh");
878        assert_eq!(basenames, vec![Some("curl".to_string()), Some("sh".to_string())]);
879    }
880
881    #[test]
882    fn pipe_three_stages() {
883        let basenames = pipeline_basenames("cat file | grep pattern | wc -l");
884        assert_eq!(
885            basenames,
886            vec![
887                Some("cat".to_string()),
888                Some("grep".to_string()),
889                Some("wc".to_string()),
890            ],
891        );
892    }
893
894    #[test]
895    fn pipe_subshell_stage_returns_none() {
896        // (cd /tmp; ls) is a Subshell — stmt_basename returns None
897        let basenames = pipeline_basenames("(cd /tmp && ls) | grep foo");
898        assert_eq!(basenames[0], None);
899        assert_eq!(basenames[1], Some("grep".to_string()));
900    }
901
902    #[test]
903    fn pipe_basename_lowercased() {
904        // Basenames are always lowercased for consistent DSL matching
905        let basenames = pipeline_basenames("Git log | grep fix");
906        assert_eq!(basenames[0], Some("git".to_string()));
907    }
908
909    #[test]
910    fn pipe_wrapped_stage_peels() {
911        // timeout wraps git — peels to git
912        let basenames = pipeline_basenames("timeout 30 git log | grep fix");
913        assert_eq!(basenames[0], Some("git".to_string()));
914    }
915}