Skip to main content

supercov_engine/
orchestration.rs

1//! Language-neutral execution plans over the Rust process supervisor.
2//!
3//! Frontends prepare instrumentation and runtime shims, then provide explicit
4//! commands. This layer owns ordering, fail-fast behavior, timings and the
5//! single persistent signal guard across every external phase.
6
7use std::{io::Write, time::Instant};
8
9use serde::{Deserialize, Serialize};
10
11use crate::process_supervision::{
12    CommandSpec, ForwardedSignal, ProcessSupervisor, SupervisedResult, SupervisionError,
13    SupervisionOptions,
14};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum PhaseKind {
19    Frontend,
20    Build,
21    Test,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ExecutionPhase {
26    pub name: String,
27    pub kind: PhaseKind,
28    pub command: CommandSpec,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ExecutionPlan {
33    pub preparation: Vec<ExecutionPhase>,
34    pub test: ExecutionPhase,
35}
36
37#[derive(Debug)]
38pub enum OrchestrationError {
39    InvalidPlan(String),
40    PhaseSetup { phase: String, reason: String },
41    Supervision(SupervisionError),
42}
43
44impl std::fmt::Display for OrchestrationError {
45    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::InvalidPlan(reason) => write!(formatter, "invalid execution plan: {reason}"),
48            Self::PhaseSetup { phase, reason } => {
49                write!(formatter, "could not prepare {phase} phase: {reason}")
50            }
51            Self::Supervision(error) => write!(formatter, "{error}"),
52        }
53    }
54}
55
56impl std::error::Error for OrchestrationError {}
57
58impl From<SupervisionError> for OrchestrationError {
59    fn from(value: SupervisionError) -> Self {
60        Self::Supervision(value)
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct PhaseExecution {
67    pub name: String,
68    pub kind: PhaseKind,
69    pub duration_ms: u64,
70    pub result: SupervisedResult,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct ExecutionResult {
76    pub phases: Vec<PhaseExecution>,
77    pub exit_code: i32,
78    pub interrupted_signal: Option<ForwardedSignal>,
79}
80
81fn duration_milliseconds(started: Instant) -> u64 {
82    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
83}
84
85fn validate(plan: &ExecutionPlan) -> Result<(), OrchestrationError> {
86    if plan.test.kind != PhaseKind::Test {
87        return Err(OrchestrationError::InvalidPlan(
88            "the terminal command must be a test phase".into(),
89        ));
90    }
91    if plan.test.name.trim().is_empty() {
92        return Err(OrchestrationError::InvalidPlan(
93            "phase names must not be empty".into(),
94        ));
95    }
96    for phase in &plan.preparation {
97        if phase.kind == PhaseKind::Test {
98            return Err(OrchestrationError::InvalidPlan(
99                "a test phase cannot appear before the terminal test command".into(),
100            ));
101        }
102        if phase.name.trim().is_empty() {
103            return Err(OrchestrationError::InvalidPlan(
104                "phase names must not be empty".into(),
105            ));
106        }
107    }
108    Ok(())
109}
110
111pub fn execute_plan(
112    plan: &ExecutionPlan,
113    options: SupervisionOptions,
114    writer: &mut dyn Write,
115    mut before_phase: impl FnMut(&ExecutionPhase, &mut dyn Write) -> Result<(), OrchestrationError>,
116) -> Result<ExecutionResult, OrchestrationError> {
117    validate(plan)?;
118    // One guard spans every external phase. Signals received after a build
119    // exits but before the test spawns remain pending and prevent that spawn.
120    let supervisor = ProcessSupervisor::new()?;
121    let mut executions = Vec::new();
122    for phase in plan.preparation.iter().chain(std::iter::once(&plan.test)) {
123        before_phase(phase, writer)?;
124        let started = Instant::now();
125        let result = supervisor.supervise(&phase.command, options, writer)?;
126        let exit_code = result.exit_code();
127        let interrupted_signal = result.interrupted_signal;
128        executions.push(PhaseExecution {
129            name: phase.name.clone(),
130            kind: phase.kind,
131            duration_ms: duration_milliseconds(started),
132            result,
133        });
134        if exit_code != 0 {
135            return Ok(ExecutionResult {
136                phases: executions,
137                exit_code,
138                interrupted_signal,
139            });
140        }
141    }
142    Ok(ExecutionResult {
143        phases: executions,
144        exit_code: 0,
145        interrupted_signal: None,
146    })
147}
148
149#[cfg(test)]
150mod tests {
151    use std::{
152        ffi::OsString,
153        fs,
154        path::{Path, PathBuf},
155        time::{SystemTime, UNIX_EPOCH},
156    };
157
158    use super::*;
159
160    fn temporary() -> PathBuf {
161        let nonce = SystemTime::now()
162            .duration_since(UNIX_EPOCH)
163            .unwrap()
164            .as_nanos();
165        let path = std::env::temp_dir().join(format!(
166            "supercov-orchestration-{}-{nonce}",
167            std::process::id()
168        ));
169        fs::create_dir_all(&path).unwrap();
170        path
171    }
172
173    fn shell(root: &Path, name: &str, script: &str) -> ExecutionPhase {
174        ExecutionPhase {
175            name: name.into(),
176            kind: if name == "test" {
177                PhaseKind::Test
178            } else {
179                PhaseKind::Build
180            },
181            command: CommandSpec {
182                program: OsString::from("/bin/sh"),
183                arguments: vec![OsString::from("-c"), OsString::from(script)],
184                cwd: root.into(),
185                environment: None,
186            },
187        }
188    }
189
190    #[cfg(unix)]
191    #[test]
192    fn executes_build_then_test_and_reports_each_result() {
193        let root = temporary();
194        let order = root.join("order");
195        let plan = ExecutionPlan {
196            preparation: vec![shell(&root, "build", "printf build > order")],
197            test: shell(&root, "test", "printf -- '-test' >> order"),
198        };
199        let mut seen = Vec::new();
200        let result = execute_plan(
201            &plan,
202            SupervisionOptions::default(),
203            &mut Vec::new(),
204            |phase, _| {
205                seen.push(phase.kind);
206                Ok(())
207            },
208        )
209        .unwrap();
210        assert_eq!(result.exit_code, 0);
211        assert_eq!(seen, [PhaseKind::Build, PhaseKind::Test]);
212        assert_eq!(fs::read_to_string(order).unwrap(), "build-test");
213        assert_eq!(result.phases.len(), 2);
214        fs::remove_dir_all(root).unwrap();
215    }
216
217    #[cfg(unix)]
218    #[test]
219    fn a_failed_build_never_starts_the_test() {
220        let root = temporary();
221        let plan = ExecutionPlan {
222            preparation: vec![shell(&root, "build", "exit 7")],
223            test: shell(&root, "test", "touch incorrectly-started"),
224        };
225        let result = execute_plan(
226            &plan,
227            SupervisionOptions::default(),
228            &mut Vec::new(),
229            |_, _| Ok(()),
230        )
231        .unwrap();
232        assert_eq!(result.exit_code, 7);
233        assert_eq!(result.phases.len(), 1);
234        assert!(!root.join("incorrectly-started").exists());
235        fs::remove_dir_all(root).unwrap();
236    }
237
238    #[test]
239    fn rejects_an_ambiguous_or_nonterminal_test_plan_before_spawning() {
240        let root = temporary();
241        let mut invalid_test = shell(&root, "test", "touch incorrectly-started");
242        invalid_test.kind = PhaseKind::Build;
243        let plan = ExecutionPlan {
244            preparation: Vec::new(),
245            test: invalid_test,
246        };
247        let result = execute_plan(
248            &plan,
249            SupervisionOptions::default(),
250            &mut Vec::new(),
251            |_, _| Ok(()),
252        );
253        assert!(matches!(result, Err(OrchestrationError::InvalidPlan(_))));
254        assert!(!root.join("incorrectly-started").exists());
255        fs::remove_dir_all(root).unwrap();
256    }
257}