//! High-level summary extracted from a parsed bash [`Program`].
//!
//! [`summarise_bash_shape`] is the single public entry point. It walks the AST
//! once and returns a [`BashShape`] that the desktop host attaches to each
//! `Bash` tool-call frame as `args._shape`. The TS side reads this for the
//! ActivityReport bucket bar — no second parse, no full AST on the wire.
//!
//! @yah:ticket(R196-T13, "BashShape.primary_program: skip pure-assignment stmts, recurse into compound bodies, unwrap assign=$(cmd)")
//! @yah:at(2026-05-16T02:42:10Z)
//! @yah:status(review)
//! @yah:parent(R196)
//! @yah:severity(P2)
//! @yah:next("Repro: noisetable session:a5025082 — 10-iter loop where ./tests/run_replay_tests.sh is buried in for/do/done > out=$(VAR=1 ./tests/run_replay_tests.sh 27 2>&1). Three statement shapes the current 'strip leading cd && / VAR=val' heuristic misses.")
//! @yah:next("Peel 1: pure-assignment stmts (pass=0; fail=0) — skip them as primary, same way leading var=val inside a command is skipped.")
//! @yah:next("Peel 2: compound stmts (for/while/if/{…}) — recurse into the body; the load-bearing program is inside do…done, not the loop keyword.")
//! @yah:next("Peel 3: assign-from-cmdsub (out=$(VAR=1 cmd …)) — when the stmt is only an assignment whose RHS is one command substitution, unwrap and take the inner command as primary. Same idea as 'cd X && cmd'.")
//! @yah:next("Edit extract_primary / extract_primary_stmt at summary.rs:431. side_effect + kind keep deriving off the unwrapped primary; cwd_hint stays untouched. all_programs unchanged.")
//! @yah:verify("cargo test -p bash-ast (add fixture mirroring the noisetable shape under tests/summary.rs — assert primary_program == \"./tests/run_replay_tests.sh\")")
//! @yah:verify("cargo test -p agent-tools --lib approval:: still 96/96 (no schema change)")
//! @yah:verify("bun run typecheck (no TS change expected — BashShape wire format unchanged)")
//! @arch:see(.yah/docs/working/W055-agent-approval-evolution.md)
//! @yah:handoff("extract_primary refactored into extract_from_stmt_list() helper (R196-T13). Three peels: (1) VariableAssignment/VariableAssignments skipped (pure assignments); (2) For/While/If/CompoundStatement recurse into body via extract_from_stmt_list; (3) VariableAssignment where RHS is a CommandSubstitution unwraps to the inner body. loop_body_stmts() helper flattens LoopBody. 7 new tests in tests/summary.rs: peel1_pure_assignment_stmts_skipped, peel2_for_loop_body_recursed, peel2_while_loop_body_recursed, peel2_compound_body_recursed, peel3_assign_from_cmdsub_unwrapped, noisetable_repro_for_loop_with_assign_cmdsub. Also fixed pre-existing AgentSubclass missing caches:true in form_tools.rs (3x) and arch_tools.rs (1x).")
//! @yah:verify("cargo test -p bash-ast # 62/62 including 7 new peel tests")
//! @yah:verify("cargo test -p agent-tools --lib approval:: # 156/156 (no schema change)")
//! @yah:verify("cargo check --workspace # clean")
use serde::{Deserialize, Serialize};
use crate::ast::{
Command, CommandArgument, CommandNameInner, ListOperator,
PrimaryExpression, Program, Redirect, RedirectedStatement, Span,
Statement, StringPart,
};
// ============================================================================
// Public types
// ============================================================================
/// Compact summary of a bash command string, attached to `args._shape` on
/// each normalised `Bash` tool-call frame. All fields are optional/additive:
/// readers must ignore unknown keys, and missing keys mean "not computed"
/// (older sessions, parse failure).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BashShape {
/// First command's program name after stripping a leading `cd X &&` prefix
/// and any leading `var=val` assignments — what the operator was actually
/// trying to run.
pub primary_program: Option<String>,
/// All program names found in the script, in invocation order, deduped.
pub all_programs: Vec<String>,
/// Top-level list operators present (`"&&"`, `"||"`), deduped.
pub list_ops: Vec<String>,
/// Maximum pipeline stage count seen (1 = no pipe, >1 = piped).
pub pipeline_stages: usize,
/// File-redirect operators (`>`, `>>`, `&>`, …) used anywhere in the script.
pub redirects: Vec<String>,
/// `true` when a `<<EOF` heredoc opens — UI can collapse the body.
pub has_heredoc: bool,
/// Working-directory hint: argument of a leading `cd` that was stripped
/// when determining `primary_program`. Rendered as "(in foo/bar)".
pub cwd_hint: Option<String>,
/// Side-effect classification derived from `primary_program` + first argument.
/// `None` = read-only or unknown.
pub side_effect: Option<SideEffect>,
/// Coarse program category derived from `primary_program` alone. Lets the
/// UI pick an icon / activity bucket without re-mapping a hardcoded program
/// list in TS. Orthogonal to `side_effect` — `kind` is *what the program is*,
/// `side_effect` is *what this invocation does*.
pub kind: Option<ProgramKind>,
/// Source byte ranges of each top-level statement in `program.statements`,
/// in order. Use this — never `raw.split('\n')` — when a consumer needs to
/// segment a multi-statement bash string back into its individual commands
/// (e.g. the conveyor modal's batch-approve UI). `\<LF>` line-continuations
/// are collapsed at the lexer level by tree-sitter, so a backslash-continued
/// multi-flag invocation yields ONE slice, not one per `\<LF>`.
#[serde(default)]
pub top_level_statements: Vec<StatementSlice>,
}
/// Byte range + coarse kind of one top-level statement in the parsed program.
/// Stable for cross-language consumers (the desktop ships this on the wire to
/// the UI). Byte indices are into the original source string.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StatementSlice {
pub byte_start: usize,
pub byte_end: usize,
pub kind: StatementKind,
}
/// Coarse tag mirroring [`crate::ast::Statement`] variants. Lets downstream
/// consumers gate on statement shape (e.g. "skip splitting if any slice is a
/// Pipeline / List / If / For") without re-walking the full AST.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StatementKind {
Command,
Pipeline,
List,
Compound,
Subshell,
If,
While,
For,
CStyleFor,
Case,
FunctionDefinition,
Redirected,
Declaration,
Unset,
Test,
Negated,
VariableAssignment,
VariableAssignments,
}
/// Rule-based side-effect classification. Intentionally narrow — expand only
/// with evidence; the classifier never fires on read-only siblings.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SideEffect {
/// `git push`, `git commit`, `git tag`, `git merge`, `git rebase`,
/// `git reset`, `git branch`, `git checkout`, `git clean`
GitWrite,
/// `gh pr create/merge/close`, `gh release create`, `gh issue create`
GitHub,
/// `cargo publish`, `npm publish`, `bun publish`
Publish,
/// `rm`, `rmdir`, `dd`, `mkfs*`
Destructive,
/// `curl`/`wget` with `-X POST`/`PUT`/`DELETE`/`PATCH`
Network,
/// `sudo *`, `brew install`, `apt install`, `apt-get install`
SudoOrInstall,
}
/// Coarse category for a primary program. Stable identifier used downstream
/// for icon selection, activity bucketing, and permission heuristics.
///
/// Adding a kind is cheap; removing or renaming one is a breaking change for
/// every consumer (the TS mirror, `kindToGlyph`, the activity report). Prefer
/// extending [`PROGRAM_KIND_TABLE`] over inventing new variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProgramKind {
/// Read-only inspection / search: `ls`, `grep`, `rg`, `find`, `cat`,
/// `head`, `tail`, `wc`, `file`, `stat`, `du`, `awk`, `sed`, `jq`.
Search,
/// Version control: `git`.
Vcs,
/// Build / package tooling: `cargo`, `npm`, `bun`, `pnpm`, `yarn`,
/// `make`, `rustc`.
Build,
/// Network clients: `curl`, `wget`, `gh`, `http`.
Network,
/// Containers: `docker`, `podman`, `kubectl`.
Container,
/// Remote shells / copy: `ssh`, `scp`, `rsync`.
Remote,
/// Privilege elevation: `sudo`, `su`.
Elevation,
/// Destructive filesystem / process ops: `rm`, `rmdir`, `mv`, `kill`,
/// `pkill`, `shutdown`, `reboot`, `dd`.
Destructive,
/// Scripting runtimes: `python`, `python3`, `node`, `ruby`, `perl`,
/// `deno`, `lua`, `php`.
ScriptingRuntime,
/// The yah CLI itself.
Yah,
/// Model Context Protocol servers / clients. Matched by exact name *or*
/// by substring on the program name (`mcp-server-foo`, `foo-mcp`) when
/// the exact-match table misses — see [`kind_for`].
Mcp,
}
/// Lookup table: program name → kind. Authoritative exact-match list for
/// [`kind_for`]. Substring fallbacks (e.g. `mcp` → [`ProgramKind::Mcp`]) live
/// in `kind_for` itself.
const PROGRAM_KIND_TABLE: &[(&str, ProgramKind)] = &[
// Search / inspect — file/text/process introspection
("ls", ProgramKind::Search),
("grep", ProgramKind::Search),
("rg", ProgramKind::Search),
("ag", ProgramKind::Search),
("ack", ProgramKind::Search),
("find", ProgramKind::Search),
("fd", ProgramKind::Search),
("cat", ProgramKind::Search),
("bat", ProgramKind::Search),
("head", ProgramKind::Search),
("tail", ProgramKind::Search),
("less", ProgramKind::Search),
("more", ProgramKind::Search),
("wc", ProgramKind::Search),
("file", ProgramKind::Search),
("stat", ProgramKind::Search),
("du", ProgramKind::Search),
("df", ProgramKind::Search),
("tree", ProgramKind::Search),
("awk", ProgramKind::Search),
("sed", ProgramKind::Search),
("jq", ProgramKind::Search),
("yq", ProgramKind::Search),
("which", ProgramKind::Search),
("whereis", ProgramKind::Search),
("pwd", ProgramKind::Search),
("echo", ProgramKind::Search),
("printf", ProgramKind::Search),
("fzf", ProgramKind::Search),
("ps", ProgramKind::Search),
("top", ProgramKind::Search),
("htop", ProgramKind::Search),
("lsof", ProgramKind::Search),
("diff", ProgramKind::Search),
// VCS
("git", ProgramKind::Vcs),
("hg", ProgramKind::Vcs),
("svn", ProgramKind::Vcs),
("jj", ProgramKind::Vcs),
// Build / package
("cargo", ProgramKind::Build),
("rustc", ProgramKind::Build),
("rustup", ProgramKind::Build),
("npm", ProgramKind::Build),
("bun", ProgramKind::Build),
("pnpm", ProgramKind::Build),
("yarn", ProgramKind::Build),
("make", ProgramKind::Build),
("cmake", ProgramKind::Build),
("ninja", ProgramKind::Build),
("bazel", ProgramKind::Build),
("tsc", ProgramKind::Build),
("cc", ProgramKind::Build),
("gcc", ProgramKind::Build),
("clang", ProgramKind::Build),
("go", ProgramKind::Build),
("dotnet", ProgramKind::Build),
("swift", ProgramKind::Build),
("maven", ProgramKind::Build),
("gradle", ProgramKind::Build),
// Network — clients + probes
("curl", ProgramKind::Network),
("wget", ProgramKind::Network),
("gh", ProgramKind::Network),
("http", ProgramKind::Network),
("httpie", ProgramKind::Network),
("ping", ProgramKind::Network),
("dig", ProgramKind::Network),
("nslookup", ProgramKind::Network),
("host", ProgramKind::Network),
("traceroute", ProgramKind::Network),
("nc", ProgramKind::Network),
("ncat", ProgramKind::Network),
("netcat", ProgramKind::Network),
("openssl", ProgramKind::Network),
// Container / orchestration
("docker", ProgramKind::Container),
("podman", ProgramKind::Container),
("nerdctl", ProgramKind::Container),
("buildah", ProgramKind::Container),
("kubectl", ProgramKind::Container),
("helm", ProgramKind::Container),
("terraform", ProgramKind::Container),
("ansible", ProgramKind::Container),
// Remote
("ssh", ProgramKind::Remote),
("scp", ProgramKind::Remote),
("rsync", ProgramKind::Remote),
("mosh", ProgramKind::Remote),
("sftp", ProgramKind::Remote),
// Elevation
("sudo", ProgramKind::Elevation),
("su", ProgramKind::Elevation),
("doas", ProgramKind::Elevation),
// Destructive
("rm", ProgramKind::Destructive),
("rmdir", ProgramKind::Destructive),
("mv", ProgramKind::Destructive),
("kill", ProgramKind::Destructive),
("pkill", ProgramKind::Destructive),
("shutdown", ProgramKind::Destructive),
("reboot", ProgramKind::Destructive),
("halt", ProgramKind::Destructive),
("dd", ProgramKind::Destructive),
("shred", ProgramKind::Destructive),
("truncate", ProgramKind::Destructive),
// Scripting runtimes
("python", ProgramKind::ScriptingRuntime),
("python3", ProgramKind::ScriptingRuntime),
("node", ProgramKind::ScriptingRuntime),
("deno", ProgramKind::ScriptingRuntime),
("ruby", ProgramKind::ScriptingRuntime),
("perl", ProgramKind::ScriptingRuntime),
("lua", ProgramKind::ScriptingRuntime),
("php", ProgramKind::ScriptingRuntime),
// yah
("yah", ProgramKind::Yah),
];
/// Map a primary program name to its [`ProgramKind`], or `None` for unknown
/// commands.
///
/// Resolution order:
/// 1. Exact match in [`PROGRAM_KIND_TABLE`].
/// 2. Substring fallback for "yah" (case-insensitive) — `yah-yubaba`,
/// `yah-camp`, `yah-image-mcp` etc. all classify as [`ProgramKind::Yah`].
/// Wins over the mcp fallback below: a yah binary that happens to speak
/// MCP is still a yah binary.
/// 3. Substring fallback for "mcp" (case-insensitive) — `mcp-server-foo`,
/// `claude-mcp`, etc. classify as [`ProgramKind::Mcp`].
///
/// Consumed by the summariser and exposed for downstream tools (permission
/// rules, indexers) that want the same classification.
pub fn kind_for(program: &str) -> Option<ProgramKind> {
if let Some(kind) = PROGRAM_KIND_TABLE
.iter()
.find_map(|(name, kind)| (*name == program).then_some(*kind))
{
return Some(kind);
}
// ASCII-only lowercase compare — fast, no allocation in the common path.
// Yah wins ties because a yah-* binary that speaks MCP is still a yah one.
if contains_ignore_ascii_case(program, "yah") {
return Some(ProgramKind::Yah);
}
if contains_ignore_ascii_case(program, "mcp") {
return Some(ProgramKind::Mcp);
}
None
}
fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
if needle.is_empty() || needle.len() > haystack.len() {
return haystack.is_empty() == needle.is_empty();
}
haystack
.as_bytes()
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
}
// ============================================================================
// Entry point
// ============================================================================
/// Walk `program` once and extract a [`BashShape`] summary.
pub fn summarise_bash_shape(program: &Program) -> BashShape {
let mut all_programs: Vec<String> = Vec::new();
let mut list_ops_set: Vec<String> = Vec::new();
let mut pipeline_stages: usize = 1;
let mut redirects: Vec<String> = Vec::new();
let mut has_heredoc = false;
for stmt in &program.statements {
collect_info(
stmt,
&mut all_programs,
&mut list_ops_set,
&mut pipeline_stages,
&mut redirects,
&mut has_heredoc,
);
}
// Dedup while preserving insertion order.
dedup_vec(&mut all_programs);
dedup_vec(&mut list_ops_set);
// Extract primary program, cwd hint, and side-effect from the first
// meaningful top-level statement.
let (primary_program, cwd_hint, side_effect) = extract_primary(program);
// Kind is computed from the *effective* program — primary_program with
// wrappers (timeout/env/nohup/…) peeled. `peel_command` parses the
// wrapper's argv to find the wrapped command (`timeout 30 git push`
// → "git"), which `all_programs` alone can't recover because wrapped
// commands live as arguments, not separate Command nodes. Falls back
// to primary_program when the script isn't a single-command shape
// (pipelines, `cd && cmd`, etc.) — peel_command rejects those.
let kind = crate::wrappers::peel_command(program)
.map(|p| p.primary)
.or_else(|| primary_program.clone())
.as_deref()
.and_then(kind_for);
let top_level_statements: Vec<StatementSlice> = program
.statements
.iter()
.map(statement_slice)
.collect();
BashShape {
primary_program,
all_programs,
list_ops: list_ops_set,
pipeline_stages,
redirects,
has_heredoc,
cwd_hint,
side_effect,
kind,
top_level_statements,
}
}
/// Lift a top-level [`Statement`] into a [`StatementSlice`]. Every statement
/// variant carries a `span: Span`; tagging the kind here lets the UI gate on
/// statement shape without a second AST walk.
fn statement_slice(stmt: &Statement) -> StatementSlice {
let (span, kind) = match stmt {
Statement::Command(s) => (s.span, StatementKind::Command),
Statement::Pipeline(s) => (s.span, StatementKind::Pipeline),
Statement::List(s) => (s.span, StatementKind::List),
Statement::CompoundStatement(s) => (s.span, StatementKind::Compound),
Statement::Subshell(s) => (s.span, StatementKind::Subshell),
Statement::If(s) => (s.span, StatementKind::If),
Statement::While(s) => (s.span, StatementKind::While),
Statement::For(s) => (s.span, StatementKind::For),
Statement::CStyleFor(s) => (s.span, StatementKind::CStyleFor),
Statement::Case(s) => (s.span, StatementKind::Case),
Statement::FunctionDefinition(s) => (s.span, StatementKind::FunctionDefinition),
Statement::Redirected(s) => (s.span, StatementKind::Redirected),
Statement::Declaration(s) => (s.span, StatementKind::Declaration),
Statement::Unset(s) => (s.span, StatementKind::Unset),
Statement::Test(s) => (s.span, StatementKind::Test),
Statement::Negated(s) => (s.span, StatementKind::Negated),
Statement::VariableAssignment(s) => (s.span, StatementKind::VariableAssignment),
Statement::VariableAssignments(s) => (s.span, StatementKind::VariableAssignments),
};
let Span { byte_start, byte_end, .. } = span;
StatementSlice { byte_start, byte_end, kind }
}
// ============================================================================
// Whole-program collector (populates all_programs / list_ops / etc.)
// ============================================================================
fn collect_info(
stmt: &Statement,
all_programs: &mut Vec<String>,
list_ops: &mut Vec<String>,
pipeline_stages: &mut usize,
redirects: &mut Vec<String>,
has_heredoc: &mut bool,
) {
match stmt {
Statement::Command(cmd) => {
if let Some(name) = cmd_name_text(cmd) {
all_programs.push(name);
}
}
Statement::Pipeline(p) => {
*pipeline_stages = (*pipeline_stages).max(p.stages.len());
for stage in &p.stages {
collect_info(stage, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
}
}
Statement::List(list) => {
let op: &str = match list.operator {
ListOperator::And => "&&",
ListOperator::Or => "||",
};
list_ops.push(op.to_owned());
collect_info(&list.left, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
collect_info(&list.right, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
}
Statement::Redirected(r) => {
collect_redirects(r, redirects, has_heredoc);
if let Some(body) = &r.body {
collect_info(body, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
}
}
Statement::CompoundStatement(cs) => {
for s in &cs.statements {
collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
}
}
Statement::Negated(n) => {
collect_info(&n.inner, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
}
Statement::If(i) => {
for s in &i.body {
collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
}
for elif in &i.elif_clauses {
for s in &elif.body {
collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
}
}
if let Some(else_cl) = &i.else_clause {
for s in &else_cl.body {
collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
}
}
}
Statement::While(w) => {
use crate::ast::LoopBody;
match &w.body {
LoopBody::Compound(cs) => {
for s in &cs.statements {
collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
}
}
LoopBody::DoGroup(dg) => {
for s in &dg.statements {
collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
}
}
}
}
// Declaration / unset / test / assignment — not runnable programs.
_ => {}
}
}
fn collect_redirects(r: &RedirectedStatement, redirects: &mut Vec<String>, has_heredoc: &mut bool) {
for redirect in &r.redirects {
match redirect {
Redirect::File(f) => redirects.push(f.operator.clone()),
Redirect::Heredoc(_) => *has_heredoc = true,
Redirect::Herestring(_) => {}
}
}
}
// ============================================================================
// Primary-command extractor
// ============================================================================
/// Returns `(primary_program, cwd_hint, side_effect)` from the first
/// meaningful statement in the program.
fn extract_primary(program: &Program) -> (Option<String>, Option<String>, Option<SideEffect>) {
extract_from_stmt_list(&program.statements, None)
}
/// Walk a statement list to find the first "real" command (R196-T13).
///
/// Three peeling rules applied in order per statement:
///
/// - **Peel 1** — pure-assignment statements (`pass=0`, `fail=0`) are
/// skipped; they carry no program name.
/// - **Peel 3** — an assignment whose sole RHS is a command substitution
/// (`out=$(VAR=1 cmd …)`) is unwrapped: the inner substitution body
/// becomes the new statement list to scan.
/// - Everything else routes to [`extract_primary_stmt`], which applies
/// Peel 2 (recurse into compound bodies) and the existing `cd &&`
/// threading logic.
fn extract_from_stmt_list(
stmts: &[Statement],
inherited_cwd: Option<String>,
) -> (Option<String>, Option<String>, Option<SideEffect>) {
for stmt in stmts {
match stmt {
// Peel 3: assignment whose RHS is a single command substitution.
Statement::VariableAssignment(a) => {
if let crate::ast::AssignmentValue::Primary(
crate::ast::PrimaryExpression::CommandSubstitution(cs),
) = &a.value
{
let result = extract_from_stmt_list(&cs.body, inherited_cwd.clone());
if result.0.is_some() {
return result;
}
}
// Peel 1: pure assignment (or cmdsub with no identifiable
// primary) — skip and continue.
}
// Peel 1: multi-assignment (`a=1 b=2`) with no command.
Statement::VariableAssignments(_) => {}
_ => {
let result = extract_primary_stmt(stmt, inherited_cwd.clone());
if result.0.is_some() {
return result;
}
// If extract_primary_stmt returned None primary (e.g. bare
// `cd /foo`), keep the updated cwd for the next iteration but
// don't return — continue scanning.
}
}
}
(None, inherited_cwd, None)
}
/// Recursively walk `stmt` to find the first "real" command, threading through
/// `cd X &&` prefixes to produce `cwd_hint`.
///
/// **Peel 2** (R196-T13): compound statements (`for`/`while`/`if`/`{…}`)
/// recurse into their body via [`extract_from_stmt_list`] so a loop whose
/// body contains the load-bearing command yields that command as primary.
///
/// Returns `(primary_program, cwd_hint, side_effect)`.
fn extract_primary_stmt(
stmt: &Statement,
inherited_cwd: Option<String>,
) -> (Option<String>, Option<String>, Option<SideEffect>) {
match stmt {
Statement::Command(cmd) => {
let prog = cmd_name_text(cmd);
if prog.as_deref() == Some("cd") {
// Leading cd — capture path and signal "no primary yet".
let cwd = cmd.arguments.first().and_then(arg_text).or(inherited_cwd);
return (None, cwd, None);
}
let first_arg = cmd.arguments.first().and_then(arg_text);
let side = classify(prog.as_deref(), first_arg.as_deref(), &cmd.arguments);
(prog, inherited_cwd, side)
}
Statement::List(list) => {
let (lprog, lcwd, lside) = extract_primary_stmt(&list.left, inherited_cwd.clone());
if lprog.is_none() {
// Left was a cd or empty — continue on the right with captured cwd.
extract_primary_stmt(&list.right, lcwd)
} else {
(lprog, lcwd, lside)
}
}
Statement::Pipeline(p) => match p.stages.first() {
Some(first) => extract_primary_stmt(first, inherited_cwd),
None => (None, inherited_cwd, None),
},
Statement::Redirected(r) => match &r.body {
Some(body) => extract_primary_stmt(body, inherited_cwd),
None => (None, inherited_cwd, None),
},
Statement::Negated(n) => extract_primary_stmt(&n.inner, inherited_cwd),
// Peel 2: compound bodies — recurse into the first meaningful statement.
Statement::For(f) => {
let stmts = loop_body_stmts(&f.body);
extract_from_stmt_list(stmts, inherited_cwd)
}
Statement::While(w) => {
let stmts = loop_body_stmts(&w.body);
extract_from_stmt_list(stmts, inherited_cwd)
}
Statement::CompoundStatement(cs) => {
extract_from_stmt_list(&cs.statements, inherited_cwd)
}
Statement::If(i) => extract_from_stmt_list(&i.body, inherited_cwd),
_ => (None, inherited_cwd, None),
}
}
/// Flatten a [`LoopBody`] to its contained statement slice.
fn loop_body_stmts(body: &crate::ast::LoopBody) -> &[Statement] {
match body {
crate::ast::LoopBody::Compound(cs) => &cs.statements,
crate::ast::LoopBody::DoGroup(dg) => &dg.statements,
}
}
// ============================================================================
// Side-effect classifier
// ============================================================================
fn classify(prog: Option<&str>, first_arg: Option<&str>, args: &[CommandArgument]) -> Option<SideEffect> {
let prog = prog?;
let fa = first_arg.unwrap_or("");
const GIT_WRITE: &[&str] = &[
"push", "commit", "tag", "merge", "rebase",
"reset", "branch", "checkout", "clean",
];
const GH_OBJECTS: &[&str] = &["pr", "release", "issue"];
const GH_WRITE_ACTIONS: &[&str] = &["create", "merge", "close"];
const HTTP_WRITE_METHODS: &[&str] = &["POST", "PUT", "DELETE", "PATCH"];
match prog {
"git" => {
if GIT_WRITE.contains(&fa) {
return Some(SideEffect::GitWrite);
}
}
"gh" => {
if GH_OBJECTS.contains(&fa) {
let second = args.get(1).and_then(arg_text);
if second.as_deref().map(|s| GH_WRITE_ACTIONS.contains(&s)).unwrap_or(false) {
return Some(SideEffect::GitHub);
}
}
}
"cargo" | "npm" | "bun" => {
if fa == "publish" {
return Some(SideEffect::Publish);
}
}
"rm" | "rmdir" | "dd" => return Some(SideEffect::Destructive),
"curl" | "wget" => {
let texts: Vec<Option<String>> = args.iter().map(arg_text).collect();
// -X POST (two tokens)
let two_token = texts.windows(2).any(|w| {
w[0].as_deref() == Some("-X")
&& w[1].as_deref().map(|s| HTTP_WRITE_METHODS.contains(&s)).unwrap_or(false)
});
// -XPOST (merged single token)
let merged = texts.iter().any(|t| {
t.as_deref()
.map(|s| s.starts_with("-X") && HTTP_WRITE_METHODS.contains(&&s[2..]))
.unwrap_or(false)
});
if two_token || merged {
return Some(SideEffect::Network);
}
}
"sudo" => return Some(SideEffect::SudoOrInstall),
"brew" => {
if fa == "install" {
return Some(SideEffect::SudoOrInstall);
}
}
"apt" | "apt-get" => {
if fa == "install" {
return Some(SideEffect::SudoOrInstall);
}
}
_ => {
if prog.starts_with("mkfs") {
return Some(SideEffect::Destructive);
}
}
}
None
}
// ============================================================================
// Text-extraction helpers
// ============================================================================
fn cmd_name_text(cmd: &Command) -> Option<String> {
match &cmd.name.inner {
CommandNameInner::Primary(p) => primary_text(p),
CommandNameInner::Concatenation(c) => {
let s: String = c.parts.iter().filter_map(primary_text).collect();
if s.is_empty() { None } else { Some(s) }
}
}
}
fn arg_text(arg: &CommandArgument) -> Option<String> {
match arg {
CommandArgument::Primary(p) => primary_text(p),
CommandArgument::Concatenation(c) => {
let s: String = c.parts.iter().filter_map(primary_text).collect();
if s.is_empty() { None } else { Some(s) }
}
CommandArgument::Regex(r) => Some(r.text.clone()),
CommandArgument::Operator { text, .. } => Some(text.clone()),
}
}
fn primary_text(p: &PrimaryExpression) -> Option<String> {
match p {
PrimaryExpression::Word(w) => Some(w.text.clone()),
PrimaryExpression::RawString(r) => Some(r.text.trim_matches('\'').to_owned()),
PrimaryExpression::StringNode(s) => {
let text: String = s.parts.iter().filter_map(|part| match part {
StringPart::Content(c) => Some(c.text.clone()),
_ => None,
}).collect();
if text.is_empty() { None } else { Some(text) }
}
PrimaryExpression::AnsiCString(a) => Some(a.text.clone()),
PrimaryExpression::SimpleExpansion(_)
| PrimaryExpression::Expansion(_)
| PrimaryExpression::CommandSubstitution(_)
| PrimaryExpression::ArithmeticExpansion(_)
| PrimaryExpression::BraceExpression(_)
| PrimaryExpression::Number(_)
| PrimaryExpression::ProcessSubstitution(_)
| PrimaryExpression::TranslatedString(_) => None,
}
}
// ============================================================================
// Utility
// ============================================================================
fn dedup_vec(v: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
v.retain(|s| seen.insert(s.clone()));
}