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()");
}
}