Skip to main content

aoc_runtime/
process.rs

1//! Child process execution.
2//!
3//! Commands are described as data ([`CommandSpec`]) and executed through the
4//! [`CommandRunner`] trait, so the per-language command tables can be asserted
5//! in tests without ever spawning `cargo`, `dotnet` or `javac`.
6
7use std::{
8    ffi::{OsStr, OsString},
9    fmt::Write as _,
10    io,
11    path::{Path, PathBuf},
12    process::{Command, Stdio},
13};
14
15/// A fully described child process invocation.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct CommandSpec {
18    program: OsString,
19    args: Vec<OsString>,
20    current_dir: Option<PathBuf>,
21}
22
23impl CommandSpec {
24    /// Creates a spec for `program` with no arguments.
25    pub fn new(program: impl AsRef<OsStr>) -> Self {
26        Self {
27            program: program.as_ref().to_os_string(),
28            args: Vec::new(),
29            current_dir: None,
30        }
31    }
32
33    /// Appends a single argument.
34    #[must_use]
35    pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
36        self.args.push(arg.as_ref().to_os_string());
37        self
38    }
39
40    /// Appends multiple arguments.
41    #[must_use]
42    pub fn args<I, S>(mut self, args: I) -> Self
43    where
44        I: IntoIterator<Item = S>,
45        S: AsRef<OsStr>,
46    {
47        self.args
48            .extend(args.into_iter().map(|arg| arg.as_ref().to_os_string()));
49        self
50    }
51
52    /// Sets the working directory the command runs in.
53    #[must_use]
54    pub fn current_dir(mut self, dir: impl AsRef<Path>) -> Self {
55        self.current_dir = Some(dir.as_ref().to_path_buf());
56        self
57    }
58
59    /// The program to execute.
60    #[must_use]
61    pub fn program(&self) -> &OsStr {
62        &self.program
63    }
64
65    /// The arguments passed to the program.
66    #[must_use]
67    pub fn arguments(&self) -> &[OsString] {
68        &self.args
69    }
70
71    /// The working directory, if one was set.
72    #[must_use]
73    pub fn working_dir(&self) -> Option<&Path> {
74        self.current_dir.as_deref()
75    }
76
77    /// Renders the invocation for diagnostics, quoting arguments with spaces.
78    #[must_use]
79    pub fn to_display_string(&self) -> String {
80        let mut rendered = self.program.to_string_lossy().into_owned();
81        for arg in &self.args {
82            let arg = arg.to_string_lossy();
83            if arg.contains(char::is_whitespace) {
84                let _ = write!(rendered, " \"{arg}\"");
85            } else {
86                let _ = write!(rendered, " {arg}");
87            }
88        }
89        rendered
90    }
91
92    fn to_command(&self) -> Command {
93        let mut command = Command::new(&self.program);
94        command.args(&self.args);
95        if let Some(dir) = &self.current_dir {
96            command.current_dir(dir);
97        }
98        command
99    }
100}
101
102/// What to do with one of a child process's output streams.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum Stream {
105    /// Capture the stream for inspection.
106    Capture,
107    /// Pass the stream through to this process's own.
108    Inherit,
109}
110
111impl Stream {
112    fn stdio(self) -> Stdio {
113        match self {
114            Self::Capture => Stdio::piped(),
115            Self::Inherit => Stdio::inherit(),
116        }
117    }
118}
119
120/// How a child process's output streams are handled.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct IoPolicy {
123    /// Handling for standard output.
124    pub stdout: Stream,
125    /// Handling for standard error.
126    pub stderr: Stream,
127}
128
129impl IoPolicy {
130    /// Capture everything - used for build steps, whose output is only
131    /// interesting when they fail.
132    pub const SILENT: Self = Self {
133        stdout: Stream::Capture,
134        stderr: Stream::Capture,
135    };
136
137    /// Capture standard output but let standard error through live - used for
138    /// the solution itself, whose stdout carries the answers and whose stderr
139    /// carries progress the user should see as it happens.
140    pub const ANSWER: Self = Self {
141        stdout: Stream::Capture,
142        stderr: Stream::Inherit,
143    };
144
145    /// Pass both streams through - used for scaffolding commands.
146    pub const INHERIT: Self = Self {
147        stdout: Stream::Inherit,
148        stderr: Stream::Inherit,
149    };
150}
151
152/// The captured result of a finished child process.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct CommandOutput {
155    /// The exit code, or `None` if the process was terminated by a signal.
156    pub code: Option<i32>,
157    /// Standard output, lossily decoded as UTF-8, empty if it was inherited.
158    pub stdout: String,
159    /// Standard error, lossily decoded as UTF-8, empty if it was inherited.
160    pub stderr: String,
161}
162
163impl CommandOutput {
164    /// Whether the process exited successfully.
165    #[must_use]
166    pub fn success(&self) -> bool {
167        self.code == Some(0)
168    }
169
170    fn status_description(&self) -> String {
171        self.code.map_or_else(
172            || "terminated by signal".to_owned(),
173            |code| format!("exit code {code}"),
174        )
175    }
176}
177
178/// Executes [`CommandSpec`]s.
179pub trait CommandRunner {
180    /// Runs the command to completion.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`ProcessError::NotFound`] or [`ProcessError::Spawn`] if the
185    /// program could not be started.
186    fn run(&self, spec: &CommandSpec, io: IoPolicy) -> Result<CommandOutput, ProcessError>;
187
188    /// Starts the command without waiting for it to finish.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`ProcessError::NotFound`] or [`ProcessError::Spawn`] if the
193    /// program could not be started.
194    fn spawn_detached(&self, spec: &CommandSpec) -> Result<(), ProcessError>;
195
196    /// Runs the command and fails if it exits unsuccessfully.
197    ///
198    /// # Errors
199    ///
200    /// As [`CommandRunner::run`], plus [`ProcessError::Failed`] if the process
201    /// exited with a non-success status.
202    fn run_checked(&self, spec: &CommandSpec, io: IoPolicy) -> Result<CommandOutput, ProcessError> {
203        let output = self.run(spec, io)?;
204        if output.success() {
205            Ok(output)
206        } else {
207            Err(ProcessError::Failed {
208                command: spec.to_display_string(),
209                status: output.status_description(),
210                stderr: output.stderr.trim_end().to_owned(),
211            })
212        }
213    }
214}
215
216/// Runs commands as real child processes.
217#[derive(Debug, Default, Clone, Copy)]
218pub struct SystemRunner;
219
220impl CommandRunner for SystemRunner {
221    fn run(&self, spec: &CommandSpec, io: IoPolicy) -> Result<CommandOutput, ProcessError> {
222        let output = spec
223            .to_command()
224            .stdin(Stdio::null())
225            .stdout(io.stdout.stdio())
226            .stderr(io.stderr.stdio())
227            .spawn()
228            .map_err(|source| spawn_error(spec, source))?
229            .wait_with_output()
230            .map_err(|source| spawn_error(spec, source))?;
231
232        Ok(CommandOutput {
233            code: output.status.code(),
234            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
235            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
236        })
237    }
238
239    fn spawn_detached(&self, spec: &CommandSpec) -> Result<(), ProcessError> {
240        spec.to_command()
241            .spawn()
242            .map(|_| ())
243            .map_err(|source| spawn_error(spec, source))
244    }
245}
246
247fn spawn_error(spec: &CommandSpec, source: io::Error) -> ProcessError {
248    if source.kind() == io::ErrorKind::NotFound {
249        ProcessError::NotFound {
250            program: spec.program().to_string_lossy().into_owned(),
251        }
252    } else {
253        ProcessError::Spawn {
254            command: spec.to_display_string(),
255            source,
256        }
257    }
258}
259
260/// Errors produced while building or executing a child process.
261#[derive(Debug, thiserror::Error)]
262pub enum ProcessError {
263    /// The program is not installed or not on `PATH`.
264    #[error("`{program}` was not found - is it installed and on your PATH?")]
265    NotFound {
266        /// The program that could not be located.
267        program: String,
268    },
269    /// The process could not be started.
270    #[error("failed to execute `{command}`")]
271    Spawn {
272        /// The rendered invocation.
273        command: String,
274        /// The underlying I/O error.
275        #[source]
276        source: io::Error,
277    },
278    /// The process ran but exited unsuccessfully.
279    #[error("`{command}` failed ({status})\n{stderr}")]
280    Failed {
281        /// The rendered invocation.
282        command: String,
283        /// How the process terminated.
284        status: String,
285        /// Captured standard error.
286        stderr: String,
287    },
288    /// A command needed the project's directory name but the path has none.
289    #[error("project path has no directory name: {path}")]
290    NoDirectoryName {
291        /// The offending path.
292        path: PathBuf,
293    },
294}
295
296#[cfg(test)]
297pub(crate) mod fake {
298    use super::{CommandOutput, CommandRunner, CommandSpec, IoPolicy, ProcessError};
299    use std::cell::RefCell;
300    use std::collections::VecDeque;
301
302    #[derive(Debug, Default)]
303    pub(crate) struct FakeRunner {
304        queued: RefCell<VecDeque<CommandOutput>>,
305        executed: RefCell<Vec<(CommandSpec, IoPolicy)>>,
306        spawned: RefCell<Vec<CommandSpec>>,
307    }
308
309    impl FakeRunner {
310        pub(crate) fn new() -> Self {
311            Self::default()
312        }
313
314        pub(crate) fn push_stdout(&self, stdout: &str) -> &Self {
315            self.queued.borrow_mut().push_back(CommandOutput {
316                code: Some(0),
317                stdout: stdout.to_owned(),
318                stderr: String::new(),
319            });
320            self
321        }
322
323        pub(crate) fn push_failure(&self, code: i32, stderr: &str) -> &Self {
324            self.queued.borrow_mut().push_back(CommandOutput {
325                code: Some(code),
326                stdout: String::new(),
327                stderr: stderr.to_owned(),
328            });
329            self
330        }
331
332        pub(crate) fn executed(&self) -> Vec<CommandSpec> {
333            self.executed
334                .borrow()
335                .iter()
336                .map(|(spec, _)| spec.clone())
337                .collect()
338        }
339
340        pub(crate) fn policies(&self) -> Vec<IoPolicy> {
341            self.executed.borrow().iter().map(|(_, io)| *io).collect()
342        }
343
344        pub(crate) fn spawned(&self) -> Vec<CommandSpec> {
345            self.spawned.borrow().clone()
346        }
347    }
348
349    impl CommandRunner for FakeRunner {
350        fn run(&self, spec: &CommandSpec, io: IoPolicy) -> Result<CommandOutput, ProcessError> {
351            self.executed.borrow_mut().push((spec.clone(), io));
352            Ok(self
353                .queued
354                .borrow_mut()
355                .pop_front()
356                .unwrap_or(CommandOutput {
357                    code: Some(0),
358                    stdout: String::new(),
359                    stderr: String::new(),
360                }))
361        }
362
363        fn spawn_detached(&self, spec: &CommandSpec) -> Result<(), ProcessError> {
364            self.spawned.borrow_mut().push(spec.clone());
365            Ok(())
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn renders_invocation_for_diagnostics() {
376        let spec = CommandSpec::new("cargo")
377            .args(["run", "--release"])
378            .arg("/tmp/my project");
379
380        assert_eq!(
381            spec.to_display_string(),
382            "cargo run --release \"/tmp/my project\""
383        );
384    }
385
386    #[test]
387    fn records_program_arguments_and_directory() {
388        let spec = CommandSpec::new("javac")
389            .arg("Main.java")
390            .current_dir("/tmp/day01");
391
392        assert_eq!(spec.program(), "javac");
393        assert_eq!(spec.arguments(), ["Main.java"]);
394        assert_eq!(spec.working_dir(), Some(Path::new("/tmp/day01")));
395    }
396
397    #[test]
398    fn reports_missing_programs_distinctly() {
399        let spec = CommandSpec::new("definitely-not-a-real-program-9271");
400
401        let error = SystemRunner
402            .run(&spec, IoPolicy::SILENT)
403            .expect_err("program should not exist");
404
405        assert!(
406            matches!(error, ProcessError::NotFound { .. }),
407            "got {error:?}"
408        );
409    }
410
411    #[test]
412    fn captures_output_of_real_processes() {
413        let spec = CommandSpec::new("echo").arg("hello");
414
415        let output = SystemRunner
416            .run_checked(&spec, IoPolicy::SILENT)
417            .expect("echo should succeed");
418
419        assert_eq!(output.stdout.trim_end(), "hello");
420        assert!(output.success());
421    }
422
423    #[test]
424    fn run_checked_surfaces_stderr_of_failing_commands() {
425        let runner = fake::FakeRunner::new();
426        runner.push_failure(101, "error: could not compile\n");
427
428        let error = runner
429            .run_checked(&CommandSpec::new("cargo").arg("build"), IoPolicy::SILENT)
430            .expect_err("command should fail");
431
432        let message = error.to_string();
433        assert!(message.contains("cargo build"), "{message}");
434        assert!(message.contains("exit code 101"), "{message}");
435        assert!(message.contains("could not compile"), "{message}");
436    }
437
438    #[test]
439    fn run_checked_returns_output_on_success() {
440        let runner = fake::FakeRunner::new();
441        runner.push_stdout("42\n");
442
443        let output = runner
444            .run_checked(
445                &CommandSpec::new("python3").arg("main.py"),
446                IoPolicy::ANSWER,
447            )
448            .expect("command should succeed");
449
450        assert_eq!(output.stdout, "42\n");
451        assert_eq!(runner.executed().len(), 1);
452        assert_eq!(runner.policies(), [IoPolicy::ANSWER]);
453    }
454}