use std::ffi::OsString;
#[cfg(any(windows, test))]
use std::ffi::OsStr;
#[cfg(any(windows, test))]
use std::path::{Path, PathBuf};
use tokio::process::Command;
use super::ToolContext;
use crate::Result;
#[cfg(windows)]
use crate::RuntimeError;
pub(crate) fn unix_tool_command(name: &str, ctx: &ToolContext) -> Result<Command> {
let env = ctx.capabilities.env.as_ref();
#[cfg(windows)]
let program = {
let path_var = match env {
Some(env) => env_lookup(&env.vars, "PATH").map(OsString::from),
None => std::env::var_os("PATH"),
};
let system_root = std::env::var_os("SystemRoot").map(PathBuf::from);
resolve_windows(name, path_var.as_deref(), system_root.as_deref(), &git_roots(path_var.as_deref()))
.ok_or_else(|| RuntimeError::Tool(missing_message(name)))?
.into_os_string()
};
#[cfg(not(windows))]
let program = OsString::from(name);
let mut cmd = Command::new(program);
if let Some(env) = env {
cmd.env_clear().envs(env.spawn_vars());
}
#[cfg(windows)]
{
let current = match env {
Some(env) => env_lookup(&env.vars, "MSYS").map(OsString::from),
None => std::env::var_os("MSYS"),
};
cmd.env("MSYS", msys_with_noglob(current.as_deref()));
}
Ok(cmd)
}
#[cfg(any(windows, test))]
fn missing_message(name: &str) -> String {
format!(
"The {name} tool needs a GNU {name}.exe, and none was found. On Windows it runs Git for \
Windows' usr\\bin\\{name}.exe: install Git for Windows, or use the powershell tool instead."
)
}
#[cfg(any(windows, test))]
fn env_lookup<'a>(env: &'a [(String, String)], key: &str) -> Option<&'a str> {
env.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, v)| v.as_str())
}
#[cfg(any(windows, test))]
fn msys_with_noglob(current: Option<&OsStr>) -> OsString {
match current.map(|c| c.to_string_lossy().trim().to_string()) {
Some(c) if c.split_whitespace().any(|o| o == "noglob") => OsString::from(c),
Some(c) if !c.is_empty() => OsString::from(format!("{c} noglob")),
_ => OsString::from("noglob"),
}
}
#[cfg(windows)]
fn git_roots(path_var: Option<&OsStr>) -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Some(path_var) = path_var {
for dir in std::env::split_paths(path_var) {
if dir.join("git.exe").is_file() {
roots.extend(git_root_from_git_dir(&dir));
}
}
}
for var in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
if let Some(pf) = std::env::var_os(var) {
roots.push(Path::new(&pf).join("Git"));
}
}
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
roots.push(Path::new(&local).join(r"Programs\Git"));
}
roots
}
#[cfg(any(windows, test))]
fn git_root_from_git_dir(dir: &Path) -> Option<PathBuf> {
let leaf = dir.file_name()?.to_string_lossy().to_ascii_lowercase();
let parent = dir.parent()?;
match leaf.as_str() {
"cmd" => Some(parent.to_path_buf()),
"bin" => {
let up = parent.file_name()?.to_string_lossy().to_ascii_lowercase();
if up == "mingw64" || up == "mingw32" || up == "clangarm64" {
parent.parent().map(Path::to_path_buf)
} else {
Some(parent.to_path_buf())
}
}
_ => None,
}
}
#[cfg(any(windows, test))]
fn resolve_windows(
name: &str,
path_var: Option<&OsStr>,
system_root: Option<&Path>,
git_roots: &[PathBuf],
) -> Option<PathBuf> {
let exe = format!("{name}.exe");
let in_system_root = |dir: &Path| {
system_root.is_some_and(|root| {
let dir = dir.to_string_lossy().to_ascii_lowercase();
let root = root.to_string_lossy().to_ascii_lowercase();
dir == root || dir.starts_with(&format!("{}\\", root.trim_end_matches('\\')))
|| dir.starts_with(&format!("{}/", root.trim_end_matches('/')))
})
};
if let Some(path_var) = path_var {
for dir in std::env::split_paths(path_var) {
if dir.as_os_str().is_empty() || in_system_root(&dir) {
continue;
}
let candidate = dir.join(&exe);
if candidate.is_file() {
return Some(candidate);
}
}
}
git_roots
.iter()
.map(|root| root.join("usr").join("bin").join(&exe))
.find(|candidate| candidate.is_file())
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(path: &Path) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, b"").unwrap();
}
fn path_of(dirs: &[&Path]) -> OsString {
std::env::join_paths(dirs).unwrap()
}
#[test]
fn the_windows_dir_is_skipped_for_git_usr_bin() {
let tmp = tempfile::tempdir().unwrap();
let windows = tmp.path().join("Windows");
let system32 = windows.join("System32");
touch(&system32.join("ls.exe"));
let git = tmp.path().join("Git");
touch(&git.join(r"usr").join("bin").join("ls.exe"));
let path = path_of(&[&system32]);
let found = resolve_windows("ls", Some(&path), Some(&windows), std::slice::from_ref(&git));
assert_eq!(found, Some(git.join("usr").join("bin").join("ls.exe")));
}
#[test]
fn a_gnu_tool_on_path_outside_the_windows_dir_wins() {
let tmp = tempfile::tempdir().unwrap();
let tools = tmp.path().join("gnu");
touch(&tools.join("ls.exe"));
let git = tmp.path().join("Git");
touch(&git.join("usr").join("bin").join("ls.exe"));
let path = path_of(&[&tools]);
let found = resolve_windows("ls", Some(&path), None, &[git]);
assert_eq!(found, Some(tools.join("ls.exe")));
}
#[test]
fn nothing_found_is_none_and_the_message_points_at_git_and_powershell() {
let tmp = tempfile::tempdir().unwrap();
let windows = tmp.path().join("Windows");
let system32 = windows.join("System32");
touch(&system32.join("ls.exe"));
let path = path_of(&[&system32]);
assert_eq!(resolve_windows("ls", Some(&path), Some(&windows), &[]), None);
assert_eq!(resolve_windows("ls", None, None, &[tmp.path().join("NoGit")]), None);
let msg = missing_message("ls");
assert!(msg.contains("Git for Windows") && msg.contains("powershell"), "{msg}");
}
#[test]
fn git_root_is_derived_from_where_git_exe_lives() {
let root = Path::new("Git");
assert_eq!(git_root_from_git_dir(&root.join("cmd")), Some(root.to_path_buf()));
assert_eq!(git_root_from_git_dir(&root.join("bin")), Some(root.to_path_buf()));
assert_eq!(
git_root_from_git_dir(&root.join("mingw64").join("bin")),
Some(root.to_path_buf())
);
assert_eq!(git_root_from_git_dir(&root.join("libexec")), None);
}
#[test]
fn msys_gets_noglob_once_and_keeps_other_options() {
assert_eq!(msys_with_noglob(None), OsString::from("noglob"));
assert_eq!(msys_with_noglob(Some(OsStr::new(""))), OsString::from("noglob"));
assert_eq!(
msys_with_noglob(Some(OsStr::new("winsymlinks:nativestrict"))),
OsString::from("winsymlinks:nativestrict noglob")
);
assert_eq!(
msys_with_noglob(Some(OsStr::new("noglob winsymlinks:native"))),
OsString::from("noglob winsymlinks:native")
);
}
#[test]
fn env_lookup_ignores_case() {
let env = vec![("Path".to_string(), "a".to_string()), ("msys".to_string(), "b".to_string())];
assert_eq!(env_lookup(&env, "PATH"), Some("a"));
assert_eq!(env_lookup(&env, "MSYS"), Some("b"));
assert_eq!(env_lookup(&env, "HOME"), None);
}
#[cfg(not(windows))]
#[test]
fn unix_command_is_the_plain_program_name() {
let ctx = super::super::test_helpers::create_tool_context();
let cmd = unix_tool_command("ls", &ctx).unwrap();
assert_eq!(cmd.as_std().get_program(), OsStr::new("ls"));
}
}