1use camino::Utf8PathBuf;
8use std::time::Duration;
9use xchecker_receipt::ReceiptManager;
10
11use crate::policy::GatePolicy;
12use crate::types::{GateCondition, GateResult};
13
14pub struct GateCommand {
19 spec_id: String,
20 policy: GatePolicy,
21}
22
23impl GateCommand {
24 pub fn new(spec_id: String, policy: GatePolicy) -> Self {
26 Self { spec_id, policy }
27 }
28
29 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 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 let min_phase_passed =
56 self.evaluate_min_phase(&receipt_manager, &mut conditions, &mut failure_reasons);
57
58 let fixups_passed =
60 self.evaluate_pending_fixups(&receipt_manager, &mut conditions, &mut failure_reasons);
61
62 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, (Some(_policy_phase), None) => {
99 false
101 }
102 (Some(policy_phase), Some(spec_phase)) => {
103 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 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, };
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, };
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
238fn 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}