Skip to main content

imp_core/workflow/
verification.rs

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}