use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use color_eyre::eyre::{Result, eyre};
use super::ActionMode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedCommand {
pub argv: Vec<String>,
pub mode: ActionMode,
}
pub(super) fn fork(command: &PreparedCommand) -> Result<()> {
let mut child = Command::new(&command.argv[0])
.args(&command.argv[1..])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|err| spawn_error(&command.argv[0], err))?;
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
pub fn exec(command: PreparedCommand) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = Command::new(&command.argv[0])
.args(&command.argv[1..])
.exec();
Err(spawn_error(&command.argv[0], err))
}
#[cfg(not(unix))]
{
let status = Command::new(&command.argv[0])
.args(&command.argv[1..])
.status()
.map_err(|err| spawn_error(&command.argv[0], err))?;
if status.success() {
Ok(())
} else {
Err(eyre!("process exited with status {status}"))
}
}
}
fn spawn_error(program: &str, err: std::io::Error) -> color_eyre::eyre::Report {
if err.kind() == std::io::ErrorKind::NotFound {
eyre!("command `{program}` not found")
} else {
eyre!("could not start `{program}`: {err}")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Requirement {
pub command: String,
pub optional: bool,
}
impl Requirement {
pub fn parse(raw: &str) -> Result<Self> {
let trimmed = raw.trim();
let mut parts = trimmed.split(',');
let command = parts.next().unwrap_or_default().trim();
if command.is_empty() {
return Err(eyre!("requirement `{raw}` has an empty program name"));
}
let Some(suffix) = parts.next() else {
return Ok(Self {
command: command.to_string(),
optional: false,
});
};
if parts.next().is_some() {
return Err(eyre!(
"requirement `{raw}` has more than one `,`; \
write `<program>` or `<program>, optional`"
));
}
if !suffix.trim().eq_ignore_ascii_case("optional") {
return Err(eyre!(
"requirement `{raw}` has an unsupported suffix `{}`; \
the only supported suffix is `, optional`",
suffix.trim()
));
}
Ok(Self {
command: command.to_string(),
optional: true,
})
}
}
impl std::fmt::Display for Requirement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.command)?;
if self.optional {
f.write_str(", optional")?;
}
Ok(())
}
}
pub(super) fn missing_requirement(requirements: &[Requirement]) -> Option<&str> {
requirements
.iter()
.filter(|requirement| !requirement.optional)
.find(|requirement| locate(&requirement.command).is_none())
.map(|requirement| requirement.command.as_str())
}
pub fn locate(program: &str) -> Option<PathBuf> {
if program.is_empty() {
return None;
}
if program.chars().any(std::path::is_separator) {
let path = PathBuf::from(program);
return is_executable(&path).then_some(path);
}
let paths = std::env::var_os("PATH")?;
std::env::split_paths(&paths)
.flat_map(|dir| candidates_in(&dir, program))
.find(|candidate| is_executable(candidate))
}
#[cfg(windows)]
fn candidates_in(dir: &Path, program: &str) -> Vec<PathBuf> {
let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
let mut candidates = vec![dir.join(program)];
candidates.extend(
pathext
.split(';')
.filter(|ext| !ext.is_empty())
.map(|ext| dir.join(format!("{program}{ext}"))),
);
candidates
}
#[cfg(not(windows))]
fn candidates_in(dir: &Path, program: &str) -> Vec<PathBuf> {
vec![dir.join(program)]
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
path.is_file()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_a_bare_program_and_the_optional_suffix() {
assert_eq!(
Requirement::parse("xdg-open").unwrap(),
Requirement {
command: "xdg-open".to_string(),
optional: false,
}
);
assert_eq!(
Requirement::parse(" browser , Optional ").unwrap(),
Requirement {
command: "browser".to_string(),
optional: true,
}
);
}
#[test]
fn parse_rejects_every_other_suffix_and_shape() {
for raw in ["foo, please", "foo, optionally", "foo, optional!", "foo,"] {
assert!(Requirement::parse(raw).is_err(), "`{raw}` must be rejected");
}
assert!(
Requirement::parse("foo, optional, bar")
.unwrap_err()
.to_string()
.contains("more than one `,`")
);
assert!(
Requirement::parse("foo,,optional")
.unwrap_err()
.to_string()
.contains("more than one `,`")
);
assert!(
Requirement::parse("foo, please")
.unwrap_err()
.to_string()
.contains("unsupported suffix `please`")
);
}
#[test]
fn parse_rejects_an_empty_program_name() {
for raw in ["", " ", ",", ", optional", " , optional"] {
assert!(
Requirement::parse(raw)
.unwrap_err()
.to_string()
.contains("empty program name"),
"`{raw}` must be rejected"
);
}
}
#[test]
fn display_round_trips_the_requirement_grammar() {
for raw in ["xdg-open", "browser, optional"] {
assert_eq!(Requirement::parse(raw).unwrap().to_string(), raw);
}
}
#[cfg(windows)]
const PRESENT_COMMAND: &str = "cmd.exe";
#[cfg(not(windows))]
const PRESENT_COMMAND: &str = "sh";
fn requirements(raw: &[&str]) -> Vec<Requirement> {
raw.iter()
.map(|entry| Requirement::parse(entry).expect("valid requirement"))
.collect()
}
#[test]
fn locate_resolves_absolute_paths_and_path_lookups() {
let exe = std::env::current_exe().unwrap();
assert!(locate(exe.to_str().unwrap()).is_some());
assert!(locate(PRESENT_COMMAND).is_some());
assert!(locate("kinjo-no-such-binary-xyz").is_none());
assert!(locate("/no/such/absolute/path/xyz").is_none());
assert!(locate("").is_none());
}
#[cfg(windows)]
#[test]
fn locate_resolves_bare_names_via_pathext() {
assert!(locate("cmd").is_some());
}
#[test]
fn missing_requirement_skips_optional_and_present_commands() {
assert_eq!(missing_requirement(&[]), None);
assert_eq!(missing_requirement(&requirements(&[PRESENT_COMMAND])), None);
assert_eq!(
missing_requirement(&requirements(&["definitely-absent-xyz, optional"])),
None
);
assert_eq!(
missing_requirement(&requirements(&[PRESENT_COMMAND, "definitely-absent-xyz"])),
Some("definitely-absent-xyz")
);
}
#[test]
fn fork_reports_a_missing_binary() {
let command = PreparedCommand {
argv: vec!["kinjo-no-such-binary-xyz".to_string()],
mode: ActionMode::Fork,
};
let err = fork(&command).unwrap_err();
assert!(
err.to_string()
.contains("command `kinjo-no-such-binary-xyz` not found")
);
}
}