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