aion_package/declared_command/contract.rs
1//! The emitted form of a declared command, and the render that turns it plus
2//! a parameter set into process invocations.
3//!
4//! # Why the wire carries a template and not a string
5//!
6//! A command declared in AWL (a just-recipe block: `name params:` over an
7//! indented body of command lines) is tokenized ONCE, at check time, into
8//! whole argv elements. The defect family the surface exists to close is the
9//! one where a command is carried as text and re-split by whatever runs it:
10//! a value containing a space becomes two arguments, a value beginning with
11//! `-` becomes an option, a value containing a quote becomes anything at
12//! all.
13//!
14//! So the wire form is the emitter's own shapes with the parameter values
15//! still absent: one entry per body line, one slot per argv element, and a
16//! fill template per slot whose holes name declared parameters. Nothing here
17//! is ever joined into a command line, and nothing that reads it re-splits
18//! one.
19//!
20//! # A body is lines, and the lines are the execution contract
21//!
22//! A command's body may carry several lines. They run SEQUENTIALLY, the
23//! first non-zero exit fails the command, and the command's captured output
24//! is the CONCATENATED stdout of every line in order. That ruling lives with
25//! the shape so no executor can answer it differently.
26//!
27//! # One render, three callers
28//!
29//! [`DeclaredCommandContract::render`] is the ONLY place a declaration and a
30//! parameter set become argvs. The AWL emitter calls it (mapping its
31//! refusals back onto source spans), the server's declared-body dispatcher
32//! calls it, and the `aion worker awl` executor calls it. A second
33//! implementation anywhere would be a second answer to "what does this
34//! command actually run".
35
36use std::collections::BTreeMap;
37
38use serde::{Deserialize, Serialize};
39
40use super::error::{RenderError, shape_word};
41use super::template::{ArgumentValue, FillTemplate};
42
43/// The end-of-options marker the language knows. The bytes are the contract.
44pub const END_OF_OPTIONS_MARKER: &str = "--";
45
46/// One declared parameter of a command, as the emitter sees it.
47///
48/// Parameters are untyped, as just's are: every value is one command-line
49/// word. A default is LITERAL text — the check layer refuses `{{…}}` inside
50/// one, so nothing here resolves a default against anything.
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct CommandParameterContract {
53 /// The parameter's name — the only thing a hole may name.
54 pub name: String,
55 /// The literal default this parameter takes when a parameter set omits
56 /// it. Absence is a legal declared state: a parameter with neither a
57 /// supplied value nor a default has nothing for this surface to invent,
58 /// and refuses.
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub default: Option<String>,
61}
62
63/// One argv slot: the template that fills it and whether it may begin with a
64/// dash.
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ArgvSlot {
67 /// The fill that produces this slot's element.
68 pub fill: FillTemplate,
69 /// How a refusal names this slot: the first interpolated parameter, or
70 /// the slot's literal text.
71 pub label: String,
72 /// Whether an element beginning with `-` is admitted here.
73 ///
74 /// `false` means the slot's opening bytes are caller-supplied (its first
75 /// piece is a hole) and no literal `--` element stands before it on the
76 /// same line, so leading-dash bytes could not be told from an option.
77 /// The condition is computed from the LINE at compile time and travels
78 /// here as a fact, never as a keyword an author could write to switch
79 /// the refusal off.
80 pub admits_leading_dash: bool,
81}
82
83/// One body line: its argv slots, in order.
84#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
85pub struct CommandLineContract {
86 /// The slots, in order. The first slot is the program that runs, and is
87 /// always literal — the check layer refuses a hole there.
88 pub slots: Vec<ArgvSlot>,
89}
90
91/// One declared environment binding: a name and its literal value.
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
93pub struct EnvBindingContract {
94 /// The variable name.
95 pub name: String,
96 /// The literal value (`export NAME := "value"` — document level, no
97 /// parameters in scope).
98 pub value: String,
99}
100
101/// The emitted, executable form of one declared command.
102///
103/// Deserialization is hand-written (in [`super::compat`]): it reads this
104/// shape as written, and reads the PRIOR archive form — `program` words plus
105/// `args` slots — by translating it into this shape at the wire, so an
106/// archive deployed before the reshape still opens everywhere.
107#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
108pub struct DeclaredCommandContract {
109 /// The declaration's own name, carried so a refusal can say which command
110 /// it is talking about.
111 pub name: String,
112 /// The declared parameters, in declared order.
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
114 pub parameters: Vec<CommandParameterContract>,
115 /// The body's logical lines, in execution order.
116 pub lines: Vec<CommandLineContract>,
117 /// The document's exported environment bindings, in declared order.
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub env: Vec<EnvBindingContract>,
120 /// The document's declared working directory, verbatim — any
121 /// `{workspace_root}` placeholder unexpanded, because expanding it is
122 /// the executing side's act against ITS workspace.
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub cwd: Option<String>,
125 /// Set ONLY by the prior-archive reader ([`super::compat`]), naming a
126 /// construct of the prior form this shape cannot express faithfully. A
127 /// contract carrying it still reads, lists and censuses everywhere;
128 /// [`Self::render`] refuses it with the construct and the cure, so the
129 /// unexpressible part can never execute as something it did not mean.
130 /// The emitter never sets it.
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub prior_form_refusal: Option<String>,
133}
134
135/// Resolved command lines: the argvs, the environment, and the working
136/// directory.
137#[derive(Clone, Debug, PartialEq, Eq)]
138pub struct RenderedCommand {
139 /// The argvs, one per body line, in execution order. Each element is
140 /// whole; nothing joins or re-splits one.
141 pub argv_lines: Vec<Vec<String>>,
142 /// The environment bindings, in declared order.
143 pub env: Vec<(String, String)>,
144 /// The declared working directory, verbatim.
145 pub cwd: Option<String>,
146}
147
148impl DeclaredCommandContract {
149 /// Every parameter name this command declares, in declared order.
150 #[must_use]
151 pub fn parameter_names(&self) -> Vec<&str> {
152 self.parameters
153 .iter()
154 .map(|parameter| parameter.name.as_str())
155 .collect()
156 }
157
158 /// Resolve this command against `supplied`.
159 ///
160 /// `supplied` must name only parameters this command declares — a name it
161 /// does not is refused rather than ignored, because a value nothing
162 /// consumes is a value nobody reviewed. A caller holding a WIDER set (an
163 /// action's parameters, say, of which the command uses some) narrows it
164 /// first with [`Self::parameter_names`].
165 ///
166 /// # Errors
167 ///
168 /// Returns the first refusal the resolution earns: an undeclared or
169 /// missing argument, a list where one value belongs, a leading-dash
170 /// operand standing where the program still reads options, or a body that
171 /// reads a parameter the command does not declare.
172 pub fn render(
173 &self,
174 supplied: &BTreeMap<String, ArgumentValue>,
175 ) -> Result<RenderedCommand, RenderError> {
176 // A prior-form archive whose command carries a construct this shape
177 // cannot express refuses HERE — at execution — never at read, so the
178 // archive stays readable everywhere and the refusal names the cure.
179 if let Some(construct) = &self.prior_form_refusal {
180 return Err(RenderError::PriorFormUnrenderable {
181 command: self.name.clone(),
182 construct: construct.clone(),
183 });
184 }
185 let bound = self.bind(supplied)?;
186
187 let mut argv_lines = Vec::with_capacity(self.lines.len());
188 for line in &self.lines {
189 let mut argv = Vec::with_capacity(line.slots.len());
190 for slot in &line.slots {
191 let element = resolve(&self.name, &slot.fill, &bound)?;
192 // THE CURE, at the bytes. Read off the slot's recorded
193 // position, never off a declared property, so a declaration
194 // cannot exempt itself.
195 if !slot.admits_leading_dash && element.starts_with('-') {
196 return Err(RenderError::LeadingDashOperand {
197 command: self.name.clone(),
198 argument: slot.label.clone(),
199 element,
200 marker: END_OF_OPTIONS_MARKER,
201 });
202 }
203 argv.push(element);
204 }
205 argv_lines.push(argv);
206 }
207
208 Ok(RenderedCommand {
209 argv_lines,
210 env: self
211 .env
212 .iter()
213 .map(|binding| (binding.name.clone(), binding.value.clone()))
214 .collect(),
215 cwd: self.cwd.clone(),
216 })
217 }
218
219 /// Every declared parameter's value: supplied, or its declared default.
220 fn bind(
221 &self,
222 supplied: &BTreeMap<String, ArgumentValue>,
223 ) -> Result<BTreeMap<String, String>, RenderError> {
224 for name in supplied.keys() {
225 if !self.parameters.iter().any(|item| item.name == *name) {
226 return Err(RenderError::ArgumentUndeclared {
227 command: self.name.clone(),
228 parameter: name.clone(),
229 });
230 }
231 }
232
233 let mut bound: BTreeMap<String, String> = BTreeMap::new();
234 for parameter in &self.parameters {
235 // No third branch, deliberately: a parameter with neither a
236 // supplied value nor a declared default has nothing for this
237 // surface to invent.
238 let value = match supplied.get(¶meter.name) {
239 Some(ArgumentValue::Scalar(value)) => value.clone(),
240 Some(value @ ArgumentValue::List(_)) => {
241 return Err(RenderError::ArgumentTypeMismatch {
242 command: self.name.clone(),
243 parameter: parameter.name.clone(),
244 observed: value.describe(),
245 declared: shape_word(false),
246 supplied: shape_word(true),
247 });
248 }
249 None => {
250 let Some(default) = ¶meter.default else {
251 return Err(RenderError::ArgumentMissing {
252 command: self.name.clone(),
253 parameter: parameter.name.clone(),
254 });
255 };
256 default.clone()
257 }
258 };
259 bound.insert(parameter.name.clone(), value);
260 }
261 Ok(bound)
262 }
263}
264
265/// A fill resolved against bound values, as ONE whole argv element — a space
266/// inside a value is a space inside that one element and never a new
267/// argument, because nothing re-splits it.
268///
269/// `bound` holds every parameter the command declares, so a hole with no
270/// entry names a parameter the command does not have. That is refused, not
271/// rendered as empty text: the empty rendering would put a DIFFERENT argv in
272/// front of the program with nothing said, which is exactly the silence this
273/// whole surface exists to close.
274fn resolve(
275 command: &str,
276 fill: &FillTemplate,
277 bound: &BTreeMap<String, String>,
278) -> Result<String, RenderError> {
279 let mut element = String::new();
280 for piece in &fill.pieces {
281 match piece {
282 super::template::FillPiece::Literal { text } => element.push_str(text),
283 super::template::FillPiece::Hole { parameter } => {
284 let Some(value) = bound.get(parameter.as_str()) else {
285 return Err(RenderError::UnboundHole {
286 command: command.to_owned(),
287 parameter: parameter.clone(),
288 });
289 };
290 element.push_str(value);
291 }
292 }
293 }
294 Ok(element)
295}