Skip to main content

cli/lib/runners/
mod.rs

1//! Process runners for npm, yarn, pnpm, schematics, and git.
2//!
3//! Upstream runners spawn commands through a shell. This module keeps command
4//! descriptions as data and only executes them when `run` or `execute` is
5//! explicitly called.
6
7use std::fmt;
8use std::path::{Path, PathBuf};
9use std::process::{Command, ExitStatus, Stdio};
10
11use crate::Result;
12use crate::error::CliError;
13
14pub mod abstract_runner;
15pub mod git_runner;
16pub mod npm_runner;
17pub mod pnpm_runner;
18pub mod runner;
19pub mod runner_factory;
20pub mod schematic_runner;
21pub mod yarn_runner;
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
24pub enum RunnerKind {
25    Schematic,
26    Npm,
27    Yarn,
28    Pnpm,
29    Git,
30    Cargo,
31}
32
33impl RunnerKind {
34    pub const fn binary(self) -> &'static str {
35        match self {
36            Self::Schematic => "node",
37            Self::Npm => "npm",
38            Self::Yarn => "yarn",
39            Self::Pnpm => "pnpm",
40            Self::Git => "git",
41            Self::Cargo => "cargo",
42        }
43    }
44}
45
46impl fmt::Display for RunnerKind {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        formatter.write_str(match self {
49            Self::Schematic => "schematic",
50            Self::Npm => "npm",
51            Self::Yarn => "yarn",
52            Self::Pnpm => "pnpm",
53            Self::Git => "git",
54            Self::Cargo => "cargo",
55        })
56    }
57}
58
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct RunnerCommand {
61    pub binary: String,
62    pub prefix_args: Vec<String>,
63    pub command: String,
64    pub collect: bool,
65    pub cwd: Option<PathBuf>,
66    pub shell: bool,
67    pub env: Vec<(String, String)>,
68}
69
70impl RunnerCommand {
71    pub fn raw_full_command(&self) -> String {
72        let mut parts = Vec::with_capacity(2 + self.prefix_args.len());
73        parts.push(self.binary.as_str());
74        parts.extend(self.prefix_args.iter().map(String::as_str));
75        parts.push(self.command.as_str());
76        parts.join(" ")
77    }
78
79    /// Execute the described command and return process details.
80    ///
81    /// This mirrors upstream `AbstractRunner.run`, including shell execution,
82    /// inherited stdio by default, and piped stdout when `collect` is true.
83    pub fn execute(&self) -> Result<RunnerExecution> {
84        let command_line = self.command_line_for_execution();
85        let mut command = if self.shell {
86            shell_command(&command_line)
87        } else {
88            let mut command = Command::new(&self.binary);
89            command.args(&self.prefix_args).arg(&self.command);
90            command
91        };
92
93        if let Some(cwd) = &self.cwd {
94            command.current_dir(cwd);
95        }
96        command.envs(self.env.iter().map(|(key, value)| (key, value)));
97
98        if self.collect {
99            command.stdout(Stdio::piped()).stderr(Stdio::piped());
100            let output = command.output()?;
101            let stdout =
102                strip_one_line_ending(String::from_utf8_lossy(&output.stdout).into_owned());
103            let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
104
105            if output.status.success() {
106                Ok(RunnerExecution {
107                    status: output.status,
108                    stdout: Some(stdout),
109                    stderr,
110                })
111            } else {
112                Err(CliError::RunnerFailed {
113                    command: self.raw_full_command(),
114                    reason: failure_reason(output.status, stderr),
115                })
116            }
117        } else {
118            command
119                .stdin(Stdio::inherit())
120                .stdout(Stdio::inherit())
121                .stderr(Stdio::inherit());
122            let status = command.status()?;
123
124            if status.success() {
125                Ok(RunnerExecution {
126                    status,
127                    stdout: None,
128                    stderr: String::new(),
129                })
130            } else {
131                Err(CliError::RunnerFailed {
132                    command: self.raw_full_command(),
133                    reason: failure_reason(status, String::new()),
134                })
135            }
136        }
137    }
138
139    /// Execute the described command and return collected stdout when requested.
140    pub fn run(&self) -> Result<Option<String>> {
141        Ok(self.execute()?.stdout)
142    }
143
144    fn command_line_for_execution(&self) -> String {
145        let mut parts = Vec::with_capacity(2 + self.prefix_args.len());
146        parts.push(quote_shell_part(&self.binary));
147        parts.extend(self.prefix_args.iter().map(|arg| quote_shell_part(arg)));
148        parts.push(self.command.clone());
149        parts.join(" ")
150    }
151}
152
153#[derive(Debug)]
154pub struct RunnerExecution {
155    pub status: ExitStatus,
156    pub stdout: Option<String>,
157    pub stderr: String,
158}
159
160impl RunnerExecution {
161    pub fn success(&self) -> bool {
162        self.status.success()
163    }
164}
165
166pub trait Runner {
167    fn kind(&self) -> RunnerKind;
168    fn binary(&self) -> &str;
169    fn prefix_args(&self) -> &[String];
170
171    fn describe(
172        &self,
173        command: impl Into<String>,
174        collect: bool,
175        cwd: Option<PathBuf>,
176    ) -> RunnerCommand {
177        RunnerCommand {
178            binary: self.binary().to_owned(),
179            prefix_args: self.prefix_args().to_vec(),
180            command: command.into(),
181            collect,
182            cwd,
183            shell: true,
184            env: Vec::new(),
185        }
186    }
187
188    fn raw_full_command(&self, command: impl AsRef<str>) -> String {
189        self.describe(command.as_ref(), false, None)
190            .raw_full_command()
191    }
192
193    fn execute(
194        &self,
195        command: impl Into<String>,
196        collect: bool,
197        cwd: Option<PathBuf>,
198    ) -> Result<RunnerExecution> {
199        self.describe(command, collect, cwd).execute()
200    }
201
202    fn run(
203        &self,
204        command: impl Into<String>,
205        collect: bool,
206        cwd: Option<PathBuf>,
207    ) -> Result<Option<String>> {
208        self.describe(command, collect, cwd).run()
209    }
210}
211
212#[derive(Clone, Debug, Eq, PartialEq)]
213pub struct ProcessRunner {
214    kind: RunnerKind,
215    binary: String,
216    prefix_args: Vec<String>,
217}
218
219impl ProcessRunner {
220    pub fn new(kind: RunnerKind) -> Self {
221        Self {
222            kind,
223            binary: kind.binary().to_owned(),
224            prefix_args: Vec::new(),
225        }
226    }
227
228    pub fn with_binary(kind: RunnerKind, binary: impl Into<String>) -> Self {
229        Self {
230            kind,
231            binary: binary.into(),
232            prefix_args: Vec::new(),
233        }
234    }
235
236    pub fn schematic(schematics_binary: impl AsRef<Path>) -> Self {
237        Self {
238            kind: RunnerKind::Schematic,
239            binary: RunnerKind::Schematic.binary().to_owned(),
240            prefix_args: vec![quote_path(schematics_binary.as_ref())],
241        }
242    }
243}
244
245impl Runner for ProcessRunner {
246    fn kind(&self) -> RunnerKind {
247        self.kind
248    }
249
250    fn binary(&self) -> &str {
251        &self.binary
252    }
253
254    fn prefix_args(&self) -> &[String] {
255        &self.prefix_args
256    }
257}
258
259#[derive(Clone, Copy, Debug, Default)]
260pub struct RunnerFactory;
261
262impl RunnerFactory {
263    pub fn create(kind: RunnerKind) -> ProcessRunner {
264        ProcessRunner::new(kind)
265    }
266
267    pub fn create_schematic(schematics_binary: impl AsRef<Path>) -> ProcessRunner {
268        ProcessRunner::schematic(schematics_binary)
269    }
270}
271
272fn quote_path(path: &Path) -> String {
273    format!("\"{}\"", path.display())
274}
275
276fn shell_command(command_line: &str) -> Command {
277    #[cfg(windows)]
278    {
279        let mut command =
280            Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()));
281        command.arg("/C").arg(command_line);
282        command
283    }
284
285    #[cfg(not(windows))]
286    {
287        let mut command = Command::new("sh");
288        command.arg("-c").arg(command_line);
289        command
290    }
291}
292
293fn quote_shell_part(part: &str) -> String {
294    if part.is_empty()
295        || part.starts_with('"')
296        || part.starts_with('\'')
297        || !part.chars().any(char::is_whitespace)
298    {
299        return part.to_owned();
300    }
301
302    #[cfg(windows)]
303    {
304        format!("\"{}\"", part.replace('"', "\\\""))
305    }
306
307    #[cfg(not(windows))]
308    {
309        format!("'{}'", part.replace('\'', "'\\''"))
310    }
311}
312
313fn strip_one_line_ending(mut value: String) -> String {
314    if value.ends_with("\r\n") {
315        value.truncate(value.len() - 2);
316    } else if value.ends_with('\n') {
317        value.truncate(value.len() - 1);
318    }
319    value
320}
321
322fn failure_reason(status: ExitStatus, stderr: String) -> String {
323    let status = match status.code() {
324        Some(code) => format!("process exited with status code {code}"),
325        None => "process terminated by signal".to_owned(),
326    };
327
328    let stderr = stderr.trim();
329    if stderr.is_empty() {
330        status
331    } else {
332        format!("{status}: {stderr}")
333    }
334}