use std::collections::BTreeMap;
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,
)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Warn,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[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>>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
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(),
}
}
pub fn all_kind_names() -> &'static [&'static str] {
&[
"duplicate",
"missing",
"shortenable",
"trailing_slash",
"case_variant",
"short_name",
"malformed",
"mise_activate_both",
]
}
#[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> {
relations
.iter()
.filter_map(|r| match r {
Relation::ConflictsWhenBothInPath { diagnostic, .. } => Some(diagnostic.clone()),
_ => None,
})
.collect()
}
pub fn has_error(diags: &[&Diagnostic]) -> bool {
diags.iter().any(|d| d.severity == Severity::Error)
}
pub fn analyze<F, V>(
entries: &[String],
sources: &BTreeMap<String, SourceDef>,
relations: &[Relation],
os: Os,
fs_exists: F,
env_lookup: V,
) -> Vec<Diagnostic>
where
F: Fn(&str) -> bool,
V: Fn(&str) -> Option<String>,
{
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);
out
}
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>,
) {
for rel in relations {
let Relation::ConflictsWhenBothInPath {
sources: src_names,
diagnostic,
} = rel
else {
continue;
};
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.clone(),
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 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,
);
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,
);
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,
);
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,
);
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")]),
);
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")]),
);
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,
);
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,
);
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);
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);
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);
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);
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);
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);
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 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");
}
}