Skip to main content

kaish_kernel/ast/
plan.rs

1//! The statement plan: an AST rendered back to shell text, **unexpanded**.
2//!
3//! A plan is parse information. It is built after validation and before
4//! execution, so `${HOME}` and `$(...)` appear exactly as written — an
5//! embedder judges what was asked, not what it resolved to, and the
6//! substitution that would resolve them has not run.
7//!
8//! Two products, one AST walk each: `render_stmt` produces the text, and
9//! [`planned_commands`] produces one [`PlannedCommand`] per command the
10//! statement contains — control-structure bodies, `if` conditions, and
11//! command substitutions included, because every one of them is a command
12//! this statement would run.
13//!
14//! The same walk collects the statement's variables: the names it reads
15//! (`free_variables`) and the names it writes (`bound_variables`). A name
16//! that is both lands bound, never free.
17//!
18//! The collection walk also lifts out any literal `--confirm=<key>` the
19//! statement's argv carries ([`StatementPlan::presented_keys`]) — the same
20//! spellings the rendering redacts. One predicate decides all three of lift,
21//! redact, and render, so they cannot disagree about what the statement
22//! presented.
23//!
24//! Redaction is the `--confirm=<key>` flag spelling and nothing else — that
25//! spelling carries a confirmation credential, and a credential must never
26//! ride into a stored plan. kaish ships no secret detector — a shell cannot
27//! define what a secret is — so an embedder that wants more redacts the
28//! plans it holds.
29
30use std::collections::BTreeSet;
31
32use kaish_types::plan::{
33    Plan, PlannedCommand, PlannedHeredoc, PlannedRedirect, PlannedValue, PLAN_RENDER_LIMIT,
34};
35use kaish_types::Value;
36
37use super::types::{
38    Arg, Assignment, BinaryOp, CaseStmt, Command, Expr, ForLoop, IfStmt, ListElem, Pipeline,
39    PipelineStage, RecordKey, Redirect, RedirectKind, Stmt, StringPart, TestExpr, ToolDef, VarPath,
40    VarSegment,
41    WhileLoop,
42};
43
44/// One statement's plan, plus the redemption credentials its argv presented.
45pub struct StatementPlan {
46    /// What the statement was asked to run, with every credential redacted.
47    pub plan: Plan,
48    /// Every literal `--confirm=<key>` (or `confirm=<key>`) the statement's
49    /// argv carries, in source order.
50    ///
51    /// **Literal only.** A plan is unexpanded, so `--confirm=${key}` reads as
52    /// `${key}` here and nothing is lifted — what the plan cannot see, the
53    /// plan cannot leak, and nothing is stripped from the argv that executes.
54    pub presented_keys: Vec<String>,
55}
56
57/// One statement of a planned program: its [`Plan`] and where it sits among
58/// the planned statements.
59///
60/// `index` is the statement's position in the returned list, so an embedder
61/// can name which statement it is talking about.
62#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub struct PlannedStatement {
64    /// The statement's position in the returned list, counted from 0 with no
65    /// gaps: `plans[i].index == i`, always. Indexing the list by this number
66    /// reads the statement it names.
67    pub index: usize,
68    /// What the statement was asked to run, with every credential redacted.
69    pub plan: Plan,
70}
71
72/// Plan every statement of `source` without executing anything.
73///
74/// A plan is parse information: `${HOME}` and `$(...)` appear exactly as
75/// written, no substitution has run, and no filesystem has been touched. That
76/// is the point — an embedder judges what the statement *asked for*, before
77/// anything it names can happen.
78///
79/// Each plan carries the statement's rendered text, every command it would
80/// run (control-structure bodies, `if` conditions, and `$(...)` bodies
81/// included), the variables it reads ([`Plan::free_variables`]) and the ones
82/// it writes ([`Plan::bound_variables`]). Reading live session state for the
83/// free set with [`Kernel::get_var`](crate::Kernel::get_var) closes the loop:
84/// plan a statement, look up what it depends on, and decide with the values
85/// in hand.
86///
87/// Every literal `--confirm=<key>` is redacted from the plans and **not
88/// returned**: the caller holds `source` and can read its own credentials;
89/// this function adds no second copy.
90///
91/// # Errors
92///
93/// Returns the parse errors when `source` does not parse. Each error's
94/// [`format`](crate::parser::ParseError::format) renders a diagnostic against
95/// the source.
96pub fn plan_program(
97    source: &str,
98) -> Result<Vec<PlannedStatement>, Vec<crate::parser::ParseError>> {
99    let program = crate::parser::parse(source)?;
100    Ok(program
101        .statements
102        .iter()
103        // An empty statement runs nothing and plans nothing. Dropping it
104        // BEFORE numbering is what keeps `index` equal to the position in the
105        // returned list: numbering first left a gap whenever the source opened
106        // with a comment or a blank line, which is most scripts.
107        .filter(|stmt| !matches!(stmt, Stmt::Empty))
108        .enumerate()
109        .map(|(index, stmt)| PlannedStatement {
110            index,
111            plan: plan_statement(stmt).plan,
112        })
113        .collect())
114}
115
116/// Build the plan for one top-level statement. Every value is
117/// [`PlannedValue::Plain`] except a presented confirm key, which the kernel
118/// redacts unconditionally.
119pub(crate) fn plan_statement(stmt: &Stmt) -> StatementPlan {
120    let collected = collect(stmt);
121    // Free = read and never written in-statement. A name that is both read
122    // and written lands in `bound` — the safe direction: an embedder that
123    // skips peeking it loses one lookup; one that peeked it would judge the
124    // statement against a value the statement itself replaces.
125    let free: Vec<String> = collected
126        .reads
127        .difference(&collected.binds)
128        .cloned()
129        .collect();
130    let bound: Vec<String> = collected.binds.into_iter().collect();
131    StatementPlan {
132        plan: Plan::new(
133            truncate_rendering(render_stmt(stmt)),
134            stmt.kind_name(),
135            collected.commands,
136        )
137        .with_variables(free, bound),
138        presented_keys: collected.keys,
139    }
140}
141
142/// Remove every `--confirm=` (or `confirm=`) token from rendered plan text,
143/// whatever it carries.
144///
145/// An embedder computing a content identity over rendered text (see
146/// `PlanDigest` in kaish-types) wants the identity to cover the operation,
147/// not any credential presented with it — `rm x` and
148/// `rm --confirm=<confirm-key> x` should digest the same.
149///
150/// Unlike [`redact_keys`], this does not need to know the key: it removes the
151/// whole token whether it carries a literal credential, the `<confirm-key>`
152/// marker a rendered plan shows, or an unexpanded `${key}` the plan could not
153/// lift.
154pub fn strip_confirm_tokens(rendered: &str) -> String {
155    rendered
156        .split_whitespace()
157        .filter(|word| {
158            !word.starts_with(&format!("--{CONFIRM_KEY}=")) && !word.starts_with(&format!("{CONFIRM_KEY}="))
159        })
160        .collect::<Vec<_>>()
161        .join(" ")
162}
163
164/// Remove every one of `keys` from captured source text — for an embedder
165/// storing source alongside plans, so the stored text never carries a
166/// credential. The whole `--confirm=<key>` token goes, not just its value,
167/// so re-running the stored text cannot re-present a spent key.
168pub fn redact_keys(source: &str, keys: &[String]) -> String {
169    let mut out = source.to_string();
170    for key in keys {
171        for spelling in [format!("--{CONFIRM_KEY}={key}"), format!("{CONFIRM_KEY}={key}")] {
172            // Take the separating space with the token so the surrounding
173            // words stay one space apart; fall back to the bare token for a
174            // spelling that opens its line.
175            out = out.replace(&format!(" {spelling}"), "");
176            out = out.replace(&spelling, "");
177        }
178    }
179    out
180}
181
182/// Cut a rendering to [`PLAN_RENDER_LIMIT`] bytes, naming the cut.
183///
184/// The marker is loud and states the number, because a classifier reading a
185/// silently shortened line would judge a statement it cannot see the end of.
186/// The structure is not lost with the text — [`Plan::commands`] still names
187/// every command.
188fn truncate_rendering(rendered: String) -> String {
189    if rendered.len() <= PLAN_RENDER_LIMIT {
190        return rendered;
191    }
192    // Back up to a character boundary so the marker lands on valid UTF-8.
193    let mut cut = PLAN_RENDER_LIMIT;
194    while cut > 0 && !rendered.is_char_boundary(cut) {
195        cut -= 1;
196    }
197    let mut out = rendered[..cut].to_string();
198    out.push_str(&format!(
199        "… [rendering truncated at {PLAN_RENDER_LIMIT} bytes]"
200    ));
201    out
202}
203
204// ───────────────────────── Command collection ─────────────────────────
205
206/// What one collection walk produces: the statement's commands, the
207/// credentials their argv presented, and its variable analysis.
208#[derive(Default)]
209struct Collected<'a> {
210    commands: Vec<PlannedCommand>,
211    keys: Vec<String>,
212    /// Every variable name the statement reads, anywhere — `${x}`, a
213    /// `"${x}"` interpolation, `${#x}`, a `[$k]` dynamic subscript, an
214    /// identifier inside `$((…))`. kaish has no `eval` and no indirect
215    /// expansion, so this set is complete by construction.
216    reads: BTreeSet<String>,
217    /// Every name the statement writes or binds — an assignment target, a
218    /// `for` variable, an env-prefix name, a tool-def parameter.
219    binds: BTreeSet<String>,
220    /// Every heredoc target the walk has reached, in the order it reached
221    /// them — the order that gives each one the flat
222    /// [`PlannedHeredoc::index`] a plan publishes, so a heredoc inside a
223    /// loop body is addressable without walking structure.
224    ///
225    /// Kept here rather than re-derived by a second walk. An address that
226    /// resolves to a *different* body than the one it published is the worst
227    /// failure this surface can have, and two traversals that have to agree
228    /// is how you get one — this walk descends into redirect targets and
229    /// interpolated strings, and a resolver written to match would have to
230    /// remember to. `heredoc_targets[i]` is the target of the heredoc
231    /// published with `index == i`, by construction.
232    heredoc_targets: Vec<&'a Expr>,
233}
234
235impl<'a> Collected<'a> {
236    /// Publish every heredoc one command declares, numbering them in the
237    /// order this walk reaches them.
238    fn take_heredocs(&mut self, cmd: &'a Command) -> Vec<PlannedHeredoc> {
239        cmd.redirects
240            .iter()
241            .filter_map(|r| match &r.kind {
242                RedirectKind::HereDoc(meta) => Some((meta, &r.target)),
243                _ => None,
244            })
245            .map(|(meta, target)| {
246                let index = self.heredoc_targets.len();
247                self.heredoc_targets.push(target);
248                // The body's own reads, not the statement's: an embedder
249                // asking what plugs into *this* program wants the answer
250                // scoped to it. A literal body reads nothing whatever it
251                // contains, because nothing in it expands.
252                let free = if meta.literal {
253                    Vec::new()
254                } else {
255                    let mut body_reads = Collected::default();
256                    collect_expr(target, false, &mut body_reads);
257                    body_reads.reads.into_iter().collect()
258                };
259                PlannedHeredoc::new(
260                    index,
261                    meta.delimiter.clone(),
262                    meta.literal,
263                    meta.strip_tabs,
264                    PlannedValue::Plain(meta.body.clone()),
265                    meta.body_offset,
266                )
267                .with_free_variables(free)
268            })
269            .collect()
270    }
271
272    /// Record every read a variable path performs: its root name, plus any
273    /// `[$k]` dynamic-subscript variable along the path.
274    fn read_path(&mut self, path: &VarPath) {
275        for (i, segment) in path.segments.iter().enumerate() {
276            match segment {
277                VarSegment::Field(name) if i == 0 => {
278                    self.reads.insert(name.clone());
279                }
280                VarSegment::Dynamic(v) => {
281                    self.reads.insert(v.clone());
282                }
283                _ => {}
284            }
285        }
286    }
287
288    /// Record the name an assignment path writes (its root), plus the reads
289    /// its dynamic subscripts perform — `x[$k]=v` writes `x` and reads `k`.
290    fn bind_path(&mut self, path: &VarPath) {
291        if let Some(VarSegment::Field(name)) = path.segments.first() {
292            self.binds.insert(name.clone());
293        }
294        for segment in path.segments.iter().skip(1) {
295            if let VarSegment::Dynamic(v) = segment {
296                self.reads.insert(v.clone());
297            }
298        }
299    }
300
301    /// Record every identifier in an arithmetic expression as a read.
302    /// kaish arithmetic is numbers, variables (bare or `${name}`), and
303    /// operators — an identifier token is always a variable.
304    fn read_arithmetic(&mut self, expr: &str) {
305        let mut name = String::new();
306        for c in expr.chars() {
307            if c == '_' || c.is_ascii_alphabetic() || (!name.is_empty() && c.is_ascii_digit()) {
308                name.push(c);
309            } else if !name.is_empty() {
310                self.reads.insert(std::mem::take(&mut name));
311            }
312        }
313        if !name.is_empty() {
314            self.reads.insert(name);
315        }
316    }
317}
318
319/// Every command the statement contains, in source order, plus any literal
320/// redemption key its argv carries.
321///
322/// A `for` body's commands, an `if` condition's command, and a `$(…)`
323/// substitution's commands are all in here: each is a command this statement
324/// would run, so each is a `cmd` resource a standing grant has to cover.
325fn collect<'a>(stmt: &'a Stmt) -> Collected<'a> {
326    let mut out = Collected::default();
327    collect_stmt(stmt, false, &mut out);
328    out
329}
330
331/// Every heredoc target the statement contains, indexed by the flat
332/// [`PlannedHeredoc::index`] the plan publishes.
333///
334/// This is the **same walk** that numbers them, not a second one that agrees
335/// with it — `heredoc_targets(stmt)[i]` is the target of the heredoc the plan
336/// published with `index == i`, by construction rather than by test.
337pub(crate) fn heredoc_targets(stmt: &Stmt) -> Vec<&Expr> {
338    collect(stmt).heredoc_targets
339}
340
341/// Every command the statement contains, in source order.
342pub fn planned_commands(stmt: &Stmt) -> Vec<PlannedCommand> {
343    collect(stmt).commands
344}
345
346fn collect_stmt<'a>(stmt: &'a Stmt, background: bool, out: &mut Collected<'a>) {
347    match stmt {
348        Stmt::Assignment(a) => {
349            out.bind_path(&a.path);
350            collect_expr(&a.value, background, out)
351        }
352        Stmt::Command(cmd) => collect_command(cmd, background, out),
353        Stmt::Pipeline(p) => {
354            for stage in &p.stages {
355                match stage {
356                    PipelineStage::Command(cmd) => {
357                        collect_command(cmd, background || p.background, out)
358                    }
359                    // A compound stage's commands belong to the enclosing
360                    // statement, same as a loop body's do.
361                    PipelineStage::Compound(stmt) => {
362                        collect_stmt(stmt, background || p.background, out)
363                    }
364                }
365            }
366        }
367        Stmt::If(s) => {
368            collect_expr(&s.condition, background, out);
369            collect_block(&s.then_branch, background, out);
370            if let Some(else_branch) = &s.else_branch {
371                collect_block(else_branch, background, out);
372            }
373        }
374        Stmt::For(s) => {
375            out.binds.insert(s.variable.clone());
376            for item in &s.items {
377                collect_expr(item, background, out);
378            }
379            collect_block(&s.body, background, out);
380        }
381        Stmt::While(s) => {
382            collect_expr(&s.condition, background, out);
383            collect_block(&s.body, background, out);
384        }
385        Stmt::Case(s) => {
386            collect_expr(&s.expr, background, out);
387            for branch in &s.branches {
388                collect_block(&branch.body, background, out);
389            }
390        }
391        Stmt::Return(e) | Stmt::Exit(e) => {
392            if let Some(e) = e {
393                collect_expr(e, background, out);
394            }
395        }
396        Stmt::ToolDef(def) => {
397            for param in &def.params {
398                out.binds.insert(param.name.clone());
399                if let Some(default) = &param.default {
400                    collect_expr(default, background, out);
401                }
402            }
403            collect_block(&def.body, background, out)
404        }
405        Stmt::Test(t) => collect_test(t, background, out),
406        Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
407            collect_stmt(left, background, out);
408            collect_stmt(right, background, out);
409        }
410        Stmt::EnvScoped { assignments, body } => {
411            for a in assignments {
412                out.bind_path(&a.path);
413                collect_expr(&a.value, background, out);
414            }
415            collect_stmt(body, background, out);
416        }
417        Stmt::Break(_) | Stmt::Continue(_) | Stmt::Empty => {}
418    }
419}
420
421fn collect_block<'a>(stmts: &'a [Stmt], background: bool, out: &mut Collected<'a>) {
422    for stmt in stmts {
423        collect_stmt(stmt, background, out);
424    }
425}
426
427fn collect_command<'a>(cmd: &'a Command, background: bool, out: &mut Collected<'a>) {
428    let args: Vec<PlannedValue> = cmd.args.iter().map(|arg| plan_arg(arg).1).collect();
429    let redirects = cmd
430        .redirects
431        .iter()
432        .map(|r| PlannedRedirect::new(r.kind.to_string(), plan_redirect_target(r)))
433        .collect();
434    let heredocs = out.take_heredocs(cmd);
435    out.commands.push(
436        PlannedCommand::new(cmd.name.clone(), args, redirects, background)
437            .with_heredocs(heredocs),
438    );
439    // Lift any literal credential out of the argv on the same pass that
440    // redacts it from the rendering — one walk, one truth about what this
441    // statement presented.
442    for arg in &cmd.args {
443        if let Some(key) = presented_key(arg) {
444            out.keys.push(key);
445        }
446    }
447    // Substitutions nested inside this command's own arguments and redirect
448    // targets are commands too, and they run before it does.
449    for arg in &cmd.args {
450        match arg {
451            Arg::Positional(e) => collect_expr(e, background, out),
452            Arg::Named { value, .. } | Arg::WordAssign { value, .. } => {
453                collect_expr(value, background, out)
454            }
455            Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
456        }
457    }
458    for redirect in &cmd.redirects {
459        collect_expr(&redirect.target, background, out);
460    }
461}
462
463fn collect_expr<'a>(expr: &'a Expr, background: bool, out: &mut Collected<'a>) {
464    match expr {
465        Expr::Command(cmd) => collect_command(cmd, background, out),
466        Expr::CommandSubst(stmts) => collect_block(stmts, background, out),
467        // The negation runs its inner command; the plan must show it.
468        Expr::Not(inner) => collect_expr(inner, background, out),
469        Expr::BinaryOp { left, right, .. } => {
470            collect_expr(left, background, out);
471            collect_expr(right, background, out);
472        }
473        Expr::Interpolated(parts) => collect_parts(parts, background, out),
474        Expr::HereDocBody { parts, .. } => {
475            for part in parts {
476                collect_part(&part.part, background, out);
477            }
478        }
479        Expr::Test(t) => collect_test(t, background, out),
480        Expr::VarWithDefault { path, default } => {
481            out.read_path(path);
482            collect_parts(default, background, out)
483        }
484        Expr::ListLiteral(elems) => {
485            for elem in elems {
486                match elem {
487                    ListElem::Item(e) | ListElem::Spread(e) => collect_expr(e, background, out),
488                }
489            }
490        }
491        Expr::RecordLiteral(entries) => {
492            for entry in entries {
493                if let RecordKey::Interpolated(parts) = &entry.key {
494                    collect_parts(parts, background, out);
495                }
496                collect_expr(&entry.value, background, out);
497            }
498        }
499        Expr::VarRef(path) | Expr::VarLength(path) => out.read_path(path),
500        Expr::Arithmetic(e) => out.read_arithmetic(e),
501        // Special forms ($1, $@, $#, $?, $$) are not session variables; an
502        // embedder cannot peek them with `get_var`, so they are not listed.
503        Expr::Literal(_)
504        | Expr::Positional(_)
505        | Expr::AllArgs
506        | Expr::ArgCount
507        | Expr::LastExitCode
508        | Expr::CurrentPid
509        | Expr::GlobPattern(_) => {}
510    }
511}
512
513fn collect_parts<'a>(parts: &'a [StringPart], background: bool, out: &mut Collected<'a>) {
514    for part in parts {
515        collect_part(part, background, out);
516    }
517}
518
519fn collect_part<'a>(part: &'a StringPart, background: bool, out: &mut Collected<'a>) {
520    match part {
521        StringPart::CommandSubst(stmts) => collect_block(stmts, background, out),
522        StringPart::VarWithDefault { path, default } => {
523            out.read_path(path);
524            collect_parts(default, background, out)
525        }
526        StringPart::Var(path) | StringPart::VarLength(path) => out.read_path(path),
527        StringPart::Arithmetic(e) => out.read_arithmetic(e),
528        // See the identical special-forms note in `collect_expr`.
529        StringPart::Literal(_)
530        | StringPart::Positional(_)
531        | StringPart::AllArgs
532        | StringPart::ArgCount
533        | StringPart::LastExitCode
534        | StringPart::CurrentPid => {}
535    }
536}
537
538fn collect_test<'a>(test: &'a TestExpr, background: bool, out: &mut Collected<'a>) {
539    match test {
540        TestExpr::FileTest { path, .. } => collect_expr(path, background, out),
541        TestExpr::StringTest { value, .. } => collect_expr(value, background, out),
542        TestExpr::Comparison { left, right, .. }
543        | TestExpr::In { left, right }
544        | TestExpr::NotIn { left, right } => {
545            collect_expr(left, background, out);
546            collect_expr(right, background, out);
547        }
548        TestExpr::And { left, right } | TestExpr::Or { left, right } => {
549            collect_test(left, background, out);
550            collect_test(right, background, out);
551        }
552        TestExpr::Not { expr } => collect_test(expr, background, out),
553    }
554}
555
556// ───────────────────────── Rendering ─────────────────────────
557
558/// Render one statement back to shell text, unexpanded.
559pub(crate) fn render_stmt(stmt: &Stmt) -> String {
560    match stmt {
561        Stmt::Assignment(a) => render_assignment(a),
562        Stmt::Command(cmd) => render_command(cmd),
563        Stmt::Pipeline(p) => render_pipeline(p),
564        Stmt::If(s) => render_if(s),
565        Stmt::For(s) => render_for(s),
566        Stmt::While(s) => render_while(s),
567        Stmt::Case(s) => render_case(s),
568        Stmt::Break(n) => render_keyword("break", n.map(|n| n.to_string())),
569        Stmt::Continue(n) => render_keyword("continue", n.map(|n| n.to_string())),
570        Stmt::Return(e) => render_keyword("return", e.as_ref().map(|e| render_expr(e))),
571        Stmt::Exit(e) => render_keyword("exit", e.as_ref().map(|e| render_expr(e))),
572        Stmt::ToolDef(def) => render_tooldef(def),
573        Stmt::Test(t) => format!("[[ {} ]]", render_test(t)),
574        Stmt::AndChain { left, right } => {
575            format!("{} && {}", render_stmt(left), render_stmt(right))
576        }
577        Stmt::OrChain { left, right } => {
578            format!("{} || {}", render_stmt(left), render_stmt(right))
579        }
580        Stmt::EnvScoped { assignments, body } => {
581            let prefix: Vec<String> = assignments.iter().map(render_assignment).collect();
582            format!("{} {}", prefix.join(" "), render_stmt(body))
583        }
584        Stmt::Empty => String::new(),
585    }
586}
587
588fn render_keyword(word: &str, operand: Option<String>) -> String {
589    match operand {
590        Some(operand) => format!("{word} {operand}"),
591        None => word.to_string(),
592    }
593}
594
595fn render_block(stmts: &[Stmt]) -> String {
596    stmts
597        .iter()
598        .filter(|s| !matches!(s, Stmt::Empty))
599        .map(render_stmt)
600        .collect::<Vec<_>>()
601        .join("; ")
602}
603
604fn render_assignment(a: &Assignment) -> String {
605    let path = render_varpath(&a.path);
606    if a.local {
607        format!("local {} = {}", path, render_expr(&a.value))
608    } else {
609        format!("{}={}", path, render_expr(&a.value))
610    }
611}
612
613/// Render one command: argv0, every argument form, and every redirect.
614pub(crate) fn render_command(cmd: &Command) -> String {
615    let mut parts = vec![cmd.name.clone()];
616    for arg in &cmd.args {
617        parts.push(plan_arg(arg).0);
618    }
619    for redirect in &cmd.redirects {
620        parts.push(render_redirect(redirect));
621    }
622    parts.join(" ")
623}
624
625/// The one argument whose value never reaches a plan unredacted:
626/// `--confirm=<token>` carries a confirmation credential, and a plan is
627/// built to be stored and shown. This is kaish's own, unconditional
628/// redaction — possible exactly because the flag spelling is kaish's
629/// convention and needs no secret detector to recognize.
630const CONFIRM_KEY: &str = "confirm";
631
632/// The [`PlannedValue::Redacted`] `kind` the kernel's confirm-key redaction
633/// marks a value with, so an auditor reading `Plan::commands` can tell the
634/// kernel's own redaction apart from an embedder's.
635const CONFIRM_KEY_KIND: &str = "confirm-key";
636
637/// The literal credential this argument presents, if it is a `confirm`
638/// argument carrying one.
639///
640/// A non-literal value (`--confirm=${key}`, `--confirm=$(cat key)`) yields
641/// `None`: the plan is unexpanded, so the value is not knowable here. That
642/// costs nothing — what the plan cannot see, it cannot leak, and the argv
643/// that executes is untouched.
644fn presented_key(arg: &Arg) -> Option<String> {
645    let value = match arg {
646        Arg::Named { key, value } | Arg::WordAssign { key, value } if key == CONFIRM_KEY => value,
647        _ => return None,
648    };
649    match value {
650        Expr::Literal(Value::String(s)) => Some(s.clone()),
651        _ => None,
652    }
653}
654
655/// Plan one argument: its flat text (for [`render_command`]) and its
656/// [`PlannedValue`] (for [`PlannedCommand::args`]), derived together so the
657/// two representations cannot disagree about what this argument was.
658///
659/// A `confirm` argument carrying a *literal* is a credential and is redacted
660/// unconditionally; every other value's flag/key prefix (if any) stays
661/// visible even when its value is redacted, so the plan still shows *that* a
662/// value was presented at that flag, never *what*.
663fn plan_arg(arg: &Arg) -> (String, PlannedValue) {
664    if presented_key(arg).is_some() {
665        let value = PlannedValue::redacted(CONFIRM_KEY_KIND, None);
666        let text = match arg {
667            // `dd` takes its operands as bare `key=value`, so `confirm=<key>`
668            // is a second spelling of the same credential.
669            Arg::WordAssign { key, .. } => format!("{key}={}", value.display()),
670            _ => format!("--{CONFIRM_KEY}={}", value.display()),
671        };
672        return (text, value);
673    }
674    let (text, value) = match arg {
675        Arg::Positional(e) => {
676            let value = PlannedValue::Plain(render_expr(e));
677            (value.display(), value)
678        }
679        Arg::Named { key, value: e } => {
680            let value = PlannedValue::Plain(render_expr(e));
681            (format!("--{key}={}", value.display()), value)
682        }
683        Arg::WordAssign { key, value: e } => {
684            let value = PlannedValue::Plain(render_expr(e));
685            (format!("{key}={}", value.display()), value)
686        }
687        Arg::ShortFlag(f) => {
688            let text = format!("-{f}");
689            (text.clone(), PlannedValue::Plain(text))
690        }
691        Arg::LongFlag(f) => {
692            let text = format!("--{f}");
693            (text.clone(), PlannedValue::Plain(text))
694        }
695        Arg::DoubleDash => ("--".to_string(), PlannedValue::Plain("--".to_string())),
696    };
697    // A redacted `--key=value` loses its `key=` prefix in the *structured*
698    // value (`PlannedValue::Redacted` has nowhere to put one) but keeps it
699    // in the flat text above; a plain value keeps the full composed text in
700    // both, so `PlannedCommand::args` round-trips into `render_command`'s
701    // output unless something was actually judged secret.
702    let structured = if value.is_redacted() {
703        value
704    } else {
705        PlannedValue::Plain(text.clone())
706    };
707    (text, structured)
708}
709
710/// Plan one redirect's target: rendered unexpanded, always plain — a
711/// redirect target is never the kernel's confirm key.
712///
713/// A heredoc's target is its delimiter word, which is what stands after `<<`
714/// in the source. Rendering the *body* here would repeat what
715/// [`PlannedCommand::heredocs`] carries structurally, and rendering it from
716/// the target expression spells every delimiter `EOF` — the body has lost the
717/// word by then.
718///
719/// [`PlannedCommand::heredocs`]: kaish_types::plan::PlannedCommand::heredocs
720fn plan_redirect_target(redirect: &Redirect) -> PlannedValue {
721    match &redirect.kind {
722        RedirectKind::HereDoc(meta) => {
723            let quote = if meta.literal { "'" } else { "" };
724            PlannedValue::Plain(format!("{quote}{}{quote}", meta.delimiter))
725        }
726        _ => PlannedValue::Plain(render_expr(&redirect.target)),
727    }
728}
729
730fn render_redirect(redirect: &Redirect) -> String {
731    // A merge redirect (`2>&1`, `1>&2`) is the whole operator: its target
732    // expression is a placeholder, and printing it would invent a filename.
733    match &redirect.kind {
734        RedirectKind::MergeStderr | RedirectKind::MergeStdout => redirect.kind.to_string(),
735        // A heredoc renders back the way it was written — its own delimiter
736        // word, its own body. Spelling every delimiter `EOF` would erase the
737        // hint the author chose (`PY`, `SQL`) from the one field a classifier
738        // reads first.
739        RedirectKind::HereDoc(meta) => {
740            let dash = if meta.strip_tabs { "-" } else { "" };
741            let quote = if meta.literal { "'" } else { "" };
742            format!(
743                "<<{dash}{quote}{delim}{quote}\n{body}{delim}",
744                delim = meta.delimiter,
745                body = meta.body,
746            )
747        }
748        _ => format!(
749            "{} {}",
750            redirect.kind,
751            plan_redirect_target(redirect).display()
752        ),
753    }
754}
755
756fn render_pipeline(p: &Pipeline) -> String {
757    let body = p
758        .stages
759        .iter()
760        .map(|stage| match stage {
761            PipelineStage::Command(cmd) => render_command(cmd),
762            PipelineStage::Compound(stmt) => render_stmt(stmt),
763        })
764        .collect::<Vec<_>>()
765        .join(" | ");
766    if p.background {
767        format!("{body} &")
768    } else {
769        body
770    }
771}
772
773fn render_if(s: &IfStmt) -> String {
774    let mut out = format!(
775        "if {}; then {}",
776        render_expr(&s.condition),
777        render_block(&s.then_branch)
778    );
779    if let Some(else_branch) = &s.else_branch {
780        let rendered = render_block(else_branch);
781        if !rendered.is_empty() {
782            out.push_str(&format!("; else {rendered}"));
783        }
784    }
785    out.push_str("; fi");
786    out
787}
788
789fn render_for(s: &ForLoop) -> String {
790    let items: Vec<String> = s.items.iter().map(render_expr).collect();
791    format!(
792        "for {} in {}; do {}; done",
793        s.variable,
794        items.join(" "),
795        render_block(&s.body)
796    )
797}
798
799fn render_while(s: &WhileLoop) -> String {
800    format!(
801        "while {}; do {}; done",
802        render_expr(&s.condition),
803        render_block(&s.body)
804    )
805}
806
807fn render_case(s: &CaseStmt) -> String {
808    let branches: Vec<String> = s
809        .branches
810        .iter()
811        .map(|b| format!("{}) {} ;;", b.patterns.join("|"), render_block(&b.body)))
812        .collect();
813    format!("case {} in {} esac", render_expr(&s.expr), branches.join(" "))
814}
815
816fn render_tooldef(def: &ToolDef) -> String {
817    let params: Vec<String> = def
818        .params
819        .iter()
820        .map(|p| match &p.default {
821            Some(default) => format!("{}={}", p.name, render_expr(default)),
822            None => p.name.clone(),
823        })
824        .collect();
825    format!(
826        "tool {}({}) {{ {} }}",
827        def.name,
828        params.join(", "),
829        render_block(&def.body)
830    )
831}
832
833/// Render one expression back to shell text, unexpanded: a variable
834/// reference stays `${NAME}` and a substitution stays `$(…)`.
835pub(crate) fn render_expr(expr: &Expr) -> String {
836    match expr {
837        Expr::Not(inner) => format!("! {}", render_expr(inner)),
838        Expr::Literal(v) => render_literal(v),
839        Expr::VarRef(path) => format!("${{{}}}", render_varpath(path)),
840        Expr::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
841        Expr::HereDocBody { parts, strip_tabs } => {
842            let dash = if *strip_tabs { "-" } else { "" };
843            let body: Vec<String> = parts.iter().map(|sp| render_part(&sp.part)).collect();
844            format!("<<{dash}EOF\n{}\nEOF", body.join(""))
845        }
846        Expr::BinaryOp { left, op, right } => {
847            let op = match op {
848                BinaryOp::And => "&&",
849                BinaryOp::Or => "||",
850            };
851            format!("{} {} {}", render_expr(left), op, render_expr(right))
852        }
853        Expr::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
854        Expr::Test(t) => format!("[[ {} ]]", render_test(t)),
855        Expr::Positional(n) => format!("${n}"),
856        Expr::AllArgs => "$@".to_string(),
857        Expr::ArgCount => "$#".to_string(),
858        Expr::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
859        Expr::VarWithDefault { path, default } => {
860            format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
861        }
862        Expr::Arithmetic(e) => format!("$(({e}))"),
863        Expr::Command(cmd) => render_command(cmd),
864        Expr::LastExitCode => "$?".to_string(),
865        Expr::CurrentPid => "$$".to_string(),
866        Expr::GlobPattern(p) => p.clone(),
867        Expr::ListLiteral(elems) => {
868            let parts: Vec<String> = elems
869                .iter()
870                .map(|e| match e {
871                    ListElem::Item(e) => render_expr(e),
872                    ListElem::Spread(e) => format!("...{}", render_expr(e)),
873                })
874                .collect();
875            format!("[{}]", parts.join(" "))
876        }
877        Expr::RecordLiteral(entries) => {
878            let parts: Vec<String> = entries
879                .iter()
880                .map(|entry| {
881                    let key = match &entry.key {
882                        RecordKey::Bare(k) => k.clone(),
883                        RecordKey::Quoted(k) => format!("\"{k}\""),
884                        RecordKey::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
885                    };
886                    format!("{key}: {}", render_expr(&entry.value))
887                })
888                .collect();
889            format!("{{{}}}", parts.join(", "))
890        }
891    }
892}
893
894/// A literal, quoted only where a shell reader would need the quotes.
895fn render_literal(value: &Value) -> String {
896    match value {
897        Value::String(s) => quote_word(s),
898        Value::Int(i) => i.to_string(),
899        Value::Float(f) => f.to_string(),
900        Value::Bool(b) => b.to_string(),
901        Value::Null => "null".to_string(),
902        Value::Json(j) => j.to_string(),
903        // Binary reaches a plan only through `execute_argv`, which takes
904        // typed values. Naming the length is honest; printing the bytes
905        // would put unreadable data in a stored plan.
906        Value::Bytes(b) => format!("<bytes len={}>", b.len()),
907    }
908}
909
910/// Single-quote a word that a shell reader could not take literally.
911fn quote_word(s: &str) -> String {
912    let needs_quotes = s.is_empty()
913        || s.chars()
914            .any(|c| c.is_whitespace() || "\"'$`&|;<>(){}[]*?#!~\\".contains(c));
915    if !needs_quotes {
916        return s.to_string();
917    }
918    // `'\''` is the one portable way to put a single quote inside a
919    // single-quoted word.
920    format!("'{}'", s.replace('\'', "'\\''"))
921}
922
923fn render_parts(parts: &[StringPart]) -> String {
924    parts.iter().map(render_part).collect::<Vec<_>>().join("")
925}
926
927fn render_part(part: &StringPart) -> String {
928    match part {
929        StringPart::Literal(s) => s.replace('\\', "\\\\").replace('"', "\\\""),
930        StringPart::Var(path) => format!("${{{}}}", render_varpath(path)),
931        StringPart::VarWithDefault { path, default } => {
932            format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
933        }
934        StringPart::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
935        StringPart::Positional(n) => format!("${n}"),
936        StringPart::AllArgs => "$@".to_string(),
937        StringPart::ArgCount => "$#".to_string(),
938        StringPart::Arithmetic(e) => format!("$(({e}))"),
939        StringPart::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
940        StringPart::LastExitCode => "$?".to_string(),
941        StringPart::CurrentPid => "$$".to_string(),
942    }
943}
944
945fn render_test(test: &TestExpr) -> String {
946    match test {
947        TestExpr::FileTest { op, path } => format!("{} {}", op, render_expr(path)),
948        TestExpr::StringTest { op, value } => format!("{} {}", op, render_expr(value)),
949        TestExpr::Comparison { left, op, right } => {
950            format!("{} {} {}", render_expr(left), op, render_expr(right))
951        }
952        TestExpr::And { left, right } => {
953            format!("{} && {}", render_test(left), render_test(right))
954        }
955        TestExpr::Or { left, right } => {
956            format!("{} || {}", render_test(left), render_test(right))
957        }
958        TestExpr::Not { expr } => format!("! {}", render_test(expr)),
959        TestExpr::In { left, right } => {
960            format!("{} in {}", render_expr(left), render_expr(right))
961        }
962        TestExpr::NotIn { left, right } => {
963            format!("{} not in {}", render_expr(left), render_expr(right))
964        }
965    }
966}
967
968/// Render a variable path in its source form: the root name, then bracket
969/// subscripts. Never dotted — kaish access is brackets-only.
970fn render_varpath(path: &VarPath) -> String {
971    let mut out = String::new();
972    for (i, segment) in path.segments.iter().enumerate() {
973        match segment {
974            VarSegment::Field(name) => {
975                if i > 0 {
976                    out.push('.');
977                }
978                out.push_str(name);
979            }
980            VarSegment::Index(idx) => out.push_str(&format!("[{idx}]")),
981            VarSegment::Key(k) => out.push_str(&format!("[{k}]")),
982            VarSegment::Dynamic(v) => out.push_str(&format!("[${v}]")),
983            VarSegment::Slice(a, b) => out.push_str(&format!(
984                "[{}:{}]",
985                a.map(|n| n.to_string()).unwrap_or_default(),
986                b.map(|n| n.to_string()).unwrap_or_default()
987            )),
988        }
989    }
990    out
991}
992
993#[cfg(test)]
994#[allow(clippy::unwrap_used, clippy::expect_used)]
995mod tests {
996    use super::*;
997    use crate::parser::parse;
998
999    fn planned_of(source: &str) -> StatementPlan {
1000        let program = parse(source).expect("the fixture parses");
1001        let stmt = program
1002            .statements
1003            .into_iter()
1004            .find(|s| !matches!(s, Stmt::Empty))
1005            .expect("one statement");
1006        plan_statement(&stmt)
1007    }
1008
1009    fn plan_of(source: &str) -> Plan {
1010        planned_of(source).plan
1011    }
1012
1013    #[test]
1014    fn a_variable_renders_unexpanded() {
1015        let plan = plan_of("rm -r \"${HOME}/build\"");
1016        assert!(
1017            plan.rendered.contains("${HOME}"),
1018            "the plan must keep the variable as written: {}",
1019            plan.rendered
1020        );
1021    }
1022
1023    #[test]
1024    fn a_substitution_renders_unexpanded_and_plans_its_own_command() {
1025        let plan = plan_of("rm $(cat list.txt)");
1026        assert!(
1027            plan.rendered.contains("$(cat list.txt)"),
1028            "got: {}",
1029            plan.rendered
1030        );
1031        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1032        assert_eq!(names, vec!["rm", "cat"], "the substitution runs too");
1033    }
1034
1035    #[test]
1036    fn a_loop_body_belongs_to_the_enclosing_statement() {
1037        let plan = plan_of("for f in a b; do rm $f; done");
1038        assert_eq!(plan.statement_kind, "for");
1039        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1040        assert_eq!(names, vec!["rm"]);
1041        assert!(plan.rendered.starts_with("for f in a b; do rm"));
1042    }
1043
1044    #[test]
1045    fn every_redirect_form_renders() {
1046        let plan = plan_of("cmd > out.txt 2> err.txt < in.txt");
1047        let kinds: Vec<&str> = plan.commands[0]
1048            .redirects
1049            .iter()
1050            .map(|r| r.kind.as_str())
1051            .collect();
1052        assert_eq!(kinds, vec![">", "2>", "<"]);
1053        assert_eq!(
1054            plan.commands[0].redirects[0].target,
1055            PlannedValue::Plain("out.txt".to_string())
1056        );
1057        assert!(plan.rendered.contains("> out.txt"), "got: {}", plan.rendered);
1058    }
1059
1060    #[test]
1061    fn a_redirect_target_stays_unexpanded() {
1062        let plan = plan_of("echo hi > ${LOG}");
1063        assert_eq!(
1064            plan.commands[0].redirects[0].target,
1065            PlannedValue::Plain("${LOG}".to_string())
1066        );
1067    }
1068
1069    #[test]
1070    fn a_merge_redirect_renders_as_its_operator_alone() {
1071        let plan = plan_of("cmd 2>&1");
1072        assert!(
1073            plan.rendered.ends_with("2>&1"),
1074            "a merge redirect has no filename: {}",
1075            plan.rendered
1076        );
1077    }
1078
1079    #[test]
1080    fn every_argument_form_renders() {
1081        let plan = plan_of("tool -v --force --key=value word -- --after");
1082        let args = &plan.commands[0].args;
1083        assert_eq!(
1084            args,
1085            &vec![
1086                PlannedValue::Plain("-v".to_string()),
1087                PlannedValue::Plain("--force".to_string()),
1088                PlannedValue::Plain("--key=value".to_string()),
1089                PlannedValue::Plain("word".to_string()),
1090                PlannedValue::Plain("--".to_string()),
1091                PlannedValue::Plain("--after".to_string()),
1092            ]
1093        );
1094    }
1095
1096    #[test]
1097    fn a_backgrounded_pipeline_marks_every_command() {
1098        let plan = plan_of("a | b &");
1099        assert!(plan.commands.iter().all(|c| c.background));
1100        assert!(plan.rendered.ends_with('&'), "got: {}", plan.rendered);
1101    }
1102
1103    #[test]
1104    fn a_pipeline_renders_every_stage() {
1105        let plan = plan_of("cat f | grep x | wc -l");
1106        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1107        assert_eq!(names, vec!["cat", "grep", "wc"]);
1108        assert_eq!(plan.rendered, "cat f | grep x | wc -l");
1109    }
1110
1111    #[test]
1112    fn an_and_chain_plans_both_sides() {
1113        let plan = plan_of("mkdir d && rm -r d");
1114        assert_eq!(plan.statement_kind, "and_chain");
1115        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1116        assert_eq!(names, vec!["mkdir", "rm"]);
1117    }
1118
1119    #[test]
1120    fn an_if_plans_its_condition_and_both_branches() {
1121        let plan = plan_of("if grep -q x f; then echo hit; else echo miss; fi");
1122        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1123        assert_eq!(names, vec!["grep", "echo", "echo"]);
1124    }
1125
1126    #[test]
1127    fn a_quoted_word_keeps_its_spaces_inside_quotes() {
1128        let plan = plan_of("echo 'two words'");
1129        assert_eq!(plan.rendered, "echo 'two words'");
1130    }
1131
1132    #[test]
1133    fn an_interpolated_string_keeps_its_variables() {
1134        let plan = plan_of("echo \"hello ${NAME}\"");
1135        assert_eq!(plan.rendered, "echo \"hello ${NAME}\"");
1136    }
1137
1138    #[test]
1139    fn a_bracket_path_renders_with_brackets_not_dots() {
1140        let plan = plan_of("echo ${servers[web]}");
1141        assert_eq!(plan.rendered, "echo ${servers[web]}");
1142    }
1143
1144    #[test]
1145    fn rendering_truncates_at_the_limit_with_a_loud_marker() {
1146        let long = "x".repeat(PLAN_RENDER_LIMIT * 2);
1147        let plan = plan_of(&format!("echo {long}"));
1148        assert!(
1149            plan.rendered.contains("[rendering truncated at 8192 bytes]"),
1150            "expected the marker, got {} bytes ending in {:?}",
1151            plan.rendered.len(),
1152            &plan.rendered[plan.rendered.len().saturating_sub(48)..]
1153        );
1154        // The structure survives the cut — that is what a classifier reads.
1155        assert_eq!(plan.commands.len(), 1);
1156        assert_eq!(plan.commands[0].name, "echo");
1157    }
1158
1159    #[test]
1160    fn a_short_rendering_carries_no_marker() {
1161        let plan = plan_of("echo hi");
1162        assert_eq!(plan.rendered, "echo hi");
1163    }
1164
1165    // ── The presented credential (spec §A.2, §C.6) ──
1166
1167    #[test]
1168    fn a_literal_key_is_lifted_and_redacted() {
1169        let planned = planned_of("rm --confirm=deadbeef target.txt");
1170        assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
1171        assert_eq!(planned.plan.rendered, "rm --confirm=<confirm-key> target.txt");
1172        assert_eq!(
1173            planned.plan.commands[0].args,
1174            vec![
1175                PlannedValue::redacted("confirm-key", None),
1176                PlannedValue::Plain("target.txt".to_string()),
1177            ]
1178        );
1179    }
1180
1181    #[test]
1182    fn the_bare_word_assign_spelling_is_lifted_too() {
1183        // `dd` takes its operands as `key=value`, so this is the same
1184        // credential wearing the other spelling.
1185        let planned = planned_of("dd if=a of=b confirm=deadbeef");
1186        assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
1187        assert!(
1188            planned.plan.rendered.ends_with("confirm=<confirm-key>"),
1189            "got: {}",
1190            planned.plan.rendered
1191        );
1192    }
1193
1194    #[test]
1195    fn a_variable_carried_key_is_neither_lifted_nor_redacted() {
1196        // Nothing to lift and nothing to leak: an unexpanded plan never held
1197        // the value, so it renders as written like any other variable.
1198        let planned = planned_of("rm --confirm=${key} target.txt");
1199        assert!(planned.presented_keys.is_empty());
1200        assert_eq!(planned.plan.rendered, "rm --confirm=${key} target.txt");
1201    }
1202
1203    #[test]
1204    fn a_key_inside_a_loop_body_is_still_lifted() {
1205        let planned = planned_of("for f in a b; do rm --confirm=deadbeef $f; done");
1206        assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
1207    }
1208
1209    #[test]
1210    fn redaction_takes_the_whole_token_and_leaves_one_space() {
1211        let source = "rm --confirm=deadbeef target.txt";
1212        assert_eq!(
1213            redact_keys(source, &["deadbeef".to_string()]),
1214            "rm target.txt"
1215        );
1216    }
1217
1218    #[test]
1219    fn redaction_leaves_a_source_that_never_presented_a_key_alone() {
1220        let source = "rm target.txt";
1221        assert_eq!(redact_keys(source, &[]), source);
1222        assert_eq!(redact_keys(source, &["deadbeef".to_string()]), source);
1223    }
1224
1225    // ── Variable analysis ──
1226
1227    #[test]
1228    fn reads_cover_interpolation_length_subscript_and_arithmetic() {
1229        let plan = plan_of(
1230            "echo \"${greeting} ${#items} ${servers[$env]}\" $((base + offset))",
1231        );
1232        assert_eq!(
1233            plan.free_variables,
1234            vec!["base", "env", "greeting", "items", "offset", "servers"],
1235            "every lexical read is listed, sorted"
1236        );
1237        assert!(plan.bound_variables.is_empty());
1238    }
1239
1240    #[test]
1241    fn a_subscripted_assignment_binds_the_root_and_reads_the_subscript() {
1242        let plan = plan_of("counts[$key]=1");
1243        assert_eq!(plan.free_variables, vec!["key"]);
1244        assert_eq!(plan.bound_variables, vec!["counts"]);
1245    }
1246
1247    #[test]
1248    fn an_env_prefix_binds_its_name_for_the_one_command() {
1249        let plan = plan_of("MODE=fast deploy ${TARGET}");
1250        assert_eq!(plan.free_variables, vec!["TARGET"]);
1251        assert_eq!(plan.bound_variables, vec!["MODE"]);
1252    }
1253
1254    // ── plan_program: the program-level surface ──
1255
1256    /// `index` is the position in the returned list, so indexing the list by it
1257    /// reads the statement it names.
1258    ///
1259    /// This used to preserve the gap left by a dropped `Stmt::Empty`, to line
1260    /// up with `Capture::Statement`'s index. That type was approval-ledger
1261    /// vocabulary; it was cut in 2481a3f3 and the ledger was deleted whole in
1262    /// 0c36dba1, before 0.14.0. The correspondence had no remaining consumer,
1263    /// and what it cost was an off-by-one in every script that opens with a
1264    /// comment.
1265    #[test]
1266    fn plan_program_indexes_are_dense_and_ordered() {
1267        let source = "echo one\n\n# a comment\necho two && echo three\nX=5";
1268        let program = parse(source).expect("the fixture parses");
1269        let expected: Vec<String> = program
1270            .statements
1271            .iter()
1272            .filter(|s| !matches!(s, Stmt::Empty))
1273            .map(|s| s.kind_name().to_string())
1274            .collect();
1275        let plans = plan_program(source).expect("the fixture parses");
1276        assert_eq!(
1277            plans.iter().map(|p| p.plan.statement_kind.clone()).collect::<Vec<_>>(),
1278            expected,
1279            "every non-empty statement is planned, in source order"
1280        );
1281        for (position, planned) in plans.iter().enumerate() {
1282            assert_eq!(
1283                planned.index, position,
1284                "index must be the position in the returned list"
1285            );
1286        }
1287    }
1288
1289    /// The shape that made the old numbering wrong. A leading comment parses to
1290    /// a `Stmt::Empty` the plan drops, and numbering before the drop started
1291    /// every later statement one too high.
1292    #[test]
1293    fn a_leading_comment_does_not_shift_the_indexes() {
1294        let plans = plan_program("# lead\necho a\necho b").expect("parses");
1295        assert_eq!(plans.len(), 2);
1296        assert_eq!(plans[0].index, 0, "a leading comment must not shift index");
1297        assert_eq!(plans[1].index, 1);
1298    }
1299
1300    #[test]
1301    fn plan_program_redacts_a_presented_key_and_returns_no_copy_of_it() {
1302        // `PlannedStatement` has no key field by design — the caller holds
1303        // the source. What must hold is that the plan itself is redacted.
1304        let plans = plan_program("rm --confirm=deadbeef x.txt").expect("parses");
1305        assert_eq!(plans[0].plan.rendered, "rm --confirm=<confirm-key> x.txt");
1306    }
1307
1308    #[test]
1309    fn plan_program_returns_the_parse_errors_for_a_broken_source() {
1310        let errors = plan_program("echo 'unclosed").expect_err("must not parse");
1311        assert!(!errors.is_empty());
1312    }
1313
1314    #[test]
1315    fn redaction_covers_both_spellings_across_a_multi_statement_source() {
1316        let source = "echo one\ndd if=a confirm=deadbeef\nrm --confirm=deadbeef x";
1317        let redacted = redact_keys(source, &["deadbeef".to_string()]);
1318        assert!(!redacted.contains("deadbeef"), "got: {redacted}");
1319        assert_eq!(redacted, "echo one\ndd if=a\nrm x");
1320    }
1321}