use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::path::Path;
use crate::config::{Relation, SourceDef};
use crate::expand;
use crate::os_detect::Os;
use crate::source_match;
pub fn fs_exists_real(path: &str) -> bool {
Path::new(path).exists()
}
pub fn env_lookup_real(var: &str) -> Option<String> {
env::var(var).ok()
}
pub fn analyze_real(
entries: &[String],
sources: &BTreeMap<String, SourceDef>,
relations: &[Relation],
os: Os,
) -> Vec<Diagnostic> {
analyze(
entries,
sources,
relations,
os,
fs_exists_real,
env_lookup_real,
fs_list_dir_real,
is_writable_dir_real,
)
}
#[cfg(unix)]
pub fn is_writable_dir_real(path: &str) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.is_dir() && m.permissions().mode() & 0o002 != 0)
.unwrap_or(false)
}
#[cfg(windows)]
pub fn is_writable_dir_real(path: &str) -> bool {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::{ERROR_SUCCESS, LocalFree};
use windows_sys::Win32::Security::Authorization::{
GetEffectiveRightsFromAclW, GetNamedSecurityInfoW, SE_FILE_OBJECT, TRUSTEE_IS_SID,
TRUSTEE_IS_WELL_KNOWN_GROUP, TRUSTEE_W,
};
use windows_sys::Win32::Security::{
ACL, AllocateAndInitializeSid, DACL_SECURITY_INFORMATION, FreeSid, PSECURITY_DESCRIPTOR,
SID_IDENTIFIER_AUTHORITY,
};
use windows_sys::Win32::Storage::FileSystem::{FILE_APPEND_DATA, FILE_GENERIC_WRITE};
use windows_sys::Win32::System::SystemServices::SECURITY_WORLD_RID;
match std::fs::metadata(path) {
Ok(m) if m.is_dir() => {}
_ => return false,
}
let wide: Vec<u16> = OsStr::new(path).encode_wide().chain([0]).collect();
let mut dacl: *mut ACL = std::ptr::null_mut();
let mut sd: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
let result = unsafe {
GetNamedSecurityInfoW(
wide.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut dacl,
std::ptr::null_mut(),
&mut sd,
)
};
if result != ERROR_SUCCESS || dacl.is_null() {
if !sd.is_null() {
unsafe { LocalFree(sd as _) };
}
return false;
}
let everyone_auth = SID_IDENTIFIER_AUTHORITY {
Value: [0, 0, 0, 0, 0, 1],
};
let mut everyone_sid: *mut core::ffi::c_void = std::ptr::null_mut();
let alloc_ok = unsafe {
AllocateAndInitializeSid(
&everyone_auth,
1,
SECURITY_WORLD_RID as u32,
0,
0,
0,
0,
0,
0,
0,
&mut everyone_sid,
)
};
if alloc_ok == 0 || everyone_sid.is_null() {
unsafe { LocalFree(sd as _) };
return false;
}
let mut trustee: TRUSTEE_W = unsafe { std::mem::zeroed() };
trustee.TrusteeForm = TRUSTEE_IS_SID;
trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;
trustee.ptstrName = everyone_sid as *mut _;
let mut rights: u32 = 0;
let eff_ok = unsafe { GetEffectiveRightsFromAclW(dacl, &trustee, &mut rights) };
unsafe {
FreeSid(everyone_sid);
LocalFree(sd as _);
}
if eff_ok != ERROR_SUCCESS {
return false;
}
rights & (FILE_GENERIC_WRITE | FILE_APPEND_DATA) != 0
}
pub fn fs_list_dir_real(path: &str) -> Vec<String> {
let Ok(read) = std::fs::read_dir(path) else {
return Vec::new();
};
read.filter_map(|entry| {
let entry = entry.ok()?;
let meta = entry.metadata().ok()?;
if !meta.is_file() {
return None;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o111 == 0 {
return None;
}
}
entry.file_name().into_string().ok()
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Warn,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Kind {
Duplicate {
first_index: usize,
},
Missing,
Shortenable {
suggestion: String,
},
TrailingSlash,
CaseVariant {
canonical: String,
},
ShortName,
Malformed {
reason: String,
},
Conflict {
diagnostic: String,
groups: Vec<Vec<usize>>,
},
PerSourceMissingRequired {
source: String,
},
DuplicateButShadowed {
command: String,
shadowed_indexes: Vec<usize>,
},
RelativePathEntry,
WriteablePathDir,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)]
pub struct Diagnostic {
pub index: usize,
pub entry: String,
pub severity: Severity,
#[serde(flatten)]
pub kind: Kind,
}
pub fn kind_name(kind: &Kind) -> &str {
match kind {
Kind::Duplicate { .. } => "duplicate",
Kind::Missing => "missing",
Kind::Shortenable { .. } => "shortenable",
Kind::TrailingSlash => "trailing_slash",
Kind::CaseVariant { .. } => "case_variant",
Kind::ShortName => "short_name",
Kind::Malformed { .. } => "malformed",
Kind::Conflict { diagnostic, .. } => diagnostic.as_str(),
Kind::PerSourceMissingRequired { .. } => "per_source_missing_required",
Kind::DuplicateButShadowed { .. } => "duplicate_but_shadowed",
Kind::RelativePathEntry => "relative_path_entry",
Kind::WriteablePathDir => "writeable_path_dir",
}
}
pub fn all_kind_names() -> &'static [&'static str] {
&[
"duplicate",
"missing",
"shortenable",
"trailing_slash",
"case_variant",
"short_name",
"malformed",
"mise_activate_both",
"per_source_missing_required",
"duplicate_but_shadowed",
"relative_path_entry",
"writeable_path_dir",
]
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Filter {
pub include: Vec<String>,
pub exclude: Vec<String>,
}
impl Filter {
pub fn apply<'a>(&self, diags: &'a [Diagnostic]) -> Vec<&'a Diagnostic> {
diags
.iter()
.filter(|d| {
let name = kind_name(&d.kind);
if !self.include.is_empty() {
self.include.iter().any(|s| s == name)
} else if !self.exclude.is_empty() {
!self.exclude.iter().any(|s| s == name)
} else {
true
}
})
.collect()
}
}
pub fn validate_filter_names(filter: &Filter, extra_known: &[String]) -> Result<(), String> {
let mut known: std::collections::BTreeSet<String> =
all_kind_names().iter().map(|s| (*s).to_string()).collect();
known.extend(extra_known.iter().cloned());
for name in filter.include.iter().chain(filter.exclude.iter()) {
if !known.contains(name) {
let mut all: Vec<String> = known.iter().cloned().collect();
all.sort();
return Err(format!(
"unknown doctor kind `{name}`; valid values: {}",
all.join(", ")
));
}
}
Ok(())
}
pub fn user_diagnostic_names(relations: &[Relation]) -> Vec<String> {
crate::catalog::RelationIndex::from_slice(relations)
.iter_conflicts()
.map(|(_sources, diagnostic)| diagnostic.to_string())
.collect()
}
pub fn has_error(diags: &[&Diagnostic]) -> bool {
diags.iter().any(|d| d.severity == Severity::Error)
}
#[allow(clippy::too_many_arguments)]
pub fn analyze<F, V, L, W>(
entries: &[String],
sources: &BTreeMap<String, SourceDef>,
relations: &[Relation],
os: Os,
fs_exists: F,
env_lookup: V,
fs_list_dir: L,
is_writable_dir: W,
) -> Vec<Diagnostic>
where
F: Fn(&str) -> bool,
V: Fn(&str) -> Option<String>,
L: Fn(&str) -> Vec<String>,
W: Fn(&str) -> bool,
{
let mut out = Vec::new();
for (i, entry) in entries.iter().enumerate() {
if let Some(d) = check_malformed(i, entry) {
out.push(d);
continue;
}
if let Some(d) = check_missing(i, entry, &fs_exists) {
out.push(d);
}
if let Some(d) = check_trailing_slash(i, entry) {
out.push(d);
}
if os == Os::Windows {
if let Some(d) = check_short_name(i, entry) {
out.push(d);
}
}
if let Some(d) = check_shortenable(i, entry, os, &env_lookup) {
out.push(d);
}
}
let normalized: Vec<String> = entries
.iter()
.map(|e| expand::normalize(&expand::expand_env(e)))
.collect();
add_duplicate_diagnostics(&normalized, entries, &mut out);
add_case_variant_diagnostics(entries, &mut out);
add_relation_conflict_diagnostics(&normalized, entries, sources, relations, os, &mut out);
add_per_source_missing_required_diagnostics(sources, os, &fs_exists, &env_lookup, &mut out);
add_duplicate_but_shadowed_diagnostics(entries, os, &fs_list_dir, &env_lookup, &mut out);
add_relative_path_entry_diagnostics(entries, os, &mut out);
add_writeable_path_dir_diagnostics(entries, &is_writable_dir, &mut out);
out
}
fn add_relative_path_entry_diagnostics(entries: &[String], os: Os, out: &mut Vec<Diagnostic>) {
for (i, entry) in entries.iter().enumerate() {
let expanded = expand::expand_env(entry);
if expanded.is_empty() {
continue;
}
if !is_absolute_for_os(&expanded, os) {
out.push(Diagnostic {
index: i,
entry: entries[i].clone(),
severity: Severity::Warn,
kind: Kind::RelativePathEntry,
});
}
}
}
fn add_writeable_path_dir_diagnostics<W>(
entries: &[String],
is_writable_dir: &W,
out: &mut Vec<Diagnostic>,
) where
W: Fn(&str) -> bool,
{
for (i, entry) in entries.iter().enumerate() {
let expanded = expand::expand_env(entry);
if expanded.is_empty() {
continue;
}
if is_writable_dir(&expanded) {
out.push(Diagnostic {
index: i,
entry: entries[i].clone(),
severity: Severity::Warn,
kind: Kind::WriteablePathDir,
});
}
}
}
fn is_absolute_for_os(s: &str, os: Os) -> bool {
match os {
Os::Windows => {
let bytes = s.as_bytes();
if bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'/' || bytes[2] == b'\\')
{
return true;
}
s.starts_with("\\\\") || s.starts_with("//")
}
Os::Macos | Os::Linux | Os::Termux => s.starts_with('/'),
}
}
fn add_duplicate_but_shadowed_diagnostics<L, V>(
entries: &[String],
os: Os,
fs_list_dir: &L,
env_lookup: &V,
out: &mut Vec<Diagnostic>,
) where
L: Fn(&str) -> Vec<String>,
V: Fn(&str) -> Option<String>,
{
let pathext_lower: Vec<String> = if os == Os::Windows {
expand::pathext_lower(|v| env_lookup(v))
} else {
Vec::new()
};
let mut by_command: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
for (i, entry) in entries.iter().enumerate() {
let expanded = expand::expand_env(entry);
if expanded.is_empty() {
continue;
}
for file in fs_list_dir(&expanded) {
let Some(cmd) = normalize_command(&file, os, &pathext_lower) else {
continue;
};
by_command.entry(cmd).or_default().insert(i);
}
}
for (cmd, indexes) in by_command {
if indexes.len() < 2 {
continue;
}
let sorted: Vec<usize> = indexes.into_iter().collect();
let winning = sorted[0];
let shadowed_indexes = sorted[1..].to_vec();
out.push(Diagnostic {
index: winning,
entry: entries[winning].clone(),
severity: Severity::Warn,
kind: Kind::DuplicateButShadowed {
command: cmd,
shadowed_indexes,
},
});
}
}
fn normalize_command(file: &str, os: Os, pathext_lower: &[String]) -> Option<String> {
if os == Os::Windows {
let lower = file.to_ascii_lowercase();
for ext in pathext_lower {
if let Some(stripped) = lower.strip_suffix(ext) {
return Some(stripped.to_string());
}
}
None
} else {
Some(file.to_string())
}
}
fn add_per_source_missing_required_diagnostics<F, V>(
sources: &BTreeMap<String, SourceDef>,
os: Os,
fs_exists: &F,
env_lookup: &V,
out: &mut Vec<Diagnostic>,
) where
F: Fn(&str) -> bool,
V: Fn(&str) -> Option<String>,
{
let builtin = crate::catalog::builtin();
for (name, def) in sources {
if builtin.contains_key(name) {
if let Some(builtin_def) = builtin.get(name) {
if builtin_def == def {
continue;
}
}
}
let Some(raw) = def.path_for(os) else {
continue;
};
let expanded = expand_with_env(raw, env_lookup);
if expanded.is_empty() {
continue;
}
if fs_exists(&expanded) {
continue;
}
out.push(Diagnostic {
index: usize::MAX,
entry: expanded,
severity: Severity::Warn,
kind: Kind::PerSourceMissingRequired {
source: name.clone(),
},
});
}
}
fn expand_with_env<V>(raw: &str, env_lookup: &V) -> String
where
V: Fn(&str) -> Option<String>,
{
let mut buf = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
if c == '~' && (chars.peek() == Some(&'/') || chars.peek().is_none()) {
if let Some(home) = env_lookup("HOME").or_else(|| env_lookup("USERPROFILE")) {
buf.push_str(&home);
continue;
}
}
if c == '$' {
let mut name = String::new();
while let Some(&nc) = chars.peek() {
if nc.is_ascii_alphanumeric() || nc == '_' {
name.push(nc);
chars.next();
} else {
break;
}
}
if !name.is_empty() {
if let Some(val) = env_lookup(&name) {
buf.push_str(&val);
continue;
}
buf.push('$');
buf.push_str(&name);
continue;
}
buf.push('$');
continue;
}
buf.push(c);
}
buf
}
fn check_malformed(index: usize, entry: &str) -> Option<Diagnostic> {
if entry.contains('\0') {
return Some(Diagnostic {
index,
entry: entry.to_string(),
severity: Severity::Error,
kind: Kind::Malformed {
reason: "embedded NUL byte".into(),
},
});
}
if cfg!(windows) {
for c in entry.chars() {
let illegal =
matches!(c, '<' | '>' | '"' | '|' | '?' | '*') || (c.is_control() && c != '\t');
if illegal {
return Some(Diagnostic {
index,
entry: entry.to_string(),
severity: Severity::Error,
kind: Kind::Malformed {
reason: format!("illegal character {c:?} in path"),
},
});
}
}
}
None
}
fn check_missing<F>(index: usize, entry: &str, fs_exists: &F) -> Option<Diagnostic>
where
F: Fn(&str) -> bool,
{
let expanded = expand::expand_env(entry);
if fs_exists(&expanded) {
return None;
}
Some(Diagnostic {
index,
entry: entry.to_string(),
severity: Severity::Warn,
kind: Kind::Missing,
})
}
fn check_trailing_slash(index: usize, entry: &str) -> Option<Diagnostic> {
if entry.len() <= 1 {
return None;
}
let last = entry.chars().last().unwrap();
if last != '/' && last != '\\' {
return None;
}
if entry == "/" || entry.ends_with(":/") || entry.ends_with(":\\") {
return None;
}
Some(Diagnostic {
index,
entry: entry.to_string(),
severity: Severity::Warn,
kind: Kind::TrailingSlash,
})
}
fn check_short_name(index: usize, entry: &str) -> Option<Diagnostic> {
for segment in entry.split(['/', '\\']) {
if looks_like_8dot3(segment) {
return Some(Diagnostic {
index,
entry: entry.to_string(),
severity: Severity::Warn,
kind: Kind::ShortName,
});
}
}
None
}
fn looks_like_8dot3(segment: &str) -> bool {
let bytes = segment.as_bytes();
let Some(tilde) = bytes.iter().position(|&b| b == b'~') else {
return false;
};
if tilde == 0 || tilde > 6 {
return false;
}
let after = &bytes[tilde + 1..];
if after.is_empty() {
return false;
}
let mut digits = 0;
while digits < after.len() && after[digits].is_ascii_digit() {
digits += 1;
}
if digits == 0 {
return false;
}
matches!(after.get(digits), None | Some(b'.'))
}
fn check_shortenable<V>(index: usize, entry: &str, os: Os, env_lookup: &V) -> Option<Diagnostic>
where
V: Fn(&str) -> Option<String>,
{
if entry.contains('%') || entry.contains('$') {
return None;
}
let normalized_entry = expand::normalize(entry);
for (var, prefer_style) in candidate_vars(os) {
let Some(raw) = env_lookup(var) else {
continue;
};
if raw.is_empty() {
continue;
}
let normalized_var = expand::normalize(&raw);
if !normalized_entry.starts_with(&normalized_var) {
continue;
}
let suffix = entry.get(normalized_var.len()..).unwrap_or("");
let suggestion = match prefer_style {
VarStyle::Percent => format!("%{var}%{suffix}"),
VarStyle::Dollar => format!("${var}{suffix}"),
};
return Some(Diagnostic {
index,
entry: entry.to_string(),
severity: Severity::Warn,
kind: Kind::Shortenable { suggestion },
});
}
None
}
#[derive(Clone, Copy)]
enum VarStyle {
Percent,
Dollar,
}
fn candidate_vars(os: Os) -> &'static [(&'static str, VarStyle)] {
match os {
Os::Windows => &[
("LocalAppData", VarStyle::Percent),
("AppData", VarStyle::Percent),
("ProgramFiles(x86)", VarStyle::Percent),
("ProgramFiles", VarStyle::Percent),
("ProgramData", VarStyle::Percent),
("UserProfile", VarStyle::Percent),
("SystemRoot", VarStyle::Percent),
],
_ => &[("HOME", VarStyle::Dollar)],
}
}
fn add_duplicate_diagnostics(normalized: &[String], raw: &[String], out: &mut Vec<Diagnostic>) {
let mut first_seen: BTreeMap<&str, usize> = BTreeMap::new();
for (i, n) in normalized.iter().enumerate() {
if n.is_empty() {
continue;
}
if let Some(&first) = first_seen.get(n.as_str()) {
out.push(Diagnostic {
index: i,
entry: raw[i].clone(),
severity: Severity::Warn,
kind: Kind::Duplicate { first_index: first },
});
} else {
first_seen.insert(n.as_str(), i);
}
}
}
fn add_relation_conflict_diagnostics(
normalized: &[String],
raw: &[String],
sources: &BTreeMap<String, SourceDef>,
relations: &[Relation],
os: Os,
out: &mut Vec<Diagnostic>,
) {
let index = crate::catalog::RelationIndex::from_slice(relations);
for (src_names, diagnostic) in index.iter_conflicts() {
let groups: Vec<Vec<usize>> = src_names
.iter()
.map(|name| matched_entries_for_source(name, normalized, sources, os))
.collect();
let active = groups.iter().filter(|g| !g.is_empty()).count();
if active < 2 {
continue;
}
let anchor = groups
.iter()
.find_map(|g| g.first().copied())
.expect("at least two groups are non-empty");
out.push(Diagnostic {
index: anchor,
entry: raw[anchor].clone(),
severity: Severity::Warn,
kind: Kind::Conflict {
diagnostic: diagnostic.to_string(),
groups,
},
});
}
}
fn matched_entries_for_source(
source_name: &str,
normalized: &[String],
sources: &BTreeMap<String, SourceDef>,
os: Os,
) -> Vec<usize> {
let Some(def) = sources.get(source_name) else {
return Vec::new();
};
let mut single = BTreeMap::new();
single.insert(source_name.to_string(), def.clone());
normalized
.iter()
.enumerate()
.filter_map(|(i, n)| {
let hit = source_match::find(n, &single, os);
if hit.is_empty() { None } else { Some(i) }
})
.collect()
}
fn add_case_variant_diagnostics(raw: &[String], out: &mut Vec<Diagnostic>) {
let mut buckets: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (i, entry) in raw.iter().enumerate() {
let key = expand::normalize(&expand::expand_env(entry));
if key.is_empty() {
continue;
}
buckets.entry(key).or_default().push(i);
}
for indices in buckets.values() {
if indices.len() < 2 {
continue;
}
let first = indices[0];
for &i in &indices[1..] {
if raw[i] == raw[first] {
continue;
}
out.push(Diagnostic {
index: i,
entry: raw[i].clone(),
severity: Severity::Warn,
kind: Kind::CaseVariant {
canonical: raw[first].clone(),
},
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entries(strs: &[&str]) -> Vec<String> {
strs.iter().map(|s| s.to_string()).collect()
}
fn kinds(diags: &[Diagnostic]) -> Vec<&Kind> {
diags.iter().map(|d| &d.kind).collect()
}
fn fs_yes(_: &str) -> bool {
true
}
fn fs_no(_: &str) -> bool {
false
}
fn env_none(_: &str) -> Option<String> {
None
}
fn env_map<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |k| {
pairs
.iter()
.find(|(name, _)| *name == k)
.map(|(_, v)| (*v).to_string())
}
}
fn fs_list_empty(_: &str) -> Vec<String> {
Vec::new()
}
fn fs_list_map<'a>(pairs: &'a [(&'a str, &'a [&'a str])]) -> impl Fn(&str) -> Vec<String> + 'a {
move |path| {
pairs
.iter()
.find(|(p, _)| *p == path)
.map(|(_, files)| files.iter().map(|s| (*s).to_string()).collect())
.unwrap_or_default()
}
}
fn fs_writable_no(_: &str) -> bool {
false
}
#[allow(dead_code)]
fn fs_writable_yes(_: &str) -> bool {
true
}
fn fs_writable_map<'a>(pairs: &'a [(&'a str, bool)]) -> impl Fn(&str) -> bool + 'a {
move |path| {
pairs
.iter()
.find(|(p, _)| *p == path)
.map(|(_, w)| *w)
.unwrap_or(false)
}
}
fn empty_sources() -> BTreeMap<String, SourceDef> {
BTreeMap::new()
}
fn unix_source(path: &str) -> SourceDef {
SourceDef {
unix: Some(path.into()),
..Default::default()
}
}
fn mise_sources_and_relations() -> (BTreeMap<String, SourceDef>, Vec<Relation>) {
let mut sources = BTreeMap::new();
sources.insert(
"mise_shims".into(),
unix_source("/home/u/.local/share/mise/shims"),
);
sources.insert(
"mise_installs".into(),
unix_source("/home/u/.local/share/mise/installs"),
);
let relations = vec![Relation::ConflictsWhenBothInPath {
sources: vec!["mise_shims".into(), "mise_installs".into()],
diagnostic: "mise_activate_both".into(),
}];
(sources, relations)
}
fn mise_relations() -> Vec<Relation> {
mise_sources_and_relations().1
}
#[test]
fn duplicate_detected_on_normalized_form() {
let e = entries(&["/usr/bin", "/usr/local/bin", "/usr/bin"]);
let diags = analyze(
&e,
&empty_sources(),
&mise_relations(),
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
let dups: Vec<_> = diags
.iter()
.filter(|d| matches!(d.kind, Kind::Duplicate { .. }))
.collect();
assert_eq!(dups.len(), 1);
assert_eq!(dups[0].index, 2);
}
#[test]
fn missing_directory_detected() {
let e = entries(&["/anywhere"]);
let diags = analyze(
&e,
&empty_sources(),
&mise_relations(),
Os::Linux,
fs_no,
env_none,
fs_list_empty,
fs_writable_no,
);
assert!(diags.iter().any(|d| matches!(d.kind, Kind::Missing)));
}
#[test]
fn trailing_slash_detected_but_root_allowed() {
let e = entries(&["/foo/", "/", "C:/"]);
let diags = analyze(
&e,
&empty_sources(),
&mise_relations(),
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
let trailing: Vec<_> = diags
.iter()
.filter(|d| matches!(d.kind, Kind::TrailingSlash))
.collect();
assert_eq!(trailing.len(), 1);
assert_eq!(trailing[0].index, 0);
}
#[test]
fn malformed_nul_is_error_severity() {
let e = entries(&["/foo\0/bar"]);
let diags = analyze(
&e,
&empty_sources(),
&mise_relations(),
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
assert!(
diags
.iter()
.any(|d| d.severity == Severity::Error && matches!(d.kind, Kind::Malformed { .. }))
);
}
#[test]
fn looks_like_8dot3_matches_typical_short_names() {
assert!(looks_like_8dot3("PROGRA~1"));
assert!(looks_like_8dot3("USERPR~2"));
assert!(looks_like_8dot3("lib~1.so"));
}
#[test]
fn looks_like_8dot3_rejects_normal_names() {
assert!(!looks_like_8dot3("Program Files"));
assert!(!looks_like_8dot3("foo~bar"));
assert!(!looks_like_8dot3("file~name~here"));
assert!(!looks_like_8dot3("~/.cargo/bin"));
}
#[test]
fn shortenable_suggests_env_var_when_entry_starts_with_one() {
let e = entries(&["C:\\Users\\Mixed\\GoLang\\bin"]);
let diags = analyze(
&e,
&empty_sources(),
&mise_relations(),
Os::Windows,
fs_yes,
env_map(&[("UserProfile", "C:\\Users\\Mixed")]),
fs_list_empty,
fs_writable_no,
);
let s = diags
.iter()
.find_map(|d| match &d.kind {
Kind::Shortenable { suggestion } => Some(suggestion.clone()),
_ => None,
})
.expect("expected Shortenable");
assert_eq!(s, "%UserProfile%\\GoLang\\bin");
}
#[test]
fn shortenable_skipped_when_already_using_env_var() {
let e = entries(&["$HOME/bin"]);
let diags = analyze(
&e,
&empty_sources(),
&mise_relations(),
Os::Linux,
fs_yes,
env_map(&[("HOME", "/home/u")]),
fs_list_empty,
fs_writable_no,
);
assert!(
!diags
.iter()
.any(|d| matches!(d.kind, Kind::Shortenable { .. }))
);
}
#[test]
fn case_variant_picked_up_when_only_case_differs() {
let e = entries(&["/Tmp/Pathlint_Case", "/tmp/pathlint_case"]);
let diags = analyze(
&e,
&empty_sources(),
&mise_relations(),
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
let case: Vec<_> = diags
.iter()
.filter(|d| matches!(d.kind, Kind::CaseVariant { .. }))
.collect();
assert!(!case.is_empty(), "diags: {diags:?}");
}
#[test]
fn empty_entries_are_silently_ignored() {
let e = entries(&[""]);
let diags = analyze(
&e,
&empty_sources(),
&mise_relations(),
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
let _ = kinds(&diags);
}
fn match_mise_activate_both(d: &Diagnostic) -> Option<(&Vec<usize>, &Vec<usize>)> {
if let Kind::Conflict { diagnostic, groups } = &d.kind {
if diagnostic == "mise_activate_both" && groups.len() == 2 {
return Some((&groups[0], &groups[1]));
}
}
None
}
#[test]
fn mise_activate_both_fires_when_shim_and_install_coexist() {
let e = entries(&[
"/home/u/.local/share/mise/shims",
"/home/u/.local/share/mise/installs/python/3.14/bin",
"/usr/bin",
]);
let (sources, relations) = mise_sources_and_relations();
let diags = analyze(
&e,
&sources,
&relations,
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
let mab: Vec<_> = diags.iter().filter_map(match_mise_activate_both).collect();
assert_eq!(mab.len(), 1);
let (shims, installs) = mab[0];
assert_eq!(shims, &vec![0]);
assert_eq!(installs, &vec![1]);
}
#[test]
fn mise_activate_both_does_not_fire_when_only_shims_present() {
let e = entries(&["/home/u/.local/share/mise/shims", "/usr/bin"]);
let (sources, relations) = mise_sources_and_relations();
let diags = analyze(
&e,
&sources,
&relations,
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
assert!(
diags
.iter()
.filter_map(match_mise_activate_both)
.next()
.is_none()
);
}
#[test]
fn mise_activate_both_does_not_fire_when_only_installs_present() {
let e = entries(&[
"/home/u/.local/share/mise/installs/python/3.14/bin",
"/usr/bin",
]);
let (sources, relations) = mise_sources_and_relations();
let diags = analyze(
&e,
&sources,
&relations,
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
assert!(
diags
.iter()
.filter_map(match_mise_activate_both)
.next()
.is_none()
);
}
#[test]
fn mise_activate_both_collects_multiple_install_entries() {
let e = entries(&[
"/home/u/.local/share/mise/shims",
"/home/u/.local/share/mise/installs/python/3.14/bin",
"/home/u/.local/share/mise/installs/node/25.9.0/bin",
"/usr/bin",
]);
let (sources, relations) = mise_sources_and_relations();
let diags = analyze(
&e,
&sources,
&relations,
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
let (shims, installs) = diags
.iter()
.filter_map(match_mise_activate_both)
.next()
.expect("mise_activate_both must fire");
assert_eq!(shims, &vec![0]);
assert_eq!(installs, &vec![1, 2]);
}
#[test]
fn conflict_with_fragment_needle_source() {
let e = entries(&[
"C:/Users/u/AppData/Local/Microsoft/WindowsApps",
"C:/peer/dir",
"C:/Windows/System32",
]);
let mut sources = BTreeMap::new();
sources.insert(
"windows_apps".into(),
SourceDef {
windows: Some("Microsoft/WindowsApps".into()),
..Default::default()
},
);
sources.insert(
"peer".into(),
SourceDef {
windows: Some("C:/peer/dir".into()),
..Default::default()
},
);
let relations = vec![Relation::ConflictsWhenBothInPath {
sources: vec!["windows_apps".into(), "peer".into()],
diagnostic: "store_vs_peer".into(),
}];
let diags = analyze(
&e,
&sources,
&relations,
Os::Windows,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
let groups = diags
.iter()
.find_map(|d| match &d.kind {
Kind::Conflict { diagnostic, groups } if diagnostic == "store_vs_peer" => {
Some(groups.clone())
}
_ => None,
})
.expect("store_vs_peer must fire for fragment-needle source");
assert_eq!(groups, vec![vec![0], vec![1]]);
}
#[test]
fn user_defined_three_way_conflict_fires() {
let e = entries(&["/foo/a", "/foo/b", "/foo/c", "/usr/bin"]);
let mut sources = BTreeMap::new();
sources.insert("a".into(), unix_source("/foo/a"));
sources.insert("b".into(), unix_source("/foo/b"));
sources.insert("c".into(), unix_source("/foo/c"));
let relations = vec![Relation::ConflictsWhenBothInPath {
sources: vec!["a".into(), "b".into(), "c".into()],
diagnostic: "abc_overlap".into(),
}];
let diags = analyze(
&e,
&sources,
&relations,
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
let groups = diags
.iter()
.find_map(|d| match &d.kind {
Kind::Conflict { diagnostic, groups } if diagnostic == "abc_overlap" => {
Some(groups.clone())
}
_ => None,
})
.expect("abc_overlap must fire");
assert_eq!(groups, vec![vec![0], vec![1], vec![2]]);
}
fn cat_local(entries: &[(&str, SourceDef)]) -> BTreeMap<String, SourceDef> {
entries
.iter()
.map(|(n, d)| ((*n).to_string(), d.clone()))
.collect()
}
#[test]
fn per_source_missing_required_fires_when_declared_dir_does_not_exist() {
let sources = cat_local(&[("cargo", unix_source("/totally/missing/dir"))]);
let diags = analyze(
&[],
&sources,
&[],
Os::Linux,
fs_no,
env_none,
fs_list_empty,
fs_writable_no,
);
let hit = diags
.iter()
.find(|d| matches!(d.kind, Kind::PerSourceMissingRequired { .. }))
.expect("PerSourceMissingRequired must fire");
match &hit.kind {
Kind::PerSourceMissingRequired { source } => assert_eq!(source, "cargo"),
other => panic!("expected PerSourceMissingRequired, got {other:?}"),
}
assert_eq!(hit.severity, Severity::Warn);
}
#[test]
fn per_source_missing_required_does_not_fire_when_path_exists() {
let sources = cat_local(&[("cargo", unix_source("/home/u/.cargo/bin"))]);
let diags = analyze(
&[],
&sources,
&[],
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
assert!(
diags
.iter()
.all(|d| !matches!(d.kind, Kind::PerSourceMissingRequired { .. }))
);
}
#[test]
fn per_source_missing_required_skips_sources_without_path_for_current_os() {
let sources = cat_local(&[(
"winget",
SourceDef {
windows: Some("C:/Users/u/AppData/Local/Microsoft/WinGet/Links".into()),
..Default::default()
},
)]);
let diags = analyze(
&[],
&sources,
&[],
Os::Linux,
fs_no,
env_none,
fs_list_empty,
fs_writable_no,
);
assert!(
diags
.iter()
.all(|d| !matches!(d.kind, Kind::PerSourceMissingRequired { .. }))
);
}
#[test]
fn per_source_missing_required_expands_env_via_injected_lookup() {
let sources = cat_local(&[("cargo", unix_source("$HOME/.cargo/bin"))]);
let env = env_map(&[("HOME", "/tmp/no_such_path")]);
let diags = analyze(
&[],
&sources,
&[],
Os::Linux,
fs_no,
env,
fs_list_empty,
fs_writable_no,
);
assert!(diags.iter().any(
|d| matches!(&d.kind, Kind::PerSourceMissingRequired { source } if source == "cargo")
));
}
fn diag(kind: Kind, severity: Severity) -> Diagnostic {
Diagnostic {
index: 0,
entry: "/anywhere".into(),
severity,
kind,
}
}
#[test]
fn filter_default_passes_everything_through() {
let diags = vec![
diag(Kind::Missing, Severity::Warn),
diag(Kind::TrailingSlash, Severity::Warn),
];
let kept = Filter::default().apply(&diags);
assert_eq!(kept.len(), 2);
}
#[test]
fn filter_include_keeps_only_named_kinds() {
let diags = vec![
diag(Kind::Missing, Severity::Warn),
diag(Kind::TrailingSlash, Severity::Warn),
diag(Kind::Malformed { reason: "x".into() }, Severity::Error),
];
let f = Filter {
include: vec!["missing".into(), "malformed".into()],
..Default::default()
};
let kept = f.apply(&diags);
let names: Vec<&str> = kept.iter().map(|d| kind_name(&d.kind)).collect();
assert_eq!(names, vec!["missing", "malformed"]);
}
#[test]
fn filter_exclude_drops_named_kinds_when_include_empty() {
let diags = vec![
diag(Kind::Missing, Severity::Warn),
diag(Kind::TrailingSlash, Severity::Warn),
];
let f = Filter {
exclude: vec!["trailing_slash".into()],
..Default::default()
};
let kept = f.apply(&diags);
assert_eq!(kept.len(), 1);
assert!(matches!(kept[0].kind, Kind::Missing));
}
#[test]
fn filter_include_takes_precedence_over_exclude_when_both_set() {
let diags = vec![
diag(Kind::Missing, Severity::Warn),
diag(Kind::TrailingSlash, Severity::Warn),
];
let f = Filter {
include: vec!["missing".into()],
exclude: vec!["missing".into()],
};
let kept = f.apply(&diags);
assert_eq!(kept.len(), 1);
assert!(matches!(kept[0].kind, Kind::Missing));
}
#[test]
fn validate_filter_names_accepts_valid() {
let f = Filter {
include: vec!["duplicate".into(), "malformed".into()],
exclude: vec![],
};
assert!(validate_filter_names(&f, &[]).is_ok());
}
#[test]
fn validate_filter_names_rejects_typo() {
let f = Filter {
include: vec!["duplicat".into()],
exclude: vec![],
};
let err = validate_filter_names(&f, &[]).unwrap_err();
assert!(err.contains("duplicat"));
assert!(err.contains("duplicate"), "valid list must be listed");
}
#[test]
fn validate_checks_exclude_too() {
let f = Filter {
include: vec![],
exclude: vec!["nope".into()],
};
assert!(validate_filter_names(&f, &[]).is_err());
}
#[test]
fn validate_filter_names_accepts_user_defined_diagnostic() {
let f = Filter {
include: vec!["foo_overlap".into()],
exclude: vec![],
};
let extra = vec!["foo_overlap".to_string()];
assert!(validate_filter_names(&f, &extra).is_ok());
}
#[test]
fn user_diagnostic_names_collects_only_conflict_kinds() {
let relations = vec![
Relation::AliasOf {
parent: "p".into(),
children: vec!["c".into()],
},
Relation::ConflictsWhenBothInPath {
sources: vec!["a".into(), "b".into()],
diagnostic: "ab_overlap".into(),
},
Relation::DependsOn {
source: "x".into(),
target: "y".into(),
},
];
let names = user_diagnostic_names(&relations);
assert_eq!(names, vec!["ab_overlap".to_string()]);
}
#[test]
fn has_error_true_when_any_kept_is_error_severity() {
let d_err = diag(Kind::Malformed { reason: "x".into() }, Severity::Error);
let d_warn = diag(Kind::Missing, Severity::Warn);
let kept: Vec<&Diagnostic> = vec![&d_warn, &d_err];
assert!(has_error(&kept));
}
#[test]
fn has_error_false_when_all_kept_are_warn() {
let d1 = diag(Kind::Missing, Severity::Warn);
let d2 = diag(Kind::TrailingSlash, Severity::Warn);
let kept: Vec<&Diagnostic> = vec![&d1, &d2];
assert!(!has_error(&kept));
}
#[test]
fn has_error_respects_filtering_excluding_malformed_lets_run_pass() {
let diags = vec![
diag(Kind::Malformed { reason: "x".into() }, Severity::Error),
diag(Kind::Missing, Severity::Warn),
];
let f = Filter {
exclude: vec!["malformed".into()],
..Default::default()
};
let kept = f.apply(&diags);
assert!(!has_error(&kept), "excluded malformed must not escalate");
}
#[test]
fn duplicate_but_shadowed_basic_unix() {
let e = entries(&["/a", "/b"]);
let listing: [(&str, &[&str]); 2] = [("/a", &["git"]), ("/b", &["git"])];
let fs_list = fs_list_map(&listing);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list,
fs_writable_no,
);
let dbs: Vec<&Diagnostic> = diags
.iter()
.filter(|d| matches!(d.kind, Kind::DuplicateButShadowed { .. }))
.collect();
assert_eq!(dbs.len(), 1, "exactly one shadow diagnostic expected");
assert_eq!(dbs[0].index, 0, "winning entry is the earliest dir");
assert_eq!(dbs[0].severity, Severity::Warn);
match &dbs[0].kind {
Kind::DuplicateButShadowed {
command,
shadowed_indexes,
} => {
assert_eq!(command, "git");
assert_eq!(shadowed_indexes, &vec![1]);
}
_ => unreachable!(),
}
}
#[test]
fn duplicate_but_shadowed_three_dirs_lists_all_shadowed() {
let e = entries(&["/a", "/b", "/c"]);
let listing: [(&str, &[&str]); 3] =
[("/a", &["node"]), ("/b", &["node"]), ("/c", &["node"])];
let fs_list = fs_list_map(&listing);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list,
fs_writable_no,
);
let dbs: Vec<&Diagnostic> = diags
.iter()
.filter(|d| matches!(d.kind, Kind::DuplicateButShadowed { .. }))
.collect();
assert_eq!(dbs.len(), 1);
assert_eq!(dbs[0].index, 0);
match &dbs[0].kind {
Kind::DuplicateButShadowed {
shadowed_indexes, ..
} => {
assert_eq!(shadowed_indexes, &vec![1, 2]);
}
_ => unreachable!(),
}
}
#[test]
fn duplicate_but_shadowed_case_insensitive_on_windows() {
let e = entries(&["C:/a", "C:/b"]);
let listing: [(&str, &[&str]); 2] = [("C:/a", &["Git.exe"]), ("C:/b", &["git.exe"])];
let fs_list = fs_list_map(&listing);
let env = env_map(&[("PATHEXT", ".EXE;.BAT;.CMD")]);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Windows,
fs_yes,
env,
fs_list,
fs_writable_no,
);
let dbs: Vec<&Diagnostic> = diags
.iter()
.filter(|d| matches!(d.kind, Kind::DuplicateButShadowed { .. }))
.collect();
assert_eq!(dbs.len(), 1);
match &dbs[0].kind {
Kind::DuplicateButShadowed {
command,
shadowed_indexes,
} => {
assert_eq!(command, "git", "Git.exe and git.exe collapse to `git`");
assert_eq!(shadowed_indexes, &vec![1]);
}
_ => unreachable!(),
}
}
#[test]
fn duplicate_but_shadowed_pathext_strips_extension() {
let e = entries(&["C:/a", "C:/b"]);
let listing: [(&str, &[&str]); 2] = [("C:/a", &["python.exe"]), ("C:/b", &["python.bat"])];
let fs_list = fs_list_map(&listing);
let env = env_map(&[("PATHEXT", ".EXE;.BAT")]);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Windows,
fs_yes,
env,
fs_list,
fs_writable_no,
);
let dbs: Vec<&Diagnostic> = diags
.iter()
.filter(|d| matches!(d.kind, Kind::DuplicateButShadowed { .. }))
.collect();
assert_eq!(
dbs.len(),
1,
"python.exe and python.bat both strip to `python` and collide"
);
match &dbs[0].kind {
Kind::DuplicateButShadowed { command, .. } => assert_eq!(command, "python"),
_ => unreachable!(),
}
}
#[test]
fn duplicate_but_shadowed_no_fire_when_only_one_dir_has_command() {
let e = entries(&["/a", "/b"]);
let listing: [(&str, &[&str]); 2] = [("/a", &["rg"]), ("/b", &["fd"])];
let fs_list = fs_list_map(&listing);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list,
fs_writable_no,
);
let dbs: Vec<&Diagnostic> = diags
.iter()
.filter(|d| matches!(d.kind, Kind::DuplicateButShadowed { .. }))
.collect();
assert!(dbs.is_empty(), "no shadow when no command repeats");
}
fn relative_kinds(diags: &[Diagnostic]) -> Vec<&Diagnostic> {
diags
.iter()
.filter(|d| matches!(d.kind, Kind::RelativePathEntry))
.collect()
}
#[test]
fn relative_path_entry_fires_on_dot() {
let e = entries(&["."]);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
let hits = relative_kinds(&diags);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].index, 0);
assert_eq!(hits[0].entry, ".");
assert_eq!(hits[0].severity, Severity::Warn);
}
#[test]
fn relative_path_entry_fires_on_relative_with_subpath() {
let e = entries(&["./bin", "bin", "app/bin"]);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
let hits = relative_kinds(&diags);
assert_eq!(hits.len(), 3, "all three relative entries fire");
}
#[test]
fn relative_path_entry_no_fire_on_absolute_unix() {
let e = entries(&["/usr/bin", "/home/u/.cargo/bin"]);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
assert!(relative_kinds(&diags).is_empty());
}
#[test]
fn relative_path_entry_no_fire_after_env_expansion() {
unsafe { env::set_var("PATHLINT_TEST_REL_HOME", "/home/u") };
let e = entries(&["$PATHLINT_TEST_REL_HOME/bin"]);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
unsafe { env::remove_var("PATHLINT_TEST_REL_HOME") };
assert!(
relative_kinds(&diags).is_empty(),
"env-expanded absolute path must not trigger relative_path_entry",
);
}
#[test]
fn relative_path_entry_fires_when_env_unresolved() {
let e = entries(&["$PATHLINT_TEST_REL_UNDEFINED_XYZ/bin"]);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
let hits = relative_kinds(&diags);
assert_eq!(
hits.len(),
1,
"unresolved env var leaves the entry verbatim ($VAR/bin), \
which is treated as relative for safety",
);
}
fn writable_kinds(diags: &[Diagnostic]) -> Vec<&Diagnostic> {
diags
.iter()
.filter(|d| matches!(d.kind, Kind::WriteablePathDir))
.collect()
}
#[test]
fn writeable_path_dir_fires_when_writable() {
let e = entries(&["/usr/bin"]);
let writable = fs_writable_map(&[("/usr/bin", true)]);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
writable,
);
let hits = writable_kinds(&diags);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].index, 0);
assert_eq!(hits[0].entry, "/usr/bin");
assert_eq!(hits[0].severity, Severity::Warn);
}
#[test]
fn writeable_path_dir_no_fire_when_readonly() {
let e = entries(&["/usr/bin", "/usr/local/bin"]);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
fs_writable_no,
);
assert!(writable_kinds(&diags).is_empty());
}
#[test]
fn writeable_path_dir_skips_empty_entry() {
let e = entries(&[""]);
let writable = fs_writable_map(&[("", true)]);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
writable,
);
assert!(writable_kinds(&diags).is_empty());
}
#[test]
fn writeable_path_dir_index_matches_entry_position() {
let e = entries(&["/usr/bin", "/tmp", "/usr/local/bin"]);
let writable = fs_writable_map(&[("/tmp", true)]);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
writable,
);
let hits = writable_kinds(&diags);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].index, 1);
assert_eq!(hits[0].entry, "/tmp");
}
#[test]
fn writeable_path_dir_after_env_expansion() {
unsafe { env::set_var("PATHLINT_TEST_WRITEABLE_DIR", "/srv/writable") };
let e = entries(&["$PATHLINT_TEST_WRITEABLE_DIR"]);
let writable = fs_writable_map(&[("/srv/writable", true)]);
let diags = analyze(
&e,
&empty_sources(),
&[],
Os::Linux,
fs_yes,
env_none,
fs_list_empty,
writable,
);
unsafe { env::remove_var("PATHLINT_TEST_WRITEABLE_DIR") };
let hits = writable_kinds(&diags);
assert_eq!(
hits.len(),
1,
"expanded path is forwarded to is_writable_dir, so the env-var entry fires",
);
}
}