Skip to main content

spatialrust_platform/
gate.rs

1//! Aggregated release-gate decision across platform surfaces.
2
3use crate::{
4    ApiStabilityClass, ConformanceReport, LtsPolicy, PerformanceBudgetReport, PlatformError,
5    PlatformResult, SecurityChecklist, StabilityRegistry,
6};
7
8/// Outcomes of evaluating a [`ReleaseGate`].
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct ReleaseGateDecision {
11    /// True when every configured surface passes.
12    pub allowed: bool,
13    /// Human-readable reasons for denial (empty when allowed).
14    pub reasons: Vec<String>,
15}
16
17/// Release gate combining stability, conformance, security, LTS, and budgets.
18#[derive(Clone, Debug, Default)]
19pub struct ReleaseGate {
20    /// API surface registry (optional for gate evaluation).
21    pub stability: Option<StabilityRegistry>,
22    /// Conformance report.
23    pub conformance: Option<ConformanceReport>,
24    /// Security checklist.
25    pub security: Option<SecurityChecklist>,
26    /// LTS policy (must declare at least one window when present).
27    pub lts: Option<LtsPolicy>,
28    /// Performance budgets.
29    pub budgets: Option<PerformanceBudgetReport>,
30    /// When true, Experimental APIs block the gate.
31    pub reject_experimental: bool,
32}
33
34impl ReleaseGate {
35    /// Creates an empty gate (nothing configured ⇒ denied).
36    #[must_use]
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// Seeds a gate with SpatialRust 1.x defaults used by north-star checks.
42    #[must_use]
43    pub fn north_star_defaults() -> Self {
44        Self {
45            stability: Some(StabilityRegistry::north_star_surface()),
46            conformance: Some(ConformanceReport::new()),
47            security: Some(SecurityChecklist::north_star_baseline()),
48            lts: Some(LtsPolicy::spatialrust_v1()),
49            budgets: Some({
50                let mut budgets = PerformanceBudgetReport::new();
51                budgets.declare(crate::PerformanceBudget {
52                    id: "north-star-e2e-latency-ms".into(),
53                    kind: crate::BudgetKind::LatencyMillis,
54                    ceiling: 5_000,
55                });
56                budgets.declare(crate::PerformanceBudget {
57                    id: "north-star-e2e-bytes-copied".into(),
58                    kind: crate::BudgetKind::BytesCopied,
59                    ceiling: 64 * 1024 * 1024,
60                });
61                budgets
62            }),
63            reject_experimental: true,
64        }
65    }
66
67    /// Evaluates configured surfaces and returns a decision.
68    pub fn evaluate(&self) -> ReleaseGateDecision {
69        let mut reasons = Vec::new();
70
71        if self.stability.is_none()
72            && self.conformance.is_none()
73            && self.security.is_none()
74            && self.lts.is_none()
75            && self.budgets.is_none()
76        {
77            reasons.push("release gate has no configured surfaces".into());
78            return ReleaseGateDecision { allowed: false, reasons };
79        }
80
81        if let Some(stability) = &self.stability {
82            if stability.items().is_empty() {
83                reasons.push("stability registry is empty".into());
84            }
85            if self.reject_experimental {
86                let experimental = stability
87                    .items()
88                    .iter()
89                    .filter(|item| item.class == ApiStabilityClass::Experimental)
90                    .count();
91                if experimental > 0 {
92                    reasons.push(format!(
93                        "{experimental} experimental API(s) present while reject_experimental=true"
94                    ));
95                }
96            }
97        }
98
99        if let Some(conformance) = &self.conformance {
100            if conformance.cases().is_empty() {
101                reasons.push("conformance report has no cases".into());
102            }
103            if let Err(err) = conformance.assert_no_failures() {
104                reasons.push(err.to_string());
105            }
106            if conformance.pass_count() == 0 && !conformance.cases().is_empty() {
107                reasons.push("conformance report has zero passes".into());
108            }
109        }
110
111        if let Some(security) = &self.security {
112            if !security.all_satisfied() {
113                reasons.push("security checklist is not fully satisfied".into());
114            }
115        }
116
117        if let Some(lts) = &self.lts {
118            if lts.windows().is_empty() {
119                reasons.push("LTS policy has no support windows".into());
120            }
121        }
122
123        if let Some(budgets) = &self.budgets {
124            if let Err(err) = budgets.assert_within_budgets() {
125                reasons.push(err.to_string());
126            }
127        }
128
129        ReleaseGateDecision { allowed: reasons.is_empty(), reasons }
130    }
131
132    /// Convenience wrapper returning [`PlatformResult`].
133    pub fn assert_allowed(&self) -> PlatformResult<()> {
134        let decision = self.evaluate();
135        if decision.allowed {
136            Ok(())
137        } else {
138            Err(PlatformError::ReleaseGateDenied { reasons: decision.reasons })
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::ReleaseGate;
146    use crate::{ConformanceStatus, SecurityChecklist};
147
148    #[test]
149    fn north_star_defaults_need_conformance_passes() {
150        let mut gate = ReleaseGate::north_star_defaults();
151        assert!(!gate.evaluate().allowed);
152        if let Some(report) = gate.conformance.as_mut() {
153            report.record("smoke", ConformanceStatus::Pass, None);
154        }
155        // Security checklist from baseline starts unsatisfied until marked.
156        if let Some(security) = gate.security.as_mut() {
157            *security = SecurityChecklist::north_star_baseline_satisfied();
158        }
159        assert!(gate.assert_allowed().is_ok());
160    }
161}