use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::Serialize;
use crate::config::{Relation, SourceDef};
use crate::expand::{expand_and_normalize, normalize};
use crate::os_detect::Os;
use crate::resolve::Resolution;
use crate::source_match;
#[derive(Debug, PartialEq, Eq)]
pub enum WhereOutcome {
Found(Found),
NotFound,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
pub struct Found {
pub command: String,
pub resolved: PathBuf,
pub matched_sources: Vec<String>,
pub uninstall: UninstallHint,
pub provenance: Option<Provenance>,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UninstallHint {
Command { command: String },
NoTemplate { source: String },
NoSource,
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Provenance {
MiseInstallerPlugin {
installer: String,
plugin_segment: String,
},
}
pub fn locate<R>(
command: &str,
sources: &BTreeMap<String, SourceDef>,
relations: &[Relation],
os: Os,
mut resolver: R,
) -> WhereOutcome
where
R: FnMut(&str) -> Option<Resolution>,
{
let Some(resolution) = resolver(command) else {
return WhereOutcome::NotFound;
};
let haystack = normalize(&resolution.full_path.to_string_lossy());
let matched = source_match::names_only(&haystack, sources, os);
let provenance = infer_provenance_from_relations(&haystack, sources, relations, os);
let uninstall = match &provenance {
Some(prov) => uninstall_for_provenance(prov, os),
None => derive_uninstall(&resolution.full_path, &matched, sources, os),
};
let matched = rank_aliases_last(matched, relations);
WhereOutcome::Found(Found {
command: command.to_string(),
resolved: resolution.full_path,
matched_sources: matched,
uninstall,
provenance,
})
}
fn rank_aliases_last(matched: Vec<String>, relations: &[Relation]) -> Vec<String> {
let parents: Vec<&str> = relations
.iter()
.filter_map(|r| match r {
Relation::AliasOf { parent, children } => {
let any_child_matched = children.iter().any(|c| matched.contains(c));
if any_child_matched {
Some(parent.as_str())
} else {
None
}
}
_ => None,
})
.collect();
if parents.is_empty() {
return matched;
}
let (deferred, others): (Vec<String>, Vec<String>) = matched
.into_iter()
.partition(|m| parents.contains(&m.as_str()));
others.into_iter().chain(deferred).collect()
}
fn derive_uninstall(
resolved: &std::path::Path,
matched: &[String],
sources: &BTreeMap<String, SourceDef>,
os: Os,
) -> UninstallHint {
if matched.is_empty() {
return UninstallHint::NoSource;
}
let bin = bin_stem(resolved);
let quoted_bin = crate::format::quote_for(os, &bin);
for name in matched {
let Some(def) = sources.get(name) else {
continue;
};
if let Some(template) = &def.uninstall_command {
return UninstallHint::Command {
command: template.replace("{bin}", "ed_bin),
};
}
}
UninstallHint::NoTemplate {
source: matched[0].clone(),
}
}
fn infer_provenance_from_relations(
normalized_haystack: &str,
sources: &BTreeMap<String, SourceDef>,
relations: &[Relation],
os: Os,
) -> Option<Provenance> {
for rel in relations {
let Relation::ServedByVia {
host,
guest_pattern,
guest_provider,
installer_token,
} = rel
else {
continue;
};
let Some(host_def) = sources.get(host) else {
continue;
};
let Some(host_raw) = host_def.path_for(os) else {
continue;
};
let host_needle = expand_and_normalize(host_raw);
if host_needle.is_empty() {
continue;
}
let Some(after) = find_after_needle(normalized_haystack, &host_needle) else {
continue;
};
let after = after.strip_prefix('/').unwrap_or(after);
let Some(segment) = after.split('/').next() else {
continue;
};
if let Some(_rest) = match_glob_prefix(guest_pattern, segment) {
let installer = installer_token
.clone()
.unwrap_or_else(|| guest_provider.clone());
return Some(Provenance::MiseInstallerPlugin {
installer,
plugin_segment: segment.to_string(),
});
}
}
None
}
fn find_after_needle<'h>(haystack: &'h str, needle: &str) -> Option<&'h str> {
haystack.find(needle).map(|i| &haystack[i + needle.len()..])
}
fn match_glob_prefix<'a>(pattern: &str, segment: &'a str) -> Option<&'a str> {
let prefix = pattern.strip_suffix('*')?;
let rest = segment.strip_prefix(prefix)?;
if rest.is_empty() { None } else { Some(rest) }
}
fn uninstall_for_provenance(prov: &Provenance, os: Os) -> UninstallHint {
match prov {
Provenance::MiseInstallerPlugin {
installer,
plugin_segment,
} => {
let rest = plugin_segment
.strip_prefix(&format!("{installer}-"))
.unwrap_or(plugin_segment);
let quoted_rest = crate::format::quote_for(os, rest);
UninstallHint::Command {
command: format!(
"mise uninstall {installer}:{quoted_rest} (best-guess; verify with `mise plugins ls`)"
),
}
}
}
}
fn bin_stem(path: &std::path::Path) -> String {
path.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn src(unix: &str) -> SourceDef {
SourceDef {
unix: Some(unix.into()),
..Default::default()
}
}
fn src_with_uninstall(unix: &str, template: &str) -> SourceDef {
SourceDef {
unix: Some(unix.into()),
uninstall_command: Some(template.into()),
..Default::default()
}
}
fn cat(entries: &[(&str, SourceDef)]) -> BTreeMap<String, SourceDef> {
entries
.iter()
.map(|(n, d)| (n.to_string(), d.clone()))
.collect()
}
fn resolution(p: &str) -> Resolution {
Resolution {
full_path: PathBuf::from(p),
}
}
fn mise_relations() -> Vec<Relation> {
vec![
Relation::AliasOf {
parent: "mise".into(),
children: vec!["mise_shims".into(), "mise_installs".into()],
},
Relation::ServedByVia {
host: "mise_installs".into(),
guest_pattern: "cargo-*".into(),
guest_provider: "cargo".into(),
installer_token: Some("cargo".into()),
},
Relation::ServedByVia {
host: "mise_installs".into(),
guest_pattern: "npm-*".into(),
guest_provider: "npm_global".into(),
installer_token: Some("npm".into()),
},
Relation::ServedByVia {
host: "mise_installs".into(),
guest_pattern: "pipx-*".into(),
guest_provider: "pip_user".into(),
installer_token: Some("pipx".into()),
},
]
}
#[test]
fn not_found_when_resolver_returns_none() {
let out = locate("ghost", &BTreeMap::new(), &[], Os::Linux, |_| None);
assert_eq!(out, WhereOutcome::NotFound);
}
#[test]
fn cargo_install_renders_cargo_uninstall_hint() {
let sources = cat(&[(
"cargo",
src_with_uninstall("/home/u/.cargo/bin", "cargo uninstall {bin}"),
)]);
let out = locate("lazygit", &sources, &[], Os::Linux, |_| {
Some(resolution("/home/u/.cargo/bin/lazygit"))
});
match out {
WhereOutcome::Found(f) => {
assert_eq!(f.matched_sources, vec!["cargo".to_string()]);
assert_eq!(
f.uninstall,
UninstallHint::Command {
command: "cargo uninstall 'lazygit'".into()
}
);
}
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn most_specific_source_wins_lead_spot() {
let sources = cat(&[
("mise", src("/home/u/.local/share/mise")),
(
"mise_installs",
src_with_uninstall("/home/u/.local/share/mise/installs", "mise uninstall {bin}"),
),
]);
let out = locate("lazygit", &sources, &mise_relations(), Os::Linux, |_| {
Some(resolution(
"/home/u/.local/share/mise/installs/cargo-lazygit/0.61/bin/lazygit",
))
});
match out {
WhereOutcome::Found(f) => {
assert_eq!(f.matched_sources[0], "mise_installs");
assert_eq!(f.matched_sources.last().unwrap(), "mise");
match &f.uninstall {
UninstallHint::Command { command } => {
assert!(
command.contains("mise uninstall cargo:'lazygit'"),
"uninstall: {command}"
);
}
other => panic!("expected Command, got {other:?}"),
}
match &f.provenance {
Some(Provenance::MiseInstallerPlugin { installer, .. }) => {
assert_eq!(installer, "cargo");
}
other => panic!("expected MiseInstallerPlugin, got {other:?}"),
}
}
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn no_template_when_source_has_none() {
let sources = cat(&[("aqua", src("/home/u/.local/share/aquaproj-aqua"))]);
let out = locate("aqua_tool", &sources, &[], Os::Linux, |_| {
Some(resolution(
"/home/u/.local/share/aquaproj-aqua/cache/foo/aqua_tool",
))
});
match out {
WhereOutcome::Found(f) => {
assert_eq!(
f.uninstall,
UninstallHint::NoTemplate {
source: "aqua".into()
}
);
}
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn no_source_when_resolved_outside_catalog() {
let sources = cat(&[("cargo", src("/home/u/.cargo/bin"))]);
let out = locate("orphan", &sources, &[], Os::Linux, |_| {
Some(resolution("/opt/local-stuff/bin/orphan"))
});
match out {
WhereOutcome::Found(f) => {
assert!(f.matched_sources.is_empty());
assert_eq!(f.uninstall, UninstallHint::NoSource);
}
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn windows_extension_is_stripped_from_bin_token() {
let sources = cat(&[(
"cargo",
src_with_uninstall("/home/u/.cargo/bin", "cargo uninstall {bin}"),
)]);
let out = locate("lazygit", &sources, &[], Os::Linux, |_| {
Some(resolution("/home/u/.cargo/bin/lazygit.exe"))
});
match out {
WhereOutcome::Found(f) => {
assert_eq!(
f.uninstall,
UninstallHint::Command {
command: "cargo uninstall 'lazygit'".into()
}
);
}
other => panic!("expected Found, got {other:?}"),
}
}
fn mise_sources() -> BTreeMap<String, SourceDef> {
cat(&[
("mise", src("/home/u/.local/share/mise")),
("mise_installs", src("/home/u/.local/share/mise/installs")),
])
}
#[test]
fn npm_plugin_segment_yields_npm_provenance() {
let out = locate(
"gemini",
&mise_sources(),
&mise_relations(),
Os::Linux,
|_| {
Some(resolution(
"/home/u/.local/share/mise/installs/npm-google-gemini-cli/0.40.0/gemini",
))
},
);
match out {
WhereOutcome::Found(f) => {
match &f.provenance {
Some(Provenance::MiseInstallerPlugin {
installer,
plugin_segment,
}) => {
assert_eq!(installer, "npm");
assert_eq!(plugin_segment, "npm-google-gemini-cli");
}
other => panic!("expected MiseInstallerPlugin, got {other:?}"),
}
match &f.uninstall {
UninstallHint::Command { command } => {
assert!(
command.starts_with("mise uninstall npm:'google-gemini-cli'"),
"uninstall: {command}"
);
}
other => panic!("expected Command, got {other:?}"),
}
}
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn runtime_segment_does_not_get_plugin_provenance() {
let out = locate(
"python",
&mise_sources(),
&mise_relations(),
Os::Linux,
|_| {
Some(resolution(
"/home/u/.local/share/mise/installs/python/3.14/bin/python",
))
},
);
match out {
WhereOutcome::Found(f) => {
assert!(f.provenance.is_none());
}
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn pipx_plugin_uses_installer_token_distinct_from_guest_provider() {
let out = locate(
"black",
&mise_sources(),
&mise_relations(),
Os::Linux,
|_| {
Some(resolution(
"/home/u/.local/share/mise/installs/pipx-black/24.0/bin/black",
))
},
);
match out {
WhereOutcome::Found(f) => match &f.provenance {
Some(Provenance::MiseInstallerPlugin {
installer,
plugin_segment,
}) => {
assert_eq!(
installer, "pipx",
"installer_token must override guest_provider"
);
assert_eq!(plugin_segment, "pipx-black");
}
other => panic!("expected MiseInstallerPlugin, got {other:?}"),
},
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn unknown_plugin_prefix_does_not_attribute() {
let out = locate("xyz", &mise_sources(), &mise_relations(), Os::Linux, |_| {
Some(resolution(
"/home/u/.local/share/mise/installs/exotic-thing/0.1/bin/xyz",
))
});
match out {
WhereOutcome::Found(f) => {
assert!(f.provenance.is_none());
}
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn provenance_only_fires_for_mise_installs_paths() {
let sources = cat(&[(
"cargo",
src_with_uninstall("/home/u/.cargo/bin", "cargo uninstall {bin}"),
)]);
let out = locate(
"cargo-lazygit",
&sources,
&mise_relations(),
Os::Linux,
|_| Some(resolution("/home/u/.cargo/bin/cargo-lazygit")),
);
match out {
WhereOutcome::Found(f) => {
assert!(f.provenance.is_none());
assert_eq!(
f.uninstall,
UninstallHint::Command {
command: "cargo uninstall 'cargo-lazygit'".into()
}
);
}
other => panic!("expected Found, got {other:?}"),
}
}
}