Skip to main content

isothrum_runner/
process.rs

1//! Running the tools a manifest names: the [`ProcessRunner`] port. [`SystemProcesses`] starts
2//! real processes (never through a shell, headless on Windows, each tool in a process group or
3//! job object so a timeout or a cancellation ends the whole tree it started); the `fake` feature's
4//! `fake::FakeProcesses` answers from a test's script instead.
5
6mod elevated;
7#[cfg(any(test, feature = "fake"))]
8pub mod fake;
9mod lines;
10mod shim;
11mod system;
12mod tail;
13
14use std::path::PathBuf;
15
16pub(crate) use elevated::Elevated;
17pub use system::SystemProcesses;
18pub(crate) use tail::Tail;
19
20use crate::BoxFuture;
21use crate::events::Stream;
22
23/// One process to start.
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct ProcessSpec {
27    /// The program: a bare tool name, found on PATH.
28    pub program: String,
29    /// Its arguments, passed as they are (no shell ever reads them).
30    pub args: Vec<String>,
31    /// Its working folder (default: the runner's).
32    pub cwd: Option<PathBuf>,
33    /// Variables added to the runner's environment.
34    pub env: Vec<(String, String)>,
35    /// A file piped into its standard input (default: none).
36    pub stdin: Option<PathBuf>,
37}
38
39impl ProcessSpec {
40    /// `program` with `args`, everything else at its default.
41    pub fn new(program: &str, args: impl IntoIterator<Item = impl Into<String>>) -> ProcessSpec {
42        ProcessSpec {
43            program: program.to_owned(),
44            args: args.into_iter().map(Into::into).collect(),
45            ..ProcessSpec::default()
46        }
47    }
48
49    /// The command as one line (`docker compose ps`), for messages and tests.
50    #[must_use]
51    pub fn line(&self) -> String {
52        command_line(&self.program, &self.args)
53    }
54}
55
56/// How a process ended.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub struct Exit {
60    /// Its exit code, when it exited (not killed by a signal).
61    pub code: Option<i32>,
62}
63
64impl Exit {
65    /// A process that ended with `code` (`None`: killed by a signal).
66    #[must_use]
67    pub fn new(code: Option<i32>) -> Exit {
68        Exit { code }
69    }
70
71    /// Whether it exited with 0.
72    #[must_use]
73    pub fn success(self) -> bool {
74        self.code == Some(0)
75    }
76}
77
78/// Why a process couldn't run to its end.
79#[derive(Debug, thiserror::Error)]
80#[non_exhaustive]
81pub enum ProcessError {
82    /// The program isn't on PATH.
83    #[error("`{program}` isn't installed or isn't on PATH")]
84    Missing {
85        /// The program.
86        program: String,
87    },
88    /// An argument a Windows `cmd /C` wrapper would read as an operator.
89    #[error("refusing to pass `{arg}` to {program}: it holds a character cmd.exe would interpret")]
90    Refused {
91        /// The program.
92        program: String,
93        /// The argument, escaped.
94        arg: String,
95    },
96    /// It needed elevation (Hyper-V's), which couldn't be had: the prompt declined, the
97    /// elevated session ended.
98    #[error("elevation: {reason}")]
99    Elevation {
100        /// Why.
101        reason: String,
102    },
103    /// Starting it, piping it or waiting for it failed.
104    #[error("`{program}`: {source}")]
105    Io {
106        /// The program.
107        program: String,
108        /// The OS's reason.
109        source: std::io::Error,
110    },
111}
112
113/// Receives each line a process prints, with the output it came from.
114pub type OnLine<'a> = &'a mut (dyn FnMut(Stream, &str) + Send);
115
116/// Starts processes. Dropping the future of [`ProcessRunner::run`] before it resolves (a timeout,
117/// a cancelled operation) ends the process and everything it started.
118pub trait ProcessRunner: Send + Sync {
119    /// Runs `spec` to its end, handing each line it prints to `on_line` (decoded lossily).
120    fn run<'a>(&'a self, spec: &'a ProcessSpec, on_line: OnLine<'a>) -> BoxFuture<'a, Result<Exit, ProcessError>>;
121}
122
123/// `program` and `args` as one line, separated by spaces.
124fn command_line(program: &str, args: &[String]) -> String {
125    std::iter::once(program).chain(args.iter().map(String::as_str)).collect::<Vec<_>>().join(" ")
126}
127
128/// What a process printed, kept: its standard output, and its last standard error lines.
129#[derive(Debug, Clone, Default, PartialEq, Eq)]
130#[non_exhaustive]
131pub struct Captured {
132    /// How it ended.
133    pub exit: Option<Exit>,
134    /// Its standard output, one line each.
135    pub stdout: Vec<String>,
136    /// Its last standard error lines (a Ruby backtrace's frames left out).
137    pub stderr: Vec<String>,
138}
139
140/// Runs `spec`, keeping what it prints.
141///
142/// # Errors
143///
144/// Fails when the process can't start: missing, refused, or an I/O error.
145pub async fn capture(runner: &dyn ProcessRunner, spec: &ProcessSpec) -> Result<Captured, ProcessError> {
146    let mut stdout = Vec::new();
147    let mut tail = Tail::default();
148    let mut keep = |stream: Stream, line: &str| match stream {
149        Stream::Stdout => stdout.push(line.to_owned()),
150        Stream::Stderr => tail.push(line),
151    };
152    let exit = runner.run(spec, &mut keep).await?;
153    Ok(Captured {
154        exit: Some(exit),
155        stdout,
156        stderr: tail.into_lines(),
157    })
158}
159
160impl From<Option<i32>> for Exit {
161    fn from(code: Option<i32>) -> Exit {
162        Exit { code }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn a_spec_reads_as_its_command_line() {
172        let spec = ProcessSpec::new("docker", ["compose", "ps"]);
173        assert_eq!(spec.line(), "docker compose ps");
174        assert!(Exit { code: Some(0) }.success() && !Exit { code: None }.success());
175    }
176}