Skip to main content

xchecker_gate/
command.rs

1//! Gate command for policy-based spec validation
2//!
3//! This module provides gate command implementation for evaluating specs
4//! against configurable policies to determine if they
5//! meet requirements for CI/CD gates.
6
7use camino::Utf8PathBuf;
8use std::time::Duration;
9use xchecker_receipt::ReceiptManager;
10
11use crate::policy::GatePolicy;
12use crate::types::{GateCondition, GateResult};
13
14/// Gate command for policy-based spec validation
15///
16/// Evaluates a spec against a configurable policy to determine if it
17/// meets requirements for CI/CD gates.
18pub struct GateCommand {
19    spec_id: String,
20    policy: GatePolicy,
21}
22
23impl GateCommand {
24    /// Create a new gate command
25    pub fn new(spec_id: String, policy: GatePolicy) -> Self {
26        Self { spec_id, policy }
27    }
28
29    /// Execute gate evaluation
30    pub fn execute(&self) -> anyhow::Result<GateResult> {
31        let base_path = crate::paths::spec_root(&self.spec_id);
32        let base_path_utf8 = Utf8PathBuf::from_path_buf(base_path)
33            .map_err(|_| anyhow::anyhow!("Invalid UTF-8 path"))?;
34        let receipt_manager = ReceiptManager::new(&base_path_utf8);
35
36        // Check if spec exists
37        if !base_path_utf8.as_path().exists() {
38            return Ok(GateResult {
39                schema_version: "gate-json.v1".to_string(),
40                spec_id: self.spec_id.clone(),
41                passed: false,
42                summary: format!("Spec '{}' does not exist", self.spec_id),
43                conditions: vec![],
44                failure_reasons: vec![format!(
45                    "Spec directory not found: {}",
46                    base_path_utf8.as_str()
47                )],
48            });
49        }
50
51        let mut conditions = Vec::new();
52        let mut failure_reasons = Vec::new();
53
54        // Evaluate minimum phase requirement
55        let min_phase_passed =
56            self.evaluate_min_phase(&receipt_manager, &mut conditions, &mut failure_reasons);
57
58        // Evaluate pending fixups requirement
59        let fixups_passed =
60            self.evaluate_pending_fixups(&receipt_manager, &mut conditions, &mut failure_reasons);
61
62        // Evaluate phase age requirement
63        let age_passed =
64            self.evaluate_phase_age(&receipt_manager, &mut conditions, &mut failure_reasons);
65
66        let passed = min_phase_passed && fixups_passed && age_passed;
67
68        let summary = if passed {
69            format!("Spec '{}' passed all gate checks", self.spec_id)
70        } else {
71            format!("Spec '{}' failed gate checks", self.spec_id)
72        };
73
74        Ok(GateResult {
75            schema_version: "gate-json.v1".to_string(),
76            spec_id: self.spec_id.clone(),
77            passed,
78            summary,
79            conditions,
80            failure_reasons,
81        })
82    }
83
84    fn evaluate_min_phase(
85        &self,
86        receipt_manager: &ReceiptManager,
87        conditions: &mut Vec<GateCondition>,
88        failure_reasons: &mut Vec<String>,
89    ) -> bool {
90        let policy_min_phase = self.policy.min_phase.as_ref();
91        let spec_latest_phase = receipt_manager
92            .list_receipts()
93            .ok()
94            .and_then(|receipts| receipts.last().map(|r| r.phase.clone()));
95
96        let passed = match (policy_min_phase, spec_latest_phase.clone()) {
97            (None, _) => true, // No minimum phase requirement
98            (Some(_policy_phase), None) => {
99                // No receipts, spec not started
100                false
101            }
102            (Some(policy_phase), Some(spec_phase)) => {
103                // Compare phase IDs
104                policy_phase.as_str() <= spec_phase.as_str()
105            }
106        };
107
108        let condition_name = format!(
109            "Minimum phase: {}",
110            policy_min_phase.map_or("none", |p| p.as_str())
111        );
112        let description = format!(
113            "Spec has completed at least phase '{}'",
114            policy_min_phase.map_or("none", |p| p.as_str())
115        );
116
117        let actual = spec_latest_phase.clone();
118        let expected = policy_min_phase.cloned();
119
120        conditions.push(GateCondition {
121            name: condition_name,
122            description,
123            passed,
124            actual: actual.map(|p| p.as_str().to_string()),
125            expected: expected.map(|p| p.as_str().to_string()),
126        });
127
128        if !passed {
129            failure_reasons.push(format!(
130                "Spec has not reached minimum required phase '{}'",
131                policy_min_phase.map_or("none", |p| p.as_str())
132            ));
133        }
134
135        passed
136    }
137
138    fn evaluate_pending_fixups(
139        &self,
140        _receipt_manager: &ReceiptManager,
141        conditions: &mut Vec<GateCondition>,
142        failure_reasons: &mut Vec<String>,
143    ) -> bool {
144        if !self.policy.fail_on_pending_fixups {
145            // Not configured to check pending fixups
146            return true;
147        }
148
149        let base_path = crate::paths::spec_root(&self.spec_id);
150        let pending_fixups = crate::pending_fixups::pending_fixups_for_spec(&base_path);
151
152        let passed = pending_fixups.targets == 0;
153
154        let condition_name = "Pending fixups".to_string();
155        let description = "No pending fixups should exist".to_string();
156
157        let actual = Some(format!(
158            "{} targets with pending changes",
159            pending_fixups.targets
160        ));
161        let expected = Some("0 targets".to_string());
162
163        conditions.push(GateCondition {
164            name: condition_name,
165            description,
166            passed,
167            actual,
168            expected,
169        });
170
171        if !passed {
172            failure_reasons.push(format!(
173                "Spec has {} pending fixups",
174                pending_fixups.targets
175            ));
176        }
177
178        passed
179    }
180
181    fn evaluate_phase_age(
182        &self,
183        receipt_manager: &ReceiptManager,
184        conditions: &mut Vec<GateCondition>,
185        failure_reasons: &mut Vec<String>,
186    ) -> bool {
187        let max_age = match self.policy.max_phase_age {
188            Some(age) => age,
189            None => return true, // No age requirement
190        };
191
192        let latest_receipt = receipt_manager
193            .list_receipts()
194            .ok()
195            .and_then(|receipts| receipts.last().cloned());
196
197        let passed = match &latest_receipt {
198            Some(receipt) => {
199                let age = chrono::Utc::now().signed_duration_since(receipt.emitted_at);
200                let age_duration = age.to_std().unwrap_or(Duration::MAX);
201                age_duration <= max_age
202            }
203            None => false, // No receipts, can't evaluate age
204        };
205
206        let condition_name = format!("Phase age: {}", format_duration(max_age));
207        let description = format!(
208            "Latest successful phase should be no older than {}",
209            format_duration(max_age)
210        );
211
212        let actual = latest_receipt.as_ref().map(|r| {
213            let age = chrono::Utc::now().signed_duration_since(r.emitted_at);
214            let age_duration = age.to_std().unwrap_or(Duration::MAX);
215            format!("{} old", format_duration(age_duration))
216        });
217        let expected = Some(format!("<= {}", format_duration(max_age)));
218
219        conditions.push(GateCondition {
220            name: condition_name,
221            description,
222            passed,
223            actual,
224            expected,
225        });
226
227        if !passed {
228            failure_reasons.push(format!(
229                "Latest phase is older than maximum allowed age of {}",
230                format_duration(max_age)
231            ));
232        }
233
234        passed
235    }
236}
237
238/// Format a duration as a human-readable string
239fn format_duration(duration: Duration) -> String {
240    let total_seconds = duration.as_secs();
241    if total_seconds >= 86400 {
242        let days = total_seconds / 86400;
243        format!("{}d", days)
244    } else if total_seconds >= 3600 {
245        let hours = total_seconds / 3600;
246        format!("{}h", hours)
247    } else if total_seconds >= 60 {
248        let minutes = total_seconds / 60;
249        format!("{}m", minutes)
250    } else {
251        format!("{}s", total_seconds)
252    }
253}