aion-package 0.27.1

Archive validation, content hashing, and namespacing for Aion workflow packages.
Documentation
//! The emitted form of a declared command, and the render that turns it plus
//! a parameter set into process invocations.
//!
//! # Why the wire carries a template and not a string
//!
//! A command declared in AWL (a just-recipe block: `name params:` over an
//! indented body of command lines) is tokenized ONCE, at check time, into
//! whole argv elements. The defect family the surface exists to close is the
//! one where a command is carried as text and re-split by whatever runs it:
//! a value containing a space becomes two arguments, a value beginning with
//! `-` becomes an option, a value containing a quote becomes anything at
//! all.
//!
//! So the wire form is the emitter's own shapes with the parameter values
//! still absent: one entry per body line, one slot per argv element, and a
//! fill template per slot whose holes name declared parameters. Nothing here
//! is ever joined into a command line, and nothing that reads it re-splits
//! one.
//!
//! # A body is lines, and the lines are the execution contract
//!
//! A command's body may carry several lines. They run SEQUENTIALLY, the
//! first non-zero exit fails the command, and the command's captured output
//! is the CONCATENATED stdout of every line in order. That ruling lives with
//! the shape so no executor can answer it differently.
//!
//! # One render, three callers
//!
//! [`DeclaredCommandContract::render`] is the ONLY place a declaration and a
//! parameter set become argvs. The AWL emitter calls it (mapping its
//! refusals back onto source spans), the server's declared-body dispatcher
//! calls it, and the `aion worker awl` executor calls it. A second
//! implementation anywhere would be a second answer to "what does this
//! command actually run".

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use super::error::{RenderError, shape_word};
use super::template::{ArgumentValue, FillTemplate};

/// The end-of-options marker the language knows. The bytes are the contract.
pub const END_OF_OPTIONS_MARKER: &str = "--";

/// One declared parameter of a command, as the emitter sees it.
///
/// Parameters are untyped, as just's are: every value is one command-line
/// word. A default is LITERAL text — the check layer refuses `{{…}}` inside
/// one, so nothing here resolves a default against anything.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandParameterContract {
    /// The parameter's name — the only thing a hole may name.
    pub name: String,
    /// The literal default this parameter takes when a parameter set omits
    /// it. Absence is a legal declared state: a parameter with neither a
    /// supplied value nor a default has nothing for this surface to invent,
    /// and refuses.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default: Option<String>,
}

/// One argv slot: the template that fills it and whether it may begin with a
/// dash.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArgvSlot {
    /// The fill that produces this slot's element.
    pub fill: FillTemplate,
    /// How a refusal names this slot: the first interpolated parameter, or
    /// the slot's literal text.
    pub label: String,
    /// Whether an element beginning with `-` is admitted here.
    ///
    /// `false` means the slot's opening bytes are caller-supplied (its first
    /// piece is a hole) and no literal `--` element stands before it on the
    /// same line, so leading-dash bytes could not be told from an option.
    /// The condition is computed from the LINE at compile time and travels
    /// here as a fact, never as a keyword an author could write to switch
    /// the refusal off.
    pub admits_leading_dash: bool,
}

/// One body line: its argv slots, in order.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandLineContract {
    /// The slots, in order. The first slot is the program that runs, and is
    /// always literal — the check layer refuses a hole there.
    pub slots: Vec<ArgvSlot>,
}

/// One declared environment binding: a name and its literal value.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvBindingContract {
    /// The variable name.
    pub name: String,
    /// The literal value (`export NAME := "value"` — document level, no
    /// parameters in scope).
    pub value: String,
}

/// The emitted, executable form of one declared command.
///
/// Deserialization is hand-written (in [`super::compat`]): it reads this
/// shape as written, and reads the PRIOR archive form — `program` words plus
/// `args` slots — by translating it into this shape at the wire, so an
/// archive deployed before the reshape still opens everywhere.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct DeclaredCommandContract {
    /// The declaration's own name, carried so a refusal can say which command
    /// it is talking about.
    pub name: String,
    /// The declared parameters, in declared order.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub parameters: Vec<CommandParameterContract>,
    /// The body's logical lines, in execution order.
    pub lines: Vec<CommandLineContract>,
    /// The document's exported environment bindings, in declared order.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub env: Vec<EnvBindingContract>,
    /// The document's declared working directory, verbatim — any
    /// `{workspace_root}` placeholder unexpanded, because expanding it is
    /// the executing side's act against ITS workspace.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Set ONLY by the prior-archive reader ([`super::compat`]), naming a
    /// construct of the prior form this shape cannot express faithfully. A
    /// contract carrying it still reads, lists and censuses everywhere;
    /// [`Self::render`] refuses it with the construct and the cure, so the
    /// unexpressible part can never execute as something it did not mean.
    /// The emitter never sets it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prior_form_refusal: Option<String>,
}

/// Resolved command lines: the argvs, the environment, and the working
/// directory.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderedCommand {
    /// The argvs, one per body line, in execution order. Each element is
    /// whole; nothing joins or re-splits one.
    pub argv_lines: Vec<Vec<String>>,
    /// The environment bindings, in declared order.
    pub env: Vec<(String, String)>,
    /// The declared working directory, verbatim.
    pub cwd: Option<String>,
}

impl DeclaredCommandContract {
    /// Every parameter name this command declares, in declared order.
    #[must_use]
    pub fn parameter_names(&self) -> Vec<&str> {
        self.parameters
            .iter()
            .map(|parameter| parameter.name.as_str())
            .collect()
    }

    /// Resolve this command against `supplied`.
    ///
    /// `supplied` must name only parameters this command declares — a name it
    /// does not is refused rather than ignored, because a value nothing
    /// consumes is a value nobody reviewed. A caller holding a WIDER set (an
    /// action's parameters, say, of which the command uses some) narrows it
    /// first with [`Self::parameter_names`].
    ///
    /// # Errors
    ///
    /// Returns the first refusal the resolution earns: an undeclared or
    /// missing argument, a list where one value belongs, a leading-dash
    /// operand standing where the program still reads options, or a body that
    /// reads a parameter the command does not declare.
    pub fn render(
        &self,
        supplied: &BTreeMap<String, ArgumentValue>,
    ) -> Result<RenderedCommand, RenderError> {
        // A prior-form archive whose command carries a construct this shape
        // cannot express refuses HERE — at execution — never at read, so the
        // archive stays readable everywhere and the refusal names the cure.
        if let Some(construct) = &self.prior_form_refusal {
            return Err(RenderError::PriorFormUnrenderable {
                command: self.name.clone(),
                construct: construct.clone(),
            });
        }
        let bound = self.bind(supplied)?;

        let mut argv_lines = Vec::with_capacity(self.lines.len());
        for line in &self.lines {
            let mut argv = Vec::with_capacity(line.slots.len());
            for slot in &line.slots {
                let element = resolve(&self.name, &slot.fill, &bound)?;
                // THE CURE, at the bytes. Read off the slot's recorded
                // position, never off a declared property, so a declaration
                // cannot exempt itself.
                if !slot.admits_leading_dash && element.starts_with('-') {
                    return Err(RenderError::LeadingDashOperand {
                        command: self.name.clone(),
                        argument: slot.label.clone(),
                        element,
                        marker: END_OF_OPTIONS_MARKER,
                    });
                }
                argv.push(element);
            }
            argv_lines.push(argv);
        }

        Ok(RenderedCommand {
            argv_lines,
            env: self
                .env
                .iter()
                .map(|binding| (binding.name.clone(), binding.value.clone()))
                .collect(),
            cwd: self.cwd.clone(),
        })
    }

    /// Every declared parameter's value: supplied, or its declared default.
    fn bind(
        &self,
        supplied: &BTreeMap<String, ArgumentValue>,
    ) -> Result<BTreeMap<String, String>, RenderError> {
        for name in supplied.keys() {
            if !self.parameters.iter().any(|item| item.name == *name) {
                return Err(RenderError::ArgumentUndeclared {
                    command: self.name.clone(),
                    parameter: name.clone(),
                });
            }
        }

        let mut bound: BTreeMap<String, String> = BTreeMap::new();
        for parameter in &self.parameters {
            // No third branch, deliberately: a parameter with neither a
            // supplied value nor a declared default has nothing for this
            // surface to invent.
            let value = match supplied.get(&parameter.name) {
                Some(ArgumentValue::Scalar(value)) => value.clone(),
                Some(value @ ArgumentValue::List(_)) => {
                    return Err(RenderError::ArgumentTypeMismatch {
                        command: self.name.clone(),
                        parameter: parameter.name.clone(),
                        observed: value.describe(),
                        declared: shape_word(false),
                        supplied: shape_word(true),
                    });
                }
                None => {
                    let Some(default) = &parameter.default else {
                        return Err(RenderError::ArgumentMissing {
                            command: self.name.clone(),
                            parameter: parameter.name.clone(),
                        });
                    };
                    default.clone()
                }
            };
            bound.insert(parameter.name.clone(), value);
        }
        Ok(bound)
    }
}

/// A fill resolved against bound values, as ONE whole argv element — a space
/// inside a value is a space inside that one element and never a new
/// argument, because nothing re-splits it.
///
/// `bound` holds every parameter the command declares, so a hole with no
/// entry names a parameter the command does not have. That is refused, not
/// rendered as empty text: the empty rendering would put a DIFFERENT argv in
/// front of the program with nothing said, which is exactly the silence this
/// whole surface exists to close.
fn resolve(
    command: &str,
    fill: &FillTemplate,
    bound: &BTreeMap<String, String>,
) -> Result<String, RenderError> {
    let mut element = String::new();
    for piece in &fill.pieces {
        match piece {
            super::template::FillPiece::Literal { text } => element.push_str(text),
            super::template::FillPiece::Hole { parameter } => {
                let Some(value) = bound.get(parameter.as_str()) else {
                    return Err(RenderError::UnboundHole {
                        command: command.to_owned(),
                        parameter: parameter.clone(),
                    });
                };
                element.push_str(value);
            }
        }
    }
    Ok(element)
}