Skip to main content

pmpx_engine/
resolve.rs

1//! Finding the real executable behind a bare command name, and classifying it.
2//!
3//! # On Windows the real path must be resolved
4//!
5//! `Command::new("pnpm")` fails on Windows: `CreateProcessW` only does a PATH lookup plus
6//! appending `.exe`, it does no PATHEXT resolution. On Windows npm / pnpm / yarn / bun are
7//! all `.cmd` shims (`pnpm.cmd`).
8//!
9//! So the real path is resolved here first, and a failed lookup becomes an error (exit code 3)
10//! carrying the PATH hints of [`super::not_found`].
11
12use std::ffi::OsStr;
13use std::path::{Path, PathBuf};
14
15use super::not_found::not_found_error;
16use crate::error::{EngineError, Result};
17
18/// The kind of backend executable that was resolved.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ProgramKind {
21    /// A real executable. Spawned directly.
22    Native,
23    /// `.cmd` / `.bat`. Needs `cmd.exe` as the interpreter.
24    CmdShim,
25    /// `.ps1`. Needs PowerShell.
26    PowerShellShim,
27}
28
29/// A resolution result.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Resolved {
32    /// The real path.
33    pub program: PathBuf,
34    /// Which kind it is.
35    pub kind: ProgramKind,
36}
37
38/// Resolve the real path of a backend executable.
39///
40/// - `program` containing a path separator means use it directly, without searching PATH (the
41///   plugin pointed at a path). A relative one is resolved against `cwd` -- the directory the
42///   command will actually run in -- and not against wherever pmpx happened to be started.
43/// - Otherwise go through `which`. On Windows it does PATHEXT resolution, which is exactly
44///   what is needed here.
45pub fn resolve(program: &OsStr, cwd: &Path) -> Result<Resolved> {
46    let as_path = Path::new(program);
47
48    let has_separator = as_path.components().any(|c| {
49        matches!(
50            c,
51            std::path::Component::RootDir | std::path::Component::ParentDir
52        )
53    }) || program.to_string_lossy().contains(['/', '\\']);
54
55    let path = if has_separator {
56        let candidate = if as_path.is_absolute() {
57            as_path.to_path_buf()
58        } else {
59            cwd.join(as_path)
60        };
61
62        if candidate.is_file() {
63            candidate
64        } else {
65            return Err(EngineError::not_found(format!(
66                "cannot find {}: there is no file at {}.",
67                as_path.display(),
68                candidate.display()
69            )));
70        }
71    } else {
72        which::which(program).map_err(|_| not_found_error(program))?
73    };
74
75    Ok(Resolved {
76        kind: kind_of(&path),
77        program: path,
78    })
79}
80
81/// Decide how to spawn based on the extension.
82fn kind_of(path: &Path) -> ProgramKind {
83    let ext = path
84        .extension()
85        .map(|e| e.to_string_lossy().to_ascii_lowercase())
86        .unwrap_or_default();
87
88    match ext.as_str() {
89        "cmd" | "bat" => ProgramKind::CmdShim,
90        "ps1" => ProgramKind::PowerShellShim,
91        _ => ProgramKind::Native,
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    // ---- kind classification -------------------------------------------------
100
101    #[test]
102    fn exe_and_extensionless_are_native() {
103        assert_eq!(kind_of(Path::new("C:/x/cargo.exe")), ProgramKind::Native);
104        assert_eq!(kind_of(Path::new("/usr/bin/cargo")), ProgramKind::Native);
105        assert_eq!(kind_of(Path::new("C:/x/tool.bin")), ProgramKind::Native);
106    }
107
108    #[test]
109    fn cmd_and_bat_are_shims() {
110        assert_eq!(kind_of(Path::new("C:/x/pnpm.cmd")), ProgramKind::CmdShim);
111        assert_eq!(kind_of(Path::new("C:/x/old.bat")), ProgramKind::CmdShim);
112    }
113
114    /// Extension matching is case-insensitive -- `.CMD` and `.cmd` are the same thing on
115    /// Windows.
116    #[test]
117    fn extension_matching_is_case_insensitive() {
118        assert_eq!(kind_of(Path::new("C:/x/PNPM.CMD")), ProgramKind::CmdShim);
119        assert_eq!(
120            kind_of(Path::new("C:/x/Run.Ps1")),
121            ProgramKind::PowerShellShim
122        );
123    }
124
125    #[test]
126    fn ps1_needs_powershell() {
127        assert_eq!(
128            kind_of(Path::new("C:/x/x.ps1")),
129            ProgramKind::PowerShellShim
130        );
131    }
132
133    // ---- resolution ----------------------------------------------------------
134
135    /// With a path separator it is used directly, without searching PATH -- relative to the
136    /// directory the command runs in.
137    #[test]
138    fn an_explicit_path_is_used_as_is() {
139        let tmp = tempfile::tempdir().unwrap();
140        let p = tmp.path().join("thing");
141        std::fs::write(&p, "").unwrap();
142
143        let r = resolve(p.as_os_str(), tmp.path()).unwrap();
144        assert_eq!(r.program, p);
145    }
146
147    /// A relative program belongs to the directory the process will run in: a plugin that says
148    /// `node_modules/.bin/tsc` means the project's, not the one pmpx was started from. (Nothing in
149    /// this crate changes its own directory, so the two can differ.)
150    #[test]
151    fn a_relative_program_is_resolved_against_the_given_directory() {
152        let tmp = tempfile::tempdir().unwrap();
153        let work = tmp.path().join("work");
154        std::fs::create_dir_all(&work).unwrap();
155        std::fs::write(work.join("tool"), "").unwrap();
156
157        let r = resolve(OsStr::new("./tool"), &work).unwrap();
158        assert!(r.program.is_file(), "{:?}", r.program);
159        assert_eq!(r.program.file_name().unwrap(), "tool");
160
161        // The same name against a directory that does not hold it fails, which is what shows the
162        // answer came from the given directory rather than from this process's own.
163        let elsewhere = tmp.path().join("elsewhere");
164        std::fs::create_dir_all(&elsewhere).unwrap();
165        assert!(resolve(OsStr::new("./tool"), &elsewhere).is_err());
166    }
167
168    #[test]
169    fn an_explicit_path_that_does_not_exist_is_an_error() {
170        let tmp = tempfile::tempdir().unwrap();
171        let p = tmp.path().join("nope").join("thing");
172
173        let err = resolve(p.as_os_str(), tmp.path()).unwrap_err();
174        assert!(
175            err.is_not_found(),
176            "a missing program is the setup kind: {err:?}"
177        );
178        assert!(
179            err.to_string().contains("nope"),
180            "the message should name what it looked for: {err}"
181        );
182    }
183
184    /// `cargo` is guaranteed to resolve right now -- we are running inside `cargo test`.
185    #[test]
186    fn resolves_a_real_program_from_path() {
187        let tmp = tempfile::tempdir().unwrap();
188        let r = resolve(OsStr::new("cargo"), tmp.path()).expect("cargo must be on PATH");
189        assert!(r.program.is_absolute(), "{:?}", r.program);
190    }
191
192    #[test]
193    fn a_missing_program_is_reported_as_not_found() {
194        let tmp = tempfile::tempdir().unwrap();
195        let err = resolve(
196            OsStr::new("pmpx-definitely-not-a-real-program-xyz"),
197            tmp.path(),
198        )
199        .unwrap_err();
200        assert!(
201            err.is_not_found(),
202            "a missing program is the setup kind: {err:?}"
203        );
204    }
205
206    /// `which` does PATHEXT resolution on Windows, which is how `pnpm` resolves to
207    /// `pnpm.cmd`.
208    ///
209    /// This test asks `which` directly (using `which_in` with an explicit search directory)
210    /// rather than going through [`resolve`]: `resolve` reads the process-level PATH, and
211    /// mutating that variable in concurrently running tests is unsafe.
212    #[cfg(windows)]
213    #[test]
214    fn which_does_pathex_resolution() {
215        let tmp = tempfile::tempdir().unwrap();
216        let shim = tmp.path().join("pmpxprobe.cmd");
217        std::fs::write(&shim, "@echo off\r\n").unwrap();
218
219        let found = which::which_in("pmpxprobe", Some(tmp.path()), tmp.path())
220            .expect("which should find the .cmd via PATHEXT");
221
222        assert_eq!(
223            found
224                .file_name()
225                .unwrap()
226                .to_string_lossy()
227                .to_ascii_lowercase(),
228            "pmpxprobe.cmd"
229        );
230        assert_eq!(kind_of(&found), ProgramKind::CmdShim);
231    }
232}