Skip to main content

pmpx_engine/
lib.rs

1//! Turning an answer into a running process: resolving the program, starting it, and saying what
2//! happened.
3//!
4//! # Who owns what
5//!
6//! A plugin answers "what to run" and nothing else. Everything about *how* it runs lives here:
7//! resolving the real path, deciding which interpreter a script needs, inheriting stdio, waiting, and
8//! translating the exit status. That is why there is exactly one implementation of each of those, and
9//! why this crate is the only place a `Command` is built.
10//!
11//! # Windows needs the real path
12//!
13//! `Command::new("pnpm")` fails on Windows: `CreateProcessW` does a `PATH` lookup and appends `.exe`,
14//! and nothing else. npm, pnpm, yarn and bun are all `.cmd` shims there. So the real path is resolved
15//! first ([`resolve`]), and the spawn then depends on the kind: `.cmd`/`.bat` go through
16//! `std::process`, which builds the `cmd.exe` line, and `.ps1` goes to `pwsh -NoProfile -File`. A
17//! failed resolution is a [`EngineError::NotFound`] listing the near-matching names on `PATH`, because
18//! "pnpm: not found" without the list is a puzzle rather than a message.
19//!
20//! # Nothing is printed
21//!
22//! What a run does is reported as [`Event`]s. The caller decides what to do with them: the CLI turns
23//! them into `--debug` lines and the "pmpx -> ..." announcement, and a test asserts the sequence. The
24//! process's own stdout and stderr are inherited, so whatever the backend prints goes straight to the
25//! person -- this crate never captures it.
26
27use std::ffi::OsString;
28use std::path::{Path, PathBuf};
29use std::time::Instant;
30
31mod backend;
32mod command;
33mod error;
34mod files;
35mod log;
36mod not_found;
37mod resolve;
38
39pub mod discovery;
40
41/// Which plugin a project belongs to, and how it is chosen. Needs the store, because the candidates are
42/// the installed plugins.
43#[cfg(feature = "store")]
44pub mod detect;
45
46/// One run's state: configuration, the project, and the installed plugins.
47#[cfg(feature = "store")]
48pub mod session;
49
50/// The whole run path: resolve, load, ask, run, and pass the exit code through.
51#[cfg(feature = "store")]
52pub mod flow;
53
54#[cfg(feature = "store")]
55pub use flow::{run_script, run_verb};
56#[cfg(feature = "store")]
57pub use session::{Options, Session};
58
59/// The plugin store: what is installed, and installing more.
60///
61/// Behind a feature, and **off by default**, because it is the one part of this crate that reaches for
62/// the network and the user's disk: an embedder that only runs commands someone else installed should
63/// not pay for any of it. `crate-plugin-kit` -- and through it `ureq`, `rustls` and `ring` -- appears in
64/// the store module, the session that owns it, and the error variant that carries its failures. Nothing in the binary mentions it -- only that
65/// binary's own tests do, through a dev-dependency, to find the cdylib they build.
66#[cfg(feature = "store")]
67pub mod store;
68
69pub use backend::{Backend, Call, PluginIdentity};
70pub use command::command_for;
71pub use error::EngineError;
72pub use files::{Declared, MAX_FILES, MAX_FILE_BYTES};
73pub use log::Levels;
74pub use resolve::{resolve, ProgramKind, Resolved};
75
76use crate::resolve::resolve as resolve_program;
77
78/// What to run, resolved from whatever a plugin answered or the project defined.
79///
80/// Deliberately this crate's own type rather than the contract's: execution needs three fields and no
81/// knowledge of plugins, and keeping it that way is what lets the whole run path be tested without a
82/// plugin at all.
83#[derive(Debug, Clone, PartialEq, Eq, Default)]
84pub struct Plan {
85    /// The executable, as written: a bare name (looked up on `PATH`), an absolute path, or one
86    /// relative to [`Plan::cwd`].
87    pub program: OsString,
88    /// Arguments, in order, handed over verbatim.
89    pub args: Vec<OsString>,
90    /// Where to run it. `None` means the fallback the caller passes to [`run`].
91    pub cwd: Option<PathBuf>,
92}
93
94impl Plan {
95    /// Name the executable.
96    pub fn new(program: impl Into<OsString>) -> Self {
97        Self {
98            program: program.into(),
99            args: Vec::new(),
100            cwd: None,
101        }
102    }
103
104    /// Append one argument.
105    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
106        self.args.push(arg.into());
107        self
108    }
109
110    /// Append a batch of arguments.
111    pub fn args<I, S>(mut self, args: I) -> Self
112    where
113        I: IntoIterator<Item = S>,
114        S: Into<OsString>,
115    {
116        self.args.extend(args.into_iter().map(Into::into));
117        self
118    }
119
120    /// Override the working directory.
121    pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
122        self.cwd = Some(dir.into());
123        self
124    }
125
126    /// The working directory this will actually run in.
127    pub fn working_dir<'a>(&'a self, fallback: &'a Path) -> &'a Path {
128        self.cwd.as_deref().unwrap_or(fallback)
129    }
130
131    /// The whole command line, for a message or a test.
132    pub fn display(&self) -> String {
133        let mut line = self.program.to_string_lossy().into_owned();
134        for arg in &self.args {
135            line.push(' ');
136            line.push_str(&arg.to_string_lossy());
137        }
138        line
139    }
140}
141
142/// Something that happened while running one command.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum Event {
145    /// The program was resolved to something concrete.
146    Resolved {
147        /// The program, as it was asked for.
148        program: OsString,
149        /// The path that will be started, when the resolution found one.
150        path: Option<PathBuf>,
151        /// How it will be started.
152        kind: ProgramKind,
153    },
154
155    /// About to start it.
156    Starting {
157        /// The whole command line, so a caller can show exactly what runs.
158        plan: Plan,
159    },
160
161    /// It finished, with this exit code.
162    Finished {
163        /// The code pmpx will exit with, already translated.
164        code: u8,
165    },
166
167    /// Something the person should know even without asking for detail.
168    Warning(String),
169
170    /// Detail for someone tracing the run.
171    Note(String),
172
173    /// Something went wrong, which did not stop the run either.
174    Error(String),
175
176    /// One phase of the run finished, with how long it took.
177    ///
178    /// The engine measures its own phases so that a caller's trace can attribute the time -- which is
179    /// where a run spends everything that is not the backend's own runtime.
180    Phase {
181        /// The phase's name, stable enough for a caller to key on.
182        name: &'static str,
183        /// How long it took.
184        micros: u128,
185        /// What it did, in one line.
186        detail: String,
187    },
188
189    /// The decision's notes, for whoever shows them: ties, and how to override the choice.
190    Notes {
191        /// The plugin that was selected.
192        plugin: String,
193        /// The notes themselves.
194        notes: Vec<String>,
195    },
196
197    /// The plugin said something while it was being called.
198    ///
199    /// The level is the contract's number, and the text is exactly what the plugin wrote: how to show
200    /// it (an id, a colour, a destination) is the caller's business.
201    PluginMessage {
202        /// The contract's level number.
203        level: u32,
204        /// The message itself.
205        text: String,
206    },
207}
208
209/// Really run it: inherit stdio, wait for it to finish, hand back its exit code verbatim.
210///
211/// `fallback_cwd` is used when the plan names no directory of its own.
212///
213/// The return value is not `Result<()>`: "the tests failed" and "pmpx failed" are two different
214/// things, and `pmpx test` has to pass a nonzero exit code through to the caller's script -- that is
215/// the only meaningful contract of this command.
216pub fn run(
217    plan: &Plan,
218    fallback_cwd: &Path,
219    child_output: ChildOutput,
220    events: &mut dyn FnMut(Event),
221) -> Result<u8, EngineError> {
222    // Resolving is a PATH lookup plus a stat, and failing it is the setup kind of error: the caller
223    // decides the exit code, but the message names what was searched.
224    let resolving = Instant::now();
225    let resolved = resolve_program(&plan.program, plan.working_dir(fallback_cwd))?;
226    events(Event::Phase {
227        name: "spawn.resolve",
228        micros: resolving.elapsed().as_micros(),
229        detail: format!("{} ({:?})", resolved.program.display(), resolved.kind),
230    });
231    events(Event::Resolved {
232        program: plan.program.clone(),
233        path: Some(resolved.program.clone()),
234        kind: resolved.kind,
235    });
236
237    events(Event::Starting { plan: plan.clone() });
238
239    let mut cmd = command::command_for(plan, fallback_cwd, child_output)?;
240
241    // This is the backend's own runtime, from here until its process exits: usually the whole of what a
242    // user perceives as "pmpx is slow", and never pmpx's own time.
243    let running = Instant::now();
244    let status = cmd.status().map_err(|source| EngineError::Start {
245        program: plan.program.clone(),
246        source,
247    })?;
248    events(Event::Phase {
249        name: "backend.run",
250        micros: running.elapsed().as_micros(),
251        detail: format!("exit {}", exit_code_of(status, &mut |_| {})),
252    });
253
254    let code = exit_code_of(status, events);
255    events(Event::Finished { code });
256    Ok(code)
257}
258
259/// Translate an `ExitStatus` into a process exit code.
260fn exit_code_of(status: std::process::ExitStatus, events: &mut dyn FnMut(Event)) -> u8 {
261    if let Some(code) = status.code() {
262        if (0..=255).contains(&code) {
263            return code as u8;
264        }
265        // On Windows an exit code can be any u32 -- `code()` reinterprets those bits as `i32`, so it is
266        // reported back as unsigned, or `exit /b -1` would read as "-1" instead of 4294967295. Take the
267        // low 8 bits instead of erroring: the user's script cares about "nonzero", not the value.
268        events(Event::Warning(format!(
269            "the backend exited with {}, which does not fit in 8 bits; passing through the low 8 bits \
270             ({})",
271            code as u32,
272            (code & 0xFF) as u8
273        )));
274        return (code & 0xFF) as u8;
275    }
276
277    // Killed by a signal (Unix). Follow the shell convention: 128 + signal.
278    #[cfg(unix)]
279    {
280        use std::os::unix::process::ExitStatusExt;
281        if let Some(sig) = status.signal() {
282            events(Event::Error(format!(
283                "the backend was killed by signal {sig}"
284            )));
285            return (128 + sig).clamp(0, 255) as u8;
286        }
287    }
288
289    events(Event::Error(
290        "cannot read the backend exit code, treating it as 1".to_string(),
291    ));
292    1
293}
294
295/// Where a started program's own output goes.
296///
297/// `Inherit` is the normal case, and the reason pmpx captures nothing: the backend's output stays
298/// live, keeps its colours (it still sees a terminal), and `pmpx build > log` keeps meaning what it
299/// always meant.
300///
301/// `OnStderr` is for `--json`, where stdout is a JSON stream a program is parsing: the backend writes
302/// to pmpx's stderr instead, so it stays live and visible without ever landing in that stream.
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum ChildOutput {
305    /// Hand the child pmpx's own stdin, stdout and stderr.
306    Inherit,
307    /// Hand the child pmpx's stderr for its stdout too, keeping pmpx's stdout for pmpx.
308    OnStderr,
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    /// Collect the events one run produced.
316    fn run_collecting(plan: &Plan, cwd: &Path) -> (Result<u8, EngineError>, Vec<Event>) {
317        let mut events = Vec::new();
318        let code = run(plan, cwd, ChildOutput::Inherit, &mut |event| {
319            events.push(event)
320        });
321        (code, events)
322    }
323
324    #[test]
325    fn a_run_reports_what_it_did_in_order() {
326        let tmp = tempfile::tempdir().unwrap();
327        let plan = Plan::new("cargo").arg("--version");
328
329        let (code, events) = run_collecting(&plan, tmp.path());
330
331        assert_eq!(code.unwrap(), 0);
332
333        // The phases and the three moments of the run, in the order they happen: resolving, then what
334        // was resolved, then the start, then how long the backend ran, then its exit code.
335        let names: Vec<String> = events
336            .iter()
337            .map(|event| match event {
338                Event::Phase { name, .. } => (*name).to_string(),
339                Event::Resolved { .. } => "resolved".to_string(),
340                Event::Starting { .. } => "starting".to_string(),
341                Event::Finished { .. } => "finished".to_string(),
342                other => format!("{other:?}"),
343            })
344            .collect();
345        assert_eq!(
346            names,
347            vec![
348                "spawn.resolve",
349                "resolved",
350                "starting",
351                "backend.run",
352                "finished"
353            ],
354            "the run reports itself in order"
355        );
356
357        assert!(
358            matches!(&events[1], Event::Resolved { program, kind, .. }
359                if program == &OsString::from("cargo") && *kind == ProgramKind::Native),
360            "{events:?}"
361        );
362        assert!(
363            matches!(&events[2], Event::Starting { plan }
364                if plan.program == *"cargo"
365                    && plan.args.len() == 1
366                    && plan.cwd.is_none()
367                    && plan.working_dir(tmp.path()) == tmp.path()),
368            "{events:?}"
369        );
370        assert_eq!(events.last(), Some(&Event::Finished { code: 0 }));
371    }
372
373    #[test]
374    fn runs_a_native_command_and_returns_its_exit_code() {
375        let tmp = tempfile::tempdir().unwrap();
376        let plan = Plan::new("cargo").arg("--version");
377
378        assert_eq!(
379            run(&plan, tmp.path(), ChildOutput::Inherit, &mut |_| {}).unwrap(),
380            0
381        );
382    }
383
384    #[test]
385    fn a_nonzero_exit_code_is_passed_through() {
386        let tmp = tempfile::tempdir().unwrap();
387
388        #[cfg(windows)]
389        let plan = Plan::new("cmd").arg("/c").arg("exit 7");
390        #[cfg(not(windows))]
391        let plan = Plan::new("sh").arg("-c").arg("exit 7");
392
393        let (code, events) = run_collecting(&plan, tmp.path());
394
395        assert_eq!(code.unwrap(), 7, "must pass through verbatim");
396        assert_eq!(events.last(), Some(&Event::Finished { code: 7 }));
397    }
398
399    /// A program that is not there is the setup kind of failure, and the message lists what was near.
400    #[test]
401    fn a_missing_program_is_not_found_and_says_so() {
402        let tmp = tempfile::tempdir().unwrap();
403        let plan = Plan::new("pmpx-definitely-not-a-real-program-xyz");
404
405        let (code, events) = run_collecting(&plan, tmp.path());
406
407        let error = code.expect_err("there is no such program");
408        assert!(error.is_not_found(), "{error:?}");
409        assert!(
410            error
411                .message()
412                .contains("pmpx-definitely-not-a-real-program-xyz"),
413            "{}",
414            error.message()
415        );
416        assert!(
417            events.is_empty(),
418            "nothing ran, so nothing was reported: {events:?}"
419        );
420    }
421
422    /// The working directory has to take effect -- both the `cwd` a plugin reports and the project
423    /// root rely on it.
424    #[test]
425    fn the_working_directory_is_honoured() {
426        let tmp = tempfile::tempdir().unwrap();
427        let plan = Plan::new("cargo").arg("--version").cwd(tmp.path());
428
429        let mut cmd = command_for(
430            &plan,
431            Path::new("/definitely/not/here"),
432            ChildOutput::Inherit,
433        )
434        .unwrap();
435        assert!(cmd.output().unwrap().status.success());
436    }
437
438    // ---- really running a cmd shim (Windows) --------------------------------
439
440    /// Really run a `.cmd` and confirm the arguments arrive.
441    #[cfg(windows)]
442    #[test]
443    fn a_cmd_shim_really_runs_and_receives_its_args() {
444        let tmp = tempfile::tempdir().unwrap();
445        let out_file = tmp.path().join("got.txt");
446        let shim = tmp.path().join("probe.cmd");
447        std::fs::write(
448            &shim,
449            format!("@echo off\r\necho %1 %2 > \"{}\"\r\n", out_file.display()),
450        )
451        .unwrap();
452
453        let plan = Plan::new(shim.as_os_str()).arg("add").arg("serde");
454        let (code, events) = run_collecting(&plan, tmp.path());
455
456        assert_eq!(code.unwrap(), 0);
457        assert!(
458            events
459                .iter()
460                .any(|event| matches!(event, Event::Resolved { kind, .. } if *kind == ProgramKind::CmdShim)),
461            "a `.cmd` is spawned through cmd.exe: {events:?}"
462        );
463        assert_eq!(
464            std::fs::read_to_string(&out_file).unwrap().trim(),
465            "add serde"
466        );
467    }
468
469    /// A `.cmd` has to run from a path with spaces too.
470    #[cfg(windows)]
471    #[test]
472    fn a_cmd_shim_in_a_path_with_spaces_still_runs() {
473        let tmp = tempfile::tempdir().unwrap();
474        let dir = tmp.path().join("a dir with spaces");
475        std::fs::create_dir_all(&dir).unwrap();
476
477        let shim = dir.join("probe.cmd");
478        std::fs::write(&shim, "@echo off\r\nexit 0\r\n").unwrap();
479
480        let plan = Plan::new(shim.as_os_str());
481        assert_eq!(
482            run(&plan, tmp.path(), ChildOutput::Inherit, &mut |_| {}).unwrap(),
483            0
484        );
485    }
486
487    /// An argument with spaces has to arrive verbatim too.
488    #[cfg(windows)]
489    #[test]
490    fn a_cmd_shim_receives_an_argument_with_spaces() {
491        let tmp = tempfile::tempdir().unwrap();
492        let out_file = tmp.path().join("got.txt");
493        let shim = tmp.path().join("probe.cmd");
494        std::fs::write(
495            &shim,
496            format!("@echo off\r\necho %~1 > \"{}\"\r\n", out_file.display()),
497        )
498        .unwrap();
499
500        let plan = Plan::new(shim.as_os_str()).arg("hello world");
501
502        assert_eq!(
503            run(&plan, tmp.path(), ChildOutput::Inherit, &mut |_| {}).unwrap(),
504            0
505        );
506        assert_eq!(
507            std::fs::read_to_string(&out_file).unwrap().trim(),
508            "hello world"
509        );
510    }
511
512    #[cfg(windows)]
513    #[test]
514    fn a_cmd_shim_passes_through_a_nonzero_exit_code() {
515        let tmp = tempfile::tempdir().unwrap();
516        let shim = tmp.path().join("probe.cmd");
517        std::fs::write(&shim, "@echo off\r\nexit /b 42\r\n").unwrap();
518
519        let plan = Plan::new(shim.as_os_str());
520        assert_eq!(
521            run(&plan, tmp.path(), ChildOutput::Inherit, &mut |_| {}).unwrap(),
522            42
523        );
524    }
525
526    /// `&`, `|`, `>` and `^` are separators, pipes, redirections and escapes to `cmd.exe`, which
527    /// re-parses the whole line before the batch file runs.
528    #[cfg(windows)]
529    #[test]
530    fn cmd_metacharacters_survive_into_a_shim() {
531        let tmp = tempfile::tempdir().unwrap();
532        let out_file = tmp.path().join("got.txt");
533        let shim = tmp.path().join("probe.cmd");
534        std::fs::write(
535            &shim,
536            format!(
537                "@echo off\r\necho \"[%~1]\" > \"{}\"\r\n",
538                out_file.display()
539            ),
540        )
541        .unwrap();
542
543        for arg in ["a&b", "a|b", "a>b", "^caret"] {
544            let plan = Plan::new(shim.as_os_str()).arg(arg);
545            assert_eq!(
546                run(&plan, tmp.path(), ChildOutput::Inherit, &mut |_| {}).unwrap(),
547                0,
548                "the shim failed on {arg}"
549            );
550
551            let got = std::fs::read_to_string(&out_file).unwrap();
552            assert_eq!(
553                got.trim(),
554                format!("\"[{arg}]\""),
555                "cmd re-parsed the argument {arg}"
556            );
557        }
558    }
559}