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

nordisk/facett

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