Skip to main content

kaish_types/
plan.rs

1//! The statement-plan vocabulary: pure data plus serde, no behavior.
2//!
3//! A [`Plan`] is what `plan_program` produces for one statement — the source
4//! rendered back **unexpanded**, every [`PlannedCommand`] it would run, and
5//! the variables it reads and writes. An embedder reads a plan to decide
6//! whether to run a statement; nothing here decides anything itself.
7
8use serde::{Deserialize, Serialize};
9
10/// A content identity for a plan — a digest over its rendered text, computed
11/// by the embedder (e.g. SHA-256). This type only carries the value, so
12/// `kaish-types` stays dependency-light.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14#[serde(transparent)]
15pub struct PlanDigest(String);
16
17impl PlanDigest {
18    /// Wrap an already-computed digest.
19    pub fn new(hex: impl Into<String>) -> Self {
20        Self(hex.into())
21    }
22
23    /// The digest's text form.
24    pub fn as_str(&self) -> &str {
25        &self.0
26    }
27}
28
29// ───────────────────────── Values ─────────────────────────
30
31/// One value inside a rendered plan. A sink serializes `PlannedValue`, never
32/// a bare `String` — kaish carries no built-in redaction today, but the
33/// `#[non_exhaustive]` vocabulary is the seam an embedder-side redaction pass
34/// writes its own variant into, without every consumer needing to switch
35/// from reading a `String` first.
36#[non_exhaustive]
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum PlannedValue {
40    /// The literal text, exactly as it would render on the command line.
41    Plain(String),
42}
43
44impl PlannedValue {
45    /// The text a sink should show: the literal, for `Plain`.
46    pub fn display(&self) -> String {
47        match self {
48            Self::Plain(s) => s.clone(),
49        }
50    }
51}
52
53// ───────────────────────── The statement plan ─────────────────────────
54
55/// What one top-level statement was asked to run (spec §C.6).
56///
57/// Built from the AST after validation and **before** execution, so it is
58/// parse information and never execution information: no substitution has
59/// run, no redirect has been opened, no loop has taken its first iteration.
60/// Nested statements — loop bodies, `if` branches, user-tool bodies — belong
61/// to their enclosing top-level statement's plan and are never planned
62/// separately.
63#[non_exhaustive]
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Plan {
66    /// The statement rendered back to shell text, **unexpanded**: `${HOME}`
67    /// and `$(...)` appear as written, because a classifier judges what was
68    /// asked, not what it resolved to. Truncated at
69    /// [`PLAN_RENDER_LIMIT`] bytes with a marker naming the limit.
70    pub rendered: String,
71    /// The statement's kind: `"command"`, `"pipeline"`, `"for"`,
72    /// `"and_chain"`, …
73    pub statement_kind: String,
74    /// Every command the statement contains, control-structure bodies
75    /// included.
76    pub commands: Vec<PlannedCommand>,
77    /// Session variables the statement reads and does not itself lexically
78    /// bind — sorted, deduplicated root names. Complete against the
79    /// statement's **lexical** surface — kaish has no `eval` and no indirect
80    /// expansion, so every read is visible in the source. It does not cover
81    /// names bound at runtime by a builtin that takes them as arguments:
82    /// `read`, `export`, `unset`, and `push` write session variables that
83    /// argv-level analysis cannot see, so `read TOKEN && echo $TOKEN`
84    /// reports `TOKEN` here, and the value an embedder peeks with
85    /// `Kernel::get_var` is the one from before the `read`. Special forms
86    /// (`$1`, `$?`, `$$`, `$@`, `$#`) are not session variables and are not
87    /// listed.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub free_variables: Vec<String>,
90    /// Names the statement itself binds **lexically** — an assignment
91    /// target, a `for` variable, an env-prefix name, a tool-def parameter.
92    /// Peeking session state for these is misleading (the statement supplies
93    /// its own value), so a name that is both read and lexically bound lands
94    /// here, never in `free_variables` — the safe direction. A name written
95    /// only through a runtime binder (`read`, `export`, `unset`, `push`) is
96    /// a plain argument, not a lexical bind: it lands in `free_variables`
97    /// when the statement also reads it, and in neither set otherwise.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub bound_variables: Vec<String>,
100}
101
102/// The byte limit [`Plan::rendered`] is truncated at: 8 KiB. A statement
103/// longer than this is a generated program, and a classifier that needs more
104/// than 8 KiB of it is reading the wrong field — [`Plan::commands`] carries
105/// the structure.
106pub const PLAN_RENDER_LIMIT: usize = 8 * 1024;
107
108impl Plan {
109    /// Assemble a plan. The only constructor for this `#[non_exhaustive]`
110    /// type — `rendered` is stored verbatim, so a producer truncates before
111    /// calling.
112    pub fn new(
113        rendered: impl Into<String>,
114        statement_kind: impl Into<String>,
115        commands: Vec<PlannedCommand>,
116    ) -> Self {
117        Self {
118            rendered: rendered.into(),
119            statement_kind: statement_kind.into(),
120            commands,
121            free_variables: Vec::new(),
122            bound_variables: Vec::new(),
123        }
124    }
125
126    /// Attach the statement's variable analysis (sorted, deduplicated).
127    pub fn with_variables(mut self, free: Vec<String>, bound: Vec<String>) -> Self {
128        self.free_variables = free;
129        self.bound_variables = bound;
130        self
131    }
132}
133
134/// One command inside a [`Plan`], as written (spec §C.6).
135#[non_exhaustive]
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct PlannedCommand {
138    /// argv0 as written — never resolved through aliases, `PATH`, or the
139    /// tool registry. Never a [`PlannedValue`]: a command name is structural.
140    pub name: String,
141    /// The arguments, rendered unexpanded (spec §A.8).
142    pub args: Vec<PlannedValue>,
143    /// The redirections this command declares.
144    pub redirects: Vec<PlannedRedirect>,
145    /// Whether the enclosing pipeline was backgrounded with `&`.
146    pub background: bool,
147    /// The heredocs this command reads on stdin, in source order. Empty for
148    /// every command that declares none.
149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
150    pub heredocs: Vec<PlannedHeredoc>,
151}
152
153impl PlannedCommand {
154    /// Name one planned command. The only constructor for this
155    /// `#[non_exhaustive]` type.
156    pub fn new(
157        name: impl Into<String>,
158        args: Vec<PlannedValue>,
159        redirects: Vec<PlannedRedirect>,
160        background: bool,
161    ) -> Self {
162        Self {
163            name: name.into(),
164            args,
165            redirects,
166            background,
167            heredocs: Vec::new(),
168        }
169    }
170
171    /// Attach the heredocs this command reads on stdin.
172    pub fn with_heredocs(mut self, heredocs: Vec<PlannedHeredoc>) -> Self {
173        self.heredocs = heredocs;
174        self
175    }
176}
177
178/// One heredoc a [`PlannedCommand`] reads on stdin — the body a command is
179/// fed, published as data.
180///
181/// Agents hand whole programs to interpreters this way (`python3 <<'PY'`,
182/// `sqlite3 <<SQL`), and the shell framing is the part that has to come off
183/// before anything can look at the program. It comes off here: the command
184/// name is on the [`PlannedCommand`], the language hint is
185/// [`delimiter`](Self::delimiter), and the program is [`body`](Self::body)
186/// with no quoting or escaping applied.
187///
188/// [`literal`](Self::literal) decides what the body is worth. A quoted
189/// delimiter (`<<'PY'`) means the body reaches the command exactly as
190/// published; an unquoted one means the shell expands `${…}` and `$(…)`
191/// first, so the published text is what was *asked for* and not what runs.
192#[non_exhaustive]
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct PlannedHeredoc {
195    /// The heredoc's position among every heredoc in its statement, counted
196    /// in source order across every command the statement contains. This is
197    /// the `heredoc` half of a [`FragmentAddr`].
198    pub index: usize,
199    /// The delimiter word as written, quotes removed: `PY` for both `<<PY`
200    /// and `<<'PY'`. Authors often pick it for the language they are about to
201    /// write, but roughly half the time it says nothing — a bare `EOF`
202    /// outnumbers every self-describing word combined in real agent traffic.
203    /// A weak hint worth keeping, never a classification.
204    pub delimiter: String,
205    /// Whether the delimiter was quoted (`<<'PY'`, `<<"PY"`). True means no
206    /// expansion runs and [`body`](Self::body) is exactly what the command
207    /// reads.
208    pub literal: bool,
209    /// Whether the `<<-` form was used. True means leading tabs come off each
210    /// body line before the command sees it; the published body keeps them.
211    pub strip_tabs: bool,
212    /// The body as written, verbatim: no tab stripping, no expansion, no
213    /// quoting, no kernel-internal rewriting. A generated program arrives
214    /// whole and unescaped, ready to hand to whatever reads that language.
215    pub body: PlannedValue,
216    /// Byte offset of the body's first character in the source that was
217    /// planned, for a caller attributing a finding back to a location.
218    pub body_offset: usize,
219    /// Session variables this body reads — sorted, deduplicated root names,
220    /// and always empty when [`literal`](Self::literal) is true. These are
221    /// the values that plug into the body, and the ones an expansion needs
222    /// supplied.
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub free_variables: Vec<String>,
225}
226
227impl PlannedHeredoc {
228    /// Assemble one planned heredoc. The only constructor for this
229    /// `#[non_exhaustive]` type.
230    pub fn new(
231        index: usize,
232        delimiter: impl Into<String>,
233        literal: bool,
234        strip_tabs: bool,
235        body: PlannedValue,
236        body_offset: usize,
237    ) -> Self {
238        Self {
239            index,
240            delimiter: delimiter.into(),
241            literal,
242            strip_tabs,
243            body,
244            body_offset,
245            free_variables: Vec::new(),
246        }
247    }
248
249    /// Attach the body's variable analysis (sorted, deduplicated).
250    pub fn with_free_variables(mut self, free: Vec<String>) -> Self {
251        self.free_variables = free;
252        self
253    }
254}
255
256// ───────────────────────── Fragment expansion ─────────────────────────
257
258/// Where one heredoc sits in a planned program: which statement, and which
259/// heredoc within it.
260///
261/// The heredoc index is flat across the whole statement — the same
262/// [`PlannedHeredoc::index`] the plan publishes — so a heredoc inside a loop
263/// body or an `if` branch is addressable without walking the structure.
264#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
265pub struct FragmentAddr {
266    /// The statement's position in the parsed program.
267    pub statement: usize,
268    /// The heredoc's position within that statement.
269    pub heredoc: usize,
270}
271
272impl FragmentAddr {
273    /// Name one fragment.
274    pub fn new(statement: usize, heredoc: usize) -> Self {
275        Self { statement, heredoc }
276    }
277}
278
279/// What expanding a fragment produced.
280///
281/// There are two outcomes and no third: either the text is complete, or it is
282/// blocked and no text comes back at all. Half-expanded source reads as
283/// ground truth to whatever parses it next and is not, so this type cannot
284/// represent it.
285///
286/// Deliberately **not** `#[non_exhaustive]`, unlike the record types around
287/// it. A caller must handle both arms, and that is the guarantee — a wildcard
288/// arm written today to satisfy the attribute is exactly where a third
289/// outcome would land unnoticed tomorrow. A new variant here would be a
290/// change every embedder must see, so it should break their build.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum Expansion {
294    /// Every expansion resolved. This is exactly the text the command reads
295    /// on stdin, given the scope that was supplied.
296    Complete(String),
297    /// A `$(…)` stands between the body and its final text. Running it is a
298    /// decision with a clock and a blast radius, so the kernel returns the
299    /// question instead of answering it.
300    Blocked {
301        /// Every substitution the body contains, in source order.
302        holes: Vec<Hole>,
303    },
304}
305
306/// One `$(…)` inside a fragment: what it would run, as a plan.
307///
308/// A caller that decides the substitution is safe runs it in a kernel of its
309/// own construction — its own capabilities, its own timeout, its own
310/// cancellation — and expands again with the answer in the scope.
311#[non_exhaustive]
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313pub struct Hole {
314    /// The substitution rendered back to shell text, unexpanded: `$(date +%s)`.
315    pub source: String,
316    /// One plan per statement in the substitution's body — the same
317    /// vocabulary the enclosing statement's plan uses, so a caller judging a
318    /// hole reads it the way it reads everything else.
319    pub plans: Vec<Plan>,
320}
321
322impl Hole {
323    /// Name one substitution. The only constructor for this
324    /// `#[non_exhaustive]` type.
325    pub fn new(source: impl Into<String>, plans: Vec<Plan>) -> Self {
326        Self {
327            source: source.into(),
328            plans,
329        }
330    }
331}
332
333/// One redirection inside a [`PlannedCommand`] (spec §C.6).
334#[non_exhaustive]
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336pub struct PlannedRedirect {
337    /// The operator as written: `">"`, `">>"`, `"2>"`, `"<"`, `"<<<"`, …
338    pub kind: String,
339    /// The target, rendered unexpanded — `> ${LOG}` keeps `${LOG}` — and
340    /// through the same redaction seam every argument passes (spec §A.8).
341    pub target: PlannedValue,
342}
343
344impl PlannedRedirect {
345    /// Name one planned redirection. The only constructor for this
346    /// `#[non_exhaustive]` type.
347    pub fn new(kind: impl Into<String>, target: PlannedValue) -> Self {
348        Self {
349            kind: kind.into(),
350            target,
351        }
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn sample_plan() -> Plan {
360        Plan::new(
361            "cargo build > ${LOG}",
362            "command",
363            vec![PlannedCommand::new(
364                "cargo",
365                vec![PlannedValue::Plain("build".to_string())],
366                vec![PlannedRedirect::new(">", PlannedValue::Plain("${LOG}".to_string()))],
367                false,
368            )],
369        )
370    }
371
372    #[test]
373    fn a_plan_round_trips_with_every_planned_field() {
374        let plan = sample_plan();
375        let json = serde_json::to_value(&plan).expect("serialize");
376        let back: Plan = serde_json::from_value(json).expect("deserialize");
377        assert_eq!(plan, back);
378        assert_eq!(back.commands[0].redirects[0].kind, ">");
379        // Unexpanded: the target keeps `${LOG}` as written, because an
380        // embedder judges what was asked, not what it resolved to.
381        assert_eq!(
382            back.commands[0].redirects[0].target,
383            PlannedValue::Plain("${LOG}".to_string())
384        );
385    }
386
387    #[test]
388    fn variables_default_to_empty_and_survive_a_round_trip() {
389        let bare = sample_plan();
390        assert!(bare.free_variables.is_empty());
391        assert!(bare.bound_variables.is_empty());
392
393        let plan = sample_plan()
394            .with_variables(vec!["LOG".to_string()], vec!["OUT".to_string()]);
395        let json = serde_json::to_value(&plan).expect("serialize");
396        let back: Plan = serde_json::from_value(json).expect("deserialize");
397        assert_eq!(back.free_variables, vec!["LOG".to_string()]);
398        assert_eq!(back.bound_variables, vec!["OUT".to_string()]);
399    }
400
401    #[test]
402    fn a_plan_digest_round_trips() {
403        let digest = PlanDigest::new("abc123");
404        let json = serde_json::to_string(&digest).expect("serialize");
405        let back: PlanDigest = serde_json::from_str(&json).expect("deserialize");
406        assert_eq!(digest, back);
407    }
408}