1use std::{
8 ffi::{OsStr, OsString},
9 fmt::Write as _,
10 io,
11 path::{Path, PathBuf},
12 process::{Command, Stdio},
13};
14
15#[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 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 #[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 #[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 #[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 #[must_use]
61 pub fn program(&self) -> &OsStr {
62 &self.program
63 }
64
65 #[must_use]
67 pub fn arguments(&self) -> &[OsString] {
68 &self.args
69 }
70
71 #[must_use]
73 pub fn working_dir(&self) -> Option<&Path> {
74 self.current_dir.as_deref()
75 }
76
77 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum Stream {
105 Capture,
107 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct IoPolicy {
123 pub stdout: Stream,
125 pub stderr: Stream,
127}
128
129impl IoPolicy {
130 pub const SILENT: Self = Self {
133 stdout: Stream::Capture,
134 stderr: Stream::Capture,
135 };
136
137 pub const ANSWER: Self = Self {
141 stdout: Stream::Capture,
142 stderr: Stream::Inherit,
143 };
144
145 pub const INHERIT: Self = Self {
147 stdout: Stream::Inherit,
148 stderr: Stream::Inherit,
149 };
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct CommandOutput {
155 pub code: Option<i32>,
157 pub stdout: String,
159 pub stderr: String,
161}
162
163impl CommandOutput {
164 #[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
178pub trait CommandRunner {
180 fn run(&self, spec: &CommandSpec, io: IoPolicy) -> Result<CommandOutput, ProcessError>;
187
188 fn spawn_detached(&self, spec: &CommandSpec) -> Result<(), ProcessError>;
195
196 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#[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#[derive(Debug, thiserror::Error)]
262pub enum ProcessError {
263 #[error("`{program}` was not found - is it installed and on your PATH?")]
265 NotFound {
266 program: String,
268 },
269 #[error("failed to execute `{command}`")]
271 Spawn {
272 command: String,
274 #[source]
276 source: io::Error,
277 },
278 #[error("`{command}` failed ({status})\n{stderr}")]
280 Failed {
281 command: String,
283 status: String,
285 stderr: String,
287 },
288 #[error("project path has no directory name: {path}")]
290 NoDirectoryName {
291 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}