nornir 0.5.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! # megagate — the CROSS-WORKSPACE completeness gate (S6, step 2)
//!
//! The single-workspace gate ([`super::gather`] → [`GateReport`] →
//! [`crate::release::gate::coverage_gate`]) verdicts ONE workspace's surface. The
//! real release ships EVERY served workspace, so the mega-gate sweeps the whole
//! [`SERVED_WORKSPACES`](nornir_testmatrix::discover::SERVED_WORKSPACES) dimension:
//! it gathers each workspace's [`GateState`] (reusing the per-workspace path
//! verbatim — same surface, same allowlist, same stale detection), composes them
//! into a [`MegaGateReport`], and — where available — folds in the cross-workspace
//! metro rollup (S6 step 4) and the LAW-9 reachability rollup.
//!
//! ```text
//! gather_all(wh, served, run_id)
//!   for each WorkspaceSpec:  gate_coverage_for_repo(...)  → GateReport (persisted)
//!   → WorkspaceGate { coverage, [utfallsrum], [reachable] }
//!   → MegaGateReport
//!   → coverage_gate_all_workspaces(&report)               (the RED/GREEN verdict)
//! ```
//!
//! NO new warehouse table: every per-workspace run persists to the SHARED
//! `surface_coverage` table (the per-workspace [`super::persist`]), so the
//! cross-workspace history is just `CoverageSelector::All`/`Workspace` over the one
//! table. The mega-gate's verdict is the conjunction of the per-workspace verdicts:
//! the FIRST workspace whose coverage gap is non-empty (or whose RESOLVED metro
//! lines aren't all green) reds the whole release, naming `(workspace, key)`.

use std::path::{Path, PathBuf};

use anyhow::Result;

use crate::warehouse::iceberg::IcebergWarehouse;
use crate::warehouse::surface_coverage::GateReport;
use nornir_testmatrix::discover::SERVED_WORKSPACES;

use super::gate_coverage_for_repo;

/// The gate inputs for ONE served workspace: which workspace, which primary repo's
/// knowledge map to read, the repo root that owns the checked-in
/// `autonom-allow.toml`, and (optionally) the live clap subcommands the binary
/// injects (empty ⇒ the canonical [`super::CLI_COMMANDS`] fallback).
#[derive(Debug, Clone)]
pub struct WorkspaceSpec {
    /// The served workspace name (the `surface_coverage` `workspace` column).
    pub workspace: String,
    /// The primary repo whose latest knowledge scan drives the surface (usually
    /// the workspace name).
    pub repo: String,
    /// The repo root that owns `.nornir/autonom-allow.toml`.
    pub repo_root: PathBuf,
    /// Live clap subcommand names (empty ⇒ canonical [`super::CLI_COMMANDS`]).
    pub cli_commands: Vec<String>,
}

impl WorkspaceSpec {
    /// A spec for `workspace`, reading the same-named repo's map under `repo_root`.
    pub fn new(workspace: impl Into<String>, repo_root: impl Into<PathBuf>) -> Self {
        let workspace = workspace.into();
        Self {
            repo: workspace.clone(),
            workspace,
            repo_root: repo_root.into(),
            cli_commands: Vec::new(),
        }
    }
}

/// The canonical served-workspace specs against `roots_dir` — one
/// [`WorkspaceSpec`] per [`SERVED_WORKSPACES`] entry, each rooted at
/// `roots_dir/<workspace>` (the sibling-repo layout the constellation uses). The
/// binary overrides this with the LIVE served set from `workspaces_list`; this is
/// the canonical fallback so the dimension is discoverable from the lib.
pub fn served_specs(roots_dir: &Path) -> Vec<WorkspaceSpec> {
    SERVED_WORKSPACES
        .iter()
        .map(|w| WorkspaceSpec::new(*w, roots_dir.join(w)))
        .collect()
}

/// One served workspace's full cross-gate verdict: its coverage [`GateReport`]
/// plus the OPTIONAL outcome-space + reachability rollups (folded in by the
/// `viz`/metro-aware caller — [`gather_all`] populates only `coverage`, keeping
/// `autonom` free of the `viz` feature).
#[derive(Debug, Clone, Default)]
pub struct WorkspaceGate {
    /// The served workspace name.
    pub workspace: String,
    /// The per-workspace completeness gate (surface − covered − allowlist + stale).
    pub coverage: GateReport,
    /// Outcome-space rollup `(surfaces meeting the threshold, surfaces with an
    /// outcome space)`, when measured. `None` when no utfallsrum was recorded.
    pub utfallsrum: Option<(usize, usize)>,
    /// Reachability rollup `(green, total)` over the RESOLVED metro lines (S6 step
    /// 4) and/or the LAW-9 walk. `None` when neither was computed. Unresolved
    /// bin→lib metro lines are EXCLUDED upstream (allowlistable, not hard-red).
    pub reachable: Option<(usize, usize)>,
}

impl WorkspaceGate {
    /// Wrap a per-workspace [`GateReport`] as a coverage-only [`WorkspaceGate`].
    pub fn from_report(workspace: impl Into<String>, coverage: GateReport) -> Self {
        Self { workspace: workspace.into(), coverage, utfallsrum: None, reachable: None }
    }

    /// Fold in the outcome-space rollup `(met, total)`.
    pub fn with_utfallsrum(mut self, met: usize, total: usize) -> Self {
        self.utfallsrum = Some((met, total));
        self
    }

    /// Fold in the reachability rollup `(green, total)` over the resolved metro
    /// lines / LAW-9 walk.
    pub fn with_reachable(mut self, green: usize, total: usize) -> Self {
        self.reachable = Some((green, total));
        self
    }

    /// GREEN ⟺ the coverage gate is green AND (when present) every RESOLVED metro
    /// line / reachable surface is green. The utfallsrum rollup is advisory here
    /// (the threshold already reflected in the coverage `covered` set).
    pub fn is_green(&self) -> bool {
        self.coverage.is_green() && self.reachable.map(|(g, t)| g == t).unwrap_or(true)
    }

    /// The first failing surface KEY in this workspace (an uncovered/un-allowlisted
    /// node, a stale allowlist entry, or the reachable shortfall) — for naming the
    /// offender in the mega verdict. `None` when [`is_green`](Self::is_green).
    pub fn first_failure(&self) -> Option<String> {
        if let Some(node) = self.coverage.gap.missing.first() {
            return Some(format!("uncovered surface `{}`", node.key_str()));
        }
        if let Some(stale) = self.coverage.stale.first() {
            return Some(format!("stale allowlist entry `{}`", stale.key));
        }
        if let Some((g, t)) = self.reachable {
            if g != t {
                return Some(format!("{}/{} resolved metro lines green", g, t));
            }
        }
        None
    }
}

/// The cross-workspace completeness verdict — one [`WorkspaceGate`] per served
/// workspace, in sweep order. The release-blocking gate
/// ([`crate::release::gate::coverage_gate_all_workspaces`]) fails on the FIRST
/// non-green workspace, naming `(workspace, key)`.
#[derive(Debug, Clone, Default)]
pub struct MegaGateReport {
    /// The run id that stamped every per-workspace persisted gate run.
    pub run_id: String,
    /// Per-workspace verdicts, in `served` order.
    pub workspaces: Vec<WorkspaceGate>,
}

impl MegaGateReport {
    /// GREEN ⟺ every workspace is green.
    pub fn is_green(&self) -> bool {
        self.workspaces.iter().all(|w| w.is_green())
    }

    /// The first failing `(workspace, key)`, or `None` when fully green.
    pub fn first_failure(&self) -> Option<(String, String)> {
        self.workspaces
            .iter()
            .find_map(|w| w.first_failure().map(|k| (w.workspace.clone(), k)))
    }

    /// A one-line human summary for the CLI / viz.
    pub fn summary(&self) -> String {
        let green = self.workspaces.iter().filter(|w| w.is_green()).count();
        format!(
            "{}/{} workspaces green — {}",
            green,
            self.workspaces.len(),
            if self.is_green() { "GREEN" } else { "RED (mega gate)" },
        )
    }
}

/// **THE CROSS-WORKSPACE GATHER** — sweep every served workspace's completeness
/// gate, persisting each to the SHARED `surface_coverage` table, and compose them
/// into a [`MegaGateReport`].
///
/// For each [`WorkspaceSpec`] this runs the SAME [`gate_coverage_for_repo`] path
/// the single-workspace gate uses (load the repo's knowledge map + checked-in
/// allowlist, difference the surface, persist the per-node rows), so the
/// per-workspace verdict — including stale-allowlist detection — is identical. The
/// metro / LAW-9 rollups are NOT computed here (they need the `viz` feature); the
/// caller folds them in via [`WorkspaceGate::with_reachable`].
pub fn gather_all(
    wh: &IcebergWarehouse,
    served: &[WorkspaceSpec],
    run_id: &str,
) -> Result<MegaGateReport> {
    let mut workspaces = Vec::with_capacity(served.len());
    for spec in served {
        let state = gate_coverage_for_repo(
            wh,
            &spec.workspace,
            &spec.repo,
            &spec.repo_root,
            spec.cli_commands.clone(),
            run_id,
        )?;
        workspaces.push(WorkspaceGate::from_report(&spec.workspace, state.report));
    }
    Ok(MegaGateReport { run_id: run_id.to_string(), workspaces })
}

#[cfg(test)]
mod tests {
    use super::*;
    use nornir_testmatrix::coverage::{AllowEntry, GateReport};
    use nornir_testmatrix::discover::{cli_commands, Surface, SurfaceNode};
    use std::collections::BTreeSet;

    fn surface_ab() -> Surface {
        let mut s = Surface::new();
        s.extend(cli_commands(["a", "b"]));
        s
    }

    fn green_report(ws: &str) -> GateReport {
        let surface = surface_ab();
        let covered: BTreeSet<String> = surface.nodes.iter().map(SurfaceNode::key_str).collect();
        GateReport::compute("r", ws, &surface, &covered, &Default::default())
    }

    fn red_report(ws: &str) -> GateReport {
        let surface = surface_ab();
        let covered: BTreeSet<String> = ["cli_command:a@na".to_string()].into_iter().collect();
        GateReport::compute("r", ws, &surface, &covered, &Default::default())
    }

    #[test]
    fn mega_is_green_iff_every_workspace_green() {
        let report = MegaGateReport {
            run_id: "r".into(),
            workspaces: vec![
                WorkspaceGate::from_report("alpha", green_report("alpha")),
                WorkspaceGate::from_report("beta", green_report("beta")),
            ],
        };
        assert!(report.is_green(), "all-green sweep is green: {}", report.summary());
        assert!(report.first_failure().is_none());
    }

    #[test]
    fn mega_names_first_failing_workspace_and_key() {
        let report = MegaGateReport {
            run_id: "r".into(),
            workspaces: vec![
                WorkspaceGate::from_report("alpha", green_report("alpha")),
                WorkspaceGate::from_report("beta", red_report("beta")), // b uncovered
            ],
        };
        assert!(!report.is_green());
        let (ws, key) = report.first_failure().expect("a failure");
        assert_eq!(ws, "beta", "names the offending workspace");
        assert!(key.contains("cli_command:b@na"), "names the uncovered surface: {key}");
    }

    #[test]
    fn reachable_shortfall_reds_a_coverage_green_workspace() {
        // Coverage is green but a resolved metro line is unlit → the workspace reds.
        let wg = WorkspaceGate::from_report("alpha", green_report("alpha")).with_reachable(1, 2);
        assert!(!wg.is_green(), "an unmet reachable rollup reds the workspace");
        assert_eq!(wg.first_failure().as_deref(), Some("1/2 resolved metro lines green"));

        // All resolved lines green → the workspace stays green.
        let wg2 = WorkspaceGate::from_report("alpha", green_report("alpha")).with_reachable(2, 2);
        assert!(wg2.is_green());
    }

    #[test]
    fn stale_allowlist_entry_is_named_as_the_failure() {
        let surface = surface_ab();
        let covered: BTreeSet<String> =
            surface.nodes.iter().map(SurfaceNode::key_str).collect();
        let allow = nornir_testmatrix::coverage::Allowlist {
            entries: vec![AllowEntry { key: "cli_command:a@na".into(), reason: "old".into() }],
        };
        let report = GateReport::compute("r", "alpha", &surface, &covered, &allow);
        let wg = WorkspaceGate::from_report("alpha", report);
        assert!(!wg.is_green(), "a stale allowlist entry reds the workspace");
        assert!(
            wg.first_failure().unwrap().contains("stale allowlist entry `cli_command:a@na`"),
            "names the stale entry: {:?}",
            wg.first_failure(),
        );
    }

    #[test]
    fn served_specs_cover_the_canonical_dimension() {
        let specs = served_specs(Path::new("/tmp/roots"));
        assert_eq!(specs.len(), SERVED_WORKSPACES.len());
        let nornir = specs.iter().find(|s| s.workspace == "nornir").unwrap();
        assert_eq!(nornir.repo, "nornir");
        assert_eq!(nornir.repo_root, PathBuf::from("/tmp/roots/nornir"));
    }
}