use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate_plugin_kit::{CratePluginKit, PluginInfo, PluginManifest};
use pmpx_plugin::abi::PmpxPluginV1;
use pmpx_plugin::Family;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstalledPlugin {
pub name: String,
pub crate_name: String,
pub version: String,
pub family: Option<Family>,
pub abi: Option<u32>,
pub dir: PathBuf,
pub strong: Vec<String>,
pub weak: Vec<String>,
}
impl InstalledPlugin {
pub fn is_usable(&self) -> bool {
self.problem().is_none()
}
pub fn problem(&self) -> Option<&'static str> {
match self.family {
None => Some("the manifest does not declare a family, so it cannot take part in family resolution"),
Some(_) if self.strong.is_empty() && self.weak.is_empty() => {
Some("the manifest's [detect] section is empty, so detection will never match it")
}
Some(_) => None,
}
}
pub fn detect_names(&self) -> impl Iterator<Item = &str> {
self.strong
.iter()
.chain(self.weak.iter())
.map(String::as_str)
}
}
#[derive(Debug, Clone, Default)]
pub struct PluginSet {
pub plugins: Vec<InstalledPlugin>,
detect_names: Vec<String>,
}
impl PluginSet {
pub fn load(kit: &CratePluginKit<PmpxPluginV1>) -> Result<Self> {
let infos = kit.list().context("failed to scan the plugin directory")?;
let mut plugins = Vec::with_capacity(infos.len());
for info in infos {
plugins.push(read_one(kit, &info)?);
}
plugins.sort_by(|a, b| a.crate_name.cmp(&b.crate_name));
let mut detect_names: Vec<String> = plugins
.iter()
.filter(|p| p.is_usable())
.flat_map(|p| p.detect_names())
.map(str::to_string)
.collect();
detect_names.sort_unstable();
detect_names.dedup();
Ok(Self {
plugins,
detect_names,
})
}
pub fn usable(&self) -> impl Iterator<Item = &InstalledPlugin> {
self.plugins.iter().filter(|p| p.is_usable())
}
pub fn by_name(&self, name: &str) -> Option<&InstalledPlugin> {
self.plugins.iter().find(|p| p.name == name)
}
pub fn by_crate_name(&self, crate_name: &str) -> Option<&InstalledPlugin> {
self.plugins.iter().find(|p| p.crate_name == crate_name)
}
pub fn detect_names(&self) -> &[String] {
&self.detect_names
}
pub fn marks_root(&self, dir: &Path) -> bool {
if dir.join(".pmpx.toml").is_file() {
return true;
}
self.detect_names()
.iter()
.any(|name| dir.join(name).exists())
}
}
fn read_one(kit: &CratePluginKit<PmpxPluginV1>, info: &PluginInfo) -> Result<InstalledPlugin> {
let manifest = kit.manifest_of(&info.crate_name).ok();
let (strong, weak) = manifest.as_ref().map(detect_patterns).unwrap_or_default();
Ok(InstalledPlugin {
name: info.name.clone(),
crate_name: info.crate_name.clone(),
version: info.version.clone(),
family: info.family.clone().map(Family::new),
abi: info.abi,
dir: info.dir.clone(),
strong,
weak,
})
}
fn detect_patterns(manifest: &PluginManifest) -> (Vec<String>, Vec<String>) {
let Some(detect) = manifest.extra.get("detect") else {
return (Vec::new(), Vec::new());
};
(
str_array(detect.get("strong")),
str_array(detect.get("weak")),
)
}
fn str_array(value: Option<&toml::Value>) -> Vec<String> {
value
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.filter_map(|x| x.as_str())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
const PNPM: &str = r#"
[plugin]
name = "pnpm"
version = "0.1.0"
abi = 1
family = "node"
[detect]
strong = ["pnpm-lock.yaml", "pnpm-workspace.yaml"]
weak = ["package.json"]
"#;
const CARGO: &str = r#"
[plugin]
name = "cargo"
version = "0.2.0"
abi = 1
family = "rust"
[detect]
strong = ["Cargo.lock"]
weak = ["Cargo.toml"]
"#;
fn store(
entries: &[(&str, &str)],
) -> (tempfile::TempDir, CratePluginKit<PmpxPluginV1>, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("store");
for (crate_name, manifest) in entries {
let dir = root.join("plugins").join(crate_name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("pmpx-plugin.toml"), manifest).unwrap();
}
let cfg = crate_plugin_kit::KitConfig::new("pmpx")
.with_data_dir(&root)
.with_lock_timeout(Duration::from_millis(500));
let kit = CratePluginKit::<PmpxPluginV1>::new(cfg).unwrap();
(tmp, kit, root)
}
#[test]
fn an_empty_store_lists_nothing() {
let (_t, kit, _) = store(&[]);
let set = PluginSet::load(&kit).unwrap();
assert!(set.plugins.is_empty());
assert!(set.detect_names().is_empty());
}
#[test]
fn reads_names_family_and_detect_patterns() {
let (_t, kit, _) = store(&[("pmpx-plugin-pnpm", PNPM)]);
let set = PluginSet::load(&kit).unwrap();
assert_eq!(set.plugins.len(), 1);
let p = &set.plugins[0];
assert_eq!(p.name, "pnpm");
assert_eq!(p.crate_name, "pmpx-plugin-pnpm");
assert_eq!(p.version, "0.1.0");
assert_eq!(p.family.as_ref().map(Family::as_str), Some("node"));
assert_eq!(p.abi, Some(1));
assert_eq!(p.strong, vec!["pnpm-lock.yaml", "pnpm-workspace.yaml"]);
assert_eq!(p.weak, vec!["package.json"]);
assert!(p.is_usable());
}
#[test]
fn results_are_sorted_by_crate_name() {
let (_t, kit, _) = store(&[("pmpx-plugin-pnpm", PNPM), ("pmpx-plugin-cargo", CARGO)]);
let set = PluginSet::load(&kit).unwrap();
let names: Vec<_> = set.plugins.iter().map(|p| p.crate_name.as_str()).collect();
assert_eq!(names, vec!["pmpx-plugin-cargo", "pmpx-plugin-pnpm"]);
}
#[test]
fn detect_names_are_deduped_across_plugins() {
let (_t, kit, _) = store(&[("pmpx-plugin-pnpm", PNPM), ("pmpx-plugin-cargo", CARGO)]);
let set = PluginSet::load(&kit).unwrap();
let names = set.detect_names();
assert!(
names.windows(2).all(|w| w[0] < w[1]),
"sorted and without duplicates: {names:?}"
);
assert!(names.contains(&"Cargo.toml".to_string()));
assert!(names.contains(&"pnpm-lock.yaml".to_string()));
assert!(!names.contains(&"package.json.lock".to_string()));
}
#[test]
fn marks_root_on_a_detect_file_or_a_pmpx_toml() {
let (_t, kit, _) = store(&[("pmpx-plugin-pnpm", PNPM)]);
let set = PluginSet::load(&kit).unwrap();
let tmp = tempfile::tempdir().unwrap();
assert!(!set.marks_root(tmp.path()));
std::fs::write(tmp.path().join("package.json"), "{}").unwrap();
assert!(
set.marks_root(tmp.path()),
"weak evidence also marks a root"
);
let other = tempfile::tempdir().unwrap();
std::fs::write(other.path().join(".pmpx.toml"), "").unwrap();
assert!(
set.marks_root(other.path()),
".pmpx.toml is itself a root marker"
);
}
#[test]
fn marks_root_on_a_strong_evidence_file_too() {
let (_t, kit, _) = store(&[("pmpx-plugin-cargo", CARGO)]);
let set = PluginSet::load(&kit).unwrap();
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("Cargo.lock"), "").unwrap();
assert!(set.marks_root(tmp.path()));
}
#[test]
fn marks_root_is_false_with_no_plugins_installed() {
let (_t, kit, _) = store(&[]);
let set = PluginSet::load(&kit).unwrap();
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
assert!(!set.marks_root(tmp.path()));
}
#[test]
fn a_manifest_without_family_is_listed_but_unusable() {
let (_t, kit, _) = store(&[(
"pmpx-plugin-weird",
"[plugin]\nname = \"weird\"\nversion = \"0.1.0\"\n\n[detect]\nstrong = [\"x\"]\n",
)]);
let set = PluginSet::load(&kit).unwrap();
assert_eq!(set.plugins.len(), 1, "still listed");
let p = &set.plugins[0];
assert!(!p.is_usable());
assert!(p.problem().unwrap().contains("family"));
assert_eq!(set.usable().count(), 0);
assert!(
set.detect_names().is_empty(),
"unusable plugins take no part in detection"
);
}
#[test]
fn a_manifest_with_an_empty_detect_section_is_unusable() {
let (_t, kit, _) = store(&[(
"pmpx-plugin-blank",
"[plugin]\nname = \"blank\"\nversion = \"0.1.0\"\nfamily = \"node\"\n\n[detect]\nstrong = []\nweak = []\n",
)]);
let set = PluginSet::load(&kit).unwrap();
let p = &set.plugins[0];
assert!(!p.is_usable());
assert!(p.problem().unwrap().contains("detect"));
}
#[test]
fn a_malformed_detect_value_is_skipped_not_fatal() {
let (_t, kit, _) = store(&[(
"pmpx-plugin-odd",
"[plugin]\nname = \"odd\"\nversion = \"0.1.0\"\nfamily = \"node\"\n\n[detect]\nstrong = \"not-an-array\"\nweak = [1, 2, \"package.json\"]\n",
)]);
let set = PluginSet::load(&kit).unwrap();
let p = &set.plugins[0];
assert!(p.strong.is_empty(), "a non-array is treated as empty");
assert_eq!(
p.weak,
vec!["package.json"],
"non-string elements are skipped"
);
assert!(
p.is_usable(),
"one usable piece of evidence is left, the plugin is still usable"
);
}
#[test]
fn by_name_and_by_crate_name_find_the_same_plugin() {
let (_t, kit, _) = store(&[("pmpx-plugin-pnpm", PNPM)]);
let set = PluginSet::load(&kit).unwrap();
assert_eq!(
set.by_name("pnpm").map(|p| &p.crate_name),
Some(&"pmpx-plugin-pnpm".to_string())
);
assert_eq!(
set.by_crate_name("pmpx-plugin-pnpm").map(|p| &p.name),
Some(&"pnpm".to_string())
);
assert!(set.by_name("nope").is_none());
}
}