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}