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::{
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    // One guard spans every external phase. Signals received after a build
168    // exits but before the test spawns remain pending and prevent that spawn.
169    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        // The clock ticks once per microsecond, so four tests that start
222        // together were drawing the same directory: one test read another's
223        // `successful.log` and another's command exited nonzero because its
224        // script had just been deleted by a neighbour's cleanup. The counter is
225        // what makes each name its own, and `create_dir` -- not
226        // `create_dir_all` -- means a repeat is a failure here rather than a
227        // mystery somewhere else.
228        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}