1use std::path::PathBuf;
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5
6use super::{VerificationRequirement, VerificationRequirementKind};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(default)]
10pub struct VerificationGate {
11 pub id: String,
12 pub name: String,
13 pub kind: VerificationGateKind,
14 pub requirement: VerificationGateRequirement,
15 pub status: VerificationGateStatus,
16 pub command: Option<VerificationCommand>,
17 pub result: Option<VerificationGateResult>,
18 pub artifacts: Vec<VerificationArtifactRef>,
19 pub source: VerificationGateSource,
20 pub reason: Option<String>,
21}
22
23impl VerificationGate {
24 pub fn command(id: impl Into<String>, command: impl Into<String>) -> Self {
25 let id = id.into();
26 let command = command.into();
27 Self {
28 name: id.clone(),
29 kind: VerificationGateKind::Command,
30 requirement: VerificationGateRequirement::Required,
31 status: VerificationGateStatus::Pending,
32 command: Some(VerificationCommand::new(command)),
33 source: VerificationGateSource::WorkflowContract,
34 id,
35 ..Self::default()
36 }
37 }
38
39 pub fn from_requirement(index: usize, requirement: &VerificationRequirement) -> Self {
40 let id = format!("verify-{}", index + 1);
41 let mut gate = match &requirement.kind {
42 VerificationRequirementKind::Command { command } => Self::command(id, command.clone()),
43 VerificationRequirementKind::Diff => Self::typed(id, VerificationGateKind::Diff),
44 VerificationRequirementKind::Policy => Self::typed(id, VerificationGateKind::Policy),
45 VerificationRequirementKind::Manual => Self::typed(id, VerificationGateKind::Manual),
46 };
47 gate.requirement = if requirement.required {
48 VerificationGateRequirement::Required
49 } else {
50 VerificationGateRequirement::Optional
51 };
52 if let Some(name) = &requirement.name {
53 gate.name = name.clone();
54 }
55 gate
56 }
57
58 pub fn typed(id: impl Into<String>, kind: VerificationGateKind) -> Self {
59 let id = id.into();
60 Self {
61 name: id.clone(),
62 kind,
63 requirement: VerificationGateRequirement::Required,
64 status: VerificationGateStatus::Pending,
65 source: VerificationGateSource::WorkflowContract,
66 id,
67 ..Self::default()
68 }
69 }
70
71 pub fn is_required(&self) -> bool {
72 self.requirement == VerificationGateRequirement::Required
73 }
74
75 pub fn closeout_effect(&self) -> VerificationCloseoutEffect {
76 match (self.requirement, self.status) {
77 (VerificationGateRequirement::Required, VerificationGateStatus::Passed) => {
78 VerificationCloseoutEffect::AllowsDone
79 }
80 (VerificationGateRequirement::Required, VerificationGateStatus::Failed) => {
81 VerificationCloseoutEffect::BlocksDoneWithConcerns
82 }
83 (
84 VerificationGateRequirement::Required,
85 VerificationGateStatus::Skipped
86 | VerificationGateStatus::Pending
87 | VerificationGateStatus::Running,
88 ) => VerificationCloseoutEffect::BlocksDoneWithConcerns,
89 (VerificationGateRequirement::Required, VerificationGateStatus::Blocked) => {
90 VerificationCloseoutEffect::BlocksDone
91 }
92 (VerificationGateRequirement::Optional | VerificationGateRequirement::Advisory, _) => {
93 VerificationCloseoutEffect::AllowsDone
94 }
95 }
96 }
97
98 pub fn mark_running(&mut self) {
99 self.status = VerificationGateStatus::Running;
100 }
101
102 pub fn mark_passed(&mut self, result: VerificationGateResult) {
103 self.status = VerificationGateStatus::Passed;
104 self.result = Some(result);
105 }
106
107 pub fn mark_failed(&mut self, result: VerificationGateResult) {
108 self.status = VerificationGateStatus::Failed;
109 self.result = Some(result);
110 }
111
112 pub fn mark_skipped(&mut self, reason: impl Into<String>) {
113 self.status = VerificationGateStatus::Skipped;
114 self.reason = Some(reason.into());
115 }
116
117 pub fn mark_blocked(&mut self, reason: impl Into<String>) {
118 self.status = VerificationGateStatus::Blocked;
119 self.reason = Some(reason.into());
120 }
121}
122
123impl Default for VerificationGate {
124 fn default() -> Self {
125 Self {
126 id: String::new(),
127 name: String::new(),
128 kind: VerificationGateKind::Manual,
129 requirement: VerificationGateRequirement::Required,
130 status: VerificationGateStatus::Pending,
131 command: None,
132 result: None,
133 artifacts: Vec::new(),
134 source: VerificationGateSource::WorkflowContract,
135 reason: None,
136 }
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "kebab-case", tag = "kind")]
142pub enum VerificationGateKind {
143 Command,
144 Diff,
145 Policy,
146 Manual,
147 Custom { name: String },
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "kebab-case")]
152pub enum VerificationGateRequirement {
153 Required,
154 Optional,
155 Advisory,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "kebab-case")]
160pub enum VerificationGateStatus {
161 Pending,
162 Running,
163 Passed,
164 Failed,
165 Skipped,
166 Blocked,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(default)]
171pub struct VerificationCommand {
172 pub command: String,
173 pub cwd: Option<PathBuf>,
174 pub timeout: Option<Duration>,
175}
176
177impl VerificationCommand {
178 pub fn new(command: impl Into<String>) -> Self {
179 Self {
180 command: command.into(),
181 cwd: None,
182 timeout: None,
183 }
184 }
185}
186
187impl Default for VerificationCommand {
188 fn default() -> Self {
189 Self::new("")
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
194#[serde(default)]
195pub struct VerificationGateResult {
196 pub exit_code: Option<i32>,
197 pub duration_ms: Option<u64>,
198 pub summary: Option<String>,
199 pub stdout_summary: Option<String>,
200 pub stderr_summary: Option<String>,
201}
202
203impl VerificationGateResult {
204 pub fn passed(exit_code: i32) -> Self {
205 Self {
206 exit_code: Some(exit_code),
207 ..Self::default()
208 }
209 }
210
211 pub fn failed(exit_code: i32) -> Self {
212 Self {
213 exit_code: Some(exit_code),
214 ..Self::default()
215 }
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(default)]
221pub struct VerificationArtifactRef {
222 pub kind: String,
223 pub path: PathBuf,
224 pub summary: Option<String>,
225 pub bytes: Option<u64>,
226 pub redaction: Option<String>,
227}
228
229impl VerificationArtifactRef {
230 pub fn new(kind: impl Into<String>, path: impl Into<PathBuf>) -> Self {
231 Self {
232 kind: kind.into(),
233 path: path.into(),
234 summary: None,
235 bytes: None,
236 redaction: None,
237 }
238 }
239}
240
241impl Default for VerificationArtifactRef {
242 fn default() -> Self {
243 Self::new("artifact", PathBuf::new())
244 }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "kebab-case", tag = "source")]
249pub enum VerificationGateSource {
250 WorkflowContract,
251 ManaTask { unit_id: Option<String> },
252 User,
253 Inferred,
254 Policy,
255 Extension { id: String },
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "kebab-case")]
260pub enum VerificationCloseoutEffect {
261 AllowsDone,
262 BlocksDoneWithConcerns,
263 BlocksDone,
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn verification_gate_command_defaults_to_required_pending() {
272 let gate = VerificationGate::command("unit-tests", "cargo test -p imp-core");
273 assert_eq!(gate.id, "unit-tests");
274 assert_eq!(gate.name, "unit-tests");
275 assert_eq!(gate.kind, VerificationGateKind::Command);
276 assert_eq!(gate.requirement, VerificationGateRequirement::Required);
277 assert_eq!(gate.status, VerificationGateStatus::Pending);
278 assert!(gate.is_required());
279 assert_eq!(
280 gate.command
281 .as_ref()
282 .map(|command| command.command.as_str()),
283 Some("cargo test -p imp-core")
284 );
285 }
286
287 #[test]
288 fn verification_gate_serde_roundtrip_preserves_status_and_artifacts() {
289 let mut gate = VerificationGate::command("fmt", "cargo fmt --check");
290 gate.source = VerificationGateSource::ManaTask {
291 unit_id: Some("394.7.2".into()),
292 };
293 gate.artifacts.push(VerificationArtifactRef::new(
294 "stdout",
295 ".imp/runs/run_1/verification/fmt/stdout.log",
296 ));
297 gate.mark_failed(VerificationGateResult::failed(1));
298
299 let json = serde_json::to_string(&gate).unwrap();
300 let decoded: VerificationGate = serde_json::from_str(&json).unwrap();
301 assert_eq!(decoded, gate);
302 assert_eq!(decoded.status, VerificationGateStatus::Failed);
303 assert_eq!(
304 decoded.closeout_effect(),
305 VerificationCloseoutEffect::BlocksDoneWithConcerns
306 );
307 }
308
309 #[test]
310 fn verification_gate_from_requirement_maps_required_and_optional() {
311 let required = VerificationRequirement::command("cargo test");
312 let gate = VerificationGate::from_requirement(0, &required);
313 assert_eq!(gate.id, "verify-1");
314 assert_eq!(gate.requirement, VerificationGateRequirement::Required);
315 assert_eq!(gate.kind, VerificationGateKind::Command);
316
317 let optional = VerificationRequirement {
318 name: Some("manual smoke".into()),
319 kind: VerificationRequirementKind::Manual,
320 required: false,
321 };
322 let gate = VerificationGate::from_requirement(1, &optional);
323 assert_eq!(gate.id, "verify-2");
324 assert_eq!(gate.name, "manual smoke");
325 assert_eq!(gate.kind, VerificationGateKind::Manual);
326 assert_eq!(gate.requirement, VerificationGateRequirement::Optional);
327 assert_eq!(
328 gate.closeout_effect(),
329 VerificationCloseoutEffect::AllowsDone
330 );
331 }
332
333 #[test]
334 fn verification_gate_status_transitions_update_closeout_effect() {
335 let mut gate = VerificationGate::command("test", "cargo test");
336 assert_eq!(
337 gate.closeout_effect(),
338 VerificationCloseoutEffect::BlocksDoneWithConcerns
339 );
340 gate.mark_running();
341 assert_eq!(gate.status, VerificationGateStatus::Running);
342 gate.mark_passed(VerificationGateResult::passed(0));
343 assert_eq!(gate.status, VerificationGateStatus::Passed);
344 assert_eq!(
345 gate.closeout_effect(),
346 VerificationCloseoutEffect::AllowsDone
347 );
348
349 gate.mark_failed(VerificationGateResult::failed(101));
350 assert_eq!(gate.status, VerificationGateStatus::Failed);
351 assert_eq!(
352 gate.closeout_effect(),
353 VerificationCloseoutEffect::BlocksDoneWithConcerns
354 );
355
356 gate.mark_blocked("missing cargo");
357 assert_eq!(gate.status, VerificationGateStatus::Blocked);
358 assert_eq!(gate.reason.as_deref(), Some("missing cargo"));
359 assert_eq!(
360 gate.closeout_effect(),
361 VerificationCloseoutEffect::BlocksDone
362 );
363 }
364}