use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::Serialize;
use super::report::{DiagnosticLevel, DiscoveryDiagnostic, DiscoveryDiagnosticKind};
use super::NexoPluginRegistrySnapshot;
#[derive(Clone, Debug, Default, Serialize)]
pub struct SkillsMergeReport {
pub skill_roots: BTreeMap<String, PathBuf>,
pub attribution: BTreeMap<String, String>,
pub contributed_per_plugin: BTreeMap<String, Vec<String>>,
pub conflicts: Vec<SkillConflict>,
pub diagnostics: Vec<DiscoveryDiagnostic>,
}
#[derive(Clone, Debug, Serialize)]
pub struct SkillConflict {
pub skill_name: String,
pub plugin_ids: Vec<String>,
}
pub fn merge_plugin_contributed_skills(snapshot: &NexoPluginRegistrySnapshot) -> SkillsMergeReport {
let mut report = SkillsMergeReport::default();
let mut conflict_index: BTreeMap<String, usize> = BTreeMap::new();
for plugin in &snapshot.plugins {
let plugin_id = plugin.manifest.plugin.id.clone();
let contributes_dir = match &plugin.manifest.plugin.skills.contributes_dir {
Some(p) => p.clone(),
None => continue,
};
let abs_dir = plugin.root_dir.join(&contributes_dir);
if !abs_dir.exists() {
report.diagnostics.push(DiscoveryDiagnostic {
level: DiagnosticLevel::Warn,
path: abs_dir.clone(),
kind: DiscoveryDiagnosticKind::SearchPathMissing {
reason: format!(
"plugin '{plugin_id}' declared skills.contributes_dir but path does not exist"
),
},
});
continue;
}
let entries = match std::fs::read_dir(&abs_dir) {
Ok(rd) => rd.filter_map(|r| r.ok()).collect::<Vec<_>>(),
Err(_) => {
report.diagnostics.push(DiscoveryDiagnostic {
level: DiagnosticLevel::Warn,
path: abs_dir.clone(),
kind: DiscoveryDiagnosticKind::PermissionDenied,
});
continue;
}
};
let mut entries: Vec<_> = entries
.into_iter()
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.collect();
entries.sort_by_key(|e| e.file_name());
report
.skill_roots
.insert(plugin_id.clone(), abs_dir.clone());
for entry in entries {
let skill_name = match entry.file_name().to_str() {
Some(s) => s.to_string(),
None => continue,
};
let skill_md = entry.path().join("SKILL.md");
if !skill_md.is_file() {
continue;
}
report
.contributed_per_plugin
.entry(plugin_id.clone())
.or_default()
.push(skill_name.clone());
if let Some(conflict_idx) = conflict_index.get(&skill_name).copied() {
report.conflicts[conflict_idx]
.plugin_ids
.push(plugin_id.clone());
} else if let Some(prior_plugin) = report.attribution.get(&skill_name) {
let prior = prior_plugin.clone();
let conflict = SkillConflict {
skill_name: skill_name.clone(),
plugin_ids: vec![prior, plugin_id.clone()],
};
conflict_index.insert(skill_name.clone(), report.conflicts.len());
report.conflicts.push(conflict);
} else {
report
.attribution
.insert(skill_name.clone(), plugin_id.clone());
}
}
}
report
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
use nexo_plugin_manifest::PluginManifest;
use super::super::report::PluginDiscoveryReport;
use super::super::DiscoveredPlugin;
struct PluginSkillFixture {
root: TempDir,
}
impl PluginSkillFixture {
fn new(plugin_id: &str) -> Self {
let root = tempfile::tempdir().unwrap();
let manifest = format!(
"[plugin]\n\
id = \"{plugin_id}\"\n\
version = \"0.1.0\"\n\
name = \"{plugin_id}\"\n\
description = \"fixture\"\n\
min_nexo_version = \">=0.0.1\"\n\
[plugin.skills]\n\
contributes_dir = \"skills\"\n",
);
fs::write(root.path().join("nexo-plugin.toml"), manifest).unwrap();
fs::create_dir_all(root.path().join("skills")).unwrap();
Self { root }
}
fn new_no_skills(plugin_id: &str) -> Self {
let root = tempfile::tempdir().unwrap();
let manifest = format!(
"[plugin]\n\
id = \"{plugin_id}\"\n\
version = \"0.1.0\"\n\
name = \"{plugin_id}\"\n\
description = \"fixture\"\n\
min_nexo_version = \">=0.0.1\"\n",
);
fs::write(root.path().join("nexo-plugin.toml"), manifest).unwrap();
Self { root }
}
fn new_dangling_dir(plugin_id: &str) -> Self {
let root = tempfile::tempdir().unwrap();
let manifest = format!(
"[plugin]\n\
id = \"{plugin_id}\"\n\
version = \"0.1.0\"\n\
name = \"{plugin_id}\"\n\
description = \"fixture\"\n\
min_nexo_version = \">=0.0.1\"\n\
[plugin.skills]\n\
contributes_dir = \"skills\"\n",
);
fs::write(root.path().join("nexo-plugin.toml"), manifest).unwrap();
Self { root }
}
fn add_skill(&self, name: &str, body: &str) -> &Self {
let dir = self.root.path().join("skills").join(name);
fs::create_dir_all(&dir).unwrap();
let content = format!("---\ndescription: {name}\n---\n\n{body}\n");
fs::write(dir.join("SKILL.md"), content).unwrap();
self
}
fn add_subdir_without_skill_md(&self, name: &str) -> &Self {
let dir = self.root.path().join("skills").join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("README.md"), "docs only").unwrap();
self
}
fn discovered(&self) -> DiscoveredPlugin {
let raw = fs::read_to_string(self.root.path().join("nexo-plugin.toml")).unwrap();
let manifest: PluginManifest = toml::from_str(&raw).unwrap();
DiscoveredPlugin {
manifest,
root_dir: self.root.path().to_path_buf(),
manifest_path: self.root.path().join("nexo-plugin.toml"),
}
}
}
fn snapshot_with(plugins: Vec<DiscoveredPlugin>) -> NexoPluginRegistrySnapshot {
NexoPluginRegistrySnapshot {
plugins,
last_report: PluginDiscoveryReport::default(),
skill_roots: BTreeMap::new(),
}
}
#[test]
fn plugin_skill_loaded_when_agent_requests_it() {
let p = PluginSkillFixture::new("alpha");
p.add_skill("triager", "you triage");
let snap = snapshot_with(vec![p.discovered()]);
let report = merge_plugin_contributed_skills(&snap);
assert_eq!(report.skill_roots.len(), 1);
assert!(report.skill_roots.contains_key("alpha"));
assert_eq!(
report.attribution.get("triager"),
Some(&"alpha".to_string())
);
assert_eq!(
report
.contributed_per_plugin
.get("alpha")
.map(|v| v.as_slice()),
Some(&["triager".to_string()][..])
);
assert!(report.conflicts.is_empty());
assert!(report.diagnostics.is_empty());
}
#[test]
fn two_plugins_same_skill_name_emits_conflict_first_wins() {
let p1 = PluginSkillFixture::new("alpha");
p1.add_skill("helper", "alpha-content");
let p2 = PluginSkillFixture::new("beta");
p2.add_skill("helper", "beta-content");
let snap = snapshot_with(vec![p1.discovered(), p2.discovered()]);
let report = merge_plugin_contributed_skills(&snap);
assert_eq!(report.skill_roots.len(), 2);
assert_eq!(report.attribution.get("helper"), Some(&"alpha".to_string()));
assert_eq!(report.conflicts.len(), 1);
assert_eq!(report.conflicts[0].skill_name, "helper");
assert_eq!(
report.conflicts[0].plugin_ids,
vec!["alpha".to_string(), "beta".to_string()]
);
}
#[test]
fn three_plugins_same_skill_name_appends_to_conflict() {
let p1 = PluginSkillFixture::new("alpha");
p1.add_skill("dup", "a");
let p2 = PluginSkillFixture::new("beta");
p2.add_skill("dup", "b");
let p3 = PluginSkillFixture::new("gamma");
p3.add_skill("dup", "c");
let snap = snapshot_with(vec![p1.discovered(), p2.discovered(), p3.discovered()]);
let report = merge_plugin_contributed_skills(&snap);
assert_eq!(report.conflicts.len(), 1);
assert_eq!(
report.conflicts[0].plugin_ids,
vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()]
);
assert_eq!(report.attribution.get("dup"), Some(&"alpha".to_string()));
}
#[test]
fn plugin_with_no_contributes_dir_no_op() {
let p = PluginSkillFixture::new_no_skills("quiet");
let snap = snapshot_with(vec![p.discovered()]);
let report = merge_plugin_contributed_skills(&snap);
assert!(report.skill_roots.is_empty());
assert!(report.attribution.is_empty());
assert!(report.contributed_per_plugin.is_empty());
assert!(report.conflicts.is_empty());
assert!(report.diagnostics.is_empty());
}
#[test]
fn missing_contributes_dir_emits_search_path_missing() {
let p = PluginSkillFixture::new_dangling_dir("ghost");
let snap = snapshot_with(vec![p.discovered()]);
let report = merge_plugin_contributed_skills(&snap);
assert!(report.skill_roots.is_empty());
assert!(report.diagnostics.iter().any(|d| matches!(
&d.kind,
DiscoveryDiagnosticKind::SearchPathMissing { reason }
if reason.contains("ghost")
)));
}
#[test]
fn subdir_without_skill_md_silently_skipped() {
let p = PluginSkillFixture::new("alpha");
p.add_skill("real_skill", "ok")
.add_subdir_without_skill_md("just_docs");
let snap = snapshot_with(vec![p.discovered()]);
let report = merge_plugin_contributed_skills(&snap);
assert_eq!(
report.attribution.get("real_skill"),
Some(&"alpha".to_string())
);
assert!(!report.attribution.contains_key("just_docs"));
let alpha_skills = report.contributed_per_plugin.get("alpha").unwrap();
assert!(!alpha_skills.iter().any(|s| s == "just_docs"));
assert!(report.diagnostics.is_empty());
}
}