use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
pub const NAME: &str = "Path";
pub trait PathSink {
fn read(&self) -> Result<Option<String>, String>;
fn write(&self, value: &str) -> Result<(), String>;
}
#[cfg(windows)]
pub struct RegistryPath;
#[cfg(windows)]
impl PathSink for RegistryPath {
fn read(&self) -> Result<Option<String>, String> {
crate::core::login_env::registry::read(NAME)
}
fn write(&self, value: &str) -> Result<(), String> {
crate::core::login_env::registry::apply_expand(NAME, value)
}
}
pub fn os_sink() -> Option<Box<dyn PathSink>> {
#[cfg(windows)]
{
Some(Box::new(RegistryPath))
}
#[cfg(not(windows))]
{
None
}
}
fn expand_with(raw: &str, lookup: &dyn Fn(&str) -> Option<String>) -> String {
let mut out = String::with_capacity(raw.len());
let mut rest = raw;
while let Some(start) = rest.find('%') {
out.push_str(&rest[..start]);
let after = &rest[start + 1..];
match after.find('%') {
Some(end) if end > 0 => match lookup(&after[..end]) {
Some(value) => {
out.push_str(&value);
rest = &after[end + 1..];
}
None => {
out.push('%');
rest = after;
}
},
_ => {
out.push('%');
rest = after;
}
}
}
out.push_str(rest);
out
}
fn env_lookup(name: &str) -> Option<String> {
std::env::var(name).ok()
}
fn normalize(entry: &str, lookup: &dyn Fn(&str) -> Option<String>) -> String {
expand_with(entry.trim(), lookup)
.replace('/', "\\")
.trim_end_matches('\\')
.to_lowercase()
}
fn same(a: &str, b: &str, lookup: &dyn Fn(&str) -> Option<String>) -> bool {
let a = normalize(a, lookup);
!a.is_empty() && a == normalize(b, lookup)
}
pub fn is_first(path: &str, entry: &str) -> bool {
path.split(';')
.find(|e| !e.trim().is_empty())
.is_some_and(|first| same(first, entry, &env_lookup))
}
pub fn contains(path: &str, entry: &str) -> bool {
path.split(';').any(|e| same(e, entry, &env_lookup))
}
pub fn remove_entry(path: &str, entry: &str) -> String {
path.split(';')
.filter(|e| !same(e, entry, &env_lookup))
.collect::<Vec<_>>()
.join(";")
}
pub fn prepend_entry(path: &str, entry: &str) -> String {
let rest = remove_entry(path, entry);
if rest.trim_matches(';').is_empty() {
entry.to_string()
} else {
format!("{entry};{}", rest.trim_start_matches(';'))
}
}
pub fn ensure_first(sink: &dyn PathSink, entry: &Path) -> Result<bool, String> {
let entry = entry.display().to_string();
let current = sink.read()?.unwrap_or_default();
if is_first(¤t, &entry) {
return Ok(false);
}
sink.write(&prepend_entry(¤t, &entry))?;
Ok(true)
}
pub fn remove(sink: &dyn PathSink, entry: &Path) -> Result<bool, String> {
let entry = entry.display().to_string();
let Some(current) = sink.read()? else {
return Ok(false);
};
if !contains(¤t, &entry) {
return Ok(false);
}
sink.write(&remove_entry(¤t, &entry))?;
Ok(true)
}
pub fn os_is_first(entry: &Path) -> bool {
os_sink()
.and_then(|s| s.read().ok().flatten())
.is_some_and(|p| is_first(&p, &entry.display().to_string()))
}
pub fn effective_path_from(process: &OsStr, user: Option<&str>, skip: &Path) -> OsString {
let skip = skip.display().to_string();
let mut seen: Vec<String> = Vec::new();
let mut out: Vec<PathBuf> = Vec::new();
let user_dirs = user
.into_iter()
.flat_map(|u| u.split(';'))
.map(|e| PathBuf::from(expand_with(e.trim(), &env_lookup)));
for dir in std::env::split_paths(process).chain(user_dirs) {
let text = dir.display().to_string();
let key = normalize(&text, &env_lookup);
if key.is_empty() || same(&text, &skip, &env_lookup) || seen.contains(&key) {
continue;
}
seen.push(key);
out.push(dir);
}
std::env::join_paths(out).unwrap_or_default()
}
pub fn effective_path() -> OsString {
let process = std::env::var_os("PATH").unwrap_or_default();
let user = os_sink().and_then(|s| s.read().ok().flatten());
let skip = crate::hooks::cli_shims_install::shims_dir(&crate::config::openlatch_dir());
effective_path_from(&process, user.as_deref(), &skip)
}
pub const DEFAULT_PATHEXT: &str = ".COM;.EXE;.BAT;.CMD";
#[cfg(windows)]
const MACHINE_ENV_KEY: &str = r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment";
#[cfg(windows)]
fn read_machine_env(name: &str) -> Result<Option<(String, bool)>, String> {
crate::core::login_env::registry::read_value(
windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE,
MACHINE_ENV_KEY,
name,
)
.map_err(|e| format!("HKLM: {e}"))
}
#[cfg(windows)]
pub fn machine_path() -> Result<Option<String>, String> {
Ok(read_machine_env(NAME)?.map(|(raw, expand)| {
if expand {
expand_with(&raw, &env_lookup)
} else {
raw
}
}))
}
pub fn combined_dirs(machine: Option<&str>, user: Option<&str>) -> Vec<PathBuf> {
combined_dirs_with(machine, user, &env_lookup)
}
fn combined_dirs_with(
machine: Option<&str>,
user: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Vec<PathBuf> {
let machine_dirs = machine
.into_iter()
.flat_map(|m| m.split(';'))
.map(str::to_string);
let user_dirs = user
.into_iter()
.flat_map(|u| u.split(';'))
.map(|e| expand_with(e.trim(), lookup));
let mut seen: Vec<String> = Vec::new();
let mut out: Vec<PathBuf> = Vec::new();
for entry in machine_dirs.chain(user_dirs) {
let entry = entry.trim().trim_matches('"').to_string();
let key = normalize(&entry, &|_| None);
if key.is_empty() || seen.contains(&key) {
continue;
}
seen.push(key);
out.push(PathBuf::from(entry));
}
out
}
pub fn present(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|m| !m.is_dir())
}
pub fn first_hit(
dirs: &[PathBuf],
name: &str,
pathext: &str,
stat: &dyn Fn(&Path) -> bool,
) -> Option<PathBuf> {
let exts: Vec<String> = pathext
.split(';')
.map(|e| e.trim().to_lowercase())
.filter(|e| !e.is_empty())
.collect();
dirs.iter().find_map(|dir| {
exts.iter()
.map(|ext| dir.join(format!("{name}{ext}")))
.find(|candidate| stat(candidate))
})
}
pub fn shadowing_in(
dirs: &[PathBuf],
name: &str,
pathext: &str,
shims_dir: &Path,
stat: &dyn Fn(&Path) -> bool,
) -> Option<PathBuf> {
let hit = first_hit(dirs, name, pathext, stat)?;
let dir = hit.parent()?.display().to_string();
(!same(&dir, &shims_dir.display().to_string(), &env_lookup)).then_some(hit)
}
pub fn pathext_of(machine: Option<String>, process: Option<String>) -> String {
let set = |v: Option<String>| v.filter(|p| !p.trim().is_empty());
set(machine)
.or_else(|| set(process))
.unwrap_or_else(|| DEFAULT_PATHEXT.to_string())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SearchPath {
pub dirs: Vec<PathBuf>,
pub pathext: String,
pub machine_unreadable: Option<String>,
}
impl SearchPath {
pub fn read() -> Self {
#[cfg(windows)]
{
let (machine, machine_unreadable) = match machine_path() {
Ok(m) => (m, None),
Err(e) => (None, Some(e)),
};
let user = os_sink().and_then(|s| s.read().ok().flatten());
let pathext = pathext_of(
read_machine_env("PATHEXT")
.ok()
.flatten()
.map(|(raw, _)| raw),
std::env::var("PATHEXT").ok(),
);
Self {
dirs: combined_dirs(machine.as_deref(), user.as_deref()),
pathext,
machine_unreadable,
}
}
#[cfg(not(windows))]
{
Self {
pathext: DEFAULT_PATHEXT.to_string(),
..Self::default()
}
}
}
pub fn shadowing(&self, name: &str, shims_dir: &Path) -> Option<PathBuf> {
shadowing_in(&self.dirs, name, &self.pathext, shims_dir, &present)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct Recording {
value: Mutex<Option<String>>,
writes: Mutex<Vec<String>>,
}
impl PathSink for Recording {
fn read(&self) -> Result<Option<String>, String> {
Ok(self.value.lock().unwrap().clone())
}
fn write(&self, value: &str) -> Result<(), String> {
*self.value.lock().unwrap() = Some(value.to_string());
self.writes.lock().unwrap().push(value.to_string());
Ok(())
}
}
fn no_vars(_: &str) -> Option<String> {
None
}
#[test]
fn expansion_replaces_known_names_and_keeps_the_rest() {
let lookup = |n: &str| (n == "HOME_X").then(|| "C:\\Users\\a".to_string());
assert_eq!(expand_with("%HOME_X%\\bin", &lookup), "C:\\Users\\a\\bin");
assert_eq!(expand_with("%NOPE%\\bin", &lookup), "%NOPE%\\bin");
assert_eq!(expand_with("50%", &lookup), "50%");
assert_eq!(expand_with("%%", &no_vars), "%%");
}
#[test]
fn entries_compare_case_insensitively_without_trailing_separator() {
assert!(same("C:\\OL\\shims\\", "c:/ol/SHIMS", &no_vars));
assert!(!same("", "", &no_vars));
assert!(!same("C:\\a", "C:\\ab", &no_vars));
}
#[test]
fn prepend_puts_the_entry_first_once() {
assert_eq!(prepend_entry("", "C:\\s"), "C:\\s");
assert_eq!(prepend_entry("C:\\a;C:\\b", "C:\\s"), "C:\\s;C:\\a;C:\\b");
assert_eq!(
prepend_entry("C:\\s;C:\\a", "C:\\s"),
"C:\\s;C:\\a",
"idempotent"
);
assert_eq!(
prepend_entry("C:\\a;c:\\S\\;C:\\b", "C:\\s"),
"C:\\s;C:\\a;C:\\b",
"a later spelling moves back to the front"
);
}
#[test]
fn other_entries_are_kept_verbatim() {
let path = "%USERPROFILE%\\AppData\\Local\\Microsoft\\WindowsApps;C:\\tools";
assert_eq!(
prepend_entry(path, "C:\\s"),
format!("C:\\s;{path}"),
"%VAR% entries are not expanded on write"
);
assert_eq!(remove_entry(&format!("C:\\s;{path}"), "C:\\s"), path);
}
#[test]
fn is_first_skips_empty_entries() {
assert!(is_first(";C:\\s;C:\\a", "C:\\s"));
assert!(!is_first("C:\\a;C:\\s", "C:\\s"));
assert!(!is_first("", "C:\\s"));
}
#[test]
fn ensure_first_writes_only_when_needed() {
let sink = Recording::default();
assert!(ensure_first(&sink, Path::new("C:\\s")).unwrap());
assert!(!ensure_first(&sink, Path::new("C:\\s")).unwrap());
assert_eq!(sink.writes.lock().unwrap().len(), 1);
*sink.value.lock().unwrap() = Some("C:\\installer;C:\\s".into());
assert!(ensure_first(&sink, Path::new("C:\\s")).unwrap());
assert_eq!(sink.read().unwrap().as_deref(), Some("C:\\s;C:\\installer"));
}
#[test]
fn remove_drops_the_entry_and_nothing_else() {
let sink = Recording::default();
assert!(!remove(&sink, Path::new("C:\\s")).unwrap(), "unset value");
*sink.value.lock().unwrap() = Some("C:\\a".into());
assert!(!remove(&sink, Path::new("C:\\s")).unwrap(), "absent entry");
*sink.value.lock().unwrap() = Some("C:\\s;C:\\a".into());
assert!(remove(&sink, Path::new("C:\\s")).unwrap());
assert_eq!(sink.read().unwrap().as_deref(), Some("C:\\a"));
}
#[test]
fn effective_path_merges_dedups_and_skips_the_shims_folder() {
let tmp = tempfile::tempdir().unwrap();
let (a, b, shims) = (
tmp.path().join("a"),
tmp.path().join("b"),
tmp.path().join("shims"),
);
let process = std::env::join_paths([&shims, &a]).unwrap();
let user = format!("{};{}", a.display(), b.display());
let merged: Vec<PathBuf> =
std::env::split_paths(&effective_path_from(&process, Some(&user), &shims)).collect();
assert_eq!(merged, vec![a, b]);
}
fn dirs(list: &[&str]) -> Vec<PathBuf> {
list.iter().map(PathBuf::from).collect()
}
#[test]
fn combined_dirs_put_the_machine_path_before_the_user_path() {
let lookup =
|n: &str| (n == "LOCALAPPDATA").then(|| "C:\\Users\\a\\AppData\\Local".to_string());
assert_eq!(
combined_dirs_with(
Some("C:\\Windows\\system32;C:\\Program Files\\nodejs\\"),
Some("%LOCALAPPDATA%\\OpenLatch\\shims;C:\\tools"),
&lookup,
),
dirs(&[
"C:\\Windows\\system32",
"C:\\Program Files\\nodejs\\",
"C:\\Users\\a\\AppData\\Local\\OpenLatch\\shims",
"C:\\tools",
])
);
}
#[test]
fn combined_dirs_drop_duplicates_and_empty_entries() {
assert_eq!(
combined_dirs_with(
Some("C:\\a;;\"C:\\b\";C:\\A\\"),
Some("c:/b;C:\\c; "),
&no_vars
),
dirs(&["C:\\a", "C:\\b", "C:\\c"]),
"a later spelling of an earlier entry, quoted or not, is the same directory"
);
}
#[test]
fn an_unreadable_machine_path_is_treated_as_empty() {
assert_eq!(
combined_dirs_with(None, Some("C:\\s;C:\\a"), &no_vars),
dirs(&["C:\\s", "C:\\a"])
);
assert!(combined_dirs_with(None, None, &no_vars).is_empty());
}
fn at(dir: &str, file: &str) -> PathBuf {
Path::new(dir).join(file)
}
fn on_disk(files: Vec<PathBuf>) -> impl Fn(&Path) -> bool {
let lower = |p: &Path| p.display().to_string().to_lowercase();
move |p: &Path| files.iter().any(|f| lower(f) == lower(p))
}
#[test]
fn first_hit_walks_directories_before_extensions() {
let stat = on_disk(vec![
at("C:\\m", "opencode.cmd"),
at("C:\\u", "opencode.exe"),
]);
assert_eq!(
first_hit(
&dirs(&["C:\\m", "C:\\u"]),
"opencode",
DEFAULT_PATHEXT,
&stat
),
Some(at("C:\\m", "opencode.cmd")),
"an earlier directory wins whatever its extension"
);
}
#[test]
fn first_hit_follows_pathext_order_within_a_directory() {
let stat = on_disk(vec![at("C:\\d", "cline.cmd"), at("C:\\d", "cline.exe")]);
let d = dirs(&["C:\\d"]);
assert_eq!(
first_hit(&d, "cline", DEFAULT_PATHEXT, &stat),
Some(at("C:\\d", "cline.exe"))
);
assert_eq!(
first_hit(&d, "cline", ".CMD; .EXE", &stat),
Some(at("C:\\d", "cline.cmd"))
);
assert_eq!(
first_hit(&d, "cline", "", &stat),
None,
"no extension, no hit"
);
}
#[test]
fn first_hit_skips_the_bare_extensionless_name() {
let stat = on_disk(vec![
at("C:\\shims", "opencode"),
at("C:\\real", "opencode.exe"),
]);
assert_eq!(
first_hit(
&dirs(&["C:\\shims", "C:\\real"]),
"opencode",
DEFAULT_PATHEXT,
&stat
),
Some(at("C:\\real", "opencode.exe"))
);
}
#[test]
fn a_reparse_point_alias_counts_as_a_hit() {
let tmp = tempfile::tempdir().unwrap();
let alias = tmp.path().join("cursor-agent.exe");
#[cfg(unix)]
std::os::unix::fs::symlink(tmp.path().join("gone"), &alias).unwrap();
#[cfg(windows)]
if let Err(e) = std::os::windows::fs::symlink_file(tmp.path().join("gone"), &alias) {
assert!(
std::env::var_os("CI").is_none(),
"the Windows CI runner must prove the alias cell: {e}"
);
eprintln!("skipped: no symlink privilege on this machine ({e})");
return;
}
assert!(!alias.exists(), "the stand-in alias must not resolve");
assert!(present(&alias));
assert_eq!(
first_hit(
&[tmp.path().to_path_buf()],
"cursor-agent",
".EXE",
&present
),
Some(alias)
);
}
#[test]
fn a_literal_machine_entry_never_hides_its_expanded_user_spelling() {
let lookup = |n: &str| (n == "OL_SHIMS").then(|| "C:\\ol\\shims".to_string());
assert_eq!(
combined_dirs_with(Some("%OL_SHIMS%;C:\\m"), Some("C:\\ol\\shims"), &lookup),
dirs(&["%OL_SHIMS%", "C:\\m", "C:\\ol\\shims"]),
"a REG_SZ entry is searched as written: the real user entry stays"
);
assert_eq!(
combined_dirs_with(None, Some("%OL_SHIMS%;C:\\ol\\shims\\"), &lookup),
dirs(&["C:\\ol\\shims"])
);
}
#[test]
fn a_directory_named_like_the_cli_is_not_a_hit() {
let tmp = tempfile::tempdir().unwrap();
let (machine, shims) = (tmp.path().join("m"), tmp.path().join("shims"));
std::fs::create_dir_all(machine.join("opencode.exe")).unwrap();
std::fs::create_dir_all(&shims).unwrap();
std::fs::write(shims.join("opencode.exe"), b"").unwrap();
assert!(!present(&machine.join("opencode.exe")));
assert_eq!(
first_hit(&[machine, shims.clone()], "opencode", ".EXE", &present),
Some(shims.join("opencode.exe"))
);
}
#[test]
fn pathext_falls_back_machine_then_process_then_default() {
let s = |v: &str| Some(v.to_string());
assert_eq!(pathext_of(s(".EXE"), s(".CMD")), ".EXE");
assert_eq!(
pathext_of(s(" "), s(".CMD")),
".CMD",
"a blank machine value is unset"
);
assert_eq!(pathext_of(None, s(".CMD")), ".CMD");
assert_eq!(pathext_of(s(""), s(" ")), DEFAULT_PATHEXT);
assert_eq!(pathext_of(None, None), DEFAULT_PATHEXT);
}
#[test]
fn shadowing_names_a_hit_outside_the_shims_folder_only() {
let shims = Path::new("C:\\Users\\a\\.openlatch\\shims");
let listed = "c:\\users\\A\\.openlatch\\shims\\";
let search = dirs(&["C:\\m", listed, "C:\\u"]);
let shim = at(listed, "opencode.exe");
assert_eq!(
shadowing_in(
&search,
"opencode",
DEFAULT_PATHEXT,
shims,
&on_disk(vec![shim.clone()])
),
None,
"the shim is the first hit"
);
let machine_install = on_disk(vec![at("C:\\m", "opencode.exe"), shim.clone()]);
assert_eq!(
shadowing_in(
&search,
"opencode",
DEFAULT_PATHEXT,
shims,
&machine_install
),
Some(at("C:\\m", "opencode.exe"))
);
let user_install_after = on_disk(vec![at("C:\\u", "opencode.exe"), shim]);
assert_eq!(
shadowing_in(
&search,
"opencode",
DEFAULT_PATHEXT,
shims,
&user_install_after
),
None,
"an install after the shims is what the shim runs"
);
assert_eq!(
shadowing_in(
&search,
"opencode",
DEFAULT_PATHEXT,
shims,
&on_disk(vec![])
),
None,
"nothing resolves"
);
}
#[cfg(not(windows))]
#[test]
fn the_search_path_is_empty_off_windows() {
let search = SearchPath::read();
assert!(search.dirs.is_empty());
assert_eq!(search.machine_unreadable, None);
assert_eq!(search.shadowing("opencode", Path::new("/x/shims")), None);
}
}