pmpx-engine 0.3.2

Runs what a plugin answers: resolving the program, starting it, and reporting what happened.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
//! Turning an answer into a running process: resolving the program, starting it, and saying what
//! happened.
//!
//! # Who owns what
//!
//! A plugin answers "what to run" and nothing else. Everything about *how* it runs lives here:
//! resolving the real path, deciding which interpreter a script needs, inheriting stdio, waiting, and
//! translating the exit status. That is why there is exactly one implementation of each of those, and
//! why this crate is the only place a `Command` is built.
//!
//! # Windows needs the real path
//!
//! `Command::new("pnpm")` fails on Windows: `CreateProcessW` does a `PATH` lookup and appends `.exe`,
//! and nothing else. npm, pnpm, yarn and bun are all `.cmd` shims there. So the real path is resolved
//! first ([`resolve`]), and the spawn then depends on the kind: `.cmd`/`.bat` go through
//! `std::process`, which builds the `cmd.exe` line, and `.ps1` goes to `pwsh -NoProfile -File`. A
//! failed resolution is a [`EngineError::NotFound`] listing the near-matching names on `PATH`, because
//! "pnpm: not found" without the list is a puzzle rather than a message.
//!
//! # Nothing is printed
//!
//! What a run does is reported as [`Event`]s. The caller decides what to do with them: the CLI turns
//! them into `--debug` lines and the "pmpx -> ..." announcement, and a test asserts the sequence. The
//! process's own stdout and stderr are inherited, so whatever the backend prints goes straight to the
//! person -- this crate never captures it.

use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::time::Instant;

mod backend;
mod command;
mod error;
mod files;
mod log;
mod not_found;
mod resolve;

pub mod discovery;

/// Which plugin a project belongs to, and how it is chosen. Needs the store, because the candidates are
/// the installed plugins.
#[cfg(feature = "store")]
pub mod detect;

/// One run's state: configuration, the project, and the installed plugins.
#[cfg(feature = "store")]
pub mod session;

/// The whole run path: resolve, load, ask, run, and pass the exit code through.
#[cfg(feature = "store")]
pub mod flow;

#[cfg(feature = "store")]
pub use flow::{run_script, run_verb};
#[cfg(feature = "store")]
pub use session::{Options, Session};

/// The plugin store: what is installed, and installing more.
///
/// Behind a feature, and **off by default**, because it is the one part of this crate that reaches for
/// the network and the user's disk: an embedder that only runs commands someone else installed should
/// not pay for any of it. `crate-plugin-kit` -- and through it `ureq`, `rustls` and `ring` -- appears in
/// the store module, the session that owns it, and the error variant that carries its failures. Nothing in the binary mentions it -- only that
/// binary's own tests do, through a dev-dependency, to find the cdylib they build.
#[cfg(feature = "store")]
pub mod store;

pub use backend::{Backend, Call, PluginIdentity};
pub use command::command_for;
pub use error::EngineError;
pub use files::{Declared, MAX_FILES, MAX_FILE_BYTES};
pub use log::Levels;
pub use resolve::{resolve, ProgramKind, Resolved};

use crate::resolve::resolve as resolve_program;

/// What to run, resolved from whatever a plugin answered or the project defined.
///
/// Deliberately this crate's own type rather than the contract's: execution needs three fields and no
/// knowledge of plugins, and keeping it that way is what lets the whole run path be tested without a
/// plugin at all.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Plan {
    /// The executable, as written: a bare name (looked up on `PATH`), an absolute path, or one
    /// relative to [`Plan::cwd`].
    pub program: OsString,
    /// Arguments, in order, handed over verbatim.
    pub args: Vec<OsString>,
    /// Where to run it. `None` means the fallback the caller passes to [`run`].
    pub cwd: Option<PathBuf>,
}

impl Plan {
    /// Name the executable.
    pub fn new(program: impl Into<OsString>) -> Self {
        Self {
            program: program.into(),
            args: Vec::new(),
            cwd: None,
        }
    }

    /// Append one argument.
    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
        self.args.push(arg.into());
        self
    }

    /// Append a batch of arguments.
    pub fn args<I, S>(mut self, args: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<OsString>,
    {
        self.args.extend(args.into_iter().map(Into::into));
        self
    }

    /// Override the working directory.
    pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
        self.cwd = Some(dir.into());
        self
    }

    /// The working directory this will actually run in.
    pub fn working_dir<'a>(&'a self, fallback: &'a Path) -> &'a Path {
        self.cwd.as_deref().unwrap_or(fallback)
    }

    /// The whole command line, for a message or a test.
    pub fn display(&self) -> String {
        let mut line = self.program.to_string_lossy().into_owned();
        for arg in &self.args {
            line.push(' ');
            line.push_str(&arg.to_string_lossy());
        }
        line
    }
}

/// Something that happened while running one command.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
    /// The program was resolved to something concrete.
    Resolved {
        /// The program, as it was asked for.
        program: OsString,
        /// The path that will be started, when the resolution found one.
        path: Option<PathBuf>,
        /// How it will be started.
        kind: ProgramKind,
    },

    /// About to start it.
    Starting {
        /// The whole command line, so a caller can show exactly what runs.
        plan: Plan,
    },

    /// It finished, with this exit code.
    Finished {
        /// The code pmpx will exit with, already translated.
        code: u8,
    },

    /// Something the person should know even without asking for detail.
    Warning(String),

    /// Detail for someone tracing the run.
    Note(String),

    /// Something went wrong, which did not stop the run either.
    Error(String),

    /// One phase of the run finished, with how long it took.
    ///
    /// The engine measures its own phases so that a caller's trace can attribute the time -- which is
    /// where a run spends everything that is not the backend's own runtime.
    Phase {
        /// The phase's name, stable enough for a caller to key on.
        name: &'static str,
        /// How long it took.
        micros: u128,
        /// What it did, in one line.
        detail: String,
    },

    /// The decision's notes, for whoever shows them: ties, and how to override the choice.
    Notes {
        /// The plugin that was selected.
        plugin: String,
        /// The notes themselves.
        notes: Vec<String>,
    },

    /// The plugin said something while it was being called.
    ///
    /// The level is the contract's number, and the text is exactly what the plugin wrote: how to show
    /// it (an id, a colour, a destination) is the caller's business.
    PluginMessage {
        /// The contract's level number.
        level: u32,
        /// The message itself.
        text: String,
    },
}

/// Really run it: inherit stdio, wait for it to finish, hand back its exit code verbatim.
///
/// `fallback_cwd` is used when the plan names no directory of its own.
///
/// The return value is not `Result<()>`: "the tests failed" and "pmpx failed" are two different
/// things, and `pmpx test` has to pass a nonzero exit code through to the caller's script -- that is
/// the only meaningful contract of this command.
pub fn run(
    plan: &Plan,
    fallback_cwd: &Path,
    child_output: ChildOutput,
    events: &mut dyn FnMut(Event),
) -> Result<u8, EngineError> {
    // Resolving is a PATH lookup plus a stat, and failing it is the setup kind of error: the caller
    // decides the exit code, but the message names what was searched.
    let resolving = Instant::now();
    let resolved = resolve_program(&plan.program, plan.working_dir(fallback_cwd))?;
    events(Event::Phase {
        name: "spawn.resolve",
        micros: resolving.elapsed().as_micros(),
        detail: format!("{} ({:?})", resolved.program.display(), resolved.kind),
    });
    events(Event::Resolved {
        program: plan.program.clone(),
        path: Some(resolved.program.clone()),
        kind: resolved.kind,
    });

    events(Event::Starting { plan: plan.clone() });

    let mut cmd = command::command_for(plan, fallback_cwd, child_output)?;

    // This is the backend's own runtime, from here until its process exits: usually the whole of what a
    // user perceives as "pmpx is slow", and never pmpx's own time.
    let running = Instant::now();
    let status = cmd.status().map_err(|source| EngineError::Start {
        program: plan.program.clone(),
        source,
    })?;
    events(Event::Phase {
        name: "backend.run",
        micros: running.elapsed().as_micros(),
        detail: format!("exit {}", exit_code_of(status, &mut |_| {})),
    });

    let code = exit_code_of(status, events);
    events(Event::Finished { code });
    Ok(code)
}

/// Translate an `ExitStatus` into a process exit code.
fn exit_code_of(status: std::process::ExitStatus, events: &mut dyn FnMut(Event)) -> u8 {
    if let Some(code) = status.code() {
        if (0..=255).contains(&code) {
            return code as u8;
        }
        // On Windows an exit code can be any u32 -- `code()` reinterprets those bits as `i32`, so it is
        // reported back as unsigned, or `exit /b -1` would read as "-1" instead of 4294967295. Take the
        // low 8 bits instead of erroring: the user's script cares about "nonzero", not the value.
        events(Event::Warning(format!(
            "the backend exited with {}, which does not fit in 8 bits; passing through the low 8 bits \
             ({})",
            code as u32,
            (code & 0xFF) as u8
        )));
        return (code & 0xFF) as u8;
    }

    // Killed by a signal (Unix). Follow the shell convention: 128 + signal.
    #[cfg(unix)]
    {
        use std::os::unix::process::ExitStatusExt;
        if let Some(sig) = status.signal() {
            events(Event::Error(format!(
                "the backend was killed by signal {sig}"
            )));
            return (128 + sig).clamp(0, 255) as u8;
        }
    }

    events(Event::Error(
        "cannot read the backend exit code, treating it as 1".to_string(),
    ));
    1
}

/// Where a started program's own output goes.
///
/// `Inherit` is the normal case, and the reason pmpx captures nothing: the backend's output stays
/// live, keeps its colours (it still sees a terminal), and `pmpx build > log` keeps meaning what it
/// always meant.
///
/// `OnStderr` is for `--json`, where stdout is a JSON stream a program is parsing: the backend writes
/// to pmpx's stderr instead, so it stays live and visible without ever landing in that stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChildOutput {
    /// Hand the child pmpx's own stdin, stdout and stderr.
    Inherit,
    /// Hand the child pmpx's stderr for its stdout too, keeping pmpx's stdout for pmpx.
    OnStderr,
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Collect the events one run produced.
    fn run_collecting(plan: &Plan, cwd: &Path) -> (Result<u8, EngineError>, Vec<Event>) {
        let mut events = Vec::new();
        let code = run(plan, cwd, ChildOutput::Inherit, &mut |event| {
            events.push(event)
        });
        (code, events)
    }

    #[test]
    fn a_run_reports_what_it_did_in_order() {
        let tmp = tempfile::tempdir().unwrap();
        let plan = Plan::new("cargo").arg("--version");

        let (code, events) = run_collecting(&plan, tmp.path());

        assert_eq!(code.unwrap(), 0);

        // The phases and the three moments of the run, in the order they happen: resolving, then what
        // was resolved, then the start, then how long the backend ran, then its exit code.
        let names: Vec<String> = events
            .iter()
            .map(|event| match event {
                Event::Phase { name, .. } => (*name).to_string(),
                Event::Resolved { .. } => "resolved".to_string(),
                Event::Starting { .. } => "starting".to_string(),
                Event::Finished { .. } => "finished".to_string(),
                other => format!("{other:?}"),
            })
            .collect();
        assert_eq!(
            names,
            vec![
                "spawn.resolve",
                "resolved",
                "starting",
                "backend.run",
                "finished"
            ],
            "the run reports itself in order"
        );

        assert!(
            matches!(&events[1], Event::Resolved { program, kind, .. }
                if program == &OsString::from("cargo") && *kind == ProgramKind::Native),
            "{events:?}"
        );
        assert!(
            matches!(&events[2], Event::Starting { plan }
                if plan.program == *"cargo"
                    && plan.args.len() == 1
                    && plan.cwd.is_none()
                    && plan.working_dir(tmp.path()) == tmp.path()),
            "{events:?}"
        );
        assert_eq!(events.last(), Some(&Event::Finished { code: 0 }));
    }

    #[test]
    fn runs_a_native_command_and_returns_its_exit_code() {
        let tmp = tempfile::tempdir().unwrap();
        let plan = Plan::new("cargo").arg("--version");

        assert_eq!(
            run(&plan, tmp.path(), ChildOutput::Inherit, &mut |_| {}).unwrap(),
            0
        );
    }

    #[test]
    fn a_nonzero_exit_code_is_passed_through() {
        let tmp = tempfile::tempdir().unwrap();

        #[cfg(windows)]
        let plan = Plan::new("cmd").arg("/c").arg("exit 7");
        #[cfg(not(windows))]
        let plan = Plan::new("sh").arg("-c").arg("exit 7");

        let (code, events) = run_collecting(&plan, tmp.path());

        assert_eq!(code.unwrap(), 7, "must pass through verbatim");
        assert_eq!(events.last(), Some(&Event::Finished { code: 7 }));
    }

    /// A program that is not there is the setup kind of failure, and the message lists what was near.
    #[test]
    fn a_missing_program_is_not_found_and_says_so() {
        let tmp = tempfile::tempdir().unwrap();
        let plan = Plan::new("pmpx-definitely-not-a-real-program-xyz");

        let (code, events) = run_collecting(&plan, tmp.path());

        let error = code.expect_err("there is no such program");
        assert!(error.is_not_found(), "{error:?}");
        assert!(
            error
                .message()
                .contains("pmpx-definitely-not-a-real-program-xyz"),
            "{}",
            error.message()
        );
        assert!(
            events.is_empty(),
            "nothing ran, so nothing was reported: {events:?}"
        );
    }

    /// The working directory has to take effect -- both the `cwd` a plugin reports and the project
    /// root rely on it.
    #[test]
    fn the_working_directory_is_honoured() {
        let tmp = tempfile::tempdir().unwrap();
        let plan = Plan::new("cargo").arg("--version").cwd(tmp.path());

        let mut cmd = command_for(
            &plan,
            Path::new("/definitely/not/here"),
            ChildOutput::Inherit,
        )
        .unwrap();
        assert!(cmd.output().unwrap().status.success());
    }

    // ---- really running a cmd shim (Windows) --------------------------------

    /// Really run a `.cmd` and confirm the arguments arrive.
    #[cfg(windows)]
    #[test]
    fn a_cmd_shim_really_runs_and_receives_its_args() {
        let tmp = tempfile::tempdir().unwrap();
        let out_file = tmp.path().join("got.txt");
        let shim = tmp.path().join("probe.cmd");
        std::fs::write(
            &shim,
            format!("@echo off\r\necho %1 %2 > \"{}\"\r\n", out_file.display()),
        )
        .unwrap();

        let plan = Plan::new(shim.as_os_str()).arg("add").arg("serde");
        let (code, events) = run_collecting(&plan, tmp.path());

        assert_eq!(code.unwrap(), 0);
        assert!(
            events
                .iter()
                .any(|event| matches!(event, Event::Resolved { kind, .. } if *kind == ProgramKind::CmdShim)),
            "a `.cmd` is spawned through cmd.exe: {events:?}"
        );
        assert_eq!(
            std::fs::read_to_string(&out_file).unwrap().trim(),
            "add serde"
        );
    }

    /// A `.cmd` has to run from a path with spaces too.
    #[cfg(windows)]
    #[test]
    fn a_cmd_shim_in_a_path_with_spaces_still_runs() {
        let tmp = tempfile::tempdir().unwrap();
        let dir = tmp.path().join("a dir with spaces");
        std::fs::create_dir_all(&dir).unwrap();

        let shim = dir.join("probe.cmd");
        std::fs::write(&shim, "@echo off\r\nexit 0\r\n").unwrap();

        let plan = Plan::new(shim.as_os_str());
        assert_eq!(
            run(&plan, tmp.path(), ChildOutput::Inherit, &mut |_| {}).unwrap(),
            0
        );
    }

    /// An argument with spaces has to arrive verbatim too.
    #[cfg(windows)]
    #[test]
    fn a_cmd_shim_receives_an_argument_with_spaces() {
        let tmp = tempfile::tempdir().unwrap();
        let out_file = tmp.path().join("got.txt");
        let shim = tmp.path().join("probe.cmd");
        std::fs::write(
            &shim,
            format!("@echo off\r\necho %~1 > \"{}\"\r\n", out_file.display()),
        )
        .unwrap();

        let plan = Plan::new(shim.as_os_str()).arg("hello world");

        assert_eq!(
            run(&plan, tmp.path(), ChildOutput::Inherit, &mut |_| {}).unwrap(),
            0
        );
        assert_eq!(
            std::fs::read_to_string(&out_file).unwrap().trim(),
            "hello world"
        );
    }

    #[cfg(windows)]
    #[test]
    fn a_cmd_shim_passes_through_a_nonzero_exit_code() {
        let tmp = tempfile::tempdir().unwrap();
        let shim = tmp.path().join("probe.cmd");
        std::fs::write(&shim, "@echo off\r\nexit /b 42\r\n").unwrap();

        let plan = Plan::new(shim.as_os_str());
        assert_eq!(
            run(&plan, tmp.path(), ChildOutput::Inherit, &mut |_| {}).unwrap(),
            42
        );
    }

    /// `&`, `|`, `>` and `^` are separators, pipes, redirections and escapes to `cmd.exe`, which
    /// re-parses the whole line before the batch file runs.
    #[cfg(windows)]
    #[test]
    fn cmd_metacharacters_survive_into_a_shim() {
        let tmp = tempfile::tempdir().unwrap();
        let out_file = tmp.path().join("got.txt");
        let shim = tmp.path().join("probe.cmd");
        std::fs::write(
            &shim,
            format!(
                "@echo off\r\necho \"[%~1]\" > \"{}\"\r\n",
                out_file.display()
            ),
        )
        .unwrap();

        for arg in ["a&b", "a|b", "a>b", "^caret"] {
            let plan = Plan::new(shim.as_os_str()).arg(arg);
            assert_eq!(
                run(&plan, tmp.path(), ChildOutput::Inherit, &mut |_| {}).unwrap(),
                0,
                "the shim failed on {arg}"
            );

            let got = std::fs::read_to_string(&out_file).unwrap();
            assert_eq!(
                got.trim(),
                format!("\"[{arg}]\""),
                "cmd re-parsed the argument {arg}"
            );
        }
    }
}