Skip to main content

mant_engine/
executable.rs

1//! Resolves directly runnable programs using native host conventions.
2
3use std::{collections::BTreeMap, env, ffi::OsStr, path::PathBuf};
4
5#[cfg(unix)]
6use std::os::unix::fs::PermissionsExt;
7
8/// Look up an environment value while respecting Windows' case-insensitive
9/// variable names.
10pub(crate) fn environment_value<'a>(
11    environment: &'a BTreeMap<String, String>,
12    name: &str,
13) -> Option<&'a str> {
14    if let Some(value) = environment.get(name) {
15        return Some(value.as_str());
16    }
17
18    #[cfg(windows)]
19    {
20        environment
21            .iter()
22            .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
23            .map(|(_, value)| value.as_str())
24    }
25    #[cfg(not(windows))]
26    {
27        None
28    }
29}
30
31/// Find a program that the current host can execute directly.
32pub(crate) fn find_executable(
33    name: &str,
34    environment: &BTreeMap<String, String>,
35) -> Option<PathBuf> {
36    let path = environment_value(environment, "PATH")?;
37    let names = executable_names(name, environment);
38    env::split_paths(OsStr::new(path))
39        .flat_map(|directory| names.iter().map(move |name| directory.join(name)))
40        .find(|candidate| is_executable(candidate))
41}
42
43/// Locate one directly runnable program using the current host's `PATH` and
44/// native executable-suffix rules without spawning it.
45#[must_use]
46pub fn find_host_executable(name: &str) -> Option<PathBuf> {
47    let environment = env::vars().collect::<BTreeMap<_, _>>();
48    find_executable(name, &environment)
49}
50
51#[cfg(unix)]
52fn executable_names(name: &str, _environment: &BTreeMap<String, String>) -> Vec<String> {
53    vec![name.to_owned()]
54}
55
56#[cfg(windows)]
57fn executable_names(name: &str, environment: &BTreeMap<String, String>) -> Vec<String> {
58    if std::path::Path::new(name).extension().is_some() {
59        return vec![name.to_owned()];
60    }
61    windows_name_candidates(name, environment_value(environment, "PATHEXT"))
62        .into_iter()
63        .skip(1)
64        .collect()
65}
66
67#[cfg(not(any(unix, windows)))]
68fn executable_names(name: &str, _environment: &BTreeMap<String, String>) -> Vec<String> {
69    vec![name.to_owned()]
70}
71
72#[cfg(unix)]
73fn is_executable(path: &std::path::Path) -> bool {
74    path.metadata()
75        .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
76}
77
78#[cfg(windows)]
79fn is_executable(path: &std::path::Path) -> bool {
80    path.is_file()
81}
82
83/// Names that preserve an exact document lookup before applying native host
84/// command-suffix conventions.
85pub(crate) fn query_name_candidates(name: &str) -> Vec<String> {
86    #[cfg(windows)]
87    {
88        windows_name_candidates(name, env::var("PATHEXT").ok().as_deref())
89    }
90    #[cfg(not(windows))]
91    {
92        vec![name.to_owned()]
93    }
94}
95
96/// Model Windows command-name elision without depending on the build host.
97///
98/// The exact name remains first because registered documents may intentionally
99/// have no executable suffix. Only extensionless names are expanded.
100#[cfg(any(windows, test))]
101fn windows_name_candidates(name: &str, pathext: Option<&str>) -> Vec<String> {
102    let mut candidates = vec![name.to_owned()];
103    if std::path::Path::new(name).extension().is_some() {
104        return candidates;
105    }
106
107    let extensions = pathext
108        .filter(|value| !value.trim().is_empty())
109        .unwrap_or(".COM;.EXE;.BAT;.CMD");
110    for extension in extensions.split(';').map(str::trim) {
111        if extension.is_empty() || extension.contains(['/', '\\']) {
112            continue;
113        }
114        let extension = if extension.starts_with('.') {
115            extension.to_owned()
116        } else {
117            format!(".{extension}")
118        };
119        let candidate = format!("{name}{extension}");
120        if !candidates
121            .iter()
122            .any(|existing| existing.eq_ignore_ascii_case(&candidate))
123        {
124            candidates.push(candidate);
125        }
126    }
127    candidates
128}
129
130#[cfg(test)]
131mod tests {
132    use super::windows_name_candidates;
133
134    #[test]
135    fn windows_candidates_keep_exact_names_before_pathext_order() {
136        assert_eq!(
137            windows_name_candidates("cargo", Some(".EXE;.CMD;.PS1")),
138            ["cargo", "cargo.EXE", "cargo.CMD", "cargo.PS1"]
139        );
140    }
141
142    #[test]
143    fn windows_candidates_do_not_expand_an_explicit_suffix() {
144        assert_eq!(
145            windows_name_candidates("where.exe", Some(".EXE;.CMD")),
146            ["where.exe"]
147        );
148    }
149
150    #[test]
151    fn windows_candidates_use_the_native_default_when_pathext_is_absent() {
152        assert_eq!(
153            windows_name_candidates("tool", None),
154            ["tool", "tool.COM", "tool.EXE", "tool.BAT", "tool.CMD"]
155        );
156    }
157
158    #[test]
159    fn windows_candidates_normalise_and_deduplicate_extensions() {
160        assert_eq!(
161            windows_name_candidates("tool", Some(" EXE ;.exe;.CMD;bad/path;.PS1 ")),
162            ["tool", "tool.EXE", "tool.CMD", "tool.PS1"]
163        );
164    }
165
166    #[cfg(not(windows))]
167    #[test]
168    fn non_windows_queries_never_elide_executable_suffixes() {
169        assert_eq!(super::query_name_candidates("tool"), ["tool"]);
170    }
171}
172
173#[cfg(not(any(unix, windows)))]
174fn is_executable(path: &std::path::Path) -> bool {
175    path.is_file()
176}