crates/nornir-release-core/src/ecosystem.rs
//! # `ecosystem` — the language-agnostic release CRUST
//!
//! Everything else in `release_doctor` reasons over Cargo directly (`cargo_metadata`,
//! `cargo publish`, crates.io). That is the CORE the forge ships today, but the
//! release machinery is not Cargo-shaped in principle: a release is *discover the
//! shipping units → read their metadata + capability variants → resolve the pins →
//! ask the registry what is published → build the artifact → publish it → and reason
//! about the API surface for skew/compat*. Every one of those verbs has an npm /
//! Maven / Go / PyPI reading too.
//!
//! [`Ecosystem`] is that verb set as ONE trait — the engine-agnostic CRUST — with a
//! single concrete impl today, [`CargoEcosystem`], built on the `cargo_metadata` dep
//! the forge already carries under the `release` feature. Cargo "features" are the
//! Cargo reading of the ecosystem-neutral notion of a **variant / capability** (an
//! optional slice of a package's surface a consumer may switch on): they map to
//! [`Variant`] here, exactly as npm optional-peer groups or Maven classifiers would.
//!
//! The trait REUSES the pure legs the core already hosts (LAW #5 — never a twin):
//! * the registry probe is the existing [`registry::RegistryBackend`],
//! * `publish` / `artifact` delegate to [`publish`] (`run_cargo_publish` /
//! `derive_publish_order` / `tarball_stats`),
//! * `extract_symbols` / `api_compat` are the [`api_skew`] surface scanners.
//!
//! Pure + offline for [`Ecosystem::discover`]/[`metadata`](Ecosystem::metadata)
//! (`cargo metadata --no-deps` reads manifests without touching the network), so the
//! Cargo impl's discovery is testable against a fixture manifest with no registry.
//!
//! [`metadata`]: Ecosystem::metadata
use std::collections::BTreeSet;
use std::path::PathBuf;
use crate::api_skew;
/// How a dependency edge gates SHIPPING, ecosystem-neutral. A `Dev` edge is stripped
/// at publish time (Cargo dev-dep / npm devDependency), so it must NOT gate the
/// publish order; `Normal` + `Build` edges must (the dep has to exist upstream first).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DepKind {
/// A runtime/normal dependency.
Normal,
/// A build-time dependency (Cargo build-dep). Gates publishing like `Normal`.
Build,
/// A test/dev-only dependency — stripped at publish, does NOT gate the order.
Dev,
}
/// One dependency edge of a [`Package`], ecosystem-neutral.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dependency {
/// The depended-on package's name.
pub name: String,
/// The version requirement, as the ecosystem writes it (e.g. `^1`, `>=0.2, <0.4`).
pub req: String,
/// Runtime / build / dev.
pub kind: DepKind,
/// Is this an OPTIONAL dependency (one a variant switches on)? Cargo optional dep
/// / npm optionalDependency.
pub optional: bool,
/// The variants/capabilities this edge itself requests ON the dependency (Cargo
/// per-dep `features = [...]`).
pub variants: Vec<String>,
}
/// A **variant / capability** — the ecosystem-neutral generalization of a Cargo
/// *feature*: a named, optional slice of a package's surface a consumer may switch
/// on, which may in turn switch on other variants or optional deps. (npm optional
/// peer-groups, Maven classifiers, PyPI extras all read as this.)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Variant {
/// The variant name (Cargo feature name).
pub name: String,
/// What enabling this variant pulls in — other variant names and/or `dep:`/optional
/// dependency activations, verbatim as the manifest lists them.
pub requires: Vec<String>,
}
/// A discovered shipping UNIT, ecosystem-neutral — the common shape [`Ecosystem`]
/// hands back regardless of Cargo / npm / Maven underneath.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Package {
/// Package name (the publish identity).
pub name: String,
/// Its current LOCAL version.
pub version: String,
/// Absolute path to the manifest that declares it (`Cargo.toml` / `package.json`).
pub manifest_path: PathBuf,
/// Is this unit PUBLISHABLE? (Cargo `publish = false` / npm `"private": true`
/// ⇒ `false`.) Unpublishable units are still discovered — they gate the graph.
pub publishable: bool,
/// The variant/capability map (Cargo `[features]`), variant-name-sorted.
pub variants: Vec<Variant>,
/// Its dependency edges (all kinds — the caller filters by [`DepKind`]).
pub deps: Vec<Dependency>,
}
impl Package {
/// Look up a variant by name.
pub fn variant(&self, name: &str) -> Option<&Variant> {
self.variants.iter().find(|v| v.name == name)
}
}
/// A resolved dependency PIN — a concrete (name, version) the ecosystem's resolver
/// locked, as read back from the lock/resolve step.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedPin {
pub name: String,
pub version: String,
}
/// A produced/located publishable ARTIFACT descriptor (the packaged `.crate` /
/// tarball), without uploading it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
/// The unit this artifact packages.
pub unit: String,
/// Its version.
pub version: String,
/// Packed size in bytes.
pub byte_len: u64,
/// Number of files in the archive.
pub file_count: usize,
}
/// The before→after API-surface verdict for one unit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApiCompat {
/// No previously-exported symbol was dropped — a non-breaking (minor/patch) change.
Compatible {
/// Symbols that appeared only in the AFTER surface.
added: BTreeSet<String>,
},
/// One or more previously-exported symbols vanished — a BREAKING (major) change.
Breaking {
/// Symbols present BEFORE but gone AFTER.
removed: BTreeSet<String>,
/// Newly-added symbols (informational).
added: BTreeSet<String>,
},
/// The surface could not be enumerated precisely (e.g. a glob re-export) — the
/// verdict is INCONCLUSIVE and the caller must fall back to a build/verify probe,
/// never a hard block.
Inconclusive,
}
impl ApiCompat {
/// Is this a breaking change? (`Inconclusive` is NOT breaking — degrade safely.)
pub fn is_breaking(&self) -> bool {
matches!(self, ApiCompat::Breaking { .. })
}
/// Symbols present BEFORE but gone AFTER — the drop that makes a bump BREAKING and
/// the blast-radius key for the world-coderoom "what-breaks" query. Empty for
/// `Compatible`/`Inconclusive`.
pub fn removed(&self) -> &BTreeSet<String> {
static EMPTY: std::sync::OnceLock<BTreeSet<String>> = std::sync::OnceLock::new();
match self {
ApiCompat::Breaking { removed, .. } => removed,
_ => EMPTY.get_or_init(BTreeSet::new),
}
}
/// The SemVer bump this delta demands (the classified `compatible?` edge grade):
/// a drop ⇒ `"major"`, only additions ⇒ `"minor"`, no surface change ⇒ `"patch"`,
/// and a non-enumerable surface ⇒ `"inconclusive"` (never silently `"patch"`).
pub fn suggested_bump(&self) -> &'static str {
match self {
ApiCompat::Breaking { .. } => "major",
ApiCompat::Compatible { added } if !added.is_empty() => "minor",
ApiCompat::Compatible { .. } => "patch",
ApiCompat::Inconclusive => "inconclusive",
}
}
}
/// Classify a before→after public-API delta from two ALREADY-EXTRACTED symbol sets —
/// the symbol-set twin of [`Ecosystem::api_compat`] (which scans raw source). This is
/// the pure **`compatible?` edge producer** the world-coderoom feeds with the two
/// ingested public surfaces of adjacent crate versions
/// ([`published_ingest::PublishedCrateApi::symbols`](super::published_ingest::PublishedCrateApi)
/// / a live `symbol_facts` snapshot via
/// [`skew_warehouse::published_api_from_symbols`](super::skew_warehouse::published_api_from_symbols)).
///
/// REUSES [`api_skew::missing_symbols`] for the set difference in BOTH directions
/// (`removed = before ∖ after`, `added = after ∖ before`) — no second diff engine, so
/// the coderoom edge, the release semver gate, and the graphdiff render all read ONE
/// definition (design §3.2).
///
/// `before_enumerable`/`after_enumerable` mirror
/// [`PublishedCrateApi::api_enumerable`](super::published_ingest::PublishedCrateApi): when
/// EITHER surface is not fully enumerable (a glob re-export the coarse scan cannot
/// expand) a "removed" verdict could be a false break, so the result is
/// [`ApiCompat::Inconclusive`] — never a false Breaking (the same honesty guardrail
/// [`api_skew::assess_pair`] and [`skew_warehouse`](crate::skew_warehouse) apply).
pub fn api_compat_from_symbols(
before: &BTreeSet<String>,
after: &BTreeSet<String>,
before_enumerable: bool,
after_enumerable: bool,
) -> ApiCompat {
if !before_enumerable || !after_enumerable {
return ApiCompat::Inconclusive;
}
let removed = api_skew::missing_symbols(before, after);
let added = api_skew::missing_symbols(after, before);
if removed.is_empty() {
ApiCompat::Compatible { added }
} else {
ApiCompat::Breaking { removed, added }
}
}