Skip to main content

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}