nornir 0.4.53

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Shared **Gate / Doctor** model — the go/no-go release verdict computed once
//! from [`release::doctor::run`](crate::release::doctor::run) (dirty trees,
//! external-dep skew with `held_by_transitive_pin`, cycle-break advice, topo +
//! repo edges) plus [`release::edition`](crate::release::edition) static findings.
//!
//! Lifted out of `release_tab` so BOTH the 🚀 Release pane AND the 🧬 nornir
//! RELEASE DASHBOARD share ONE gate computation + ONE `gate_json` shape — the
//! dashboard must not recompute or re-shape what the Release pane already ships.

use std::path::PathBuf;

use crate::release::doctor::{self, DepPolicy, DoctorReport};
use crate::release::edition::{self, EditionReport};

/// The cached **Gate / Doctor** model — the advisory release report (doctor) plus
/// the edition-2024 gate result, computed on load/reload (NOT every repaint) so a
/// pane can render dirty trees, external-dep skew, cycle-break advice, and edition
/// findings without re-shelling cargo or re-scanning Cargo.tomls per frame.
pub struct GateModel {
    /// The doctor advisory (dirty / skew / topo / blast / cycle_advice / repo_edges).
    pub doctor: DoctorReport,
    /// The edition-2024 gate. The always-on view uses only `static_findings`; the
    /// dynamic lint pass (which shells `cargo check`) is opt-in behind a button and
    /// fills `lints` / `lint_pass_ran` when run.
    pub edition: EditionReport,
}

impl GateModel {
    /// Build directly from a doctor + edition report (the test-inject seam).
    pub fn new(doctor: DoctorReport, edition: EditionReport) -> Self {
        Self { doctor, edition }
    }

    /// Compute the gate model from a workspace's repo checkouts. The edition
    /// dynamic lint pass (`cargo check`) only runs when `lint` — otherwise only
    /// the cheap `static_findings_only` is read (the perf caveat). Local only.
    /// Returns `Err` with the doctor's failure string.
    pub fn compute(
        repos: &[(String, PathBuf)],
        policy: &DepPolicy,
        lint: bool,
    ) -> Result<Self, String> {
        let doctor = doctor::run(repos, policy).map_err(|e| format!("{e:#}"))?;
        let edition = if lint {
            let root = repos.first().map(|(_, p)| p.clone());
            match root.as_deref() {
                Some(p) => edition::run_gate(p).unwrap_or(EditionReport {
                    static_findings: Vec::new(),
                    lints: Vec::new(),
                    lint_pass_ran: false,
                }),
                None => EditionReport {
                    static_findings: Vec::new(),
                    lints: Vec::new(),
                    lint_pass_ran: false,
                },
            }
        } else {
            let root = repos.first().map(|(_, p)| p.clone());
            let static_findings = root
                .as_deref()
                .and_then(|p| edition::static_findings_only(p).ok())
                .unwrap_or_default();
            EditionReport { static_findings, lints: Vec::new(), lint_pass_ran: false }
        };
        Ok(Self { doctor, edition })
    }

    /// Repos behind on a crate AND held back by a transitive pin (the ⛔ rows) —
    /// the names the robot test asserts on.
    pub fn held_back(&self) -> Vec<String> {
        let mut out: Vec<String> = self
            .doctor
            .skew
            .iter()
            .flat_map(|c| {
                c.entries
                    .iter()
                    .filter(|e| e.held_by_transitive_pin)
                    .map(|e| e.repo.clone())
            })
            .collect();
        out.sort();
        out.dedup();
        out
    }

    /// Dirty working trees (advisory).
    pub fn dirty(&self) -> Vec<String> {
        self.doctor.dirty.iter().filter(|d| d.dirty).map(|d| d.repo.clone()).collect()
    }

    /// The overall go/no-go verdict: a gate FAILS (no-go) when the edition gate is
    /// dirty OR any repo is held back by a transitive pin (the blocking ⛔ rows).
    /// Dirty trees + plain skew are advisory and do NOT block on their own.
    pub fn gate_ok(&self) -> bool {
        self.edition.is_clean() && self.held_back().is_empty()
    }

    /// The structured **Gate / Doctor** block folded into `state_json` — the SAME
    /// shape the 🚀 Release pane ships. Stable keys; the `present`/zeroed variant
    /// for an absent model lives in [`gate_json_absent`].
    pub fn gate_json(&self, error: Option<&str>) -> serde_json::Value {
        let held_back = self.held_back();
        let dirty = self.dirty();
        let edition_findings = self.edition.static_findings.len();
        let edition_clean = self.edition.is_clean();
        let cycle_first = self
            .doctor
            .cycle_advice
            .first()
            .map(|a| serde_json::Value::String(a.rationale.clone()))
            .unwrap_or(serde_json::Value::Null);
        serde_json::json!({
            "present": true,
            "error": error,
            "skew_count": self.doctor.skew.len(),
            "held_back": held_back,
            "cycle_advice": self.doctor.cycle_advice.len(),
            "cycle_advice_first": cycle_first,
            "edition_clean": edition_clean,
            "edition_findings": edition_findings,
            "edition_lint_ran": self.edition.lint_pass_ran,
            "edition_lints": self.edition.lints.len(),
            "dirty": dirty,
            "gate_ok": self.gate_ok(),
        })
    }
}

/// The `gate_json` shape for an ABSENT model (remote / unconfigured) — the same
/// stable keys, zeroed, with `present: false`.
pub fn gate_json_absent(error: Option<&str>) -> serde_json::Value {
    serde_json::json!({
        "present": false,
        "error": error,
        "skew_count": 0,
        "held_back": [],
        "cycle_advice": 0,
        "cycle_advice_first": serde_json::Value::Null,
        "edition_clean": true,
        "edition_findings": 0,
        "edition_lint_ran": false,
        "edition_lints": 0,
        "dirty": [],
        "gate_ok": true,
    })
}