1use 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#[cfg(feature = "store")]
44pub mod detect;
45
46#[cfg(feature = "store")]
48pub mod session;
49
50#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
84pub struct Plan {
85 pub program: OsString,
88 pub args: Vec<OsString>,
90 pub cwd: Option<PathBuf>,
92}
93
94impl Plan {
95 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 pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
106 self.args.push(arg.into());
107 self
108 }
109
110 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 pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
122 self.cwd = Some(dir.into());
123 self
124 }
125
126 pub fn working_dir<'a>(&'a self, fallback: &'a Path) -> &'a Path {
128 self.cwd.as_deref().unwrap_or(fallback)
129 }
130
131 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#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum Event {
145 Resolved {
147 program: OsString,
149 path: Option<PathBuf>,
151 kind: ProgramKind,
153 },
154
155 Starting {
157 plan: Plan,
159 },
160
161 Finished {
163 code: u8,
165 },
166
167 Warning(String),
169
170 Note(String),
172
173 Error(String),
175
176 Phase {
181 name: &'static str,
183 micros: u128,
185 detail: String,
187 },
188
189 Notes {
191 plugin: String,
193 notes: Vec<String>,
195 },
196
197 PluginMessage {
202 level: u32,
204 text: String,
206 },
207}
208
209pub fn run(
217 plan: &Plan,
218 fallback_cwd: &Path,
219 child_output: ChildOutput,
220 events: &mut dyn FnMut(Event),
221) -> Result<u8, EngineError> {
222 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 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
259fn 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 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum ChildOutput {
305 Inherit,
307 OnStderr,
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 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 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 #[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 #[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 #[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 #[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 #[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 #[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}