//! `ToolInvocation` extraction from a [`PeeledCommand`] (R295-F1).
//!
//! Extracts a structured [`ToolInvocation`] once the peeled command's
//! basename is matched against a registered [`ToolLookup`]. The schema
//! (global-flag specs, max subcommand depth) lives in [`ToolSchemaInfo`],
//! which the full policy types in `agent_tools::tool_schema` embed.
//!
//! Dependency direction is intentionally one-way:
//! - `bash-ast` (this crate) defines extraction + the lookup trait.
//! - `agent-tools` defines policy (`ApprovalTier`, `ToolSchema`) and
//! implements `ToolLookup` on its `ToolRegistry`.
//! No circular deps arise.
//!
//! @arch:see(.yah/docs/working/W086-git-subcommand-gate.md)
//!
//! @yah:ticket(R295-F7, "bash-ast: arg provenance + path scope + pipe-edge facts")
//! @yah:assignee(agent:miravel)
//! @yah:at(2026-05-25T23:42:54Z)
//! @yah:status(review)
//! @yah:parent(R295)
//! @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.")
//! @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.")
//! @yah:verify("cargo test -p bash-ast # provenance per node-kind + path_scope buckets (within/outside/system/unresolvable) + glob detection")
//! @arch:see(.yah/docs/working/W086-git-subcommand-gate.md)
//! @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.")
//! @yah:verify("cargo test -p bash-ast tool_invocation # 49/49 green")
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::ast::{CommandArgument, Pipeline, PrimaryExpression, SimpleExpansionElement, Statement, StringPart};
use crate::wrappers::{peel_simple_command_loose, PeeledCommand};
// ---------- GlobalFlagSpec ----------
/// Describes how a single global flag (one that appears between the tool name
/// and the subcommand chain) should be consumed during extraction.
///
/// Global flags in subcommand-oriented tools precede the subcommand: for
/// `git -C /tmp stash list`, `-C /tmp` are global flags consumed before
/// `stash list`. This spec tells the extractor how many tokens to consume.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GlobalFlagSpec {
/// A bare flag that takes no following value.
/// E.g. `--bare`, `-p`, `--paginate`, `--no-pager`.
Bare { flag: String },
/// A flag that carries a value either via `=` (attached: `--git-dir=/tmp`)
/// or a separate next token (`--git-dir /tmp`, `-C /tmp`).
/// The extractor consumes one extra token when there is no `=`.
TakesValue { flag: String },
}
impl GlobalFlagSpec {
pub fn flag(&self) -> &str {
match self {
Self::Bare { flag } | Self::TakesValue { flag } => flag,
}
}
}
// ---------- ToolSchemaInfo ----------
/// Minimal schema info needed for [`tool_invocation_of`].
///
/// This is the intersection of what extraction needs — the full policy
/// (tier table, leaf-flag escalation) lives in `agent_tools::tool_schema`.
/// Returned by [`ToolLookup::lookup`]; cheap to clone since schemas are
/// small static data.
#[derive(Debug, Clone)]
pub struct ToolSchemaInfo {
/// Lowercase basename of the tool (e.g. `"git"`, `"gh"`).
pub tool: String,
/// Global flags to peel before the subcommand chain.
pub global_flags: Vec<GlobalFlagSpec>,
/// Maximum consecutive non-flag positionals to consume as the subcommand
/// chain. `1` for flat tools (cargo, npm); `2` for git (`stash list`),
/// docker (`container rm`); `3` for aws (`s3api put-object`).
pub max_depth: u8,
}
// ---------- ToolLookup ----------
/// Abstraction over a tool-schema registry. [`tool_invocation_of`] calls
/// this to resolve the basename of the peeled primary to a schema.
///
/// Implementors: `agent_tools::tool_schema::ToolRegistry` (production),
/// and the `MockRegistry` in the tests below.
pub trait ToolLookup {
/// Return the schema info for `basename` (already lowercase-stripped),
/// or `None` if the tool is not registered.
fn lookup(&self, basename: &str) -> Option<ToolSchemaInfo>;
}
// ---------- ToolInvocation ----------
/// Structured representation of a single tool invocation extracted from a
/// [`PeeledCommand`]. All string fields borrow from the source `PeeledCommand`
/// to avoid heap allocation in the hot approval-gate path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolInvocation<'a> {
/// Lowercase basename of the matched tool (e.g. `"git"`).
/// Borrows from [`PeeledCommand::primary`].
pub tool: &'a str,
/// The subcommand chain, up to `max_depth` non-flag positionals after the
/// global flags. E.g. `["stash", "list"]` for `git -C /tmp stash list`.
pub subcommand: Vec<&'a str>,
/// Non-flag positionals after the subcommand chain (the "rest" args like
/// branch names, file paths, refs). E.g. `["origin", "main"]` for
/// `git push origin main`.
pub rest: Vec<&'a str>,
/// Raw tokens stripped as global flags (including value tokens for
/// `TakesValue` specs). E.g. `["-C", "/tmp"]` for `git -C /tmp …`.
pub global_flags: Vec<&'a str>,
/// Flag tokens appearing after the subcommand chain, used by F2's
/// leaf-flag escalation (e.g. `"--hard"` in `git reset --hard HEAD`).
pub leaf_flags: Vec<&'a str>,
}
// ---------- tool_invocation_of ----------
/// Extract a [`ToolInvocation`] from `peeled`, looking the primary up in
/// `registry`. Returns `None` when the primary's basename is not registered.
///
/// `peeled` must have had wrappers stripped via [`crate::wrappers::peel_command`]
/// or one of its siblings. Variable-primary commands (e.g. `$TOOL status`)
/// cause `peel_command` to return `None`, so they never reach this function.
pub fn tool_invocation_of<'a>(
peeled: &'a PeeledCommand,
registry: &impl ToolLookup,
) -> Option<ToolInvocation<'a>> {
let basename = tool_basename(&peeled.primary);
let info = registry.lookup(basename)?;
let args = &peeled.args;
let mut i = 0usize;
// --- 1. Peel global flags ---
let mut global_flags: Vec<&'a str> = Vec::new();
while i < args.len() {
let arg: &'a str = args[i].as_str();
if !arg.starts_with('-') {
break; // first non-flag positional starts the subcommand chain
}
match find_global_flag(arg, &info.global_flags) {
Some(GlobalFlagSpec::Bare { .. }) => {
global_flags.push(arg);
i += 1;
}
Some(GlobalFlagSpec::TakesValue { flag }) => {
global_flags.push(arg);
i += 1;
// If arg is exactly the flag name (no attached `=value`), eat
// the next token as the value.
if arg == flag.as_str() {
if let Some(value) = args.get(i) {
global_flags.push(value.as_str());
i += 1;
}
}
// If arg.starts_with(flag + "="), the value is attached; already consumed.
}
None => break, // unrecognised flag — stop global peeling
}
}
// --- 2. Consume subcommand chain ---
let mut subcommand: Vec<&'a str> = Vec::new();
while i < args.len() && subcommand.len() < info.max_depth as usize {
let arg: &'a str = args[i].as_str();
if arg.starts_with('-') {
break; // flag interrupts the subcommand chain
}
subcommand.push(arg);
i += 1;
}
// --- 3. Split remaining into leaf_flags and rest ---
let mut leaf_flags: Vec<&'a str> = Vec::new();
let mut rest: Vec<&'a str> = Vec::new();
for arg in &args[i..] {
if arg.starts_with('-') {
leaf_flags.push(arg.as_str());
} else {
rest.push(arg.as_str());
}
}
Some(ToolInvocation {
tool: basename,
subcommand,
rest,
global_flags,
leaf_flags,
})
}
// ---------- Provenance ----------
/// How statically bounded a command argument's value is.
///
/// Derived purely from the tree-sitter-bash AST node kind of the argument —
/// no heuristics, no disk I/O. Controls whether [`path_scope`] is computable
/// and feeds `every_arg`/`any_arg` quantifiers in the policy DSL (R295-F9).
///
/// Ordinal order is severity: `CommandSub` > `ProcessSub` > `Variable` > `Glob`
/// > `Literal`. The `Ord` impl enables the fold in [`arg_provenance`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Provenance {
/// Fully static — value known at parse time (`Word`, `RawString`, `Number`, …).
Literal,
/// Unquoted `Word` containing `*`, `?`, or `[` — expands to an unknown set of paths.
Glob,
/// `$VAR`, `${…}`, `$((…))` — value unknown at gate time.
Variable,
/// `<(cmd)` process substitution — data flows from a subprocess file descriptor.
ProcessSub,
/// `$(cmd)` command substitution — injection point; result may contain arbitrary text.
CommandSub,
}
// ---------- PathScope ----------
/// How far a literal-provenance argument reaches relative to `camp_root`.
///
/// Only computable when [`Provenance`] is `Literal`; [`arg_fact`] sets
/// `ArgFact::scope` to `None` for all other provenances.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PathScope {
/// Resolves to a path at or under `camp_root` (safe for read operations).
WithinCamp,
/// Non-path argument (git ref, flag value, …) or relative with no static
/// anchor — cannot determine scope statically.
Unresolvable,
/// Absolute path outside `camp_root`, or relative path that escapes via `..`.
OutsideCamp,
/// Under a well-known OS system directory: `/etc`, `/usr`, `/bin`, `/sbin`,
/// `/lib`, `/var`, `/dev`, `/proc`, `/sys`, `/boot`, `/root`, `/run`.
System,
}
// ---------- ArgFact ----------
/// Per-argument provenance and path scope derived from the raw AST node.
///
/// Built by [`arg_fact`]; consumed by the floor walker (R295-F8) when
/// assembling `CommandFact.args`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArgFact {
/// Best-effort text representation (for display and DSL `raw:` matching).
pub raw: String,
/// How statically bounded this argument is.
pub provenance: Provenance,
/// `Some(_)` iff `provenance == Literal`. `None` otherwise.
pub scope: Option<PathScope>,
}
// ---------- arg_provenance ----------
/// Classify the provenance of a [`CommandArgument`] from the parse tree.
///
/// Pure — no disk I/O. Useful as a standalone predicate when `scope` is not
/// needed; see [`arg_fact`] for the full `(provenance, scope)` pair.
pub fn arg_provenance(arg: &CommandArgument) -> Provenance {
match arg {
CommandArgument::Primary(p) => primary_provenance(p),
CommandArgument::Concatenation(c) => {
c.parts.iter().map(primary_provenance).fold(Provenance::Literal, Ord::max)
}
CommandArgument::Regex(_) | CommandArgument::Operator { .. } => Provenance::Literal,
}
}
/// Build an [`ArgFact`] for a single command argument.
///
/// `camp_root` is used to compute [`PathScope`] for literal arguments;
/// other provenances get `scope: None`.
pub fn arg_fact(arg: &CommandArgument, camp_root: &Path) -> ArgFact {
let raw = arg_raw_text(arg);
let provenance = arg_provenance(arg);
let scope = if provenance == Provenance::Literal {
Some(path_scope(&raw, camp_root))
} else {
None
};
ArgFact { raw, provenance, scope }
}
// ---------- path_scope ----------
/// Classify a literal argument string by how far it can reach relative to
/// `camp_root`. Pure — no disk I/O; uses static path analysis only.
///
/// Call this only when `arg_provenance` returned `Literal`; the result is
/// meaningless for variable/glob/substitution arguments.
pub fn path_scope(literal: &str, camp_root: &Path) -> PathScope {
if literal.is_empty() {
return PathScope::Unresolvable;
}
let p = Path::new(literal);
if p.is_absolute() {
return if is_system_path(literal) {
PathScope::System
} else if p.starts_with(camp_root) {
PathScope::WithinCamp
} else {
PathScope::OutsideCamp
};
}
// Tilde-relative → home dir, which is outside any camp.
if literal.starts_with('~') {
return PathScope::OutsideCamp;
}
// Non-path-looking args (git refs, flag values, remote names, …).
if !looks_like_path(literal) {
return PathScope::Unresolvable;
}
// Relative path — check statically whether `..` components escape the camp.
if escapes_camp(p) {
PathScope::OutsideCamp
} else {
PathScope::WithinCamp
}
}
// ---------- pipeline_stage_basenames ----------
/// For each stage of `pipeline`, resolve the lowercased tool basename after
/// peeling wrappers. `None` for stages that are not simple commands or that
/// cannot be peeled (subshells, control flow, …).
///
/// The floor walker (R295-F8) uses this to stamp `CommandFact::pipe_into` /
/// `CommandFact::pipe_from` when building approval manifests.
pub fn pipeline_stage_basenames(pipeline: &Pipeline) -> Vec<Option<String>> {
pipeline.stages.iter().map(stmt_basename).collect()
}
// ---------- private helpers ----------
/// Return a sub-slice of `primary` that is the basename after the last `/`.
/// `/usr/bin/git` → `"git"`, `"git"` → `"git"`.
fn tool_basename(primary: &str) -> &str {
primary.rsplit('/').next().unwrap_or(primary)
}
/// Find the first `GlobalFlagSpec` in `specs` that matches `arg`.
///
/// Matches `arg` exactly for `Bare` specs. For `TakesValue` specs, also
/// matches the `--flag=value` form (arg starts with flag immediately
/// followed by `=`).
fn find_global_flag<'s>(arg: &str, specs: &'s [GlobalFlagSpec]) -> Option<&'s GlobalFlagSpec> {
for spec in specs {
let flag = spec.flag();
if arg == flag {
return Some(spec);
}
if matches!(spec, GlobalFlagSpec::TakesValue { .. }) {
// --flag=value: flag must be an exact prefix before '='
if arg.starts_with(flag) && arg.as_bytes().get(flag.len()) == Some(&b'=') {
return Some(spec);
}
}
}
None
}
fn primary_provenance(p: &PrimaryExpression) -> Provenance {
match p {
PrimaryExpression::Word(w) => {
if w.text.contains(['*', '?', '[']) {
Provenance::Glob
} else {
Provenance::Literal
}
}
PrimaryExpression::RawString(_)
| PrimaryExpression::Number(_)
| PrimaryExpression::AnsiCString(_) => Provenance::Literal,
PrimaryExpression::StringNode(s) => string_parts_provenance(&s.parts),
PrimaryExpression::TranslatedString(t) => string_parts_provenance(&t.parts),
PrimaryExpression::SimpleExpansion(_)
| PrimaryExpression::Expansion(_)
| PrimaryExpression::ArithmeticExpansion(_) => Provenance::Variable,
PrimaryExpression::CommandSubstitution(_) => Provenance::CommandSub,
PrimaryExpression::ProcessSubstitution(_) => Provenance::ProcessSub,
// Brace expansion e.g. {a,b,c} — expands to a set of words.
PrimaryExpression::BraceExpression(_) => Provenance::Glob,
}
}
fn string_parts_provenance(parts: &[StringPart]) -> Provenance {
parts
.iter()
.map(|part| match part {
StringPart::Content(_) | StringPart::Raw { .. } => Provenance::Literal,
StringPart::SimpleExpansion(_)
| StringPart::Expansion(_)
| StringPart::ArithmeticExpansion(_) => Provenance::Variable,
StringPart::CommandSubstitution(_) => Provenance::CommandSub,
})
.fold(Provenance::Literal, Ord::max)
}
pub fn arg_raw_text(arg: &CommandArgument) -> String {
match arg {
CommandArgument::Primary(p) => primary_raw_text(p),
CommandArgument::Concatenation(c) => {
c.parts.iter().map(primary_raw_text).collect()
}
CommandArgument::Regex(r) => r.text.clone(),
CommandArgument::Operator { text, .. } => text.clone(),
}
}
fn primary_raw_text(p: &PrimaryExpression) -> String {
match p {
PrimaryExpression::Word(w) => w.text.clone(),
PrimaryExpression::RawString(r) => r.text.trim_matches('\'').to_owned(),
PrimaryExpression::Number(n) => n.text.clone(),
PrimaryExpression::AnsiCString(a) => a.text.clone(),
PrimaryExpression::BraceExpression(b) => b.text.clone(),
PrimaryExpression::StringNode(s) => string_parts_text(&s.parts),
PrimaryExpression::TranslatedString(t) => string_parts_text(&t.parts),
PrimaryExpression::SimpleExpansion(se) => match &se.element {
SimpleExpansionElement::VariableName(v) => format!("${}", v.text),
SimpleExpansionElement::SpecialVariableName(s) => format!("${}", s.text),
},
PrimaryExpression::Expansion(_) => "${…}".to_owned(),
PrimaryExpression::CommandSubstitution(_) => "$(…)".to_owned(),
PrimaryExpression::ProcessSubstitution(_) => "<(…)".to_owned(),
PrimaryExpression::ArithmeticExpansion(_) => "$((…))".to_owned(),
}
}
fn string_parts_text(parts: &[StringPart]) -> String {
parts
.iter()
.map(|part| match part {
StringPart::Content(c) => c.text.clone(),
StringPart::Raw { text, .. } => text.clone(),
StringPart::SimpleExpansion(se) => match &se.element {
SimpleExpansionElement::VariableName(v) => format!("${}", v.text),
SimpleExpansionElement::SpecialVariableName(s) => format!("${}", s.text),
},
StringPart::Expansion(_) => "${…}".to_owned(),
StringPart::CommandSubstitution(_) => "$(…)".to_owned(),
StringPart::ArithmeticExpansion(_) => "$((…))".to_owned(),
})
.collect()
}
const SYSTEM_DIR_PREFIXES: &[&str] = &[
"/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64",
"/var", "/dev", "/proc", "/sys", "/boot", "/root", "/run", "/snap",
];
fn is_system_path(literal: &str) -> bool {
if literal == "/" {
return true;
}
SYSTEM_DIR_PREFIXES.iter().any(|dir| {
literal == *dir || literal.starts_with(&format!("{}/", dir))
})
}
fn looks_like_path(literal: &str) -> bool {
literal.contains('/') || literal.starts_with('.') || literal.starts_with('~')
}
/// Returns `true` if the relative path `p` escapes the camp root via `..`.
///
/// Simulates resolving `p` from the camp root by tracking directory depth.
/// If depth ever goes negative, the path escapes.
fn escapes_camp(p: &Path) -> bool {
let mut depth: i32 = 0;
for component in p.components() {
match component {
std::path::Component::ParentDir => {
depth -= 1;
if depth < 0 {
return true;
}
}
std::path::Component::Normal(_) => depth += 1,
std::path::Component::CurDir | std::path::Component::RootDir => {}
std::path::Component::Prefix(_) => {}
}
}
false
}
fn stmt_basename(stmt: &Statement) -> Option<String> {
let cmd = match stmt {
Statement::Command(c) => c,
Statement::Redirected(r) => match r.body.as_deref() {
Some(Statement::Command(c)) => c,
_ => return None,
},
_ => return None,
};
peel_simple_command_loose(cmd).map(|p| tool_basename(&p.primary).to_lowercase())
}
// ---------- tests ----------
#[cfg(test)]
mod tests {
use super::*;
use crate::{parse_to_ast, wrappers::peel_command};
/// Minimal mock registry — enough peeling info for a fake tool named
/// "mock" with max_depth=2. No policy; that lives in agent-tools.
struct MockRegistry {
schemas: Vec<ToolSchemaInfo>,
}
impl ToolLookup for MockRegistry {
fn lookup(&self, basename: &str) -> Option<ToolSchemaInfo> {
let key = basename.to_lowercase();
self.schemas.iter().find(|s| s.tool == key).cloned()
}
}
fn mock_registry() -> MockRegistry {
MockRegistry {
schemas: vec![ToolSchemaInfo {
tool: "mock".to_string(),
global_flags: vec![
GlobalFlagSpec::Bare { flag: "--bare".to_string() },
GlobalFlagSpec::TakesValue { flag: "-X".to_string() },
GlobalFlagSpec::TakesValue { flag: "--dir".to_string() },
],
max_depth: 2,
}],
}
}
/// Parse + peel `src`, then extract via `registry`. Returns five owned
/// vecs (tool, subcommand, rest, global_flags, leaf_flags) so lifetime
/// issues with the locally-owned `PeeledCommand` don't escape the helper.
fn extract(
src: &str,
registry: &MockRegistry,
) -> Option<(String, Vec<String>, Vec<String>, Vec<String>, Vec<String>)> {
let prog = parse_to_ast(src).ok()?;
let peeled = peel_command(&prog)?;
let inv = tool_invocation_of(&peeled, registry)?;
Some((
inv.tool.to_owned(),
inv.subcommand.iter().map(|s| s.to_string()).collect(),
inv.rest.iter().map(|s| s.to_string()).collect(),
inv.global_flags.iter().map(|s| s.to_string()).collect(),
inv.leaf_flags.iter().map(|s| s.to_string()).collect(),
))
}
#[test]
fn bare_invocation() {
let reg = mock_registry();
let (tool, subcmd, rest, gflags, lflags) = extract("mock sub1", ®).unwrap();
assert_eq!(tool, "mock");
assert_eq!(subcmd, ["sub1"]);
assert!(rest.is_empty());
assert!(gflags.is_empty());
assert!(lflags.is_empty());
}
#[test]
fn dash_flag_with_attached_value() {
// TakesValue with = form — one token consumed
let reg = mock_registry();
let (_, subcmd, _, gflags, _) = extract("mock --dir=/tmp sub1", ®).unwrap();
assert_eq!(gflags, ["--dir=/tmp"]);
assert_eq!(subcmd, ["sub1"]);
}
#[test]
fn dash_dash_flag_equals_with_space() {
// TakesValue with separate token — two tokens consumed
let reg = mock_registry();
let (_, subcmd, _, gflags, _) = extract("mock --dir /tmp sub1", ®).unwrap();
assert_eq!(gflags, ["--dir", "/tmp"]);
assert_eq!(subcmd, ["sub1"]);
}
#[test]
fn short_flag_takes_value() {
let reg = mock_registry();
let (_, subcmd, _, gflags, _) = extract("mock -X value sub1", ®).unwrap();
assert_eq!(gflags, ["-X", "value"]);
assert_eq!(subcmd, ["sub1"]);
}
#[test]
fn bare_global_flag() {
let reg = mock_registry();
let (_, subcmd, _, gflags, _) = extract("mock --bare sub1", ®).unwrap();
assert_eq!(gflags, ["--bare"]);
assert_eq!(subcmd, ["sub1"]);
}
#[test]
fn deepest_chain_the_mock_schema_declares() {
// max_depth=2 → consumes sub1 + sub2; extra positional goes to rest
let reg = mock_registry();
let (_, subcmd, rest, _, lflags) =
extract("mock sub1 sub2 extra --leaf-flag", ®).unwrap();
assert_eq!(subcmd, ["sub1", "sub2"]);
assert_eq!(rest, ["extra"]);
assert_eq!(lflags, ["--leaf-flag"]);
}
#[test]
fn leaf_flags_after_subcommand() {
let reg = mock_registry();
let (_, subcmd, rest, _, lflags) = extract("mock sub1 --opt target", ®).unwrap();
assert_eq!(subcmd, ["sub1"]);
assert_eq!(rest, ["target"]);
assert_eq!(lflags, ["--opt"]);
}
#[test]
fn absolute_path_primary() {
// /usr/bin/mock should match the "mock" schema
let reg = mock_registry();
let (tool, subcmd, _, _, _) = extract("/usr/bin/mock sub1", ®).unwrap();
assert_eq!(tool, "mock");
assert_eq!(subcmd, ["sub1"]);
}
#[test]
fn unknown_primary_returns_none() {
let reg = mock_registry();
let prog = parse_to_ast("notregistered status").expect("parse");
let peeled = peel_command(&prog).expect("peel");
assert!(tool_invocation_of(&peeled, ®).is_none());
}
#[test]
fn variable_primary_returns_none() {
// peel_command refuses variable primaries — so no PeeledCommand exists
// to feed into tool_invocation_of. This test exercises the composition.
let prog = parse_to_ast("$TOOL status").expect("parse");
assert!(
peel_command(&prog).is_none(),
"peel_command should return None for variable primary"
);
}
#[test]
fn global_flag_stops_at_unknown_flag() {
// An unrecognised flag stops global peeling. Since it starts with '-'
// it also blocks the subcommand chain (which only consumes non-flag
// positionals). Both the unknown flag and the trailing positional end
// up in the remaining bucket: flag → leaf_flags, positional → rest.
let reg = mock_registry();
let (_, subcmd, rest, gflags, lflags) =
extract("mock --unknown-flag sub1", ®).unwrap();
assert!(gflags.is_empty());
assert!(subcmd.is_empty());
assert_eq!(lflags, ["--unknown-flag"]);
assert_eq!(rest, ["sub1"]);
}
// ===== arg_provenance tests =====
fn first_arg_provenance(src: &str) -> Provenance {
let prog = parse_to_ast(src).expect("parse");
match &prog.statements[0] {
crate::ast::Statement::Command(c) => arg_provenance(&c.arguments[0]),
_ => panic!("expected Command statement for: {src}"),
}
}
#[test]
fn provenance_word_is_literal() {
assert_eq!(first_arg_provenance("echo hello"), Provenance::Literal);
}
#[test]
fn provenance_number_is_literal() {
// `sleep 30` — the `30` is a Word node (not Number in the command context)
assert_eq!(first_arg_provenance("sleep 30"), Provenance::Literal);
}
#[test]
fn provenance_raw_string_is_literal() {
assert_eq!(first_arg_provenance("echo 'hello world'"), Provenance::Literal);
}
#[test]
fn provenance_glob_star() {
assert_eq!(first_arg_provenance("rm *.rs"), Provenance::Glob);
}
#[test]
fn provenance_glob_question_mark() {
assert_eq!(first_arg_provenance("ls file?.txt"), Provenance::Glob);
}
#[test]
fn provenance_glob_bracket() {
assert_eq!(first_arg_provenance("ls [abc].txt"), Provenance::Glob);
}
#[test]
fn provenance_simple_expansion_is_variable() {
assert_eq!(first_arg_provenance("echo $VAR"), Provenance::Variable);
}
#[test]
fn provenance_command_sub_is_command_sub() {
assert_eq!(first_arg_provenance("echo $(date)"), Provenance::CommandSub);
}
#[test]
fn provenance_process_sub_is_process_sub() {
// diff <(ls) <(ls /tmp) — first arg is a process substitution
assert_eq!(first_arg_provenance("diff <(ls) <(ls /tmp)"), Provenance::ProcessSub);
}
#[test]
fn provenance_double_quoted_all_literal() {
// "hello world" has only string_content parts → Literal
assert_eq!(first_arg_provenance(r#"echo "hello world""#), Provenance::Literal);
}
#[test]
fn provenance_double_quoted_with_var() {
// "hello $VAR" has a simple_expansion part → Variable
assert_eq!(first_arg_provenance(r#"echo "hello $VAR""#), Provenance::Variable);
}
#[test]
fn provenance_double_quoted_with_cmd_sub() {
// "ts=$(date)" — command substitution dominates
assert_eq!(first_arg_provenance(r#"echo "ts=$(date)""#), Provenance::CommandSub);
}
#[test]
fn provenance_ordering_cmd_sub_dominates_variable() {
// In a concatenation, CommandSub beats Variable
assert!(Provenance::CommandSub > Provenance::Variable);
assert!(Provenance::Variable > Provenance::Glob);
assert!(Provenance::Glob > Provenance::Literal);
}
#[test]
fn provenance_absolute_path_word_is_literal() {
assert_eq!(first_arg_provenance("cat /etc/hosts"), Provenance::Literal);
}
#[test]
fn provenance_relative_path_word_is_literal() {
assert_eq!(first_arg_provenance("rm ./build/output"), Provenance::Literal);
}
// ===== path_scope tests =====
fn camp() -> std::path::PathBuf {
std::path::PathBuf::from("/home/user/project")
}
#[test]
fn scope_absolute_within_camp() {
assert_eq!(
path_scope("/home/user/project/src/main.rs", &camp()),
PathScope::WithinCamp,
);
}
#[test]
fn scope_absolute_camp_root_itself() {
assert_eq!(path_scope("/home/user/project", &camp()), PathScope::WithinCamp);
}
#[test]
fn scope_absolute_outside_camp() {
assert_eq!(
path_scope("/home/user/other/file.rs", &camp()),
PathScope::OutsideCamp,
);
}
#[test]
fn scope_absolute_system_etc() {
assert_eq!(path_scope("/etc/hosts", &camp()), PathScope::System);
}
#[test]
fn scope_absolute_system_usr_bin() {
assert_eq!(path_scope("/usr/bin/python3", &camp()), PathScope::System);
}
#[test]
fn scope_absolute_system_root() {
assert_eq!(path_scope("/", &camp()), PathScope::System);
}
#[test]
fn scope_absolute_system_var() {
assert_eq!(path_scope("/var/log/syslog", &camp()), PathScope::System);
}
#[test]
fn scope_absolute_tmp_is_outside_not_system() {
// /tmp is not in our SYSTEM_DIR_PREFIXES list, so it's OutsideCamp.
assert_eq!(path_scope("/tmp/foo", &camp()), PathScope::OutsideCamp);
}
#[test]
fn scope_relative_no_escape() {
assert_eq!(path_scope("src/main.rs", &camp()), PathScope::WithinCamp);
}
#[test]
fn scope_relative_dot_slash() {
assert_eq!(path_scope("./build/output", &camp()), PathScope::WithinCamp);
}
#[test]
fn scope_relative_single_dotdot_escapes() {
// `../sibling` from camp_root goes outside
assert_eq!(path_scope("../sibling", &camp()), PathScope::OutsideCamp);
}
#[test]
fn scope_relative_dotdot_then_back_within() {
// `../project/foo` — goes up then back in — still escapes (depth < 0 at `..`)
assert_eq!(path_scope("../project/foo", &camp()), PathScope::OutsideCamp);
}
#[test]
fn scope_relative_descends_then_up_stays_within() {
// `src/../README.md` — up but never escapes root: depth 1 → 0 (not negative)
assert_eq!(path_scope("src/../README.md", &camp()), PathScope::WithinCamp);
}
#[test]
fn scope_tilde_is_outside() {
assert_eq!(path_scope("~/secrets", &camp()), PathScope::OutsideCamp);
}
#[test]
fn scope_git_ref_is_unresolvable() {
assert_eq!(path_scope("HEAD~1", &camp()), PathScope::Unresolvable);
}
#[test]
fn scope_remote_name_is_unresolvable() {
assert_eq!(path_scope("origin", &camp()), PathScope::Unresolvable);
}
#[test]
fn scope_branch_name_is_unresolvable() {
assert_eq!(path_scope("main", &camp()), PathScope::Unresolvable);
}
#[test]
fn scope_empty_is_unresolvable() {
assert_eq!(path_scope("", &camp()), PathScope::Unresolvable);
}
// ===== pipeline_stage_basenames tests =====
fn pipeline_basenames(src: &str) -> Vec<Option<String>> {
let prog = parse_to_ast(src).expect("parse");
match &prog.statements[0] {
crate::ast::Statement::Pipeline(p) => pipeline_stage_basenames(p),
_ => panic!("expected Pipeline statement for: {src}"),
}
}
#[test]
fn pipe_two_simple_stages() {
let basenames = pipeline_basenames("curl url | sh");
assert_eq!(basenames, vec![Some("curl".to_string()), Some("sh".to_string())]);
}
#[test]
fn pipe_three_stages() {
let basenames = pipeline_basenames("cat file | grep pattern | wc -l");
assert_eq!(
basenames,
vec![
Some("cat".to_string()),
Some("grep".to_string()),
Some("wc".to_string()),
],
);
}
#[test]
fn pipe_subshell_stage_returns_none() {
// (cd /tmp; ls) is a Subshell — stmt_basename returns None
let basenames = pipeline_basenames("(cd /tmp && ls) | grep foo");
assert_eq!(basenames[0], None);
assert_eq!(basenames[1], Some("grep".to_string()));
}
#[test]
fn pipe_basename_lowercased() {
// Basenames are always lowercased for consistent DSL matching
let basenames = pipeline_basenames("Git log | grep fix");
assert_eq!(basenames[0], Some("git".to_string()));
}
#[test]
fn pipe_wrapped_stage_peels() {
// timeout wraps git — peels to git
let basenames = pipeline_basenames("timeout 30 git log | grep fix");
assert_eq!(basenames[0], Some("git".to_string()));
}
}