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

nordisk/edda

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