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 console: None,
257 },
258 }
259 }
260
261 #[cfg(unix)]
262 #[test]
263 fn executes_build_then_test_and_reports_each_result() {
264 let root = temporary();
265 let order = root.join("order");
266 let plan = ExecutionPlan {
267 preparation: vec![shell(&root, "build", "printf build > order")],
268 test: shell(&root, "test", "printf -- '-test' >> order"),
269 };
270 let mut seen = Vec::new();
271 let result = execute_plan(
272 &plan,
273 SupervisionOptions::default(),
274 &mut Vec::new(),
275 |phase, _| {
276 seen.push(phase.kind);
277 Ok(())
278 },
279 )
280 .unwrap();
281 assert_eq!(result.exit_code, 0);
282 assert_eq!(seen, [PhaseKind::Build, PhaseKind::Test]);
283 assert_eq!(fs::read_to_string(order).unwrap(), "build-test");
284 assert_eq!(result.phases.len(), 2);
285 fs::remove_dir_all(root).unwrap();
286 }
287
288 #[cfg(unix)]
289 #[test]
290 fn a_failed_build_never_starts_the_test() {
291 let root = temporary();
292 let plan = ExecutionPlan {
293 preparation: vec![shell(&root, "build", "exit 7")],
294 test: shell(&root, "test", "touch incorrectly-started"),
295 };
296 let result = execute_plan(
297 &plan,
298 SupervisionOptions::default(),
299 &mut Vec::new(),
300 |_, _| Ok(()),
301 )
302 .unwrap();
303 assert_eq!(result.exit_code, 7);
304 assert_eq!(result.phases.len(), 1);
305 assert!(!root.join("incorrectly-started").exists());
306 fs::remove_dir_all(root).unwrap();
307 }
308
309 #[test]
310 fn rejects_an_ambiguous_or_nonterminal_test_plan_before_spawning() {
311 let root = temporary();
312 let mut invalid_test = shell(&root, "test", "touch incorrectly-started");
313 invalid_test.kind = PhaseKind::Build;
314 let plan = ExecutionPlan {
315 preparation: Vec::new(),
316 test: invalid_test,
317 };
318 let result = execute_plan(
319 &plan,
320 SupervisionOptions::default(),
321 &mut Vec::new(),
322 |_, _| Ok(()),
323 );
324 assert!(matches!(result, Err(OrchestrationError::InvalidPlan(_))));
325 assert!(!root.join("incorrectly-started").exists());
326 fs::remove_dir_all(root).unwrap();
327 }
328
329 #[cfg(unix)]
330 #[test]
331 fn successful_preparation_is_quiet_but_failed_output_is_retained() {
332 let root = temporary();
333 let successful_log = root.join("successful.log");
334 let mut successful = shell(&root, "build", "printf noisy-success");
335 successful.command.captured_output = Some(successful_log.clone());
336 let mut writer = Vec::new();
337 let result = execute_plan(
338 &ExecutionPlan {
339 preparation: vec![successful],
340 test: shell(&root, "test", "exit 0"),
341 },
342 SupervisionOptions::default(),
343 &mut writer,
344 |_, _| Ok(()),
345 )
346 .unwrap();
347 assert_eq!(result.exit_code, 0);
348 assert!(writer.is_empty());
349 assert!(!successful_log.exists());
350
351 let failed_log = root.join("failed.log");
352 let mut failed = shell(&root, "build", "printf useful-failure; exit 7");
353 failed.command.captured_output = Some(failed_log.clone());
354 let result = execute_plan(
355 &ExecutionPlan {
356 preparation: vec![failed],
357 test: shell(&root, "test", "exit 0"),
358 },
359 SupervisionOptions::default(),
360 &mut writer,
361 |_, _| Ok(()),
362 )
363 .unwrap();
364 assert_eq!(result.exit_code, 7);
365 assert!(
366 String::from_utf8(writer)
367 .unwrap()
368 .contains("useful-failure")
369 );
370 assert!(!failed_log.exists());
371 fs::remove_dir_all(root).unwrap();
372 }
373}