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