use std::collections::BTreeMap;
use std::path::PathBuf;
use crate::config::{Expectation, Kind, Severity, SourceDef};
use crate::expand::normalize;
use crate::os_detect::Os;
use crate::source_match;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Ok,
NgWrongSource,
NgUnknownSource,
NgNotFound,
NgNotExecutable,
Skip, NotApplicable,
ConfigError,
}
#[derive(Debug, Clone)]
pub struct Outcome {
pub command: String,
pub status: Status,
pub resolved: Option<PathBuf>,
pub matched_sources: Vec<String>,
pub prefer: Vec<String>,
pub avoid: Vec<String>,
pub severity: Severity,
pub reason: Option<String>,
}
impl Default for Outcome {
fn default() -> Self {
Outcome {
command: String::new(),
status: Status::Ok,
resolved: None,
matched_sources: Vec::new(),
prefer: Vec::new(),
avoid: Vec::new(),
severity: Severity::Error,
reason: None,
}
}
}
#[derive(serde::Serialize, schemars::JsonSchema)]
pub struct CheckOutcomeView {
pub command: String,
pub kind: Status,
pub severity: Severity,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved: Option<String>,
pub matched_sources: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prefer: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub avoid: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diagnosis: Option<Diagnosis>,
}
impl From<&Outcome> for CheckOutcomeView {
fn from(o: &Outcome) -> Self {
CheckOutcomeView {
command: o.command.clone(),
kind: o.status.clone(),
severity: o.severity,
resolved: o.resolved.as_ref().map(|p| p.display().to_string()),
matched_sources: o.matched_sources.clone(),
prefer: o.prefer.clone(),
avoid: o.avoid.clone(),
reason: o.reason.clone(),
diagnosis: diagnose(o),
}
}
}
impl Outcome {
pub fn initial(expect: &Expectation) -> Self {
Outcome {
command: expect.command.clone(),
status: Status::Ok,
resolved: None,
matched_sources: Vec::new(),
prefer: expect.prefer.clone(),
avoid: expect.avoid.clone(),
severity: expect.severity,
reason: None,
}
}
pub fn with_status(mut self, status: Status) -> Self {
self.status = status;
self
}
pub fn with_resolved(mut self, resolved: PathBuf) -> Self {
self.resolved = Some(resolved);
self
}
pub fn with_matched_sources(mut self, matched: Vec<String>) -> Self {
self.matched_sources = matched;
self
}
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Diagnosis {
WrongSource {
matched: Vec<String>,
prefer_missed: Vec<String>,
avoid_hits: Vec<String>,
},
UnknownSource { prefer: Vec<String> },
NotFound { prefer: Vec<String> },
NotExecutable {
reason: String,
matched: Vec<String>,
},
Config { message: String },
}
pub fn is_failure(status: &Status) -> bool {
matches!(
status,
Status::NgWrongSource
| Status::NgUnknownSource
| Status::NgNotFound
| Status::NgNotExecutable
)
}
pub fn has_config_error(outcomes: &[Outcome]) -> bool {
outcomes
.iter()
.any(|o| matches!(o.status, Status::ConfigError))
}
pub fn exit_code(outcomes: &[Outcome]) -> u8 {
if has_config_error(outcomes) {
return 2;
}
let any_error_failure = outcomes
.iter()
.any(|o| is_failure(&o.status) && o.severity == Severity::Error);
if any_error_failure { 1 } else { 0 }
}
pub fn diagnose(o: &Outcome) -> Option<Diagnosis> {
match &o.status {
Status::Ok | Status::Skip | Status::NotApplicable => None,
Status::NgWrongSource => {
let avoid_hits: Vec<String> = o
.matched_sources
.iter()
.filter(|m| o.avoid.iter().any(|a| a == *m))
.cloned()
.collect();
Some(Diagnosis::WrongSource {
matched: o.matched_sources.clone(),
prefer_missed: o.prefer.clone(),
avoid_hits,
})
}
Status::NgUnknownSource => Some(Diagnosis::UnknownSource {
prefer: o.prefer.clone(),
}),
Status::NgNotFound => Some(Diagnosis::NotFound {
prefer: o.prefer.clone(),
}),
Status::NgNotExecutable => Some(Diagnosis::NotExecutable {
reason: o.reason.clone().unwrap_or_default(),
matched: o.matched_sources.clone(),
}),
Status::ConfigError => Some(Diagnosis::Config {
message: o.reason.clone().unwrap_or_default(),
}),
}
}
pub fn evaluate<R, S>(
expectations: &[Expectation],
sources: &BTreeMap<String, SourceDef>,
os: Os,
mut resolver: R,
mut shape_check: S,
) -> Vec<Outcome>
where
R: FnMut(&str) -> Option<PathBuf>,
S: FnMut(&std::path::Path, Kind) -> Result<(), String>,
{
expectations
.iter()
.map(|e| evaluate_one(e, sources, os, &mut resolver, &mut shape_check))
.collect()
}
fn evaluate_one<R, S>(
expect: &Expectation,
sources: &BTreeMap<String, SourceDef>,
os: Os,
resolver: &mut R,
shape_check: &mut S,
) -> Outcome
where
R: FnMut(&str) -> Option<PathBuf>,
S: FnMut(&std::path::Path, Kind) -> Result<(), String>,
{
let base = Outcome::initial(expect);
if !crate::os_detect::os_filter_applies(&expect.os, os) {
return base.with_status(Status::NotApplicable);
}
if let Some(name) = first_undefined(&expect.prefer, &expect.avoid, sources) {
return base
.with_status(Status::ConfigError)
.with_reason(format!("undefined source name: {name}"));
}
let Some(resolved_path) = resolver(&expect.command) else {
let status = if expect.optional {
Status::Skip
} else {
Status::NgNotFound
};
return base.with_status(status);
};
let haystack = normalize(&resolved_path.to_string_lossy());
let matched = source_match::names_only(&haystack, sources, os);
let source_status = decide(&matched, &expect.prefer, &expect.avoid);
let (final_status, shape_reason) = match (&source_status, expect.kind) {
(Status::Ok, Some(kind)) => match shape_check(&resolved_path, kind) {
Ok(()) => (Status::Ok, None),
Err(reason) => (Status::NgNotExecutable, Some(reason)),
},
_ => (source_status, None),
};
let mut out = base
.with_resolved(resolved_path)
.with_matched_sources(matched)
.with_status(final_status);
if let Some(r) = shape_reason {
out = out.with_reason(r);
}
out
}
pub fn check_shape_filesystem(path: &std::path::Path, kind: Kind) -> Result<(), String> {
match kind {
Kind::Executable => check_executable(path),
}
}
fn check_executable(path: &std::path::Path) -> Result<(), String> {
let md = match std::fs::metadata(path) {
Ok(md) => md,
Err(_) => {
return Err(if path.is_symlink() {
"broken symlink".into()
} else {
"cannot stat".into()
});
}
};
if md.is_dir() {
return Err("is a directory".into());
}
if !md.is_file() {
return Err("not a regular file".into());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if md.permissions().mode() & 0o111 == 0 {
return Err("not executable (no +x bit)".into());
}
}
Ok(())
}
fn first_undefined<'a>(
prefer: &'a [String],
avoid: &'a [String],
sources: &BTreeMap<String, SourceDef>,
) -> Option<&'a str> {
for name in prefer.iter().chain(avoid.iter()) {
if !sources.contains_key(name) {
return Some(name.as_str());
}
}
None
}
fn decide(matched: &[String], prefer: &[String], avoid: &[String]) -> Status {
let in_avoid = matched.iter().any(|m| avoid.iter().any(|a| a == m));
if in_avoid {
return Status::NgWrongSource;
}
if prefer.is_empty() {
return Status::Ok;
}
if matched.is_empty() {
return Status::NgUnknownSource;
}
let in_prefer = matched.iter().any(|m| prefer.iter().any(|p| p == m));
if in_prefer {
Status::Ok
} else {
Status::NgWrongSource
}
}
#[cfg(test)]
mod tests {
use super::*;
fn src(unix: &str) -> SourceDef {
SourceDef {
unix: Some(unix.into()),
..Default::default()
}
}
fn src_win(win: &str) -> SourceDef {
SourceDef {
windows: Some(win.into()),
..Default::default()
}
}
fn cat(entries: &[(&str, SourceDef)]) -> BTreeMap<String, SourceDef> {
entries
.iter()
.map(|(n, d)| (n.to_string(), d.clone()))
.collect()
}
fn resolved(p: &str) -> PathBuf {
PathBuf::from(p)
}
fn shape_ok(_: &std::path::Path, _: crate::config::Kind) -> Result<(), String> {
Ok(())
}
#[test]
fn ok_when_resolved_under_preferred_source() {
let sources = cat(&[("cargo", src("/home/u/.cargo/bin"))]);
let expectations = vec![Expectation {
command: "runex".into(),
prefer: vec!["cargo".into()],
avoid: vec![],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&sources,
Os::Linux,
|_| Some(resolved("/home/u/.cargo/bin/runex")),
shape_ok,
);
assert_eq!(out[0].status, Status::Ok);
assert_eq!(out[0].matched_sources, vec!["cargo".to_string()]);
}
#[test]
fn ng_wrong_source_when_avoid_hits() {
let sources = cat(&[
("cargo", src("/home/u/.cargo/bin")),
("winget", src_win("WinGet")),
]);
let expectations = vec![Expectation {
command: "runex".into(),
prefer: vec!["cargo".into()],
avoid: vec!["winget".into()],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&sources,
Os::Windows,
|_| {
Some(resolved(
r"C:\Users\u\AppData\Local\Microsoft\WinGet\Links\runex.exe",
))
},
shape_ok,
);
assert_eq!(out[0].status, Status::NgWrongSource);
assert!(out[0].matched_sources.contains(&"winget".to_string()));
}
#[test]
fn unknown_source_when_no_match_but_prefer_set() {
let sources = cat(&[("cargo", src("/home/u/.cargo/bin"))]);
let expectations = vec![Expectation {
command: "runex".into(),
prefer: vec!["cargo".into()],
avoid: vec![],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&sources,
Os::Linux,
|_| Some(resolved("/usr/local/bin/runex")),
shape_ok,
);
assert_eq!(out[0].status, Status::NgUnknownSource);
}
#[test]
fn not_found_unless_optional() {
let expectations = vec![Expectation {
command: "runex".into(),
prefer: vec![],
avoid: vec![],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&BTreeMap::new(),
Os::Linux,
|_| None,
shape_ok,
);
assert_eq!(out[0].status, Status::NgNotFound);
let optional = vec![Expectation {
command: "runex".into(),
prefer: vec![],
avoid: vec![],
os: None,
optional: true,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(&optional, &BTreeMap::new(), Os::Linux, |_| None, shape_ok);
assert_eq!(out[0].status, Status::Skip);
}
#[test]
fn os_filter_excludes() {
let expectations = vec![Expectation {
command: "runex".into(),
prefer: vec![],
avoid: vec![],
os: Some(vec!["windows".into()]),
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&BTreeMap::new(),
Os::Linux,
|_| panic!("resolver must not be called for n/a expectations"),
shape_ok,
);
assert_eq!(out[0].status, Status::NotApplicable);
}
#[test]
fn config_error_on_undefined_source() {
let expectations = vec![Expectation {
command: "runex".into(),
prefer: vec!["nonexistent".into()],
avoid: vec![],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&BTreeMap::new(),
Os::Linux,
|_| panic!("must not resolve when config is invalid"),
shape_ok,
);
assert_eq!(out[0].status, Status::ConfigError);
assert!(
out[0]
.reason
.as_deref()
.is_some_and(|r| r.contains("undefined source")),
"reason must explain the misuse: {:?}",
out[0].reason
);
}
#[test]
fn empty_prefer_with_avoid_only_passes_when_avoid_misses() {
let sources = cat(&[("winget", src_win("WinGet"))]);
let expectations = vec![Expectation {
command: "runex".into(),
prefer: vec![],
avoid: vec!["winget".into()],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&sources,
Os::Windows,
|_| Some(resolved(r"C:\Users\u\.cargo\bin\runex.exe")),
shape_ok,
);
assert_eq!(out[0].status, Status::Ok);
}
#[test]
fn lazygit_any_of_three_preferred_is_ok() {
let sources = cat(&[
("cargo", src("/home/u/.cargo/bin")),
("winget", src_win("WinGet")),
("mise", src("/home/u/.local/share/mise")),
]);
let expectations = vec![Expectation {
command: "lazygit".into(),
prefer: vec!["cargo".into(), "winget".into(), "mise".into()],
avoid: vec![],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&sources,
Os::Linux,
|_| {
Some(resolved(
"/home/u/.local/share/mise/installs/lazygit/0.42/bin/lazygit",
))
},
shape_ok,
);
assert_eq!(out[0].status, Status::Ok);
assert_eq!(out[0].matched_sources, vec!["mise".to_string()]);
}
#[test]
fn multiple_sources_match_same_path_all_recorded() {
let sources = cat(&[
("mise", src("/home/u/.local/share/mise")),
("python_install", src("/installs/python/")),
]);
let expectations = vec![Expectation {
command: "python".into(),
prefer: vec!["mise".into()],
avoid: vec![],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&sources,
Os::Linux,
|_| {
Some(resolved(
"/home/u/.local/share/mise/installs/python/3.12/bin/python",
))
},
shape_ok,
);
assert_eq!(out[0].status, Status::Ok);
assert_eq!(out[0].matched_sources.len(), 2);
assert!(out[0].matched_sources.contains(&"mise".to_string()));
assert!(
out[0]
.matched_sources
.contains(&"python_install".to_string())
);
}
#[test]
fn avoid_overrides_prefer_when_both_match() {
let sources = cat(&[
("mise", src("/home/u/.local/share/mise")),
("dangerous_subdir", src("/installs/python/3.10/")),
]);
let expectations = vec![Expectation {
command: "python".into(),
prefer: vec!["mise".into()],
avoid: vec!["dangerous_subdir".into()],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&sources,
Os::Linux,
|_| {
Some(resolved(
"/home/u/.local/share/mise/installs/python/3.10/bin/python",
))
},
shape_ok,
);
assert_eq!(out[0].status, Status::NgWrongSource);
}
#[test]
fn mise_layered_match_shim_path_hits_mise_and_mise_shims() {
let sources = cat(&[
("mise", src("/home/u/.local/share/mise")),
("mise_shims", src("/home/u/.local/share/mise/shims")),
("mise_installs", src("/home/u/.local/share/mise/installs")),
]);
let expectations = vec![Expectation {
command: "python".into(),
prefer: vec!["mise_shims".into()],
avoid: vec![],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&sources,
Os::Linux,
|_| Some(resolved("/home/u/.local/share/mise/shims/python")),
shape_ok,
);
assert_eq!(out[0].status, Status::Ok);
assert!(out[0].matched_sources.contains(&"mise".to_string()));
assert!(out[0].matched_sources.contains(&"mise_shims".to_string()));
assert!(
!out[0]
.matched_sources
.contains(&"mise_installs".to_string())
);
}
#[test]
fn mise_layered_match_install_path_hits_mise_and_mise_installs() {
let sources = cat(&[
("mise", src("/home/u/.local/share/mise")),
("mise_shims", src("/home/u/.local/share/mise/shims")),
("mise_installs", src("/home/u/.local/share/mise/installs")),
]);
let expectations = vec![Expectation {
command: "python".into(),
prefer: vec!["mise_installs".into()],
avoid: vec![],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&sources,
Os::Linux,
|_| {
Some(resolved(
"/home/u/.local/share/mise/installs/python/3.14/bin/python",
))
},
shape_ok,
);
assert_eq!(out[0].status, Status::Ok);
assert!(out[0].matched_sources.contains(&"mise".to_string()));
assert!(
out[0]
.matched_sources
.contains(&"mise_installs".to_string())
);
assert!(!out[0].matched_sources.contains(&"mise_shims".to_string()));
}
#[test]
fn mise_alias_remains_for_backwards_compat() {
let sources = cat(&[
("mise", src("/home/u/.local/share/mise")),
("mise_shims", src("/home/u/.local/share/mise/shims")),
("mise_installs", src("/home/u/.local/share/mise/installs")),
]);
let expectations = vec![Expectation {
command: "python".into(),
prefer: vec!["mise".into()],
avoid: vec![],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out_shim = evaluate(
&expectations,
&sources,
Os::Linux,
|_| Some(resolved("/home/u/.local/share/mise/shims/python")),
shape_ok,
);
let out_install = evaluate(
&expectations,
&sources,
Os::Linux,
|_| {
Some(resolved(
"/home/u/.local/share/mise/installs/python/3.14/bin/python",
))
},
shape_ok,
);
assert_eq!(out_shim[0].status, Status::Ok);
assert_eq!(out_install[0].status, Status::Ok);
}
use crate::config::Kind;
fn expect_with_kind(command: &str, source: &str, kind: Kind) -> Expectation {
Expectation {
command: command.into(),
prefer: vec![source.into()],
avoid: vec![],
severity: crate::config::Severity::Error,
os: None,
optional: false,
kind: Some(kind),
}
}
fn src_anywhere(p: &str) -> SourceDef {
SourceDef {
windows: Some(p.into()),
unix: Some(p.into()),
..Default::default()
}
}
fn shape_err(reason: &'static str) -> impl Fn(&std::path::Path, Kind) -> Result<(), String> {
move |_, _| Err(reason.into())
}
#[test]
fn kind_executable_routes_shape_check_err_into_ng_not_executable() {
let sources = cat(&[("rogue", src_anywhere("/some/dir"))]);
let expectations = vec![expect_with_kind("rogue_bin", "rogue", Kind::Executable)];
let out = evaluate(
&expectations,
&sources,
Os::Linux,
|_| Some(resolved("/some/dir/rogue_bin")),
shape_err("is a directory"),
);
assert_eq!(out[0].status, Status::NgNotExecutable);
assert_eq!(out[0].reason.as_deref(), Some("is a directory"));
}
#[test]
fn kind_executable_passes_reason_through_for_each_failure_mode() {
for reason in ["broken symlink", "cannot stat", "not a regular file"] {
let sources = cat(&[("anywhere", src_anywhere("/no/such/place"))]);
let expectations = vec![expect_with_kind("ghost", "anywhere", Kind::Executable)];
let out = evaluate(
&expectations,
&sources,
Os::Linux,
|_| Some(resolved("/no/such/place/ghost")),
shape_err(reason),
);
assert_eq!(out[0].status, Status::NgNotExecutable);
assert_eq!(out[0].reason.as_deref(), Some(reason));
}
}
#[test]
fn kind_unset_skips_shape_check_entirely() {
let sources = cat(&[("anywhere", src_anywhere("/no/such/place"))]);
let expectations = vec![Expectation {
command: "ghost".into(),
prefer: vec!["anywhere".into()],
avoid: vec![],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&sources,
Os::current(),
|_| Some(resolved("/no/such/place/ghost")),
shape_ok,
);
assert_eq!(out[0].status, Status::Ok);
}
#[test]
fn kind_executable_does_not_override_wrong_source() {
let sources = cat(&[("good", src("/home/u/good")), ("bad", src("/home/u/bad"))]);
let expectations = vec![Expectation {
command: "x".into(),
prefer: vec!["good".into()],
avoid: vec!["bad".into()],
os: None,
optional: false,
kind: Some(Kind::Executable),
severity: crate::config::Severity::Error,
}];
let out = evaluate(
&expectations,
&sources,
Os::Linux,
|_| Some(resolved("/home/u/bad/x")),
shape_ok,
);
assert!(matches!(out[0].status, Status::NgWrongSource));
}
fn outcome(status: Status, matched: &[&str], prefer: &[&str], avoid: &[&str]) -> Outcome {
Outcome {
command: "rg".into(),
status,
resolved: Some(PathBuf::from("/usr/local/bin/rg")),
matched_sources: matched.iter().map(|s| s.to_string()).collect(),
prefer: prefer.iter().map(|s| s.to_string()).collect(),
avoid: avoid.iter().map(|s| s.to_string()).collect(),
severity: Severity::Error,
reason: None,
}
}
#[test]
fn diagnose_returns_none_for_non_failure_statuses() {
for status in [Status::Ok, Status::Skip, Status::NotApplicable] {
let o = outcome(status, &["cargo"], &["cargo"], &[]);
assert!(
diagnose(&o).is_none(),
"status should yield None: {:?}",
o.status
);
}
}
#[test]
fn diagnose_wrong_source_collects_avoid_hits_when_intersection_non_empty() {
let o = outcome(
Status::NgWrongSource,
&["winget", "scoop"],
&["cargo"],
&["winget"],
);
let d = diagnose(&o).unwrap();
match d {
Diagnosis::WrongSource {
matched,
prefer_missed,
avoid_hits,
} => {
assert_eq!(matched, vec!["winget", "scoop"]);
assert_eq!(prefer_missed, vec!["cargo"]);
assert_eq!(avoid_hits, vec!["winget"]);
}
other => panic!("expected WrongSource, got {other:?}"),
}
}
#[test]
fn diagnose_wrong_source_with_no_avoid_overlap_returns_empty_avoid_hits() {
let o = outcome(Status::NgWrongSource, &["scoop"], &["cargo"], &[]);
let d = diagnose(&o).unwrap();
match d {
Diagnosis::WrongSource { avoid_hits, .. } => assert!(avoid_hits.is_empty()),
other => panic!("expected WrongSource, got {other:?}"),
}
}
#[test]
fn diagnose_unknown_source_carries_only_prefer() {
let o = outcome(Status::NgUnknownSource, &[], &["cargo"], &[]);
let d = diagnose(&o).unwrap();
assert!(
matches!(d, Diagnosis::UnknownSource { ref prefer } if prefer == &["cargo".to_string()])
);
}
#[test]
fn diagnose_not_found_carries_prefer() {
let o = outcome(Status::NgNotFound, &[], &["cargo", "winget"], &[]);
let d = diagnose(&o).unwrap();
assert!(matches!(d, Diagnosis::NotFound { ref prefer } if prefer.len() == 2));
}
#[test]
fn diagnose_not_executable_keeps_reason_and_matched() {
let mut o = outcome(Status::NgNotExecutable, &["custom"], &["custom"], &[]);
o.reason = Some("is a directory".into());
let d = diagnose(&o).unwrap();
match d {
Diagnosis::NotExecutable { reason, matched } => {
assert_eq!(reason, "is a directory");
assert_eq!(matched, vec!["custom"]);
}
other => panic!("expected NotExecutable, got {other:?}"),
}
}
#[test]
fn diagnose_config_error_propagates_message() {
let mut o = outcome(Status::ConfigError, &[], &[], &[]);
o.reason = Some("undefined source name: typo".into());
let d = diagnose(&o).unwrap();
assert!(matches!(d, Diagnosis::Config { ref message } if message.contains("typo")));
}
#[test]
fn diagnosis_serializes_with_kind_discriminator() {
let d = Diagnosis::WrongSource {
matched: vec!["scoop".into()],
prefer_missed: vec!["cargo".into()],
avoid_hits: vec![],
};
let json = serde_json::to_value(&d).unwrap();
assert_eq!(json["kind"], "wrong_source");
assert_eq!(json["matched"][0], "scoop");
}
fn outcome_status(status: Status) -> Outcome {
outcome(status, &[], &[], &[])
}
#[test]
fn exit_code_zero_when_all_outcomes_pass() {
let out = vec![
outcome_status(Status::Ok),
outcome_status(Status::Skip),
outcome_status(Status::NotApplicable),
];
assert_eq!(exit_code(&out), 0);
}
#[test]
fn exit_code_one_when_any_failure_present() {
let out = vec![
outcome_status(Status::Ok),
outcome_status(Status::NgNotFound),
];
assert_eq!(exit_code(&out), 1);
}
#[test]
fn exit_code_two_when_any_config_error_present() {
let out = vec![
outcome_status(Status::Ok),
outcome_status(Status::ConfigError),
];
assert_eq!(exit_code(&out), 2);
}
#[test]
fn exit_code_two_wins_over_one_when_both_present() {
let out = vec![
outcome_status(Status::NgWrongSource),
outcome_status(Status::ConfigError),
];
assert_eq!(exit_code(&out), 2);
}
#[test]
fn exit_code_zero_for_empty_outcome_list() {
let out: Vec<Outcome> = vec![];
assert_eq!(exit_code(&out), 0);
}
fn outcome_with_severity(status: Status, severity: Severity) -> Outcome {
Outcome {
severity,
..outcome_status(status)
}
}
#[test]
fn exit_code_zero_when_only_warn_severity_failures() {
let out = vec![
outcome_with_severity(Status::NgWrongSource, Severity::Warn),
outcome_with_severity(Status::Ok, Severity::Error),
];
assert_eq!(exit_code(&out), 0);
}
#[test]
fn exit_code_one_when_any_error_severity_failure_present() {
let out = vec![
outcome_with_severity(Status::NgWrongSource, Severity::Warn),
outcome_with_severity(Status::NgNotFound, Severity::Error),
];
assert_eq!(exit_code(&out), 1);
}
#[test]
fn exit_code_two_still_wins_over_warn_severity() {
let out = vec![
outcome_with_severity(Status::NgWrongSource, Severity::Warn),
outcome_with_severity(Status::ConfigError, Severity::Warn),
];
assert_eq!(exit_code(&out), 2);
}
#[test]
fn is_failure_true_for_each_ng_variant() {
assert!(is_failure(&Status::NgWrongSource));
assert!(is_failure(&Status::NgUnknownSource));
assert!(is_failure(&Status::NgNotFound));
assert!(is_failure(&Status::NgNotExecutable));
}
#[test]
fn is_failure_false_for_non_ng_variants() {
assert!(!is_failure(&Status::Ok));
assert!(!is_failure(&Status::Skip));
assert!(!is_failure(&Status::NotApplicable));
assert!(!is_failure(&Status::ConfigError));
}
fn expect_simple() -> Expectation {
Expectation {
command: "rg".into(),
prefer: vec!["cargo".into()],
avoid: vec!["winget".into()],
os: None,
optional: false,
kind: None,
severity: crate::config::Severity::Error,
}
}
#[test]
fn outcome_initial_copies_command_prefer_avoid_from_expectation() {
let e = expect_simple();
let o = Outcome::initial(&e);
assert_eq!(o.command, "rg");
assert_eq!(o.prefer, vec!["cargo".to_string()]);
assert_eq!(o.avoid, vec!["winget".to_string()]);
}
#[test]
fn outcome_initial_starts_with_ok_status_and_no_resolution() {
let o = Outcome::initial(&expect_simple());
assert_eq!(o.status, Status::Ok);
assert!(o.resolved.is_none());
assert!(o.matched_sources.is_empty());
}
#[test]
fn outcome_with_status_replaces_status_only() {
let o = Outcome::initial(&expect_simple()).with_status(Status::NgNotFound);
assert_eq!(o.status, Status::NgNotFound);
assert_eq!(o.command, "rg");
assert_eq!(o.prefer, vec!["cargo".to_string()]);
}
#[test]
fn outcome_builders_chain() {
let o = Outcome::initial(&expect_simple())
.with_resolved(PathBuf::from("/usr/local/bin/rg"))
.with_matched_sources(vec!["scoop".into()])
.with_status(Status::NgWrongSource);
assert_eq!(
o.resolved.as_deref(),
Some(std::path::Path::new("/usr/local/bin/rg"))
);
assert_eq!(o.matched_sources, vec!["scoop".to_string()]);
assert_eq!(o.status, Status::NgWrongSource);
}
}