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)
}
#[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]);
}
}