1use 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 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}