pmpx_engine/command.rs
1//! Building the [`Command`] for a program that has already been resolved.
2//!
3//! Kept apart from [`super::run`] for testability: tests need `output()` to capture output, while
4//! `run` inherits stdio. One platform difference is left -- a `.ps1` goes through
5//! `pwsh -NoProfile -File`. Why `.cmd` / `.bat` is deliberately *not* one of them is the one
6//! thing worth reading in [`command_for`].
7
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10
11use crate::{ChildOutput, Plan};
12
13use super::resolve::{resolve, ProgramKind};
14use crate::error::{EngineError, Result};
15
16/// Build a [`Command`] for a [`Resolved`](super::resolve::Resolved) kind, without running it.
17///
18/// This step is split out for testability: tests need `output()` to capture output, while
19/// [`run`](super::run) inherits stdio.
20///
21/// `cwd` is only the fallback: a working directory the plugin put in the spec **wins**, and it is
22/// applied here rather than by every caller, so a plugin's `cwd` cannot silently depend on the
23/// caller remembering to apply it. It is also decided *before* the program is resolved, so a
24/// relative program is looked up in the directory the process will actually run in.
25pub fn command_for(plan: &Plan, cwd: &Path, child_output: ChildOutput) -> Result<Command> {
26 let cwd = plan.cwd.as_deref().unwrap_or(cwd);
27 let resolved = resolve(&plan.program, cwd)?;
28
29 let mut cmd = match resolved.kind {
30 // A batch file is started by handing the *file* to `Command`: std knows that a
31 // `.cmd` / `.bat` cannot be started by `CreateProcess` directly, and builds the
32 // `cmd.exe` line for it -- forcing quotes around every batch argument, doubling inner
33 // quotes and neutralising `%`.
34 //
35 // Building that line here instead was measurably wrong. `cmd.exe` re-parses the whole
36 // line before the batch file ever runs, so a hand-built line delivered `a&b` as `a`
37 // (the tail ran as a second command), broke the line on `a|b`, expanded `%TEMP%` into
38 // a path and ate the caret of `^caret` -- while `pmpx exec` / `run` forward the user's
39 // own argv.
40 ProgramKind::Native | ProgramKind::CmdShim => {
41 let mut c = Command::new(&resolved.program);
42 c.args(&plan.args);
43 c
44 }
45
46 ProgramKind::PowerShellShim => {
47 // `-NoProfile` is deliberate: the user's PowerShell profile should not affect how
48 // the package manager behaves, and it can be slow. `-ExecutionPolicy Bypass` is
49 // not added -- changing security policy is not pmpx's job, and when a policy
50 // blocks it PowerShell should report the real reason itself.
51 //
52 // The interpreter is looked up like any other tool, so a machine without one gets the
53 // same exit-3 "not on PATH" error as a missing backend instead of a bare
54 // "failed to start pwsh".
55 let interpreter = resolve_powershell()?;
56 let mut c = Command::new(interpreter);
57 c.arg("-NoProfile").arg("-File").arg(&resolved.program);
58 c.args(&plan.args);
59 c
60 }
61 };
62
63 cmd.current_dir(cwd);
64 // The environment is inherited verbatim -- pmpx takes no part in proxies / mirrors /
65 // registry switching; those are configured in the shell.
66 cmd.stdin(Stdio::inherit())
67 .stdout(match child_output {
68 ChildOutput::Inherit => Stdio::inherit(),
69 // The child's stdout becomes *our* stderr: live, visible, and out of the JSON stream.
70 ChildOutput::OnStderr => our_stderr()?,
71 })
72 .stderr(Stdio::inherit());
73
74 Ok(cmd)
75}
76
77/// pmpx's own stderr, as something a child can write to.
78///
79/// A duplicated handle rather than a captured pipe, so the backend keeps writing live and keeps
80/// believing it has a terminal. Both branches are safe: `try_clone_to_owned` gives an owned
81/// descriptor, and `Stdio` takes it from there.
82fn our_stderr() -> Result<Stdio> {
83 #[cfg(unix)]
84 {
85 use std::os::fd::AsFd;
86 return Ok(Stdio::from(
87 std::io::stderr()
88 .as_fd()
89 .try_clone_to_owned()
90 .map_err(|error| {
91 EngineError::Setup(format!("cannot redirect the backend output: {error}"))
92 })?,
93 ));
94 }
95
96 #[cfg(windows)]
97 {
98 use std::os::windows::io::AsHandle;
99 return Ok(Stdio::from(
100 std::io::stderr()
101 .as_handle()
102 .try_clone_to_owned()
103 .map_err(|error| {
104 EngineError::Setup(format!("cannot redirect the backend output: {error}"))
105 })?,
106 ));
107 }
108
109 #[allow(unreachable_code)]
110 Ok(Stdio::inherit())
111}
112
113/// PowerShell to run a `.ps1` shim with: PowerShell 7 first, then the one Windows ships.
114///
115/// `.ps1` only ever wins a PATH lookup when `PATHEXT` has been extended with it, so this is a rare
116/// path -- but a rare path with an unusable error message is still worth ten lines.
117fn resolve_powershell() -> Result<PathBuf> {
118 for name in ["pwsh", "powershell"] {
119 if let Ok(path) = which::which(name) {
120 return Ok(path);
121 }
122 }
123
124 Err(EngineError::not_found(
125 "pmpx runs .ps1 shims with PowerShell, and neither `pwsh` nor `powershell` is on PATH."
126 .to_string(),
127 ))
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn a_missing_program_fails_before_spawning() {
136 let tmp = tempfile::tempdir().unwrap();
137 let spec = Plan::new("pmpx-definitely-not-a-real-program-xyz");
138
139 assert!(command_for(&spec, tmp.path(), ChildOutput::Inherit).is_err());
140 }
141
142 /// A `cwd` the plugin asked for has to win over the caller's directory, wherever the caller
143 /// happens to be looking from.
144 #[test]
145 fn a_cwd_in_the_spec_overrides_the_callers_directory() {
146 let outer = tempfile::tempdir().unwrap();
147 let inner = tempfile::tempdir().unwrap();
148
149 let spec = Plan::new("cargo").arg("--version").cwd(inner.path());
150 let cmd = command_for(&spec, outer.path(), ChildOutput::Inherit).unwrap();
151
152 assert_eq!(cmd.get_current_dir(), Some(inner.path()));
153 }
154
155 /// Without one, the caller's directory is what is used.
156 #[test]
157 fn without_a_cwd_in_the_spec_the_callers_directory_is_used() {
158 let outer = tempfile::tempdir().unwrap();
159 let spec = Plan::new("cargo").arg("--version");
160
161 let cmd = command_for(&spec, outer.path(), ChildOutput::Inherit).unwrap();
162 assert_eq!(cmd.get_current_dir(), Some(outer.path()));
163 }
164}