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;
#[cfg(feature = "store")]
pub mod detect;
#[cfg(feature = "store")]
pub mod session;
#[cfg(feature = "store")]
pub mod flow;
#[cfg(feature = "store")]
pub use flow::{run_script, run_verb};
#[cfg(feature = "store")]
pub use session::{Options, Session};
#[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;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Plan {
pub program: OsString,
pub args: Vec<OsString>,
pub cwd: Option<PathBuf>,
}
impl Plan {
pub fn new(program: impl Into<OsString>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
cwd: None,
}
}
pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
self.args.push(arg.into());
self
}
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
}
pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
self.cwd = Some(dir.into());
self
}
pub fn working_dir<'a>(&'a self, fallback: &'a Path) -> &'a Path {
self.cwd.as_deref().unwrap_or(fallback)
}
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
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
Resolved {
program: OsString,
path: Option<PathBuf>,
kind: ProgramKind,
},
Starting {
plan: Plan,
},
Finished {
code: u8,
},
Warning(String),
Note(String),
Error(String),
Phase {
name: &'static str,
micros: u128,
detail: String,
},
Notes {
plugin: String,
notes: Vec<String>,
},
PluginMessage {
level: u32,
text: String,
},
}
pub fn run(
plan: &Plan,
fallback_cwd: &Path,
child_output: ChildOutput,
events: &mut dyn FnMut(Event),
) -> Result<u8, EngineError> {
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)?;
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)
}
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;
}
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;
}
#[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
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChildOutput {
Inherit,
OnStderr,
}
#[cfg(test)]
mod tests {
use super::*;
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);
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 }));
}
#[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:?}"
);
}
#[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());
}
#[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"
);
}
#[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
);
}
#[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
);
}
#[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}"
);
}
}
}