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/promote_adapter.rs

//! **The promotion-ladder adapter** — a slot ladder → facett's engine-agnostic
//! [`PipelineModel`](crate::PipelineModel), so a promotion ladder renders on the
//! *existing* DAG viewer.
//!
//! A promotion ladder is a DAG with per-node status, which is precisely what
//! [`PipelineView`](crate::PipelineView) already draws. So showing one is **an
//! adapter, not a new crate** — the same rule that made runa's and knut's
//! pipelines share this viewer. (There are already three facett crates that draw
//! boxes-with-status; a fourth would be duplication, not capability.)
//!
//! ## The dependency rule this shape exists to obey
//!
//! facett is a **UI library**. The promotion *engine* — `skidbladnir::promote` —
//! is a heavy, headless provisioning library path-depended by four consumers that
//! must never be handed an `egui` stack. So the edge is wrong in **both**
//! directions: facett must not depend on the engine, and the engine must not
//! depend on facett.
//!
//! The adapter therefore reads a **small local input type** that the *caller*
//! populates ([`PromotionLadder`]) — exactly the way
//! [`RunaPipelineAdapter`](crate::RunaPipelineAdapter) consumes runa's model
//! without facett owning runa's engine. Unlike the runa adapter this module is
//! **not** feature-gated: that flag exists to keep an optional *dependency*
//! (`dwarves-runa`) out of a default build, and this module pulls in nothing at
//! all. Gating a zero-dependency adapter would be cargo-culting the flag instead
//! of its reason.
//!
//! ## Mapping the host writes (≈ a few lines)
//!
//! | promotion engine | → | this module |
//! |---|---|---|
//! | one slot / environment | → | a [`Rung`] (`id` = the slot key, `name` = what to show) |
//! | the slot's role in the constellation | → | [`SlotRole`] |
//! | the slot's guard flag (prod guard / two-key override) | → | [`Rung::guarded`] |
//! | the promotion order (`a` may promote into `b`) | → | a [`Promotion`] `a → b` |
//! | the observed state of a slot | → | a [`RungStatus`] |
//! | **no observation for a slot** | → | [`RungStatus::Unknown`] + why — never `Pending` |
//!
//! The rungs are **DATA**. Njord's ladder happens to be
//! `test2 → test1 → lab → g11.se`; someone else runs `dev → qa → staging → prod`;
//! a third has one rung and no prod. Nothing in this file knows any of those
//! names, and nothing in it may learn them.
//!
//! ## The law: an unobserved rung is `Unknown`, never `Idle`, never `Done`
//!
//! For a *pipeline*, a stage nobody has run is honestly `Idle` — the engine owns
//! the run and knows it has not started. For a **ladder** the nodes are live
//! environments that exist whether or not we looked, so "we have never observed
//! g11.se" and "g11.se is idle" are different facts. Rendering the first as the
//! second is a **fake green**: a calm neutral box over an environment nobody has
//! checked. That pattern has been found repeatedly in this codebase — a DHCP
//! *request* read as a lease, a handshake *initiation* read as a completed
//! handshake — and every instance shipped a green light over something that was
//! not working.
//!
//! So this adapter inverts the runa adapter's default. Where a runa stage missing
//! from the status map stays [`StageStatus::Idle`], a rung missing from
//! [`PromoteLadderAdapter::with_observations`] becomes
//! [`StageStatus::Unknown`]`(`[`UnknownReason::NotProbed`]`)`, which renders
//! amber, dashed and `?`-marked. [`RungStatus`]'s own `Default` is `Unknown` for
//! the same reason: the cheapest thing to write must be the honest thing.

use std::collections::HashMap;

use crate::{
    PipelineAdapter, PipelineEdge, PipelineModel, Stage, StageStatus, UnknownReason,
};

/// What a slot **is** in the constellation. Purely descriptive — the adapter draws
/// it as part of the node's sublabel and never changes behaviour on it (a guard is
/// [`Rung::guarded`], an explicit flag, not something inferred from the role).
///
/// `#[non_exhaustive]`: roles will be added.
#[non_exhaustive]
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum SlotRole {
    /// The live, customer-facing slot. Top of the ladder; data never flows up into it.
    Prod,
    /// A production-shaped rehearsal slot (staging / pre-prod / lab).
    Preprod,
    /// A test slot.
    #[default]
    Test,
    /// A slot whose role the caller does not classify.
    Other,
}

impl SlotRole {
    /// A short, stable token for the sublabel and for text/JSON.
    pub fn token(self) -> &'static str {
        match self {
            SlotRole::Prod => "prod",
            SlotRole::Preprod => "preprod",
            SlotRole::Test => "test",
            SlotRole::Other => "slot",
        }
    }
}

/// The **observed** state of one rung, as the promotion engine reports it.
///
/// Deliberately ladder-shaped rather than a re-use of [`StageStatus`]: a ladder's
/// nodes are environments, not steps, so the vocabulary differs — and keeping it
/// separate is what forces the caller to answer *"did you actually look?"*.
///
/// `Default` is [`Unknown`](RungStatus::Unknown)`(NotProbed)` — **not** `Pending`.
/// A rung you have not said anything about is one you have not looked at.
///
/// `#[non_exhaustive]`: match with a `_ =>` arm.
#[non_exhaustive]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RungStatus {
    /// Observed, and holding the release it is supposed to hold. → `Done`.
    Promoted,
    /// A promotion into this rung is running right now. → `Running`.
    Promoting,
    /// The last promotion into this rung failed (put the message on
    /// [`Rung::note`]). → `Failed`.
    Failed,
    /// Observed and healthy, but has **not** received the release yet — it is a
    /// later rung on the ladder and is genuinely waiting. → `Idle`.
    ///
    /// Only say this when you looked. If you did not look, that is
    /// [`Unknown`](RungStatus::Unknown).
    Pending,
    /// **Could not be determined**, with the reason. → `Unknown`, rendered amber /
    /// dashed / `?` — never the neutral grey of `Pending`.
    Unknown(UnknownReason),
}

impl Default for RungStatus {
    fn default() -> Self {
        RungStatus::Unknown(UnknownReason::NotProbed)
    }
}

impl RungStatus {
    /// Map onto the neutral [`StageStatus`] the viewer renders. The only
    /// interesting arm is the last one: an undetermined rung carries its reason
    /// straight through, so it can be neither `Idle` nor `Done` by construction.
    pub fn to_stage_status(self) -> StageStatus {
        match self {
            RungStatus::Promoted => StageStatus::Done,
            RungStatus::Promoting => StageStatus::Running,
            RungStatus::Failed => StageStatus::Failed,
            RungStatus::Pending => StageStatus::Idle,
            RungStatus::Unknown(r) => StageStatus::Unknown(r),
            // No `_` arm: a rung state added later must be mapped DELIBERATELY.
            // In-crate exhaustiveness still bites under `#[non_exhaustive]`, so
            // the compiler stops anyone from letting a new state fall into `Idle`.
        }
    }
}

impl From<RungStatus> for StageStatus {
    fn from(s: RungStatus) -> Self {
        s.to_stage_status()
    }
}

/// One rung of the ladder: a slot/environment the release is promoted through.
#[derive(Clone, Debug)]
pub struct Rung {
    /// Stable identity — what [`Promotion`] endpoints reference (the slot key).
    pub id: String,
    /// Human display name (often the host, e.g. the slot's public name).
    pub name: String,
    /// What this slot is (prod / preprod / test).
    pub role: SlotRole,
    /// Whether promoting **into** this rung is guarded (a prod guard, a two-key
    /// override, a manual approval). Descriptive: the adapter shows it, the
    /// engine enforces it.
    pub guarded: bool,
    /// The observed state. Defaults to [`RungStatus::Unknown`].
    pub status: RungStatus,
    /// The release/version this rung is holding, when known (shown in the
    /// sublabel). `None` = not reported.
    pub release: Option<String>,
    /// Free-text detail — the failure message for a [`RungStatus::Failed`] rung,
    /// or any note the host wants in the detail panel.
    pub note: Option<String>,
}

impl Rung {
    /// A rung with `id` = `name`, the given role, unguarded, and — critically —
    /// [`RungStatus::Unknown`] until someone says otherwise.
    pub fn new(name: impl Into<String>, role: SlotRole) -> Self {
        let name = name.into();
        Rung {
            id: name.clone(),
            name,
            role,
            guarded: false,
            status: RungStatus::default(),
            release: None,
            note: None,
        }
    }
    /// Set a distinct stable id (when the slot key differs from the display name).
    pub fn with_id(mut self, id: impl Into<String>) -> Self {
        self.id = id.into();
        self
    }
    /// Mark promotion into this rung as guarded.
    pub fn guarded(mut self, guarded: bool) -> Self {
        self.guarded = guarded;
        self
    }
    /// Attach the observed status.
    pub fn with_status(mut self, status: RungStatus) -> Self {
        self.status = status;
        self
    }
    /// Attach the release/version this rung holds.
    pub fn with_release(mut self, release: impl Into<String>) -> Self {
        self.release = Some(release.into());
        self
    }
    /// Attach a note (failure message / operator remark).
    pub fn with_note(mut self, note: impl Into<String>) -> Self {
        self.note = Some(note.into());
        self
    }

    /// The node sublabel: `role · guarded · release`, skipping the parts that are
    /// absent (e.g. `"prod · guarded · v2.4.1"`, or just `"test"`).
    fn kind(&self) -> String {
        let mut parts: Vec<&str> = vec![self.role.token()];
        if self.guarded {
            parts.push("guarded");
        }
        match &self.release {
            Some(r) => format!("{} · {}", parts.join(" · "), r),
            None => parts.join(" · "),
        }
    }
}

/// One step of the promotion order: the release moves **`from` → `to`**. Endpoints
/// are [`Rung::id`]s. This is the ladder's edge set, and it is data — a ladder may
/// branch (one rung promoting into two) or converge; nothing here assumes a chain.
#[derive(Clone, Default, Debug, PartialEq, Eq)]
pub struct Promotion {
    /// The upstream rung the release is promoted **from**.
    pub from: String,
    /// The downstream rung the release is promoted **into**.
    pub to: String,
    /// Optional edge label (e.g. `"guarded"`, `"manual"`, a gate name).
    pub label: Option<String>,
}

impl Promotion {
    /// A bare `from → to` promotion step.
    pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
        Promotion {
            from: from.into(),
            to: to.into(),
            label: None,
        }
    }
    /// Attach an edge label (drawn at the edge midpoint).
    pub fn with_label(mut self, label: impl Into<String>) -> Self {
        self.label = Some(label.into());
        self
    }
}

/// **The adapter's input** — the whole ladder: a title, the rungs, and the
/// promotion order between them.
///
/// This is the type the Njord-side host builds from `skidbladnir::promote`'s serde
/// topology. It is intentionally tiny and owns no engine types, so that mapping is
/// a `map`/`collect` pair rather than a translation layer.
#[derive(Clone, Default, Debug)]
pub struct PromotionLadder {
    /// Ladder title (e.g. the application or customer name).
    pub title: String,
    /// The rungs, in a stable display order.
    pub rungs: Vec<Rung>,
    /// The promotion order (edges between rung ids). Endpoints that name no rung
    /// are dropped by the viewer, exactly as for any [`PipelineModel`].
    pub promotions: Vec<Promotion>,
}

impl PromotionLadder {
    /// An empty ladder with a title.
    pub fn new(title: impl Into<String>) -> Self {
        PromotionLadder {
            title: title.into(),
            ..Default::default()
        }
    }

    /// A **linear** ladder: the rungs in the given order, chained
    /// `rungs[0] → rungs[1] → …`. The common case — and still data: the caller
    /// supplies the rungs and hence the order.
    pub fn linear(title: impl Into<String>, rungs: Vec<Rung>) -> Self {
        let promotions = rungs
            .windows(2)
            .map(|w| Promotion::new(w[0].id.clone(), w[1].id.clone()))
            .collect();
        PromotionLadder {
            title: title.into(),
            rungs,
            promotions,
        }
    }

    /// Builder: append a rung.
    pub fn with_rung(mut self, rung: Rung) -> Self {
        self.rungs.push(rung);
        self
    }
    /// Builder: append a promotion step.
    pub fn with_promotion(mut self, promotion: Promotion) -> Self {
        self.promotions.push(promotion);
        self
    }
    /// Index of the rung with this id, if present.
    pub fn rung_index(&self, id: &str) -> Option<usize> {
        self.rungs.iter().position(|r| r.id == id)
    }
}

/// Build the normalized [`PipelineModel`] from a [`PromotionLadder`] plus an
/// optional per-rung observation map (keyed by [`Rung::id`]).
///
/// **A rung absent from `observed` keeps the rung's own status**, which defaults
/// to [`RungStatus::Unknown`] — so the free-function form is safe to call with an
/// empty map: an unobserved ladder renders entirely amber-`?`, not grey-idle.
pub fn to_pipeline_model(
    ladder: &PromotionLadder,
    observed: &HashMap<String, RungStatus>,
) -> PipelineModel {
    let stages: Vec<Stage> = ladder
        .rungs
        .iter()
        .map(|r| {
            // The observation wins when present; otherwise the rung's own status,
            // which is Unknown unless the caller set it. There is no code path
            // here that can invent Idle or Done for a rung nobody reported.
            let status = observed.get(&r.id).copied().unwrap_or(r.status);
            let mut stage = Stage::new(r.name.clone(), status.to_stage_status()).with_id(r.id.clone());
            stage = stage.with_kind(r.kind());
            if let Some(note) = &r.note {
                stage = stage.with_error(note.clone());
            }
            stage
        })
        .collect();

    let edges: Vec<PipelineEdge> = ladder
        .promotions
        .iter()
        .map(|p| {
            let e = PipelineEdge::new(p.from.clone(), p.to.clone());
            match &p.label {
                Some(l) => e.with_label(l.clone()),
                None => e,
            }
        })
        .collect();

    PipelineModel {
        title: ladder.title.clone(),
        stages,
        edges,
        // A ladder carries no row/byte dataflow metrics — the progress bar stays
        // off. A host running a live promotion layers its own.
        metrics: None,
    }
}

/// The promotion-ladder [`PipelineAdapter`]: a thin wrapper over a
/// `&PromotionLadder` (plus an optional per-rung observation map) so the viewer
/// takes `&impl PipelineAdapter` uniformly
/// ([`PipelineView::from_adapter`](crate::PipelineView::from_adapter)).
///
/// ```
/// use facett_pipeline::{
///     PipelineAdapter, PromoteLadderAdapter, PromotionLadder, Rung, RungStatus, SlotRole,
///     StageStatus, UnknownReason,
/// };
///
/// // The rungs are DATA — these names are this caller's, not facett's.
/// let ladder = PromotionLadder::linear(
///     "acme",
///     vec![
///         Rung::new("dev", SlotRole::Test).with_status(RungStatus::Promoted),
///         Rung::new("staging", SlotRole::Preprod).with_status(RungStatus::Promoting),
///         Rung::new("prod", SlotRole::Prod).guarded(true), // never probed
///     ],
/// );
/// let model = PromoteLadderAdapter::new(&ladder).to_pipeline_model();
///
/// assert_eq!(model.stages.len(), 3);
/// assert_eq!(model.edges.len(), 2); // dev → staging → prod
/// // The rung nobody looked at is UNKNOWN — not Idle, not Done.
/// assert_eq!(
///     model.stages[2].status,
///     StageStatus::Unknown(UnknownReason::NotProbed),
/// );
/// assert_eq!(model.stages[2].status.label(), "unknown:not-probed");
/// ```
pub struct PromoteLadderAdapter<'a> {
    ladder: &'a PromotionLadder,
    observed: HashMap<String, RungStatus>,
}

impl<'a> PromoteLadderAdapter<'a> {
    /// Wrap a ladder for rendering. With no observations attached, each rung keeps
    /// its own status — which defaults to [`RungStatus::Unknown`], so a ladder
    /// built from topology alone renders as "we have not looked", not as "idle".
    pub fn new(ladder: &'a PromotionLadder) -> Self {
        Self {
            ladder,
            observed: HashMap::new(),
        }
    }

    /// Attach a per-rung observation map (keyed by [`Rung::id`]).
    ///
    /// **Note the difference from
    /// [`RunaPipelineAdapter::with_status`](crate::RunaPipelineAdapter::with_status):**
    /// there, a stage missing from the map is `Idle` ("not yet started" — the
    /// engine owns the run and knows). Here, a rung missing from the map is
    /// whatever the rung says, and a rung says `Unknown` unless told otherwise.
    /// A live environment nobody probed is not idle; it is unobserved.
    pub fn with_observations(mut self, observed: HashMap<String, RungStatus>) -> Self {
        self.observed = observed;
        self
    }

    /// Mark **every** rung not present in the observation map with an explicit
    /// reason — the honest spelling for "the probe run itself failed / we were not
    /// authorised", as opposed to the default "never probed".
    pub fn unobserved_reason(mut self, reason: UnknownReason) -> Self {
        for r in &self.ladder.rungs {
            self.observed
                .entry(r.id.clone())
                .or_insert(RungStatus::Unknown(reason));
        }
        self
    }
}

impl PipelineAdapter for PromoteLadderAdapter<'_> {
    fn to_pipeline_model(&self) -> PipelineModel {
        to_pipeline_model(self.ladder, &self.observed)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Njord's own ladder shape — but note the test builds it as DATA. The
    /// `names_are_data` test below runs a completely different set of names
    /// through the identical code path to prove nothing is baked in.
    fn njord_ladder() -> PromotionLadder {
        PromotionLadder::linear(
            "yvra",
            vec![
                Rung::new("test2", SlotRole::Test),
                Rung::new("test1", SlotRole::Test),
                Rung::new("lab", SlotRole::Preprod),
                Rung::new("g11.se", SlotRole::Prod).guarded(true),
            ],
        )
    }

    #[test]
    fn rungs_become_nodes_and_the_promotion_order_becomes_edges() {
        let m = PromoteLadderAdapter::new(&njord_ladder()).to_pipeline_model();
        assert_eq!(m.stages.len(), 4, "one node per rung");
        assert_eq!(m.edges.len(), 3, "one edge per promotion step");
        let ids: Vec<&str> = m.stages.iter().map(|s| s.id.as_str()).collect();
        assert_eq!(ids, vec!["test2", "test1", "lab", "g11.se"]);
        // Direction is `from → to`: the release is promoted UP the ladder.
        let into_prod = m.edges.iter().find(|e| e.to == "g11.se").expect("an edge into prod");
        assert_eq!(into_prod.from, "lab");
        assert_eq!(m.title, "yvra");
    }

    /// ⚠ **The law.** A rung nobody observed must be `Unknown` with a reason —
    /// not `Idle` (which claims we looked and it had not started) and not `Done`
    /// (which is the fake green outright). Revert `RungStatus::default()` to
    /// `Pending`, or map `Unknown` onto `Idle`, and this goes red.
    #[test]
    fn an_unobserved_rung_is_unknown_never_idle_never_done() {
        let m = PromoteLadderAdapter::new(&njord_ladder()).to_pipeline_model();
        for s in &m.stages {
            assert_eq!(
                s.status,
                StageStatus::Unknown(UnknownReason::NotProbed),
                "{} was never observed, so it must be Unknown(NotProbed)",
                s.id
            );
            assert_ne!(s.status, StageStatus::Idle, "{} must NOT render as idle", s.id);
            assert_ne!(s.status, StageStatus::Done, "{} must NOT render as done", s.id);
            assert!(s.status.is_unknown());
            assert_eq!(s.status.label(), "unknown:not-probed", "the label carries the reason");
        }
        // And the tally accounts for every one of them.
        let t = m.status_tally();
        assert_eq!(t.unknown, 4);
        assert_eq!(t.idle, 0, "not one of them may be counted as idle");
        assert_eq!(t.done, 0);
        assert_eq!(t.total(), m.stages.len());
    }

    /// The default is the honest one: writing the least possible about a rung must
    /// not yield a green (or even a calm grey) light.
    #[test]
    fn the_default_rung_status_is_unknown() {
        assert_eq!(RungStatus::default(), RungStatus::Unknown(UnknownReason::NotProbed));
        assert_eq!(
            Rung::new("anything", SlotRole::Test).status,
            RungStatus::Unknown(UnknownReason::NotProbed),
        );
        assert!(RungStatus::default().to_stage_status().is_unknown());
    }

    #[test]
    fn observations_light_the_ladder_and_the_gap_stays_unknown() {
        let ladder = njord_ladder();
        let observed = HashMap::from([
            ("test2".to_string(), RungStatus::Promoted),
            ("test1".to_string(), RungStatus::Promoting),
            ("lab".to_string(), RungStatus::Pending),
            // `g11.se` intentionally absent — the probe never reached prod.
        ]);
        let m = PromoteLadderAdapter::new(&ladder)
            .with_observations(observed)
            .to_pipeline_model();

        let by_id = |id: &str| m.stages.iter().find(|s| s.id == id).unwrap().status;
        assert_eq!(by_id("test2"), StageStatus::Done);
        assert_eq!(by_id("test1"), StageStatus::Running);
        assert_eq!(by_id("lab"), StageStatus::Idle, "Pending means we LOOKED and it is waiting");
        assert_eq!(
            by_id("g11.se"),
            StageStatus::Unknown(UnknownReason::NotProbed),
            "the rung missing from the observation map stays Unknown — the exact \
             place where a pipeline adapter would have said Idle",
        );
        let t = m.status_tally();
        assert_eq!((t.done, t.running, t.idle, t.failed, t.unknown), (1, 1, 1, 0, 1));
    }

    /// The whole-ladder reason override: "we tried and could not reach anything"
    /// is a different fact from "we never tried", and both must be sayable.
    #[test]
    fn unobserved_reason_overrides_the_default_not_probed() {
        let ladder = njord_ladder();
        let m = PromoteLadderAdapter::new(&ladder)
            .with_observations(HashMap::from([("test2".to_string(), RungStatus::Promoted)]))
            .unobserved_reason(UnknownReason::NotAuthorised)
            .to_pipeline_model();
        let by_id = |id: &str| m.stages.iter().find(|s| s.id == id).unwrap().status;
        assert_eq!(by_id("test2"), StageStatus::Done, "an observed rung is untouched");
        assert_eq!(by_id("g11.se"), StageStatus::Unknown(UnknownReason::NotAuthorised));
        assert_eq!(by_id("g11.se").label(), "unknown:not-authorised");
    }

    /// ⚠ **The rungs are DATA.** A ladder with entirely different names, a
    /// different length and a branch produces the same shape through the same
    /// code. Hardcode `test2`/`lab`/`g11.se` anywhere in this module and this
    /// goes red.
    #[test]
    fn names_and_topology_are_data_not_baked_in() {
        let ladder = PromotionLadder::new("acme")
            .with_rung(Rung::new("dev", SlotRole::Test).with_status(RungStatus::Promoted))
            .with_rung(Rung::new("qa", SlotRole::Test).with_status(RungStatus::Promoted))
            .with_rung(Rung::new("staging", SlotRole::Preprod).with_status(RungStatus::Promoting))
            .with_rung(Rung::new("prod-eu", SlotRole::Prod).guarded(true))
            .with_rung(Rung::new("prod-us", SlotRole::Prod).guarded(true))
            // A BRANCH: staging promotes into two prods.
            .with_promotion(Promotion::new("dev", "qa"))
            .with_promotion(Promotion::new("qa", "staging"))
            .with_promotion(Promotion::new("staging", "prod-eu").with_label("guarded"))
            .with_promotion(Promotion::new("staging", "prod-us").with_label("guarded"));

        let m = PromoteLadderAdapter::new(&ladder).to_pipeline_model();
        assert_eq!(m.stages.len(), 5);
        assert_eq!(m.edges.len(), 4);
        let out_of_staging: Vec<&str> = m.edges.iter().filter(|e| e.from == "staging").map(|e| e.to.as_str()).collect();
        assert_eq!(out_of_staging, vec!["prod-eu", "prod-us"], "a ladder may branch");
        // Both unprobed prods are Unknown, exactly as Njord's would be.
        for id in ["prod-eu", "prod-us"] {
            let s = m.stages.iter().find(|s| s.id == id).unwrap();
            assert!(s.status.is_unknown(), "{id} was never probed");
        }
        assert_eq!(m.edges[2].label.as_deref(), Some("guarded"));
    }

    #[test]
    fn role_guard_and_release_ride_the_sublabel() {
        let ladder = PromotionLadder::new("t")
            .with_rung(Rung::new("p", SlotRole::Prod).guarded(true).with_release("v2.4.1"))
            .with_rung(Rung::new("t1", SlotRole::Test));
        let m = PromoteLadderAdapter::new(&ladder).to_pipeline_model();
        assert_eq!(m.stages[0].kind.as_deref(), Some("prod · guarded · v2.4.1"));
        assert_eq!(m.stages[1].kind.as_deref(), Some("test"), "unguarded, no release → just the role");
    }

    #[test]
    fn a_failed_rungs_note_becomes_the_stage_error() {
        let ladder = PromotionLadder::new("t").with_rung(
            Rung::new("lab", SlotRole::Preprod)
                .with_status(RungStatus::Failed)
                .with_note("rollout timed out after 300s"),
        );
        let m = PromoteLadderAdapter::new(&ladder).to_pipeline_model();
        assert_eq!(m.stages[0].status, StageStatus::Failed);
        assert_eq!(m.stages[0].error.as_deref(), Some("rollout timed out after 300s"));
    }

    #[test]
    fn a_distinct_slot_key_and_display_name_both_survive() {
        let ladder = PromotionLadder::new("t")
            .with_rung(Rung::new("Kundens produktion", SlotRole::Prod).with_id("slot-prod-1"))
            .with_rung(Rung::new("Labb", SlotRole::Preprod).with_id("slot-lab"))
            .with_promotion(Promotion::new("slot-lab", "slot-prod-1"));
        let m = PromoteLadderAdapter::new(&ladder).to_pipeline_model();
        assert_eq!(m.stage_index("slot-prod-1"), Some(0), "edges resolve on the ID");
        assert_eq!(m.stages[0].name, "Kundens produktion", "the NAME is what is drawn");
    }

    #[test]
    fn adapter_trait_and_free_fn_agree() {
        let ladder = njord_ladder();
        let empty = HashMap::new();
        let a = to_pipeline_model(&ladder, &empty);
        let b = PromoteLadderAdapter::new(&ladder).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);
        assert_eq!(a.status_tally(), b.status_tally());
    }

    #[test]
    fn linear_chains_in_order_and_an_empty_ladder_has_no_edges() {
        let one = PromotionLadder::linear("t", vec![Rung::new("only", SlotRole::Prod)]);
        assert!(one.promotions.is_empty(), "a one-rung ladder has no promotion step");
        let none = PromotionLadder::linear("t", vec![]);
        assert!(none.promotions.is_empty());
        assert!(PromoteLadderAdapter::new(&none).to_pipeline_model().stages.is_empty());
    }

    #[test]
    fn rung_index_finds_by_id() {
        let l = njord_ladder();
        assert_eq!(l.rung_index("lab"), Some(2));
        assert_eq!(l.rung_index("nope"), None);
    }
}