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