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

nordisk/facett

facett-releaseview/src/model.rs

//! The **engine-agnostic release-convergence model** — the neutral shape a release
//! engine feeds in and [`ReleaseView`](crate::ReleaseView) renders. Nothing here
//! depends on dwarves / the release engine: the engine maps its own graph + the
//! three version sources into these plain types and hands them **in**, exactly the
//! one-way [`RunaPipelineAdapter`](facett_pipeline) pattern (types come in, a model
//! goes out, the viewer never learns the engine).
//!
//! ## The idea (three converging graphs)
//!
//! The same constellation dependency DAG exists in **three states** that a healthy
//! release drives to **converge**:
//!
//! 1. **LOCAL** — the working-tree / manifest versions (what you release *from*).
//! 2. **SPARRING** — the Gate-B holger local-rehearsal versions (the safe preview).
//! 3. **CRATES_IO** — the published, immutable versions (the target).
//!
//! All three equal ⇒ **converged** (green). Divergence is a fix/release signal:
//!
//! | condition | [`ConvergeStatus`] | meaning |
//! |---|---|---|
//! | `local == sparring == cratesio` | [`Converged`](ConvergeStatus::Converged) | done (green) |
//! | `cratesio < local` | [`NeedsRelease`](ConvergeStatus::NeedsRelease) | crates.io behind local → publish (red) |
//! | `sparring != cratesio` | [`LockDesync`](ConvergeStatus::LockDesync) | resolved ≠ published → lock/registry desync (red) |
//! | `sparring != local` | [`BumpDrift`](ConvergeStatus::BumpDrift) | rehearsal hoisted a bump local hasn't (amber) |
//! | (live event) | [`Publishing`](ConvergeStatus::Publishing) | a publish is in flight (pulsing) |
//!
//! [`derive_status`] is the pure kernel of that table; [`ReleaseConvergence::apply`]
//! is the realtime seam that flips one crate's state as a live cascade runs.

use serde::{Deserialize, Serialize};
use std::cmp::Ordering;

/// The convergence state of one crate across the three graphs.
///
/// The four version-derived states come from [`derive_status`]; [`Publishing`] is a
/// **transient live-event** state ([`ReleaseConvergence::apply`] sets it while a
/// publish is in flight, then an [`ReleaseEvent::Published`] resolves it — usually
/// to [`Converged`](ConvergeStatus::Converged)).
///
/// [`Publishing`]: ConvergeStatus::Publishing
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
pub enum ConvergeStatus {
    /// `local == sparring == cratesio` — the three graphs agree (green).
    #[default]
    Converged,
    /// `cratesio < local` — the published version is behind what we release from;
    /// this crate needs a release (red, the strongest signal).
    NeedsRelease,
    /// `sparring != cratesio` — the rehearsal-resolved version and the published
    /// version disagree: the lockfile / registry is out of sync with the safe
    /// preview (red).
    LockDesync,
    /// `sparring != local` — the rehearsal hoisted a bump the working tree hasn't
    /// (amber; a drift to reconcile, not yet a blocker).
    BumpDrift,
    /// A publish for this crate is in flight (set by a live event; pulses).
    Publishing,
}

impl ConvergeStatus {
    /// A short, stable label for text / JSON / a11y. Deliberately avoids the
    /// universal error markers ("failed", "unavailable", …) — a divergence is
    /// *data to act on*, never a surfaced error.
    pub fn label(self) -> &'static str {
        match self {
            ConvergeStatus::Converged => "converged",
            ConvergeStatus::NeedsRelease => "needs-release",
            ConvergeStatus::LockDesync => "lock-desync",
            ConvergeStatus::BumpDrift => "bump-drift",
            ConvergeStatus::Publishing => "publishing",
        }
    }

    /// `true` when the three graphs agree (the release is done for this crate).
    pub fn is_converged(self) -> bool {
        matches!(self, ConvergeStatus::Converged)
    }

    /// A severity band for the marker colour: `2` = red (release/lock blocker),
    /// `1` = amber (drift), `0` = green/neutral (converged / in-flight). The
    /// [`ReleaseView`](crate::ReleaseView) maps this to the themed marker colour.
    pub fn severity(self) -> u8 {
        match self {
            ConvergeStatus::NeedsRelease | ConvergeStatus::LockDesync => 2,
            ConvergeStatus::BumpDrift => 1,
            ConvergeStatus::Converged | ConvergeStatus::Publishing => 0,
        }
    }
}

/// Compare two version strings as `major.minor.patch` (pre-release / build suffix
/// after `-`/`+` ignored). Falls back to a plain string compare when either side
/// doesn't parse — so non-semver tags still order deterministically and equal
/// strings still read as equal.
pub fn version_cmp(a: &str, b: &str) -> Ordering {
    match (parse_semver(a), parse_semver(b)) {
        (Some(x), Some(y)) => x.cmp(&y),
        _ => a.cmp(b),
    }
}

/// `(major, minor, patch)` from a `x.y.z…` string, or `None` if the first three
/// dot-parts aren't all integers. Trailing parts and any `-pre`/`+build` on the
/// patch are ignored (`0.5.4-rc.1` → `(0,5,4)`).
fn parse_semver(v: &str) -> Option<(u64, u64, u64)> {
    let core = v.trim();
    let mut it = core.split('.');
    let major = it.next()?.parse().ok()?;
    let minor = it.next()?.parse().ok()?;
    let patch_raw = it.next()?;
    // Strip a `-pre` / `+build` suffix off the patch component.
    let patch_str: String = patch_raw.chars().take_while(|c| c.is_ascii_digit()).collect();
    let patch = patch_str.parse().ok()?;
    Some((major, minor, patch))
}

/// **The pure convergence kernel** — derive one crate's [`ConvergeStatus`] from its
/// three versions. Total, deterministic, no I/O, no engine types: this is the
/// unit-tested heart of the model.
///
/// Precedence (highest first): all-equal ⇒ [`Converged`]; `cratesio < local` ⇒
/// [`NeedsRelease`]; `sparring != cratesio` ⇒ [`LockDesync`]; otherwise (something
/// still differs, so `sparring != local`) ⇒ [`BumpDrift`]. It never returns
/// [`Publishing`] — that is a live-event state, not a function of the versions.
///
/// [`Converged`]: ConvergeStatus::Converged
/// [`NeedsRelease`]: ConvergeStatus::NeedsRelease
/// [`LockDesync`]: ConvergeStatus::LockDesync
/// [`BumpDrift`]: ConvergeStatus::BumpDrift
/// [`Publishing`]: ConvergeStatus::Publishing
pub fn derive_status(local: &str, sparring: &str, cratesio: &str) -> ConvergeStatus {
    if local == sparring && sparring == cratesio {
        return ConvergeStatus::Converged;
    }
    // crates.io strictly behind local → this crate must be released.
    if version_cmp(cratesio, local) == Ordering::Less {
        return ConvergeStatus::NeedsRelease;
    }
    // The rehearsal-resolved (sparring) version disagrees with the published one:
    // the lockfile / registry is out of sync with the safe preview.
    if sparring != cratesio {
        return ConvergeStatus::LockDesync;
    }
    // Everything else that isn't all-equal: sparring agrees with cratesio but local
    // differs — a bump hoisted into the rehearsal the working tree hasn't adopted.
    ConvergeStatus::BumpDrift
}

/// One crate node across the three graphs: its three versions and the resulting
/// [`ConvergeStatus`]. The `status` field is kept in sync with the versions by
/// [`new`](CrateConvergence::new) / [`recompute`](CrateConvergence::recompute); a
/// live [`ReleaseEvent`] can override it to [`ConvergeStatus::Publishing`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrateConvergence {
    /// The crate name (the stable node id; edges reference it by index into
    /// [`ReleaseConvergence::nodes`]).
    pub krate: String,
    /// LOCAL — the working-tree / manifest version (what we release from).
    pub local_ver: String,
    /// SPARRING — the Gate-B holger rehearsal-resolved version (the safe preview).
    pub sparring_ver: String,
    /// CRATES_IO — the published, immutable version (the target).
    pub cratesio_ver: String,
    /// The convergence status. Derived from the three versions (or `Publishing`
    /// while a live publish is in flight).
    pub status: ConvergeStatus,
}

impl CrateConvergence {
    /// Build a node from a crate name + its three versions; the [`status`] is
    /// [`derive_status`]-computed from them.
    ///
    /// [`status`]: CrateConvergence::status
    pub fn new(
        krate: impl Into<String>,
        local: impl Into<String>,
        sparring: impl Into<String>,
        cratesio: impl Into<String>,
    ) -> Self {
        let local_ver = local.into();
        let sparring_ver = sparring.into();
        let cratesio_ver = cratesio.into();
        let status = derive_status(&local_ver, &sparring_ver, &cratesio_ver);
        Self { krate: krate.into(), local_ver, sparring_ver, cratesio_ver, status }
    }

    /// The status **purely derived** from the current three versions (ignoring any
    /// transient `Publishing` override). The recompute source of truth.
    pub fn derived(&self) -> ConvergeStatus {
        derive_status(&self.local_ver, &self.sparring_ver, &self.cratesio_ver)
    }

    /// Re-derive [`status`](CrateConvergence::status) from the versions (clearing a
    /// `Publishing` override). Called after any version change.
    pub fn recompute(&mut self) {
        self.status = self.derived();
    }

    /// Which of the three chips diverges from the converged target, for the
    /// tri-badge render: `(local_off, sparring_off, cratesio_off)`. A crate is
    /// converged ⇒ all `false`. The "target" is the maximum of the three versions
    /// (a release drives everything up to the highest declared version), so a chip
    /// below the max is flagged.
    pub fn chip_divergence(&self) -> (bool, bool, bool) {
        let hi = [&self.local_ver, &self.sparring_ver, &self.cratesio_ver]
            .into_iter()
            .max_by(|a, b| version_cmp(a, b))
            .cloned()
            .unwrap_or_default();
        (
            self.local_ver != hi,
            self.sparring_ver != hi,
            self.cratesio_ver != hi,
        )
    }
}

/// A dependency edge between two crate nodes, by index into
/// [`ReleaseConvergence::nodes`] (`from` depends on `to`, the dwarves graph_diff
/// direction: the dependent is `from`, the dependency is `to`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DepEdge {
    /// The dependent crate (index into `nodes`).
    pub from: usize,
    /// The depended-on crate (index into `nodes`).
    pub to: usize,
}

impl DepEdge {
    /// A bare `from → to` edge.
    pub fn new(from: usize, to: usize) -> Self {
        Self { from, to }
    }
}

/// **The engine-agnostic release-convergence model**: the constellation's crate
/// nodes (each carrying its three versions + convergence status) and the dependency
/// edges. This is the single shape [`ReleaseView`](crate::ReleaseView) renders, and
/// the single shape a release engine feeds in (from its `graph_diff` + the three
/// version sources — see the crate docs).
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ReleaseConvergence {
    /// A human title for the release (e.g. `"nordisk release-3"`).
    pub title: String,
    /// The crate nodes, in a stable order (edges index into this).
    pub nodes: Vec<CrateConvergence>,
    /// The dependency edges (by node index).
    pub edges: Vec<DepEdge>,
}

/// A realtime update event — the seam a live release cascade drives the model
/// through (via [`ReleaseConvergence::apply`]), so nodes flip state one by one and
/// the render animates the convergence.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReleaseEvent {
    /// A publish for `krate` has started — flip it to [`ConvergeStatus::Publishing`].
    PublishStart { krate: String },
    /// `krate` was published at `version` — set its crates.io version + re-derive
    /// (a successful publish that matches local/sparring lands it on
    /// [`ConvergeStatus::Converged`]).
    Published { krate: String, version: String },
    /// The Gate-B rehearsal resolved `krate` at `version` — set its sparring
    /// version + re-derive.
    SparringResolved { krate: String, version: String },
    /// The working tree bumped `krate` to `version` — set its local version +
    /// re-derive.
    LocalBumped { krate: String, version: String },
}

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

    /// Builder: append a crate node.
    pub fn with_node(mut self, node: CrateConvergence) -> Self {
        self.nodes.push(node);
        self
    }

    /// Builder: append a dependency edge.
    pub fn with_edge(mut self, edge: DepEdge) -> Self {
        self.edges.push(edge);
        self
    }

    /// Index of the node with this crate name, if present.
    pub fn node_index(&self, krate: &str) -> Option<usize> {
        self.nodes.iter().position(|n| n.krate == krate)
    }

    /// Per-status counts `(converged, needs_release, lock_desync, bump_drift,
    /// publishing)` across all nodes.
    pub fn status_counts(&self) -> (usize, usize, usize, usize, usize) {
        let mut c = (0, 0, 0, 0, 0);
        for n in &self.nodes {
            match n.status {
                ConvergeStatus::Converged => c.0 += 1,
                ConvergeStatus::NeedsRelease => c.1 += 1,
                ConvergeStatus::LockDesync => c.2 += 1,
                ConvergeStatus::BumpDrift => c.3 += 1,
                ConvergeStatus::Publishing => c.4 += 1,
            }
        }
        c
    }

    /// `true` when **every** node is [`ConvergeStatus::Converged`] — the whole
    /// constellation has converged (the release is green).
    pub fn is_converged(&self) -> bool {
        !self.nodes.is_empty() && self.nodes.iter().all(|n| n.status.is_converged())
    }

    /// The converged fraction in `[0,1]` — how far the three graphs have snapped
    /// together. Drives the plane separation in the 3-plane render (1.0 ⇒ planes
    /// coincide). `0.0` for an empty model.
    pub fn converged_fraction(&self) -> f32 {
        if self.nodes.is_empty() {
            return 0.0;
        }
        let done = self.nodes.iter().filter(|n| n.status.is_converged()).count();
        done as f32 / self.nodes.len() as f32
    }

    /// **The realtime update seam.** Apply one [`ReleaseEvent`], flipping exactly one
    /// crate's state (and re-deriving it, except for `PublishStart` which sets the
    /// transient `Publishing`). Returns `true` if a node changed — a live cascade
    /// calls this per step and the render animates the convergence node-by-node.
    pub fn apply(&mut self, event: ReleaseEvent) -> bool {
        match event {
            ReleaseEvent::PublishStart { krate } => {
                if let Some(i) = self.node_index(&krate) {
                    self.nodes[i].status = ConvergeStatus::Publishing;
                    return true;
                }
            }
            ReleaseEvent::Published { krate, version } => {
                if let Some(i) = self.node_index(&krate) {
                    self.nodes[i].cratesio_ver = version;
                    self.nodes[i].recompute();
                    return true;
                }
            }
            ReleaseEvent::SparringResolved { krate, version } => {
                if let Some(i) = self.node_index(&krate) {
                    self.nodes[i].sparring_ver = version;
                    self.nodes[i].recompute();
                    return true;
                }
            }
            ReleaseEvent::LocalBumped { krate, version } => {
                if let Some(i) = self.node_index(&krate) {
                    self.nodes[i].local_ver = version;
                    self.nodes[i].recompute();
                    return true;
                }
            }
        }
        false
    }
}

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

    #[test]
    fn version_cmp_orders_semver_and_falls_back_to_string() {
        assert_eq!(version_cmp("0.5.4", "0.5.4"), Ordering::Equal);
        assert_eq!(version_cmp("0.5.3", "0.5.4"), Ordering::Less);
        assert_eq!(version_cmp("0.6.0", "0.5.9"), Ordering::Greater);
        assert_eq!(version_cmp("1.0.0", "0.9.9"), Ordering::Greater);
        // pre-release suffix on the patch is ignored for the core compare.
        assert_eq!(version_cmp("0.5.4-rc.1", "0.5.4"), Ordering::Equal);
        // non-semver falls back to a string compare (still deterministic + equal-safe).
        assert_eq!(version_cmp("main", "main"), Ordering::Equal);
        assert_ne!(version_cmp("main", "dev"), Ordering::Equal);
    }
}