use std::collections::BTreeMap;
use std::path::Path;
use pmpx_plugin::Family;
use super::failure::DetectFailure;
use super::{score_all, FamilyScore, ScoredPlugin};
use crate::config::{GlobalConfig, MergedProjectConfig};
use crate::plugins::PluginSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Selection {
pub crate_name: String,
pub name: String,
pub family: Family,
pub score: u32,
pub matched: Vec<String>,
pub notes: Vec<String>,
}
fn rank(order: &[String], name: &str) -> usize {
order.iter().position(|x| x == name).unwrap_or(order.len())
}
fn matched_files(scored: &ScoredPlugin) -> Vec<String> {
scored.all_hits().map(str::to_string).collect()
}
pub fn select(
set: &PluginSet,
root: &Path,
merged: &MergedProjectConfig,
global: &GlobalConfig,
explicit: Option<&str>,
) -> Result<Selection, DetectFailure> {
if explicit.is_some() {
return select_from_scores(set, root, &BTreeMap::new(), merged, global, explicit);
}
let families = score_all(set, root, merged);
select_from_scores(set, root, &families, merged, global, explicit)
}
pub fn select_from_scores(
set: &PluginSet,
root: &Path,
families: &BTreeMap<Family, FamilyScore>,
merged: &MergedProjectConfig,
global: &GlobalConfig,
explicit: Option<&str>,
) -> Result<Selection, DetectFailure> {
if let Some(name) = explicit {
let Some(plugin) = set.by_name(name) else {
return Err(DetectFailure::UnknownPlugin {
name: name.to_string(),
available: set.usable().map(|p| p.name.clone()).collect(),
});
};
let Some(family) = plugin.family.clone() else {
return Err(DetectFailure::UnknownPlugin {
name: name.to_string(),
available: set.usable().map(|p| p.name.clone()).collect(),
});
};
return Ok(Selection {
crate_name: plugin.crate_name.clone(),
name: plugin.name.clone(),
family,
score: 0,
matched: matched_files(&ScoredPlugin::score(plugin, root)),
notes: Vec::new(),
});
}
let mut candidates: Vec<&FamilyScore> = families.values().filter(|f| f.score > 0).collect();
if candidates.is_empty() {
return Err(DetectFailure::NothingDetected);
}
candidates.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| {
rank(&global.plugin.family_priority, a.family.as_str())
.cmp(&rank(&global.plugin.family_priority, b.family.as_str()))
})
.then_with(|| a.family.as_str().cmp(b.family.as_str()))
});
let winner = candidates[0];
let mut notes = Vec::new();
if candidates.get(1).is_some_and(|r| r.score == winner.score) {
let tied: Vec<&str> = candidates
.iter()
.filter(|c| c.score == winner.score)
.map(|c| c.family.as_str())
.collect();
notes.push(format!(
"Multiple candidates detected ({} tied at {} points), selected {}",
tied.join(" / "),
winner.score,
winner.family
));
}
let family_name = winner.family.as_str().to_string();
if let Some(pinned) = merged.pinned_plugin(&family_name) {
if let Some(p) = winner.plugins.iter().find(|p| p.name == pinned) {
return Ok(Selection {
crate_name: p.crate_name.clone(),
name: p.name.clone(),
family: winner.family.clone(),
score: p.score,
matched: matched_files(p),
notes,
});
}
if let Some(existing) = set.by_name(pinned) {
return Err(DetectFailure::PluginUnusable {
name: pinned.to_string(),
problem: existing
.problem()
.unwrap_or("its declared family does not match the one written in .pmpx.toml")
.to_string(),
});
}
return Err(DetectFailure::PinnedNotInstalled {
family: family_name,
name: pinned.to_string(),
});
}
let mut ranked: Vec<&ScoredPlugin> = winner.plugins.iter().filter(|p| p.score > 0).collect();
if ranked.is_empty() {
return Err(DetectFailure::FamilyWithoutPlugin {
family: family_name,
score: winner.score,
});
}
ranked.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| {
rank(&global.plugin.priority, &a.name).cmp(&rank(&global.plugin.priority, &b.name))
})
.then_with(|| a.name.cmp(&b.name))
});
if ranked.len() > 1 && ranked[1].score == ranked[0].score {
let tied: Vec<&str> = ranked
.iter()
.filter(|p| p.score == ranked[0].score)
.map(|p| p.name.as_str())
.collect();
notes.push(format!(
"Multiple candidates detected ({} tied at {} points), selected {}",
tied.join(" / "),
ranked[0].score,
ranked[0].name
));
}
let best = ranked[0];
Ok(Selection {
crate_name: best.crate_name.clone(),
name: best.name.clone(),
family: winner.family.clone(),
score: best.score,
matched: matched_files(best),
notes,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ranking_puts_listed_before_unlisted() {
let order = vec!["node".to_string(), "rust".to_string()];
assert_eq!(rank(&order, "node"), 0);
assert_eq!(rank(&order, "rust"), 1);
assert_eq!(rank(&order, "python"), 2, "unlisted ones rank last");
}
}