1use std::{
8 fs::{self, File},
9 io::{Read, Seek, SeekFrom, Write},
10 path::Path,
11 time::Instant,
12};
13
14use serde::{Deserialize, Serialize};
15
16use crate::process_supervision::{
17 CommandSpec, ForwardedSignal, ProcessSupervisor, SupervisedResult, SupervisionError,
18 SupervisionOptions,
19};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum PhaseKind {
24 Frontend,
25 Build,
26 Test,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ExecutionPhase {
31 pub name: String,
32 pub kind: PhaseKind,
33 pub command: CommandSpec,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ExecutionPlan {
38 pub preparation: Vec<ExecutionPhase>,
39 pub test: ExecutionPhase,
40}
41
42#[derive(Debug)]
43pub enum OrchestrationError {
44 InvalidPlan(String),
45 PhaseSetup { phase: String, reason: String },
46 Supervision(SupervisionError),
47}
48
49impl std::fmt::Display for OrchestrationError {
50 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 Self::InvalidPlan(reason) => write!(formatter, "invalid execution plan: {reason}"),
53 Self::PhaseSetup { phase, reason } => {
54 write!(formatter, "could not prepare {phase} phase: {reason}")
55 }
56 Self::Supervision(error) => write!(formatter, "{error}"),
57 }
58 }
59}
60
61impl std::error::Error for OrchestrationError {}
62
63impl From<SupervisionError> for OrchestrationError {
64 fn from(value: SupervisionError) -> Self {
65 Self::Supervision(value)
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct PhaseExecution {
72 pub name: String,
73 pub kind: PhaseKind,
74 pub duration_ms: u64,
75 pub result: SupervisedResult,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct ExecutionResult {
81 pub phases: Vec<PhaseExecution>,
82 pub exit_code: i32,
83 pub interrupted_signal: Option<ForwardedSignal>,
84}
85
86fn duration_milliseconds(started: Instant) -> u64 {
87 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
88}
89
90const CAPTURED_OUTPUT_LIMIT: u64 = 1024 * 1024;
91
92fn verbose_output() -> bool {
93 std::env::var("SUPERCOV_VERBOSE")
94 .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
95 .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"))
96}
97
98fn publish_captured_output(path: &Path, phase: &str, failed: bool, writer: &mut dyn Write) {
99 if failed || verbose_output() {
100 let result = (|| -> std::io::Result<()> {
101 let mut file = File::open(path)?;
102 let length = file.metadata()?.len();
103 if length > CAPTURED_OUTPUT_LIMIT {
104 file.seek(SeekFrom::End(-(CAPTURED_OUTPUT_LIMIT as i64)))?;
105 writeln!(
106 writer,
107 "[supercov] {phase} output truncated to the final {} bytes",
108 CAPTURED_OUTPUT_LIMIT
109 )?;
110 }
111 let mut buffer = Vec::with_capacity(length.min(CAPTURED_OUTPUT_LIMIT) as usize);
112 file.read_to_end(&mut buffer)?;
113 writer.write_all(&buffer)?;
114 writer.flush()
115 })();
116 if let Err(error) = result {
117 let _ = writeln!(writer, "[supercov] could not read {phase} output: {error}");
118 }
119 }
120 let _ = fs::remove_file(path);
121}
122
123fn validate(plan: &ExecutionPlan) -> Result<(), OrchestrationError> {
124 if plan.test.kind != PhaseKind::Test {
125 return Err(OrchestrationError::InvalidPlan(
126 "the terminal command must be a test phase".into(),
127 ));
128 }
129 if plan.test.name.trim().is_empty() {
130 return Err(OrchestrationError::InvalidPlan(
131 "phase names must not be empty".into(),
132 ));
133 }
134 for phase in &plan.preparation {
135 if phase.kind == PhaseKind::Test {
136 return Err(OrchestrationError::InvalidPlan(
137 "a test phase cannot appear before the terminal test command".into(),
138 ));
139 }
140 if phase.name.trim().is_empty() {
141 return Err(OrchestrationError::InvalidPlan(
142 "phase names must not be empty".into(),
143 ));
144 }
145 }
146 Ok(())
147}
148
149pub fn execute_plan(
150 plan: &ExecutionPlan,
151 options: SupervisionOptions,
152 writer: &mut dyn Write,
153 before_phase: impl FnMut(&ExecutionPhase, &mut dyn Write) -> Result<(), OrchestrationError>,
154) -> Result<ExecutionResult, OrchestrationError> {
155 let supervisor = ProcessSupervisor::new()?;
156 execute_plan_with_supervisor(&supervisor, plan, options, writer, before_phase)
157}
158
159pub fn execute_plan_with_supervisor(
160 supervisor: &ProcessSupervisor,
161 plan: &ExecutionPlan,
162 options: SupervisionOptions,
163 writer: &mut dyn Write,
164 mut before_phase: impl FnMut(&ExecutionPhase, &mut dyn Write) -> Result<(), OrchestrationError>,
165) -> Result<ExecutionResult, OrchestrationError> {
166 validate(plan)?;
167 let mut executions = Vec::new();
170 for phase in plan.preparation.iter().chain(std::iter::once(&plan.test)) {
171 before_phase(phase, writer)?;
172 let started = Instant::now();
173 let result = match supervisor.supervise(&phase.command, options, writer) {
174 Ok(result) => result,
175 Err(error) => {
176 if let Some(path) = &phase.command.captured_output {
177 publish_captured_output(path, &phase.name, true, writer);
178 }
179 return Err(error.into());
180 }
181 };
182 let exit_code = result.exit_code();
183 if let Some(path) = &phase.command.captured_output {
184 publish_captured_output(path, &phase.name, exit_code != 0, writer);
185 }
186 let interrupted_signal = result.interrupted_signal;
187 executions.push(PhaseExecution {
188 name: phase.name.clone(),
189 kind: phase.kind,
190 duration_ms: duration_milliseconds(started),
191 result,
192 });
193 if exit_code != 0 {
194 return Ok(ExecutionResult {
195 phases: executions,
196 exit_code,
197 interrupted_signal,
198 });
199 }
200 }
201 Ok(ExecutionResult {
202 phases: executions,
203 exit_code: 0,
204 interrupted_signal: None,
205 })
206}
207
208#[cfg(test)]
209mod tests {
210 use std::{
211 ffi::OsString,
212 fs,
213 path::{Path, PathBuf},
214 sync::atomic::{AtomicU64, Ordering},
215 time::{SystemTime, UNIX_EPOCH},
216 };
217
218 use super::*;
219
220 fn temporary() -> PathBuf {
221 static UNIQUE: AtomicU64 = AtomicU64::new(0);
229 let nonce = SystemTime::now()
230 .duration_since(UNIX_EPOCH)
231 .unwrap()
232 .as_nanos();
233 let path = std::env::temp_dir().join(format!(
234 "supercov-orchestration-{}-{nonce}-{}",
235 std::process::id(),
236 UNIQUE.fetch_add(1, Ordering::Relaxed)
237 ));
238 fs::create_dir(&path).unwrap();
239 path
240 }
241
242 fn shell(root: &Path, name: &str, script: &str) -> ExecutionPhase {
243 ExecutionPhase {
244 name: name.into(),
245 kind: if name == "test" {
246 PhaseKind::Test
247 } else {
248 PhaseKind::Build
249 },
250 command: CommandSpec {
251 program: OsString::from("/bin/sh"),
252 arguments: vec![OsString::from("-c"), OsString::from(script)],
253 cwd: root.into(),
254 environment: None,
255 captured_output: None,
256 },
257 }
258 }
259
260 #[cfg(unix)]
261 #[test]
262 fn executes_build_then_test_and_reports_each_result() {
263 let root = temporary();
264 let order = root.join("order");
265 let plan = ExecutionPlan {
266 preparation: vec![shell(&root, "build", "printf build > order")],
267 test: shell(&root, "test", "printf -- '-test' >> order"),
268 };
269 let mut seen = Vec::new();
270 let result = execute_plan(
271 &plan,
272 SupervisionOptions::default(),
273 &mut Vec::new(),
274 |phase, _| {
275 seen.push(phase.kind);
276 Ok(())
277 },
278 )
279 .unwrap();
280 assert_eq!(result.exit_code, 0);
281 assert_eq!(seen, [PhaseKind::Build, PhaseKind::Test]);
282 assert_eq!(fs::read_to_string(order).unwrap(), "build-test");
283 assert_eq!(result.phases.len(), 2);
284 fs::remove_dir_all(root).unwrap();
285 }
286
287 #[cfg(unix)]
288 #[test]
289 fn a_failed_build_never_starts_the_test() {
290 let root = temporary();
291 let plan = ExecutionPlan {
292 preparation: vec![shell(&root, "build", "exit 7")],
293 test: shell(&root, "test", "touch incorrectly-started"),
294 };
295 let result = execute_plan(
296 &plan,
297 SupervisionOptions::default(),
298 &mut Vec::new(),
299 |_, _| Ok(()),
300 )
301 .unwrap();
302 assert_eq!(result.exit_code, 7);
303 assert_eq!(result.phases.len(), 1);
304 assert!(!root.join("incorrectly-started").exists());
305 fs::remove_dir_all(root).unwrap();
306 }
307
308 #[test]
309 fn rejects_an_ambiguous_or_nonterminal_test_plan_before_spawning() {
310 let root = temporary();
311 let mut invalid_test = shell(&root, "test", "touch incorrectly-started");
312 invalid_test.kind = PhaseKind::Build;
313 let plan = ExecutionPlan {
314 preparation: Vec::new(),
315 test: invalid_test,
316 };
317 let result = execute_plan(
318 &plan,
319 SupervisionOptions::default(),
320 &mut Vec::new(),
321 |_, _| Ok(()),
322 );
323 assert!(matches!(result, Err(OrchestrationError::InvalidPlan(_))));
324 assert!(!root.join("incorrectly-started").exists());
325 fs::remove_dir_all(root).unwrap();
326 }
327
328 #[cfg(unix)]
329 #[test]
330 fn successful_preparation_is_quiet_but_failed_output_is_retained() {
331 let root = temporary();
332 let successful_log = root.join("successful.log");
333 let mut successful = shell(&root, "build", "printf noisy-success");
334 successful.command.captured_output = Some(successful_log.clone());
335 let mut writer = Vec::new();
336 let result = execute_plan(
337 &ExecutionPlan {
338 preparation: vec![successful],
339 test: shell(&root, "test", "exit 0"),
340 },
341 SupervisionOptions::default(),
342 &mut writer,
343 |_, _| Ok(()),
344 )
345 .unwrap();
346 assert_eq!(result.exit_code, 0);
347 assert!(writer.is_empty());
348 assert!(!successful_log.exists());
349
350 let failed_log = root.join("failed.log");
351 let mut failed = shell(&root, "build", "printf useful-failure; exit 7");
352 failed.command.captured_output = Some(failed_log.clone());
353 let result = execute_plan(
354 &ExecutionPlan {
355 preparation: vec![failed],
356 test: shell(&root, "test", "exit 0"),
357 },
358 SupervisionOptions::default(),
359 &mut writer,
360 |_, _| Ok(()),
361 )
362 .unwrap();
363 assert_eq!(result.exit_code, 7);
364 assert!(
365 String::from_utf8(writer)
366 .unwrap()
367 .contains("useful-failure")
368 );
369 assert!(!failed_log.exists());
370 fs::remove_dir_all(root).unwrap();
371 }
372}