Skip to main content

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(&parameter.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) = &parameter.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}