Skip to main content

edda_conductor/check/
engine.rs

1use crate::check::CheckOutput;
2use crate::plan::schema::CheckSpec;
3use crate::state::machine::{CheckResult, CheckStatus, ErrorInfo, ErrorType};
4use std::path::PathBuf;
5
6/// Result of running all checks for a phase.
7#[derive(Debug)]
8pub struct CheckRunResult {
9    pub all_passed: bool,
10    pub results: Vec<CheckResult>,
11    pub error: Option<ErrorInfo>,
12}
13
14/// Check engine: runs check specs against the filesystem.
15pub struct CheckEngine {
16    cwd: PathBuf,
17}
18
19impl CheckEngine {
20    pub fn new(cwd: PathBuf) -> Self {
21        Self { cwd }
22    }
23
24    /// Run all checks in order. Short-circuit on first failure.
25    pub async fn run_all(
26        &self,
27        checks: &[CheckSpec],
28        phase_started_at: Option<&str>,
29    ) -> CheckRunResult {
30        let mut results = Vec::new();
31
32        for (i, spec) in checks.iter().enumerate() {
33            let output = self.run_one(spec, phase_started_at).await;
34            let status = if output.passed {
35                CheckStatus::Passed
36            } else {
37                CheckStatus::Failed
38            };
39
40            results.push(CheckResult {
41                check_type: spec.type_name().to_string(),
42                status,
43                detail: output.detail.clone(),
44                duration_ms: output.duration.as_millis() as u64,
45            });
46
47            if !output.passed {
48                // Mark remaining as Waiting
49                for check in &checks[(i + 1)..] {
50                    results.push(CheckResult {
51                        check_type: check.type_name().to_string(),
52                        status: CheckStatus::Waiting,
53                        detail: None,
54                        duration_ms: 0,
55                    });
56                }
57                return CheckRunResult {
58                    all_passed: false,
59                    results,
60                    error: Some(ErrorInfo {
61                        error_type: ErrorType::CheckFailed,
62                        message: output
63                            .detail
64                            .unwrap_or_else(|| format!("check {} failed", spec.type_name())),
65                        retryable: spec.is_retryable(),
66                        check_index: Some(i),
67                        timestamp: now_rfc3339(),
68                    }),
69                };
70            }
71        }
72
73        CheckRunResult {
74            all_passed: true,
75            results,
76            error: None,
77        }
78    }
79
80    /// Run a single check spec.
81    async fn run_one(&self, spec: &CheckSpec, phase_started_at: Option<&str>) -> CheckOutput {
82        match spec {
83            CheckSpec::FileExists { path } => {
84                crate::check::file_exists::check_file_exists(path, &self.cwd)
85            }
86            CheckSpec::CmdSucceeds { cmd, timeout_sec } => {
87                crate::check::cmd_succeeds::check_cmd_succeeds(cmd, *timeout_sec, &self.cwd).await
88            }
89            CheckSpec::FileContains { path, pattern } => {
90                crate::check::file_contains::check_file_contains(path, pattern, &self.cwd)
91            }
92            CheckSpec::GitClean { allow_untracked } => {
93                crate::check::git_clean::check_git_clean(*allow_untracked, &self.cwd).await
94            }
95            CheckSpec::EddaEvent { event_type, after } => {
96                let after_val = after.as_deref().map(|a| {
97                    if a == "$phase_start" {
98                        phase_started_at.unwrap_or("")
99                    } else {
100                        a
101                    }
102                });
103                crate::check::edda_event::check_edda_event(event_type, after_val, &self.cwd).await
104            }
105            CheckSpec::WaitUntil {
106                check,
107                interval_sec,
108                timeout_sec,
109                backoff,
110            } => {
111                crate::check::wait_until::check_wait_until(
112                    check,
113                    *interval_sec,
114                    *timeout_sec,
115                    *backoff,
116                    &self.cwd,
117                    phase_started_at,
118                )
119                .await
120            }
121        }
122    }
123}
124
125fn now_rfc3339() -> String {
126    time::OffsetDateTime::now_utc()
127        .format(&time::format_description::well_known::Rfc3339)
128        .unwrap_or_default()
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::plan::schema::CheckSpec;
135
136    #[tokio::test]
137    async fn empty_checks_pass() {
138        let dir = tempfile::tempdir().unwrap();
139        let engine = CheckEngine::new(dir.path().to_path_buf());
140        let result = engine.run_all(&[], None).await;
141        assert!(result.all_passed);
142        assert!(result.results.is_empty());
143    }
144
145    #[tokio::test]
146    async fn file_exists_check_passes() {
147        let dir = tempfile::tempdir().unwrap();
148        std::fs::write(dir.path().join("test.txt"), "hello").unwrap();
149
150        let engine = CheckEngine::new(dir.path().to_path_buf());
151        let checks = vec![CheckSpec::FileExists {
152            path: "test.txt".into(),
153        }];
154        let result = engine.run_all(&checks, None).await;
155        assert!(result.all_passed);
156        assert_eq!(result.results.len(), 1);
157        assert_eq!(result.results[0].status, CheckStatus::Passed);
158    }
159
160    #[tokio::test]
161    async fn file_exists_check_fails() {
162        let dir = tempfile::tempdir().unwrap();
163        let engine = CheckEngine::new(dir.path().to_path_buf());
164        let checks = vec![CheckSpec::FileExists {
165            path: "nonexistent.txt".into(),
166        }];
167        let result = engine.run_all(&checks, None).await;
168        assert!(!result.all_passed);
169        assert!(result.error.is_some());
170    }
171
172    #[tokio::test]
173    async fn short_circuit_on_failure() {
174        let dir = tempfile::tempdir().unwrap();
175        let engine = CheckEngine::new(dir.path().to_path_buf());
176
177        let checks = vec![
178            CheckSpec::FileExists {
179                path: "nonexistent.txt".into(),
180            },
181            CheckSpec::FileExists {
182                path: "also-missing.txt".into(),
183            },
184        ];
185        let result = engine.run_all(&checks, None).await;
186        assert!(!result.all_passed);
187        assert_eq!(result.results.len(), 2);
188        assert_eq!(result.results[0].status, CheckStatus::Failed);
189        assert_eq!(result.results[1].status, CheckStatus::Waiting); // short-circuited
190    }
191}