use std::ffi::OsStr;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Shadowing {
Agrees,
Shadowed { found: PathBuf },
NotOnPath,
}
impl Shadowing {
pub fn is_shadowed(&self) -> bool {
matches!(self, Shadowing::Shadowed { .. })
}
}
pub fn resolve_in(path_var: Option<&OsStr>, name: &str) -> Option<PathBuf> {
if name.is_empty() || name.contains('/') || name.contains('\\') {
return None;
}
let path_var = path_var?;
for dir in std::env::split_paths(path_var) {
let dir = if dir.as_os_str().is_empty() {
PathBuf::from(".")
} else {
dir
};
let candidate = dir.join(name);
if is_executable_file(&candidate) {
return Some(candidate);
}
}
None
}
#[cfg(unix)]
fn is_executable_file(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(p) {
Ok(m) => m.is_file() && m.permissions().mode() & 0o111 != 0,
Err(_) => false,
}
}
#[cfg(not(unix))]
fn is_executable_file(p: &Path) -> bool {
p.is_file()
}
pub fn check(
path_var: Option<&OsStr>,
name: &str,
ours: &Path,
dispatcher: Option<&Path>,
) -> Shadowing {
let Some(found) = resolve_in(path_var, name) else {
return Shadowing::NotOnPath;
};
let real = found.canonicalize().unwrap_or_else(|_| found.clone());
if let Ok(b) = ours.canonicalize()
&& real == b
{
return Shadowing::Agrees;
}
if let Some(d) = dispatcher
&& let Ok(d) = d.canonicalize()
&& real == d
{
return Shadowing::Agrees;
}
Shadowing::Shadowed { found }
}
pub fn describe(name: &str, ours: &Path, found: &Path) -> String {
format!(
"`{name}` on your PATH is {found}, not the pinned {ours}.\n\
Your shell will run the first one; varve dispatches the second, so \
`varve which` and `varve run` disagree with what you get by typing \
`{name}`.\n\
Fix: run `varve shim install` and put the shim directory FIRST on \
PATH (`. \"$VARVE_ROOT/env\"`, default ~/.varve/env), or remove the \
earlier entry.",
found = found.display(),
ours = ours.display(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
fn bin_dir(name: &str, body: &str) -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join(name);
std::fs::write(&p, body).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
tmp
}
fn path_of(dirs: &[&Path]) -> OsString {
std::env::join_paths(dirs.iter().map(|d| d.to_path_buf())).unwrap()
}
#[test]
fn a_different_binary_earlier_on_path_is_reported_as_shadowing() {
let ours_dir = bin_dir("rivet", "#!/bin/sh\necho pinned\n");
let other = bin_dir("rivet", "#!/bin/sh\necho WRONG\n");
let ours = ours_dir.path().join("rivet");
let path = path_of(&[other.path(), ours_dir.path()]);
let verdict = check(Some(&path), "rivet", &ours, None);
match &verdict {
Shadowing::Shadowed { found } => {
assert_eq!(found, &other.path().join("rivet"), "names the winner");
}
other => panic!("expected Shadowed, got {other:?}"),
}
assert!(verdict.is_shadowed());
let msg = describe("rivet", &ours, &other.path().join("rivet"));
assert!(msg.contains("rivet"), "names the tool: {msg}");
assert!(msg.contains("varve shim install"), "carries its fix: {msg}");
assert!(
msg.contains("FIRST on PATH"),
"says WHERE the shim must go, not merely to install it: {msg}"
);
}
#[test]
fn our_own_binary_first_on_path_agrees() {
let ours_dir = bin_dir("rivet", "#!/bin/sh\n");
let other = bin_dir("rivet", "#!/bin/sh\n");
let ours = ours_dir.path().join("rivet");
let path = path_of(&[ours_dir.path(), other.path()]);
assert_eq!(check(Some(&path), "rivet", &ours, None), Shadowing::Agrees);
}
#[test]
fn a_real_shim_symlinked_to_varve_itself_agrees() {
#[cfg(unix)]
{
let store = bin_dir("rivet", "#!/bin/sh\n");
let varve_dir = bin_dir("varve", "#!/bin/sh\n");
let dispatcher = varve_dir.path().join("varve");
let shims = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(&dispatcher, shims.path().join("rivet")).unwrap();
let ours = store.path().join("rivet");
let path = path_of(&[shims.path()]);
assert_eq!(
check(Some(&path), "rivet", &ours, Some(&dispatcher)),
Shadowing::Agrees,
"a shim is a symlink to VARVE, not to the tool — it must agree"
);
assert!(
check(Some(&path), "rivet", &ours, None).is_shadowed(),
"this asserts WHY the dispatcher argument exists"
);
}
}
#[test]
fn a_tool_absent_from_path_is_not_shadowed() {
let ours_dir = bin_dir("rivet", "#!/bin/sh\n");
let empty = tempfile::tempdir().unwrap();
let path = path_of(&[empty.path()]);
assert_eq!(
check(Some(&path), "rivet", &ours_dir.path().join("rivet"), None),
Shadowing::NotOnPath
);
assert_eq!(
check(None, "rivet", &ours_dir.path().join("rivet"), None),
Shadowing::NotOnPath
);
}
#[test]
fn resolution_follows_path_order_and_the_executable_bit() {
let first = tempfile::tempdir().unwrap();
std::fs::write(first.path().join("rivet"), "not executable").unwrap();
std::fs::create_dir(first.path().join("also")).unwrap();
let second = bin_dir("rivet", "#!/bin/sh\n");
let path = path_of(&[first.path(), second.path()]);
assert_eq!(
resolve_in(Some(&path), "rivet"),
Some(second.path().join("rivet")),
"a non-executable file of the same name is skipped, as a shell skips it"
);
let dir_named = tempfile::tempdir().unwrap();
std::fs::create_dir(dir_named.path().join("rivet")).unwrap();
let path2 = path_of(&[dir_named.path(), second.path()]);
assert_eq!(
resolve_in(Some(&path2), "rivet"),
Some(second.path().join("rivet"))
);
}
#[test]
fn a_name_with_a_separator_is_not_a_path_lookup() {
let d = bin_dir("rivet", "#!/bin/sh\n");
let path = path_of(&[d.path()]);
assert_eq!(resolve_in(Some(&path), "./rivet"), None);
assert_eq!(resolve_in(Some(&path), "bin/rivet"), None);
assert_eq!(resolve_in(Some(&path), ""), None);
}
}