facett-pipeline/src/runa_adapter.rs
//! **The `runa` pipeline adapter** — a dwarves forge pipeline ([`dwarves_runa::Pipeline`])
//! → facett's engine-agnostic [`PipelineModel`](crate::PipelineModel).
//!
//! This is facett's [`PipelineAdapter`](crate::PipelineAdapter) impl for the
//! **runa** engine: the one place that knows *both* runa's typed pipeline shape
//! (a `*.runa.ron` — a Stage DAG over a typed [`Step`](dwarves_runa::Step) catalog) and
//! facett's neutral model. It mirrors knut's `SparkPipelineAdapter` and dwarves'
//! `ForgePipelineAdapter` one-for-one: **"show another engine's pipeline = a new
//! ADAPTER, not a new viewer"** (see the [`model`](crate::model) design note).
//!
//! ## Why the adapter lives HERE (not in runa)
//!
//! runa is a **pure edda leaf**: it never depends on facett. So — unlike knut,
//! which owns its adapter behind a `facett-adapter` feature — the runa adapter
//! cannot live in runa without dragging facett into it. It lives in
//! `facett-pipeline` instead, behind the off-by-default `runa-adapter` feature.
//! It is a strict **one-way** map: `runa` types come **in**, a
//! [`PipelineModel`](crate::PipelineModel) goes **out**; facett's model types
//! never leak back into runa, and runa stays a pure leaf.
//!
//! ## The mapping
//!
//! | runa | → | [`PipelineModel`](crate::PipelineModel) |
//! |---|---|---|
//! | each [`Stage`](dwarves_runa::Stage) | → | a [`Stage`](crate::Stage) (id = name = stage name) |
//! | the stage's [`Step`](dwarves_runa::Step)s | → | the stage `kind` sublabel (e.g. `"CargoBuild, Nextest, Lint"`) |
//! | each [`needs`](dwarves_runa::Stage::needs) entry | → | a [`PipelineEdge`](crate::PipelineEdge) (`need → stage`, source → consumer) |
//! | an optional per-stage run status | → | the [`StageStatus`](crate::StageStatus) (no status supplied ⇒ **Idle**) |
//! | [`Pipeline::name`](dwarves_runa::Pipeline) | → | the model `title` |
//!
//! A **static** pipeline (no status map) still renders: every stage is `Idle`. A
//! caller with a live run passes a `stage name → StageStatus` map (see
//! [`RunaPipelineAdapter::with_status`]); a stage absent from the map stays `Idle`
//! ("not yet started"), so the DAG animates a run as it progresses.
use std::collections::HashMap;
use dwarves_runa::{Pipeline, Step};
use crate::{PipelineAdapter, PipelineEdge, PipelineModel, Stage, StageStatus};
/// The short catalog label for a [`dwarves_runa::Step`] — the text a stage's `kind`
/// sublabel is built from (e.g. `CargoBuild` → `"CargoBuild"`). One stable label
/// per catalog variant; the typed params (features/profile/variant/…) are not
/// spelled out here — the sublabel is a compact "what this stage runs" summary.
pub fn step_label(step: &Step) -> &'static str {
match step {
Step::CargoBuild { .. } => "CargoBuild",
Step::Nextest { .. } => "Nextest",
Step::CargoTest { .. } => "CargoTest",
Step::Bench { .. } => "Bench",
Step::Coverage { .. } => "Coverage",
Step::Trace { .. } => "Trace",
Step::Profile { .. } => "Profile",
Step::LiveTrace { .. } => "LiveTrace",
Step::Lint { .. } => "Lint",
Step::Build { .. } => "Build",
Step::BuildWorkspace { .. } => "BuildWorkspace",
Step::ContainerBuild { .. } => "ContainerBuild",
Step::VmBoot => "VmBoot",
Step::Sbom { .. } => "Sbom",
Step::HolgerPackage { .. } => "HolgerPackage",
Step::ReleaseDoctor => "ReleaseDoctor",
Step::PublishCrates => "PublishCrates",
Step::DeployPages => "DeployPages",
Step::SecurityScan { .. } => "SecurityScan",
Step::PushImage { .. } => "PushImage",
Step::Deploy { .. } => "Deploy",
Step::ApprovalGate { .. } => "ApprovalGate",
Step::Notify { .. } => "Notify",
Step::ProvisionTestEnv { .. } => "ProvisionTestEnv",
Step::Xtask { .. } => "Xtask",
Step::SpinFleetMember { .. } => "SpinFleetMember",
Step::RobotDriveMember { .. } => "RobotDriveMember",
Step::CoverageGate { .. } => "CoverageGate",
Step::Sandbox { .. } => "Sandbox",
Step::Forge { .. } => "Forge",
}
}
/// The `kind` sublabel for a stage: its steps' catalog labels joined with `", "`
/// (e.g. `"CargoBuild, Nextest, Lint"`). `None` for a stage with no steps, so
/// the view falls back to the status label rather than an empty line.
fn stage_kind(steps: &[Step]) -> Option<String> {
if steps.is_empty() {
return None;
}
Some(
steps
.iter()
.map(step_label)
.collect::<Vec<_>>()
.join(", "),
)
}
/// Build the normalized [`PipelineModel`] from a [`dwarves_runa::Pipeline`] plus an
/// optional per-stage run-status map (keyed by stage name; a missing stage ⇒
/// [`StageStatus::Idle`]). The free-function form — pass an empty map for a static
/// pipeline. Equivalent to `RunaPipelineAdapter::new(p).with_status(status)
/// .to_pipeline_model()`.
pub fn to_pipeline_model(
pipeline: &Pipeline,
status: &HashMap<String, StageStatus>,
) -> PipelineModel {
// Each runa Stage → a neutral Stage node: id = name = stage name, the steps
// become the `kind` sublabel, and the run status is looked up by name (Idle
// when the caller supplied none for this stage).
let stages: Vec<Stage> = pipeline
.stages
.iter()
.map(|s| {
let st = status.get(&s.name).copied().unwrap_or_default();
let mut stage = Stage::new(s.name.clone(), st);
if let Some(kind) = stage_kind(&s.steps) {
stage = stage.with_kind(kind);
}
stage
})
.collect();
// Each `needs` entry → a source→consumer edge (`need → stage`): the needed
// stage is the upstream/producer, this stage is the downstream/consumer —
// exactly runa's own DAG edge direction (`need → stage`).
let mut edges = Vec::new();
for s in &pipeline.stages {
for need in &s.needs {
edges.push(PipelineEdge::new(need.clone(), s.name.clone()));
}
}
PipelineModel {
title: pipeline.name.clone(),
stages,
edges,
// A CI/CD authoring pipeline carries no row/byte dataflow metrics — the
// progress bar is left off (a live runner would layer its own).
metrics: None,
}
}
/// The runa [`PipelineAdapter`]: a thin wrapper over a `&dwarves_runa::Pipeline` (plus an
/// optional per-stage status map) so the viewer can take `&impl PipelineAdapter`
/// uniformly ([`PipelineView::from_adapter`](crate::PipelineView::from_adapter)).
pub struct RunaPipelineAdapter<'a> {
pipeline: &'a Pipeline,
status: HashMap<String, StageStatus>,
}
impl<'a> RunaPipelineAdapter<'a> {
/// Wrap a runa pipeline for rendering. With no status attached every stage is
/// [`StageStatus::Idle`] — a static pipeline still renders as a DAG.
pub fn new(pipeline: &'a Pipeline) -> Self {
Self {
pipeline,
status: HashMap::new(),
}
}
/// Attach a per-stage run-status map (keyed by stage name). A stage absent
/// from the map stays [`StageStatus::Idle`] ("not yet started"), so a partial
/// (in-flight) run animates the DAG as it progresses.
pub fn with_status(mut self, status: HashMap<String, StageStatus>) -> Self {
self.status = status;
self
}
}
impl PipelineAdapter for RunaPipelineAdapter<'_> {
fn to_pipeline_model(&self) -> PipelineModel {
to_pipeline_model(self.pipeline, &self.status)
}
}
#[cfg(test)]
mod tests {
use super::*;
use dwarves_runa::{LintTool, Profile, Stage as RunaStage, Step, Trigger};
/// A 3-stage runa pipeline: `build` → `test` (needs build) → `package`
/// (needs test). Built in code (no RON) so the test carries no parser dep.
fn three_stage() -> Pipeline {
Pipeline {
name: "demo-ci".to_string(),
triggers: vec![Trigger::Push { branch: "main".to_string() }],
runs_on: Default::default(),
stages: vec![
RunaStage {
name: "build".to_string(),
needs: vec![],
runs_on: None,
steps: vec![Step::CargoBuild {
features: vec![],
profile: Profile::Release,
target: None,
}],
},
RunaStage {
name: "test".to_string(),
needs: vec!["build".to_string()],
runs_on: None,
steps: vec![
Step::Nextest { all_features: true, only: Default::default() },
Step::Lint { tool: LintTool::Clippy, deny_warnings: true },
],
},
RunaStage {
name: "package".to_string(),
needs: vec!["test".to_string()],
runs_on: None,
steps: vec![Step::Build { variant: "holger-embedded".to_string(), opts: Vec::new(), params: Vec::new() }],
},
],
}
}
#[test]
fn three_stages_two_edges_with_source_to_consumer_direction() {
let p = three_stage();
let m = RunaPipelineAdapter::new(&p).to_pipeline_model();
// Exactly 3 nodes + 2 edges.
assert_eq!(m.stages.len(), 3, "one node per runa stage");
assert_eq!(m.edges.len(), 2, "one edge per `needs` entry");
// Stage ids == names (so edges resolve), in declaration order.
let ids: Vec<&str> = m.stages.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, vec!["build", "test", "package"]);
// Direction is `need → stage` (source/producer → consumer): the edge INTO
// `test` comes FROM `build`; the edge INTO `package` comes FROM `test`.
let into_test = m.edges.iter().find(|e| e.to == "test").expect("an edge into test");
assert_eq!(into_test.from, "build", "build is the upstream source of test");
let into_package = m.edges.iter().find(|e| e.to == "package").expect("an edge into package");
assert_eq!(into_package.from, "test", "test is the upstream source of package");
}
#[test]
fn steps_become_the_kind_sublabel() {
let p = three_stage();
let m = RunaPipelineAdapter::new(&p).to_pipeline_model();
let build = m.stages.iter().find(|s| s.id == "build").unwrap();
assert_eq!(build.kind.as_deref(), Some("CargoBuild"));
let test = m.stages.iter().find(|s| s.id == "test").unwrap();
assert_eq!(test.kind.as_deref(), Some("Nextest, Lint"), "the steps join into the sublabel");
}
#[test]
fn no_status_map_renders_all_idle() {
let p = three_stage();
let m = RunaPipelineAdapter::new(&p).to_pipeline_model();
// (idle, running, done, failed)
assert_eq!(m.status_counts(), (3, 0, 0, 0), "a static pipeline is all Idle");
}
#[test]
fn status_map_lights_the_dag() {
let p = three_stage();
let status = HashMap::from([
("build".to_string(), StageStatus::Done),
("test".to_string(), StageStatus::Running),
// `package` intentionally absent → stays Idle.
]);
let m = RunaPipelineAdapter::new(&p).with_status(status).to_pipeline_model();
assert_eq!(m.stages.iter().find(|s| s.id == "build").unwrap().status, StageStatus::Done);
assert_eq!(m.stages.iter().find(|s| s.id == "test").unwrap().status, StageStatus::Running);
assert_eq!(
m.stages.iter().find(|s| s.id == "package").unwrap().status,
StageStatus::Idle,
"a stage missing from the status map stays Idle (not yet started)"
);
assert_eq!(m.status_counts(), (1, 1, 1, 0));
}
#[test]
fn title_is_the_pipeline_name() {
let p = three_stage();
let m = RunaPipelineAdapter::new(&p).to_pipeline_model();
assert_eq!(m.title, "demo-ci");
}
#[test]
fn adapter_trait_and_free_fn_agree() {
let p = three_stage();
let empty = HashMap::new();
let a = to_pipeline_model(&p, &empty);
let b = RunaPipelineAdapter::new(&p).to_pipeline_model();
assert_eq!(a.stages.len(), b.stages.len());
assert_eq!(a.edges.len(), b.edges.len());
assert_eq!(a.title, b.title);
}
}