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

nordisk/Skidbladnir

src/promote/schema.rs

//! **The environment-ladder schema** — Skíðblaðnir's public, versioned format for
//! describing *someone's* sites and the order code is promoted through them.
//!
//! This is a **contract, not an internal type**. Any consumer authors one file in
//! this schema and the promoter reads it; Njord's
//! `Njord-deploys/env/<customer>/<slot>/slot.yaml` is one *instance* of it, not a
//! format Njord invented. A third party writes their own — `dev → qa → staging →
//! prod`, or two parallel test rungs that fan in before production — without
//! reading a line of this crate's source. The schema is documented in
//! `.nornir/promote-design.md` §The ladder schema, and a reference instance ships
//! as [`EXAMPLE_LADDER`].
//!
//! # Four rules the format is built around
//!
//! **1. The ladder is data.** No rung name appears in any type, variant or match
//! arm in this crate. `lab`, `test1`, `prod` are strings in somebody's file.
//!
//! **2. Roles are declared, never inferred.** Nothing here concludes "this is
//! production because it is called `prod`", and nothing concludes "this is safe
//! because it is a subdomain". Both are real holes in the tool this generalises.
//! [`Slot::role`] and [`Slot::guarded`] are **required fields** — a slot that does
//! not declare them is a parse error, not a test slot. Fail closed.
//!
//! **3. The version is stated and checked.** [`API_VERSION`] is required, and an
//! unrecognised value is a loud refusal, never a best-effort parse. A silently
//! misparsed environment descriptor is precisely the failure this module exists
//! to prevent.
//!
//! **4. Order is expressed as a DAG, not a number.** [`Slot::promotes_to`] names
//! the rungs code may move to next. Edges were chosen over a `rank` integer
//! because a rank cannot express two parallel test rungs that both feed one
//! pre-production, nor a fan-in that requires several rungs before production —
//! and both exist in the wild. A rank is still *available*: [`Ladder::rank`]
//! **computes** topological depth for a viewer that wants a linear layout, so the
//! file never carries redundant authored state that can drift from the edges.
//!
//! # The two directions
//!
//! Edges point the way **code** travels: up the ladder, toward production. **Data
//! travels the other way** — a copy of production's data into a test rung is
//! normal; the reverse replaces production with somebody's test rows. Both
//! questions are answered from the same edges:
//! [`Ladder::check_promotion`] and [`Ladder::check_data_flow`].

use std::collections::{BTreeMap, BTreeSet, VecDeque};

use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};

/// The one schema version this build understands. Present in every file; an
/// unknown value is refused rather than guessed at.
pub const API_VERSION: &str = "skidbladnir.nordisk/promote.v1";

/// A complete, valid reference instance — a fictional consumer, deliberately not
/// anyone's real hosts. Parsed by the conformance test, and quotable in a guide.
pub const EXAMPLE_LADDER: &str = r#"apiVersion: skidbladnir.nordisk/promote.v1
# One consumer's environment ladder. Code is promoted along `promotes_to`;
# data may only be copied the other way.
meta:
  customer: Example Corp
  owner: platform-team@example.com
slots:
  - name: dev
    namespace: example-dev
    hosts: [dev.example.test]
    role: development
    guarded: false
    promotes_to: [qa-a, qa-b]
    meta:
      note: two parallel QA rungs fan in before staging

  - name: qa-a
    namespace: example-qa-a
    hosts: [qa-a.example.test]
    role: test
    guarded: false
    promotes_to: [staging]

  - name: qa-b
    namespace: example-qa-b
    hosts: [qa-b.example.test]
    role: test
    guarded: false
    promotes_to: [staging]

  - name: staging
    namespace: example-staging
    hosts: [staging.example.test]
    role: pre-production
    guarded: false
    promotes_to: [live]

  - name: live
    namespace: example-live
    hosts: [example.test, www.example.test]
    role: production
    guarded: true
    promotes_to: []
    meta:
      ticket-queue: OPS
"#;

/// What a slot is *for*, as its owner declares it.
///
/// A closed vocabulary with an open escape hatch. Common spellings are folded to
/// the canonical value ([`SlotRole::parse`]) so `prod`, `production` and `live`
/// all mean the same thing in the **role field** — that is reading a declared
/// field tolerantly, which is not the same as inferring a role from a slot's
/// *name*. The latter never happens anywhere in this crate.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum SlotRole {
    /// The real thing. Must be [`Slot::guarded`]; see [`Ladder::validate`].
    Production,
    /// The rung immediately below production — often the one that actually
    /// carries live traffic for some function.
    PreProduction,
    Staging,
    Test,
    Development,
    /// A role this vocabulary does not name. Perfectly legal; it simply carries no
    /// extra meaning to the ship, and — like every other role — must still declare
    /// [`Slot::guarded`] explicitly.
    Custom(String),
}

impl SlotRole {
    /// The canonical spelling.
    pub fn as_str(&self) -> &str {
        match self {
            SlotRole::Production => "production",
            SlotRole::PreProduction => "pre-production",
            SlotRole::Staging => "staging",
            SlotRole::Test => "test",
            SlotRole::Development => "development",
            SlotRole::Custom(s) => s,
        }
    }

    /// Fold a declared spelling onto the vocabulary. Unknown strings become
    /// [`SlotRole::Custom`] verbatim.
    pub fn parse(s: &str) -> SlotRole {
        match s
            .trim()
            .to_ascii_lowercase()
            .replace(['_', ' '], "-")
            .as_str()
        {
            "production" | "prod" | "prd" | "produktion" | "live" | "prd-live" => {
                SlotRole::Production
            }
            "pre-production" | "preproduction" | "preprod" | "pre-prod" | "uat" => {
                SlotRole::PreProduction
            }
            "staging" | "stage" | "stg" => SlotRole::Staging,
            "test" | "qa" | "qual" | "testing" => SlotRole::Test,
            "development" | "dev" | "sandbox" => SlotRole::Development,
            other => SlotRole::Custom(other.to_string()),
        }
    }

    /// Is this the role that must always be fenced?
    pub fn is_production(&self) -> bool {
        matches!(self, SlotRole::Production)
    }
}

impl Serialize for SlotRole {
    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
        s.serialize_str(self.as_str())
    }
}

impl<'de> Deserialize<'de> for SlotRole {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        if s.trim().is_empty() {
            return Err(serde::de::Error::custom("`role` must not be empty"));
        }
        Ok(SlotRole::parse(&s))
    }
}

/// One slot on the ladder — one site, one environment.
///
/// Note what has **no `#[serde(default)]`**: `role` and `guarded`. Omitting either
/// is a parse error naming the field, because "unspecified" must never quietly
/// become "safe".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Slot {
    /// The name humans, CLIs and edges use. Unique within a ladder.
    pub name: String,
    /// The Kubernetes namespace this slot's workloads live in. Unique within a
    /// ladder.
    pub namespace: String,
    /// Every host this slot answers on. **Authoritative**: a host that is not
    /// listed belongs to no slot, and no host is derived from another by prefix
    /// or suffix rules.
    ///
    /// An entry may be a **declared zone** — `*.example.test` — meaning "this slot
    /// owns every subdomain of `example.test` that no other slot has claimed by
    /// name". That is still a declaration, not an inference: the operator states
    /// the ownership and [`Slot::matches_zone`] honours the statement. It exists so
    /// a production apex cannot have its own data plane (`db.example.test`) read as
    /// *not* production merely because nobody remembered to list it. An exact entry
    /// on any slot always outranks another slot's zone — see
    /// [`SlotRegistry::resolve`](super::registry::SlotRegistry::resolve).
    ///
    /// A zone entry never matches the apex itself; declare the apex too if the slot
    /// answers on it.
    #[serde(default)]
    pub hosts: Vec<String>,
    /// What this slot is for. **Required.**
    pub role: SlotRole,
    /// Whether the prod guard fences this slot. **Required, and never derived
    /// from the name.** [`SlotRole::Production`] must set it `true`.
    pub guarded: bool,
    /// The rungs code may be promoted to from here. Edges point *up* the ladder.
    #[serde(default)]
    pub promotes_to: Vec<String>,
    /// Whatever the consumer needs carried alongside — customer, owner, ticket
    /// queue, node pool. **The ship stores and round-trips this and never branches
    /// on it.** Nothing in this crate reads a key out of `meta`.
    #[serde(default)]
    pub meta: BTreeMap<String, String>,
}

impl Slot {
    /// A slot built in code — the **baseline** a caller declares before layering
    /// configuration over it. `guarded` is explicit here for the same reason it is
    /// required in the file.
    pub fn new(name: &str, namespace: &str, role: SlotRole, guarded: bool) -> Self {
        Slot {
            name: name.to_string(),
            namespace: namespace.to_string(),
            hosts: Vec::new(),
            role,
            guarded,
            promotes_to: Vec::new(),
            meta: BTreeMap::new(),
        }
    }

    /// Add a host this slot answers on.
    pub fn host(mut self, host: &str) -> Self {
        self.hosts.push(host.to_string());
        self
    }

    /// Add an outgoing promotion edge.
    pub fn promotes_to(mut self, next: &str) -> Self {
        self.promotes_to.push(next.to_string());
        self
    }

    /// Attach a consumer fact the ship will carry and never interpret.
    pub fn meta(mut self, k: &str, v: &str) -> Self {
        self.meta.insert(k.to_string(), v.to_string());
        self
    }

    /// Is this slot behind the fence?
    ///
    /// Reads the declared field. There is no code path in this crate that answers
    /// this question from a name, a host suffix, or anything else.
    pub fn is_guarded(&self) -> bool {
        self.guarded
    }

    /// Does `needle` name this slot, by **any** of its identities?
    ///
    /// Name, namespace, any declared host, or a generation namespace derived from
    /// the name or the namespace (`test1-20260721-1432`). Matching is exact per
    /// identity — never a substring — so `test10` does not match `test1`.
    ///
    /// One deliberate asymmetry: for a **guarded** slot a leading `www.` is folded
    /// away, so `www.example.test` matches a slot that only listed
    /// `example.test`. This is not an inference about safety — it can only ever
    /// cause the fence to refuse *more*, never less, and a fence that
    /// over-matches costs an operator one extra flag while one that under-matches
    /// costs a production database. Hosts remain authoritative in the other
    /// direction: nothing is ever concluded to be *safe* from its shape.
    pub fn matches(&self, needle: &str) -> bool {
        let n = needle.trim().to_ascii_lowercase();
        if n.is_empty() {
            return false;
        }
        if eq(&self.name, &n) || eq(&self.namespace, &n) {
            return true;
        }
        if super::generation::Generation::is_of(&self.name, &n)
            || super::generation::Generation::is_of(&self.namespace, &n)
        {
            return true;
        }
        let bare = n.strip_prefix("www.").unwrap_or(&n);
        self.exact_hosts()
            .any(|h| eq(h, &n) || (self.guarded && eq(h, bare)))
    }

    /// The declared zones this slot owns — the `*.example.test` entries in
    /// [`Slot::hosts`], with the `*.` stripped (so `example.test`).
    pub fn zones(&self) -> impl Iterator<Item = &str> {
        self.hosts.iter().filter_map(|h| zone_suffix(h))
    }

    /// The plain, exact host entries — [`Slot::hosts`] minus the zone wildcards.
    /// A wildcard is never treated as a literal hostname.
    pub fn exact_hosts(&self) -> impl Iterator<Item = &str> {
        self.hosts
            .iter()
            .filter(|h| zone_suffix(h).is_none())
            .map(|h| h.as_str())
    }

    /// Does `needle` fall inside a zone this slot **declared** it owns?
    ///
    /// True for a strict subdomain only: a slot declaring `*.example.test` matches
    /// `db.example.test` and `a.b.example.test`, but **not** `example.test` itself
    /// (the apex is a separate declaration) and not `notexample.test`.
    ///
    /// This is deliberately *not* consulted by [`Slot::matches`]. Zone ownership is
    /// the weaker claim, and [`SlotRegistry::resolve`](super::registry::SlotRegistry::resolve)
    /// only reaches for it after every slot's exact identities have missed — so a
    /// sibling that named `lab.example.test` outright keeps it.
    pub fn matches_zone(&self, needle: &str) -> bool {
        let n = needle.trim().to_ascii_lowercase();
        if n.is_empty() {
            return false;
        }
        self.zones().any(|z| {
            // Strict subdomain: `.{zone}` must be a real suffix, and something must
            // precede it. `eq` on the remainder is not enough — compare case-folded.
            n.len() > z.len() + 1
                && n.as_bytes()[n.len() - z.len() - 1] == b'.'
                && eq(&n[n.len() - z.len()..], z)
        })
    }
}

/// `*.example.test` → `Some("example.test")`; anything else → `None`.
///
/// The ONE reader of the wildcard form — [`Slot::zones`], [`Slot::exact_hosts`] and
/// [`Ladder::validate`] all route through it, so what counts as a zone entry is
/// decided in exactly one place.
pub(crate) fn zone_suffix(host: &str) -> Option<&str> {
    let z = host.trim().strip_prefix("*.")?;
    (!z.is_empty() && !z.contains('*')).then_some(z)
}

pub(crate) fn eq(a: &str, b: &str) -> bool {
    a.eq_ignore_ascii_case(b)
}

/// A whole ladder: the schema version, the slots, and the consumer's own facts.
///
/// Construct it from a file ([`Ladder::from_file`]) or in code
/// ([`Ladder::from_slots`]); either way it is **validated** before it exists, so
/// holding a `Ladder` means the DAG is acyclic, every edge resolves, and no
/// slot's guarded status is ambiguous.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ladder {
    /// Must equal [`API_VERSION`].
    #[serde(rename = "apiVersion")]
    pub api_version: String,
    /// Ladder-wide consumer facts. Carried, never interpreted.
    #[serde(default)]
    pub meta: BTreeMap<String, String>,
    pub slots: Vec<Slot>,
}

impl Ladder {
    /// Build and validate a ladder in code.
    pub fn from_slots(slots: Vec<Slot>) -> Result<Self> {
        let l = Ladder {
            api_version: API_VERSION.to_string(),
            meta: BTreeMap::new(),
            slots,
        };
        l.validate("<code>")?;
        Ok(l)
    }

    /// Parse + validate YAML. `source` names the file in every error message.
    #[cfg(feature = "promote")]
    pub fn from_yaml(source: &str, text: &str) -> Result<Self> {
        let l: Ladder = serde_yaml::from_str(text)
            .map_err(|e| anyhow::anyhow!("{source}: not a valid ladder document: {e}"))?;
        l.validate(source)?;
        Ok(l)
    }

    /// Parse + validate JSON.
    pub fn from_json(source: &str, text: &str) -> Result<Self> {
        let l: Ladder = serde_json::from_str(text)
            .map_err(|e| anyhow::anyhow!("{source}: not a valid ladder document: {e}"))?;
        l.validate(source)?;
        Ok(l)
    }

    /// Read a ladder file. `.json` parses as JSON, anything else as YAML (which
    /// also accepts JSON). The path is the `source` in every error.
    #[cfg(feature = "promote")]
    pub fn from_file(path: &std::path::Path) -> Result<Self> {
        let source = path.display().to_string();
        let text = std::fs::read_to_string(path)
            .map_err(|e| anyhow::anyhow!("{source}: cannot read ladder file: {e}"))?;
        if path
            .extension()
            .is_some_and(|e| e.eq_ignore_ascii_case("json"))
        {
            Self::from_json(&source, &text)
        } else {
            Self::from_yaml(&source, &text)
        }
    }

    /// Every check the format promises, each naming `source` and the offending
    /// field. Called by every constructor; there is no way to hold an invalid
    /// `Ladder`.
    pub fn validate(&self, source: &str) -> Result<()> {
        if self.api_version.trim() != API_VERSION {
            bail!(
                "{source}: apiVersion '{}' is not supported by this build.\n   \
                 Supported: '{API_VERSION}'.\n   \
                 A ladder written for another version is NOT parsed best-effort — a \
                 misread environment descriptor is how the wrong namespace gets wiped. \
                 Upgrade the tool, or write the file to the supported version.",
                self.api_version
            );
        }
        if self.slots.is_empty() {
            bail!("{source}: `slots` is empty — a ladder with no rungs describes nothing");
        }

        let mut names: BTreeSet<String> = BTreeSet::new();
        let mut namespaces: BTreeMap<String, String> = BTreeMap::new();
        let mut hosts: BTreeMap<String, String> = BTreeMap::new();

        for s in &self.slots {
            if s.name.trim().is_empty() {
                bail!("{source}: a slot has an empty `name`");
            }
            if s.namespace.trim().is_empty() {
                bail!("{source}: slot '{}' has an empty `namespace`", s.name);
            }
            if !names.insert(s.name.to_ascii_lowercase()) {
                bail!("{source}: slot '{}' is declared twice", s.name);
            }
            if let Some(other) = namespaces.insert(s.namespace.to_ascii_lowercase(), s.name.clone())
            {
                bail!(
                    "{source}: slots '{other}' and '{}' both claim namespace '{}' — \
                     a namespace must identify exactly one slot",
                    s.name,
                    s.namespace
                );
            }
            for h in &s.hosts {
                if h.trim().is_empty() {
                    bail!("{source}: slot '{}' has an empty entry in `hosts`", s.name);
                }
                // A `*` in a host entry is only meaningful as the zone form
                // `*.example.test`. Anything else (`*`, `*.`, `*g11.se`,
                // `a.*.test`) is a typo that would otherwise silently become a
                // literal hostname no request can ever match — i.e. a fence the
                // author believes exists and does not.
                if h.contains('*') && zone_suffix(h).is_none() {
                    bail!(
                        "{source}: slot '{}' has a malformed host entry '{h}' — a wildcard \
                         must be written `*.<zone>` (e.g. `*.example.test`) and the zone \
                         may not itself contain `*`",
                        s.name
                    );
                }
                if let Some(other) = hosts.insert(h.to_ascii_lowercase(), s.name.clone()) {
                    bail!(
                        "{source}: slots '{other}' and '{}' both claim host '{h}' — \
                         a host must identify exactly one slot",
                        s.name
                    );
                }
            }
            // The one rule that ties the two required fields together: a slot may
            // be guarded under any role, but the production role is never open.
            if s.role.is_production() && !s.guarded {
                bail!(
                    "{source}: slot '{}' declares `role: production` with `guarded: false`.\n   \
                     That is ambiguous, and the ambiguity is the whole failure mode this \
                     schema exists to prevent. Either it is production — then set \
                     `guarded: true` — or its role is something else.",
                    s.name
                );
            }
        }

        // Edges: every reference must resolve, and no slot may promote to itself.
        for s in &self.slots {
            for next in &s.promotes_to {
                if eq(next, &s.name) {
                    bail!(
                        "{source}: slot '{}' has `promotes_to` pointing at itself",
                        s.name
                    );
                }
                if !names.contains(&next.to_ascii_lowercase()) {
                    bail!(
                        "{source}: slot '{}' promotes to '{next}', which is not a declared slot.\n   \
                         Declared slots: {}",
                        s.name,
                        self.slots
                            .iter()
                            .map(|x| x.name.as_str())
                            .collect::<Vec<_>>()
                            .join(", ")
                    );
                }
            }
        }

        if let Some(cycle) = self.find_cycle() {
            bail!(
                "{source}: the promotion ladder has a cycle: {}.\n   \
                 Code promotes in one direction; a cycle means there is no 'next rung' \
                 and no 'last rung', so no hop can be checked.",
                cycle.join(" → ")
            );
        }
        Ok(())
    }

    /// The slot named `name`, if any (case-insensitive).
    pub fn slot(&self, name: &str) -> Option<&Slot> {
        self.slots.iter().find(|s| eq(&s.name, name))
    }

    /// Every guarded slot.
    pub fn guarded(&self) -> Vec<&Slot> {
        self.slots.iter().filter(|s| s.guarded).collect()
    }

    /// Rungs with no incoming edge — where a change enters the ladder.
    pub fn entry_points(&self) -> Vec<&Slot> {
        let targets: BTreeSet<String> = self
            .slots
            .iter()
            .flat_map(|s| s.promotes_to.iter())
            .map(|n| n.to_ascii_lowercase())
            .collect();
        self.slots
            .iter()
            .filter(|s| !targets.contains(&s.name.to_ascii_lowercase()))
            .collect()
    }

    /// Rungs with no outgoing edge — where the ladder ends.
    pub fn terminals(&self) -> Vec<&Slot> {
        self.slots
            .iter()
            .filter(|s| s.promotes_to.is_empty())
            .collect()
    }

    /// **Computed** topological depth: 0 for an entry point, otherwise one more
    /// than the deepest predecessor. Never authored — a rank in the file could
    /// drift from the edges, and then two things would disagree about the order.
    pub fn rank(&self, name: &str) -> Option<u32> {
        self.ranks().get(&name.to_ascii_lowercase()).copied()
    }

    /// Every rank, keyed by lowercased slot name.
    pub fn ranks(&self) -> BTreeMap<String, u32> {
        let mut rank: BTreeMap<String, u32> = BTreeMap::new();
        for s in &self.slots {
            rank.insert(s.name.to_ascii_lowercase(), 0);
        }
        // The ladder is acyclic (validated), so relaxing |V| times settles the
        // longest path. |V| is a handful of slots; clarity beats Kahn here.
        for _ in 0..self.slots.len() {
            let mut changed = false;
            for s in &self.slots {
                let here = rank[&s.name.to_ascii_lowercase()];
                for next in &s.promotes_to {
                    let k = next.to_ascii_lowercase();
                    if let Some(r) = rank.get_mut(&k) {
                        if *r < here + 1 {
                            *r = here + 1;
                            changed = true;
                        }
                    }
                }
            }
            if !changed {
                break;
            }
        }
        rank
    }

    fn find_cycle(&self) -> Option<Vec<String>> {
        // Kahn: whatever survives with a non-zero in-degree sits on a cycle.
        let mut indeg: BTreeMap<String, usize> = self
            .slots
            .iter()
            .map(|s| (s.name.to_ascii_lowercase(), 0))
            .collect();
        for s in &self.slots {
            for n in &s.promotes_to {
                if let Some(d) = indeg.get_mut(&n.to_ascii_lowercase()) {
                    *d += 1;
                }
            }
        }
        let mut q: VecDeque<String> = indeg
            .iter()
            .filter(|(_, d)| **d == 0)
            .map(|(k, _)| k.clone())
            .collect();
        let mut removed = 0usize;
        while let Some(k) = q.pop_front() {
            removed += 1;
            if let Some(s) = self.slot(&k) {
                for n in &s.promotes_to {
                    let nk = n.to_ascii_lowercase();
                    if let Some(d) = indeg.get_mut(&nk) {
                        *d -= 1;
                        if *d == 0 {
                            q.push_back(nk);
                        }
                    }
                }
            }
        }
        if removed == self.slots.len() {
            return None;
        }
        let mut stuck: Vec<String> = indeg
            .iter()
            .filter(|(_, d)| **d > 0)
            .map(|(k, _)| k.clone())
            .collect();
        stuck.sort();
        Some(stuck)
    }

    /// The shortest promotion path from `from` to `to`, inclusive of both.
    /// `None` when code cannot travel that way at all.
    pub fn path(&self, from: &str, to: &str) -> Option<Vec<String>> {
        let (from, to) = (self.slot(from)?, self.slot(to)?);
        if eq(&from.name, &to.name) {
            return Some(vec![from.name.clone()]);
        }
        let mut prev: BTreeMap<String, String> = BTreeMap::new();
        let mut seen: BTreeSet<String> = BTreeSet::from([from.name.to_ascii_lowercase()]);
        let mut q = VecDeque::from([from.name.clone()]);
        while let Some(cur) = q.pop_front() {
            let Some(s) = self.slot(&cur) else { continue };
            for next in &s.promotes_to {
                let k = next.to_ascii_lowercase();
                if !seen.insert(k.clone()) {
                    continue;
                }
                prev.insert(k.clone(), cur.clone());
                if eq(next, &to.name) {
                    let mut path = vec![to.name.clone()];
                    let mut at = k;
                    while let Some(p) = prev.get(&at) {
                        path.push(p.clone());
                        at = p.to_ascii_lowercase();
                    }
                    path.reverse();
                    return Some(path);
                }
                q.push_back(next.clone());
            }
        }
        None
    }

    /// **Is this a legal next hop for code?** The question the schema exists to
    /// answer — see [`Hop`].
    pub fn check_promotion(&self, from: &str, to: &str) -> Hop {
        if self.slot(from).is_none() {
            return Hop::UnknownSlot(from.to_string());
        }
        if self.slot(to).is_none() {
            return Hop::UnknownSlot(to.to_string());
        }
        if eq(from, to) {
            return Hop::SameSlot;
        }
        match self.path(from, to) {
            Some(p) if p.len() == 2 => Hop::Legal,
            Some(p) => Hop::SkipsRungs(p[1..p.len() - 1].to_vec()),
            None => match self.path(to, from) {
                Some(_) => Hop::WrongDirection,
                None => Hop::NotOnLadder,
            },
        }
    }

    /// **Is this a legal data copy?** Data travels *down* the ladder: a copy is
    /// legal exactly when `to` promotes (transitively) to `from`. The reverse is
    /// how production gets replaced by somebody's test rows.
    pub fn check_data_flow(&self, from: &str, to: &str) -> Hop {
        if self.slot(from).is_none() {
            return Hop::UnknownSlot(from.to_string());
        }
        if self.slot(to).is_none() {
            return Hop::UnknownSlot(to.to_string());
        }
        if eq(from, to) {
            return Hop::SameSlot;
        }
        match self.path(to, from) {
            Some(p) if p.len() == 2 => Hop::Legal,
            // Copying prod → test2 skips no rung in any meaningful sense; data is
            // not staged rung by rung. Any downward path is simply legal.
            Some(_) => Hop::Legal,
            None => match self.path(from, to) {
                Some(_) => Hop::WrongDirection,
                None => Hop::NotOnLadder,
            },
        }
    }
}

/// The verdict on one hop, in either direction.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Hop {
    /// Exactly one edge. Go.
    Legal,
    /// There is a path, but it jumps over these rungs. Refusable, or allowed with
    /// an explicit override that says which rungs were skipped —
    /// see [`Hop::require`].
    SkipsRungs(Vec<String>),
    /// The edge exists the other way round. For code that means promoting
    /// downward; for data it means copying upward.
    WrongDirection,
    /// The two rungs are not connected at all.
    NotOnLadder,
    /// Source and destination are the same rung.
    SameSlot,
    /// One of the two names is not on this ladder.
    UnknownSlot(String),
}

impl Hop {
    pub fn is_legal(&self) -> bool {
        matches!(self, Hop::Legal)
    }

    /// Turn the verdict into a `Result`. `allow_skip` is the explicit override for
    /// [`Hop::SkipsRungs`] — and even when it is granted the skipped rungs come
    /// back in `Ok(Vec<String>)` so the caller reports them rather than swallowing
    /// them. Nothing else is overridable here.
    pub fn require(&self, from: &str, to: &str, allow_skip: bool) -> Result<Vec<String>> {
        match self {
            Hop::Legal => Ok(Vec::new()),
            Hop::SkipsRungs(rungs) if allow_skip => Ok(rungs.clone()),
            Hop::SkipsRungs(rungs) => bail!(
                "refusing to go '{from}' → '{to}': it skips {}.\n   \
                 The ladder exists so a change is seen on every rung before the next. \
                 Promote through {} first, or say explicitly that you are skipping them.",
                rungs.join(", "),
                rungs.join(" then ")
            ),
            Hop::WrongDirection => bail!(
                "refusing '{from}' → '{to}': that is the wrong way along the ladder.\n   \
                 Code is promoted upward and data is copied downward; this move is neither."
            ),
            Hop::NotOnLadder => bail!(
                "refusing '{from}' → '{to}': the ladder has no path between them.\n   \
                 Add the edge to the ladder file if the move is meant to be possible."
            ),
            Hop::SameSlot => bail!("'{from}' and '{to}' are the same rung — nothing to do"),
            Hop::UnknownSlot(n) => {
                bail!("refusing '{from}' → '{to}': '{n}' is not a slot on this ladder")
            }
        }
    }
}

#[cfg(feature = "promote")]
#[cfg(test)]
mod tests {
    use super::*;

    // ── the reference instance is the conformance proof ──────────────────────

    #[test]
    fn the_shipped_example_parses_and_describes_what_it_claims() {
        let l = Ladder::from_yaml("EXAMPLE_LADDER", EXAMPLE_LADDER).expect("example must parse");
        assert_eq!(l.slots.len(), 5);
        assert_eq!(
            l.meta.get("customer").map(String::as_str),
            Some("Example Corp")
        );

        let live = l.slot("live").unwrap();
        assert!(live.is_guarded());
        assert_eq!(live.role, SlotRole::Production);
        assert_eq!(
            live.meta.get("ticket-queue").map(String::as_str),
            Some("OPS")
        );

        // Two parallel QA rungs fan in — the shape a `rank` integer cannot express.
        assert_eq!(l.rank("dev"), Some(0));
        assert_eq!(l.rank("qa-a"), Some(1));
        assert_eq!(l.rank("qa-b"), Some(1));
        assert_eq!(l.rank("staging"), Some(2));
        assert_eq!(l.rank("live"), Some(3));

        assert_eq!(
            l.entry_points()
                .iter()
                .map(|s| s.name.as_str())
                .collect::<Vec<_>>(),
            ["dev"]
        );
        assert_eq!(
            l.terminals()
                .iter()
                .map(|s| s.name.as_str())
                .collect::<Vec<_>>(),
            ["live"]
        );
    }

    #[test]
    fn the_example_round_trips_through_serde() {
        let l = Ladder::from_yaml("x", EXAMPLE_LADDER).unwrap();
        let json = serde_json::to_string(&l).unwrap();
        let back = Ladder::from_json("round-trip", &json).unwrap();
        assert_eq!(l, back);
    }

    // ── the ladder is data: nothing here knows a rung's name ─────────────────

    #[test]
    fn a_completely_different_ladder_works_just_as_well() {
        let l = Ladder::from_yaml(
            "other",
            "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
             - {name: sandbox, namespace: sb, role: development, guarded: false, promotes_to: [main]}\n\
             - {name: main, namespace: main, role: production, guarded: true}\n",
        )
        .unwrap();
        assert!(l.check_promotion("sandbox", "main").is_legal());
        assert!(l.slot("main").unwrap().is_guarded());
    }

    // ── fail closed on an undeclared role / guard ────────────────────────────

    #[test]
    fn a_slot_that_does_not_declare_its_role_is_an_error_not_a_test_slot() {
        let err = Ladder::from_yaml(
            "bad.yaml",
            "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n  - {name: x, namespace: x, guarded: false}\n",
        )
        .expect_err("missing role must not default");
        assert!(format!("{err:#}").contains("role"), "{err:#}");
        assert!(
            format!("{err:#}").contains("bad.yaml"),
            "the error must name the file: {err:#}"
        );
    }

    #[test]
    fn a_slot_that_does_not_declare_guarded_is_an_error() {
        let err = Ladder::from_yaml(
            "bad.yaml",
            "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n  - {name: x, namespace: x, role: test}\n",
        )
        .expect_err("missing guarded must not default to open");
        assert!(format!("{err:#}").contains("guarded"), "{err:#}");
    }

    #[test]
    fn production_with_guarded_false_is_ambiguous_and_refused() {
        let err = Ladder::from_yaml(
            "bad.yaml",
            "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n  - {name: p, namespace: p, role: production, guarded: false}\n",
        )
        .expect_err("ambiguous prod status must be refused");
        let m = format!("{err:#}");
        assert!(m.contains("ambiguous"), "{m}");
        assert!(
            m.contains("guarded: true"),
            "the error must name the fix: {m}"
        );
    }

    /// The role field is read tolerantly; the slot's NAME is never read at all.
    #[test]
    fn role_spellings_fold_but_names_never_imply_a_role() {
        assert_eq!(SlotRole::parse("prod"), SlotRole::Production);
        assert_eq!(SlotRole::parse("PRODUCTION"), SlotRole::Production);
        assert_eq!(SlotRole::parse("live"), SlotRole::Production);
        assert_eq!(SlotRole::parse("preprod"), SlotRole::PreProduction);
        assert_eq!(SlotRole::parse("qa"), SlotRole::Test);
        assert_eq!(
            SlotRole::parse("bananas"),
            SlotRole::Custom("bananas".into())
        );

        // A slot CALLED prod whose declared role is test is not production.
        let l = Ladder::from_yaml(
            "x",
            "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n  - {name: prod, namespace: prod, role: test, guarded: false}\n",
        )
        .unwrap();
        assert!(
            !l.slot("prod").unwrap().is_guarded(),
            "the name must imply nothing"
        );
        // …and a slot called `harmless` whose declared role is production IS.
        let l = Ladder::from_yaml(
            "x",
            "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n  - {name: harmless, namespace: h, role: production, guarded: true}\n",
        )
        .unwrap();
        assert!(l.slot("harmless").unwrap().is_guarded());
    }

    // ── versioning ───────────────────────────────────────────────────────────

    #[test]
    fn an_unknown_api_version_is_refused_loudly_not_parsed_best_effort() {
        let err = Ladder::from_yaml(
            "future.yaml",
            "apiVersion: skidbladnir.nordisk/promote.v9\nslots:\n  - {name: x, namespace: x, role: test, guarded: false}\n",
        )
        .expect_err("unknown schema version must be refused");
        let m = format!("{err:#}");
        assert!(m.contains("promote.v9") && m.contains(API_VERSION), "{m}");
        assert!(m.contains("future.yaml"), "{m}");
    }

    #[test]
    fn a_missing_api_version_is_refused() {
        assert!(
            Ladder::from_yaml(
                "x",
                "slots:\n  - {name: x, namespace: x, role: test, guarded: false}\n"
            )
            .is_err()
        );
    }

    // ── validation refuses ambiguity ─────────────────────────────────────────

    #[test]
    fn duplicate_names_namespaces_and_hosts_are_all_refused() {
        let base = "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n";
        for (doc, needle) in [
            (
                "  - {name: a, namespace: n1, role: test, guarded: false}\n  - {name: A, namespace: n2, role: test, guarded: false}\n",
                "declared twice",
            ),
            (
                "  - {name: a, namespace: n, role: test, guarded: false}\n  - {name: b, namespace: N, role: test, guarded: false}\n",
                "both claim namespace",
            ),
            (
                "  - {name: a, namespace: n1, hosts: [h.test], role: test, guarded: false}\n  - {name: b, namespace: n2, hosts: [H.TEST], role: test, guarded: false}\n",
                "both claim host",
            ),
        ] {
            let err = Ladder::from_yaml("dup.yaml", &format!("{base}{doc}")).expect_err(needle);
            assert!(format!("{err:#}").contains(needle), "{err:#}");
        }
    }

    #[test]
    fn a_dangling_edge_is_refused_and_lists_what_exists() {
        let err = Ladder::from_yaml(
            "e.yaml",
            "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n  - {name: a, namespace: a, role: test, guarded: false, promotes_to: [ghost]}\n",
        )
        .expect_err("dangling edge");
        let m = format!("{err:#}");
        assert!(
            m.contains("ghost") && m.contains("Declared slots: a"),
            "{m}"
        );
    }

    #[test]
    fn a_cycle_is_refused_and_named() {
        let err = Ladder::from_yaml(
            "c.yaml",
            "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
             - {name: a, namespace: a, role: test, guarded: false, promotes_to: [b]}\n\
             - {name: b, namespace: b, role: test, guarded: false, promotes_to: [a]}\n",
        )
        .expect_err("cycle");
        let m = format!("{err:#}");
        assert!(
            m.contains("cycle") && m.contains('a') && m.contains('b'),
            "{m}"
        );
    }

    #[test]
    fn a_self_edge_is_refused() {
        assert!(Ladder::from_yaml(
            "s.yaml",
            "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n  - {name: a, namespace: a, role: test, guarded: false, promotes_to: [a]}\n",
        )
        .is_err());
    }

    #[test]
    fn an_empty_ladder_is_refused() {
        assert!(
            Ladder::from_yaml(
                "x",
                "apiVersion: skidbladnir.nordisk/promote.v1\nslots: []\n"
            )
            .is_err()
        );
    }

    // ── promotion order is enforceable ───────────────────────────────────────

    fn example() -> Ladder {
        Ladder::from_yaml("x", EXAMPLE_LADDER).unwrap()
    }

    #[test]
    fn one_edge_is_legal_and_a_jump_names_the_rungs_it_skipped() {
        let l = example();
        assert_eq!(l.check_promotion("staging", "live"), Hop::Legal);
        assert_eq!(
            l.check_promotion("dev", "live"),
            Hop::SkipsRungs(vec!["qa-a".into(), "staging".into()])
        );
        let err = l
            .check_promotion("dev", "live")
            .require("dev", "live", false)
            .unwrap_err();
        assert!(
            format!("{err:#}").contains("skips qa-a, staging"),
            "{err:#}"
        );
        // The override does not silence the fact — it hands it back to be reported.
        assert_eq!(
            l.check_promotion("dev", "live")
                .require("dev", "live", true)
                .unwrap(),
            vec!["qa-a".to_string(), "staging".to_string()]
        );
    }

    #[test]
    fn promoting_downward_is_the_wrong_direction() {
        let l = example();
        assert_eq!(l.check_promotion("live", "staging"), Hop::WrongDirection);
        assert!(
            l.check_promotion("live", "staging")
                .require("live", "staging", true)
                .is_err()
        );
    }

    #[test]
    fn parallel_rungs_are_not_on_each_others_ladder() {
        assert_eq!(example().check_promotion("qa-a", "qa-b"), Hop::NotOnLadder);
    }

    /// Data goes down. Copying production's data into a test rung is the normal
    /// case; the reverse replaces production with somebody's test rows.
    #[test]
    fn data_flows_down_the_ladder_and_never_up() {
        let l = example();
        assert!(l.check_data_flow("live", "staging").is_legal());
        assert!(
            l.check_data_flow("live", "dev").is_legal(),
            "prod → dev is a normal copy"
        );
        assert_eq!(l.check_data_flow("dev", "live"), Hop::WrongDirection);
        let err = l
            .check_data_flow("dev", "live")
            .require("dev", "live", true)
            .unwrap_err();
        assert!(format!("{err:#}").contains("wrong way"), "{err:#}");
    }

    #[test]
    fn an_unknown_rung_in_a_hop_is_named() {
        assert_eq!(
            example().check_promotion("dev", "nowhere"),
            Hop::UnknownSlot("nowhere".into())
        );
    }

    // ── meta is carried, never interpreted ───────────────────────────────────

    #[test]
    fn meta_survives_a_round_trip_untouched() {
        let l = example();
        let back = Ladder::from_json("rt", &serde_json::to_string(&l).unwrap()).unwrap();
        assert_eq!(back.slot("dev").unwrap().meta, l.slot("dev").unwrap().meta);
        assert_eq!(back.meta, l.meta);
    }
}