Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/dwarves-runa/src/lib.rs

//! # dwarves-runa — the runes the dwarves' forge reads
//!
//! `dwarves-runa` (Rust path `dwarves_runa::`, *runa* in prose) is the
//! **authoring layer** for the dwarves CI/CD forge: the typed pipeline
//! vocabulary a `*.runa.ron` file is written in. Pipelines *read like
//! config, validate like Rust, and drop to compiled `xtask` for real logic* —
//! pure-Rust, zero-shell, typed end to end.
//!
//! The package is namespaced `dwarves-runa` because the bare crates.io name
//! `runa` is owned by an unrelated publisher; the file format (`*.runa.ron`)
//! and the step vocabulary are unchanged.
//!
//! Three layers (see `.nornir/rust-cicd-design.md` in dwarves):
//! 1. **shape** — a typed [`Pipeline`] / [`Stage`] / [`Step`] tree authored in RON;
//! 2. **vocabulary** — the pre-made typed [`Step`] catalog (each variant wraps an
//!    existing capability with typed params);
//! 3. **logic** — [`Step::Xtask`], a reference to a compiled/tested `xtask`
//!    subcommand (the Groovy replacement, done right — no embedded scripting).
//!
//! dwarves reads the RON, topo-orders the stage DAG (funnel-core's engine, reused
//! here in the [`validate`] gate), and runs each step through its `StepRunner`.
//!
//! ## Errors at load, not at run
//!
//! [`Pipeline::from_ron_str`] parses **and validates** in one call: a syntactically
//! bad file, a `needs` that names an unknown stage, or a cyclic stage graph all
//! fail *at load*, before a single step runs.
//!
//! ```
//! use dwarves_runa::Pipeline;
//! let src = r#"Pipeline(
//!     name: "demo",
//!     triggers: [ Push(branch: "main") ],
//!     stages: [
//!         Stage(name: "build", steps: [ CargoBuild(profile: Release) ]),
//!         Stage(name: "test", needs: ["build"], steps: [ Nextest(all_features: true) ]),
//!     ],
//! )"#;
//! let pipeline = Pipeline::from_ron_str(src).expect("valid pipeline");
//! assert_eq!(pipeline.name, "demo");
//! assert_eq!(pipeline.stages.len(), 2);
//! ```

mod pipeline;
mod validate;

pub use pipeline::{
    DeployTarget, LintTool, NotifyChannel, NotifyOn, Pipeline, Profile, ProvisionBackend,
    Mount, RunTarget, RunsOn, SbomPolicy, ScanKind, Stage, Step, TestScope, Tmpfs, Trigger, WorkspaceScope, alias_is_a_name,
    env_looks_secret, image_by_digest, is_a_name, is_plain_absolute,
    xtask_alias,
};

/// Why loading a `*.runa.ron` failed. A [`Pipeline`] only exists once it has
/// passed every one of these checks.
#[derive(Debug, thiserror::Error)]
pub enum RunaError {
    /// The RON was syntactically or structurally malformed (failed to
    /// deserialise into the [`Pipeline`] schema).
    #[error("failed to parse .runa.ron: {0}")]
    Parse(#[from] ron::error::SpannedError),

    /// The pipeline parsed, but failed a structural [`ValidationError`] check.
    #[error(transparent)]
    Validation(#[from] ValidationError),
}

/// A structural problem in an otherwise well-parsed [`Pipeline`] — caught at load
/// so it can never surface mid-run.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ValidationError {
    /// Two stages share a name, which would make `needs` references ambiguous.
    #[error("duplicate stage name `{name}`")]
    DuplicateStage {
        /// The repeated stage name.
        name: String,
    },

    /// A stage's `needs` references a stage that does not exist.
    #[error("stage `{stage}` needs unknown stage `{needs}`")]
    UnknownNeed {
        /// The stage carrying the bad dependency.
        stage: String,
        /// The dangling `needs` entry.
        needs: String,
    },

    /// An `Xtask` step names an `alias` that is not a cargo alias name.
    #[error("stage `{stage}`: Xtask alias `{alias}` is not a cargo alias name ([a-z0-9_-]+)")]
    BadAlias {
        /// The stage carrying the step.
        stage: String,
        /// The refused alias, verbatim.
        alias: String,
    },

    /// A `BuildWorkspace` step names a workspace or a member that is not a NAME.
    #[error("stage `{stage}`: BuildWorkspace {what} `{name}` is not a name ([A-Za-z0-9_.-]+)")]
    BadName {
        /// The stage carrying the step.
        stage: String,
        /// `workspace` or `repo`.
        what: String,
        /// The refused name, verbatim.
        name: String,
    },

    /// A `Sandbox` step that cannot be sealed as written.
    #[error("stage `{stage}`: Sandbox {why}")]
    BadSandbox {
        /// The stage carrying the step.
        stage: String,
        /// What is wrong, by name.
        why: String,
    },

    /// A `runs_on` names a place with no runner backend yet (`Kubernetes`). Refused
    /// by name at load, never run somewhere else in its place.
    #[error("{}runs_on: {runs_on} has no backend yet — refused, not run elsewhere", match stage { Some(s) => format!("stage `{s}`: "), None => String::new() })]
    RunsOnNotWired {
        /// The stage that set it; `None` = the pipeline-level setting.
        stage: Option<String>,
        /// The refused value.
        runs_on: RunsOn,
    },

    /// The stage DAG contains a cycle (`needs` cannot be satisfied in any order).
    #[error("stage dependency cycle detected involving: {}", nodes.join(", "))]
    Cycle {
        /// Stage name(s) implicated in the cycle (as reported by the topo engine).
        nodes: Vec<String>,
    },
}

impl Pipeline {
    /// Parse a `*.runa.ron` source string into a **validated** [`Pipeline`].
    ///
    /// This is the only sanctioned way to load a pipeline: it deserialises the
    /// RON *and* runs the [`validate`] gate (unknown-`needs` + DAG cycle check),
    /// so the returned value is guaranteed structurally sound. Errors surface at
    /// load, never mid-run.
    pub fn from_ron_str(src: &str) -> Result<Self, RunaError> {
        let pipeline: Pipeline = ron::from_str(&bare_unit_steps(src))?;
        validate::validate(&pipeline)?;
        Ok(pipeline)
    }

    /// Run the load-time gate on a pipeline built in code (one that never went through
    /// [`from_ron_str`](Pipeline::from_ron_str)): the same checks, the same refusals.
    pub fn validate(&self) -> Result<(), ValidationError> {
        validate::validate(self)
    }

    /// Serialise this pipeline back to pretty RON (round-trips with
    /// [`from_ron_str`](Pipeline::from_ron_str)).
    pub fn to_ron_string(&self) -> Result<String, ron::Error> {
        ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
    }
}

/// Steps that WERE unit variants and are struct variants now, and so must be
/// written `Name()` for RON — every shipped pipeline spells them bare.
const ONCE_UNIT_STEPS: &[&str] = &["CargoTest"];

/// Rewrite a bare [`ONCE_UNIT_STEPS`] identifier (not followed by `(`) to
/// `Name()`, leaving string literals (`"…"`, raw `r#"…"#`), char literals and
/// comments (`//`, nested `/* */`) untouched. RON 0.12 refuses a bare struct
/// variant (`ExpectedStructLike`), so without this `CargoTest` stopped loading
/// the day it gained `only`.
fn bare_unit_steps(src: &str) -> std::borrow::Cow<'_, str> {
    if !ONCE_UNIT_STEPS.iter().any(|n| src.contains(n)) {
        return std::borrow::Cow::Borrowed(src);
    }
    let b = src.as_bytes();
    let mut out = String::with_capacity(src.len() + 8);
    let mut i = 0;
    let mut last = 0;
    let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_';
    while i < b.len() {
        let c = b[i];
        if c == b'/' && b.get(i + 1) == Some(&b'/') {
            while i < b.len() && b[i] != b'\n' {
                i += 1;
            }
        } else if c == b'/' && b.get(i + 1) == Some(&b'*') {
            let mut depth = 0usize;
            while i < b.len() {
                if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
                    depth += 1;
                    i += 2;
                } else if b[i] == b'*' && b.get(i + 1) == Some(&b'/') {
                    depth -= 1;
                    i += 2;
                    if depth == 0 {
                        break;
                    }
                } else {
                    i += 1;
                }
            }
        } else if c == b'r' && (b.get(i + 1) == Some(&b'"') || b.get(i + 1) == Some(&b'#')) && (i == 0 || !ident(b[i - 1])) {
            // raw string r#…#"…"#…#
            let mut j = i + 1;
            let mut hashes = 0;
            while b.get(j) == Some(&b'#') {
                hashes += 1;
                j += 1;
            }
            if b.get(j) == Some(&b'"') {
                j += 1;
                loop {
                    match b.get(j) {
                        None => break,
                        Some(b'"') if b[j + 1..].iter().take(hashes).filter(|h| **h == b'#').count() == hashes && b.len() >= j + 1 + hashes => {
                            j += 1 + hashes;
                            break;
                        }
                        _ => j += 1,
                    }
                }
                i = j;
            } else {
                i += 1;
            }
        } else if c == b'"' || c == b'\'' {
            let q = c;
            i += 1;
            while i < b.len() && b[i] != q {
                i += if b[i] == b'\\' { 2 } else { 1 };
            }
            i += 1;
        } else if ident(c) {
            let start = i;
            while i < b.len() && ident(b[i]) {
                i += 1;
            }
            let word = &src[start..i];
            if ONCE_UNIT_STEPS.contains(&word) {
                let mut j = i;
                while j < b.len() && b[j].is_ascii_whitespace() {
                    j += 1;
                }
                if b.get(j) != Some(&b'(') {
                    out.push_str(&src[last..i]);
                    out.push_str("()");
                    last = i;
                }
            }
        } else {
            i += 1;
        }
    }
    out.push_str(&src[last.min(src.len())..]);
    std::borrow::Cow::Owned(out)
}

#[cfg(test)]
mod bare_tests {
    use super::bare_unit_steps;

    #[test]
    fn only_a_bare_identifier_is_rewritten() {
        assert_eq!(bare_unit_steps("[CargoTest, CargoTest(), CargoTest (only: All)]"), "[CargoTest(), CargoTest(), CargoTest (only: All)]");
        assert_eq!(bare_unit_steps(r#"(n: "CargoTest", s: [CargoTest]) // CargoTest"#), r#"(n: "CargoTest", s: [CargoTest()]) // CargoTest"#);
        assert_eq!(bare_unit_steps("/* CargoTest /* CargoTest */ */ CargoTest"), "/* CargoTest /* CargoTest */ */ CargoTest()");
        assert_eq!(bare_unit_steps(r##"(a: r#"CargoTest "x" CargoTest"#, b: CargoTest)"##), r##"(a: r#"CargoTest "x" CargoTest"#, b: CargoTest())"##);
        assert_eq!(bare_unit_steps(r#"(a: "es\"c CargoTest", b: MyCargoTest, c: CargoTests)"#), r#"(a: "es\"c CargoTest", b: MyCargoTest, c: CargoTests)"#);
        assert_eq!(bare_unit_steps("CargoTest"), "CargoTest()");
    }
}