use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::error::{ItemKind, MindError, Result};
use crate::frontmatter;
use crate::mindfile::{Discover, HookEvent, ItemDecl, KindGlobs, MindToml, ResolvedHook};
use crate::namespace;
use crate::paths::Paths;
use crate::plugin_manifest;
use crate::source::{Registry, Source};
#[derive(Debug, Clone)]
pub struct CatalogItem {
pub kind: ItemKind,
pub name: String,
pub source: String,
pub prefix: Option<String>,
pub path: PathBuf,
pub description: Option<String>,
pub link_rel: Option<String>,
pub bin: Option<String>,
pub build: Option<String>,
pub install: Option<String>,
pub uninstall: Option<String>,
pub requires: Vec<String>,
pub hooks: Vec<ResolvedHook>,
}
impl CatalogItem {
pub fn effective_name(&self) -> String {
namespace::apply(&self.name, &self.prefix)
}
pub fn agent_harness_name(&self) -> Option<String> {
if self.kind != ItemKind::Agent {
return None;
}
if let Some(fm_name) = frontmatter::file_field(&self.path, "name") {
let trimmed = fm_name.trim().to_string();
if !trimmed.is_empty() && is_safe_item_name(&trimmed) {
return Some(trimmed);
}
}
Some(self.name.clone())
}
pub fn resolved_bin(&self) -> Option<String> {
if self.kind != ItemKind::Tool {
return None;
}
if let Some(bin) = &self.bin {
return Some(bin.clone());
}
self.path
.join(&self.name)
.is_file()
.then(|| self.name.clone())
}
pub fn install_hooks(&self) -> Vec<&ResolvedHook> {
self.hooks
.iter()
.filter(|h| h.event == HookEvent::Install)
.collect()
}
pub fn uninstall_hooks(&self) -> Vec<&ResolvedHook> {
self.hooks
.iter()
.filter(|h| h.event == HookEvent::Uninstall)
.collect()
}
pub fn key(&self) -> String {
format!("{}:{}", self.kind.as_str(), self.effective_name())
}
pub fn as_path_sibling(&self) -> namespace::PathSibling {
namespace::PathSibling {
kind: self.kind,
name: self.name.clone(),
bin: self.resolved_bin(),
}
}
}
pub(crate) fn matches_query(item: &CatalogItem, query: &str) -> bool {
if query.is_empty() {
return true;
}
let q = query.to_lowercase();
if item.effective_name().to_lowercase().contains(&q) {
return true;
}
item.description
.as_deref()
.is_some_and(|d| d.to_lowercase().contains(&q))
}
pub fn scan(paths: &Paths, registry: &Registry) -> Result<Vec<CatalogItem>> {
let mut items = Vec::new();
for source in ®istry.sources {
scan_source(paths, source, &mut items)?;
}
Ok(items)
}
pub(crate) fn scan_source(
paths: &Paths,
source: &Source,
out: &mut Vec<CatalogItem>,
) -> Result<()> {
let clone_root = source.clone_dir(paths);
scan_source_at(clone_root, source, out)
}
pub(crate) fn scan_source_at(
clone_root: impl AsRef<std::path::Path>,
source: &Source,
out: &mut Vec<CatalogItem>,
) -> Result<()> {
let clone_root = clone_root.as_ref();
let mindfile = MindToml::load(clone_root)?;
if let Some(required) = mindfile
.as_ref()
.and_then(|m| m.source.min_mind_version.as_deref())
&& !crate::mindfile::version_at_least(env!("CARGO_PKG_VERSION"), required)
{
return Err(MindError::IncompatibleVersion {
source_name: source.name.clone(),
required: required.to_string(),
running: env!("CARGO_PKG_VERSION").to_string(),
});
}
let prefix = source
.alias
.clone()
.or_else(|| mindfile.as_ref().and_then(|m| m.source.prefix.clone()))
.filter(|p| !p.is_empty());
match mindfile {
Some(mt) if mt.is_authoritative() => {
let mut seen: std::collections::HashSet<(crate::error::ItemKind, String)> =
std::collections::HashSet::new();
for decl in &mt.items {
let item = from_decl(clone_root, source, &prefix, decl)?;
let key = (item.kind, item.name.clone());
if !seen.insert(key.clone()) {
return Err(MindError::DuplicateItem {
source_name: source.name.clone(),
kind: key.0,
name: key.1,
});
}
out.push(item);
}
if let Some(discover) = &mt.discover {
scan_globs(clone_root, source, &prefix, discover, out)?;
}
Ok(())
}
ref mt => {
if let Some(plugin_path) = plugin_manifest::find_plugin_manifest(clone_root) {
let manifest = plugin_manifest::load_plugin_manifest(&plugin_path)?;
let plugin_prefix = if source.alias.is_some()
|| mt.as_ref().and_then(|m| m.source.prefix.as_ref()).is_some()
{
prefix.clone()
} else {
let plugin_name = manifest.name.trim().to_string();
match namespace::validate_prefix(&plugin_name) {
Ok(()) if !plugin_name.is_empty() => Some(plugin_name),
_ => None,
}
};
scan_plugin_components(clone_root, source, &plugin_prefix, out)?;
return Ok(());
}
let effective_roots: Vec<String> = source
.roots
.clone()
.or_else(|| mt.as_ref().and_then(|m| m.source.roots.clone()))
.unwrap_or_else(|| vec![".".to_string()]);
for r in &effective_roots {
if std::path::Path::new(r).is_absolute() {
return Err(MindError::InvalidRoot {
source_name: source.name.clone(),
root: r.clone(),
});
}
let full = clone_root.join(r);
if !full
.canonicalize()
.unwrap_or_else(|_| full.clone())
.starts_with(
clone_root
.canonicalize()
.unwrap_or_else(|_| clone_root.to_path_buf()),
)
{
return Err(MindError::InvalidRoot {
source_name: source.name.clone(),
root: r.clone(),
});
}
if !full.is_dir() {
return Err(MindError::InvalidRoot {
source_name: source.name.clone(),
root: r.clone(),
});
}
}
let flat_skills =
source.flat_skills || mt.as_ref().map(|m| m.source.flat_skills).unwrap_or(false);
let pre_scan_len = out.len();
for r in &effective_roots {
let scan_root = clone_root.join(r);
scan_convention(&scan_root, source, &prefix, flat_skills, out)?;
}
let new_items = &out[pre_scan_len..];
let mut seen: std::collections::HashSet<(crate::error::ItemKind, String)> =
std::collections::HashSet::new();
for item in new_items {
let key = (item.kind, item.name.clone());
if !seen.insert(key.clone()) {
return Err(MindError::DuplicateItem {
source_name: source.name.clone(),
kind: key.0,
name: key.1,
});
}
}
Ok(())
}
}
}
fn from_decl(
root: &Path,
source: &Source,
prefix: &Option<String>,
decl: &ItemDecl,
) -> Result<CatalogItem> {
let kind = ItemKind::parse(&decl.kind).ok_or_else(|| MindError::MindToml {
path: root.join("mind.toml"),
msg: format!("unknown item kind '{}' for '{}'", decl.kind, decl.name),
})?;
if !is_safe_item_name(&decl.name) {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"item name '{}' is unsafe: it must be a single path component (no '/', '\\', \
'.', '..', or NUL)",
decl.name
),
});
}
if let Some(link) = &decl.link
&& !is_safe_link_rel(link)
{
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"item '{}' has an unsafe link '{}': it must be a relative path inside the agent \
home (no leading '/' or '~', no '..' component, no NUL)",
decl.name, link
),
});
}
if kind != ItemKind::Tool && (decl.bin.is_some() || decl.build.is_some()) {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"`bin`/`build` are only valid on a tool item, not '{}' ('{}')",
decl.kind, decl.name
),
});
}
if !is_safe_link_rel(&decl.path) {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"item '{}' has an unsafe path '{}': must be a relative path inside the clone \
(no leading '/' or '~', no '..' component, no NUL)",
decl.name, decl.path
),
});
}
let path = root.join(&decl.path);
let meta = meta_file(kind, &path);
let hooks = decl.resolved_item_hooks(&root.join("mind.toml"))?;
Ok(build_item(
source,
prefix,
kind,
decl.name.clone(),
path,
&meta,
ItemOverrides {
description: decl.description.clone(),
link: decl.link.clone(),
bin: decl.bin.clone(),
build: decl.build.clone(),
install: decl.install.clone(),
uninstall: decl.uninstall.clone(),
hooks: Some(hooks),
},
))
}
fn is_safe_item_name(name: &str) -> bool {
if name.is_empty() || name == "." || name == ".." {
return false;
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
return false;
}
let mut comps = Path::new(name).components();
matches!(comps.next(), Some(std::path::Component::Normal(_))) && comps.next().is_none()
}
fn is_safe_link_rel(rel: &str) -> bool {
if rel.is_empty() || rel.contains('\0') || rel.starts_with('~') {
return false;
}
let p = Path::new(rel);
if p.is_absolute() {
return false;
}
use std::path::Component;
p.components()
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
}
fn lifecycle_frontmatter(kind: ItemKind, meta: &Path, key: &str) -> Option<String> {
if kind != ItemKind::Tool {
return None;
}
nonempty(frontmatter::file_field(meta, key))
}
fn nonempty(v: Option<String>) -> Option<String> {
v.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
fn tool_field(kind: ItemKind, explicit: Option<String>, meta: &Path, key: &str) -> Option<String> {
if kind != ItemKind::Tool {
return None;
}
explicit.or_else(|| frontmatter::file_field(meta, key))
}
#[derive(Default)]
struct ItemOverrides {
description: Option<String>,
link: Option<String>,
bin: Option<String>,
build: Option<String>,
install: Option<String>,
uninstall: Option<String>,
hooks: Option<Vec<ResolvedHook>>,
}
fn build_item(
source: &Source,
prefix: &Option<String>,
kind: ItemKind,
name: String,
path: PathBuf,
meta: &Path,
ov: ItemOverrides,
) -> CatalogItem {
let install = nonempty(ov.install).or_else(|| lifecycle_frontmatter(kind, meta, "install"));
let uninstall =
nonempty(ov.uninstall).or_else(|| lifecycle_frontmatter(kind, meta, "uninstall"));
let hooks = ov.hooks.unwrap_or_else(|| {
let mut out: Vec<ResolvedHook> = Vec::new();
for (cmd, event) in [
(&install, HookEvent::Install),
(&uninstall, HookEvent::Uninstall),
] {
if let Some(c) = cmd {
out.push(ResolvedHook {
run: c.clone(),
name: None,
optional: false,
event,
});
}
}
out
});
let requires: Vec<String> = frontmatter::file_field(meta, "requires")
.map(|s| s.split_whitespace().map(str::to_owned).collect())
.unwrap_or_default();
CatalogItem {
kind,
name,
source: source.name.clone(),
prefix: prefix.clone(),
path,
description: ov.description.or_else(|| frontmatter::description(meta)),
link_rel: ov.link,
bin: tool_field(kind, ov.bin, meta, "bin"),
build: tool_field(kind, ov.build, meta, "build"),
install,
uninstall,
requires,
hooks,
}
}
fn scan_globs(
root: &Path,
source: &Source,
prefix: &Option<String>,
discover: &Discover,
out: &mut Vec<CatalogItem>,
) -> Result<()> {
for skill_md in resolve_globs(root, &discover.skills)? {
if let Some(dir) = skill_md.parent() {
out.push(make_item(
source,
prefix,
ItemKind::Skill,
dir.to_path_buf(),
&skill_md,
));
}
}
for (kind, globs) in [
(ItemKind::Agent, &discover.agents),
(ItemKind::Rule, &discover.rules),
] {
for md in resolve_globs(root, globs)? {
out.push(make_item(source, prefix, kind, md.clone(), &md));
}
}
for dir in resolve_globs(root, &discover.tools)? {
let meta = dir.join("TOOL.md");
out.push(make_item(source, prefix, ItemKind::Tool, dir, &meta));
}
Ok(())
}
fn resolve_globs(root: &Path, globs: &KindGlobs) -> Result<Vec<PathBuf>> {
let mut included = BTreeSet::new();
for pattern in &globs.include {
included.extend(glob_paths(root, pattern)?);
}
let mut excluded = BTreeSet::new();
for pattern in &globs.exclude {
excluded.extend(glob_paths(root, pattern)?);
}
Ok(included.difference(&excluded).cloned().collect())
}
fn scan_convention(
root: &Path,
source: &Source,
prefix: &Option<String>,
flat_skills: bool,
out: &mut Vec<CatalogItem>,
) -> Result<()> {
let skills_dir = if flat_skills {
root.to_path_buf()
} else {
root.join(ItemKind::Skill.dir())
};
for entry in read_dir_opt(&skills_dir)? {
let skill_md = entry.join("SKILL.md");
if entry.is_dir() && skill_md.is_file() {
out.push(make_item(source, prefix, ItemKind::Skill, entry, &skill_md));
}
}
for kind in [ItemKind::Agent, ItemKind::Rule] {
let kind_dir = root.join(kind.dir());
for entry in read_dir_opt(&kind_dir)? {
if entry.is_file() && entry.extension().is_some_and(|e| e == "md") {
out.push(make_item(source, prefix, kind, entry.clone(), &entry));
}
}
}
let tools_dir = root.join(ItemKind::Tool.dir());
for entry in read_dir_opt(&tools_dir)? {
if entry.is_dir() {
let meta = entry.join("TOOL.md");
out.push(make_item(source, prefix, ItemKind::Tool, entry, &meta));
}
}
Ok(())
}
fn make_item(
source: &Source,
prefix: &Option<String>,
kind: ItemKind,
path: PathBuf,
meta: &Path,
) -> CatalogItem {
let bare = match kind {
ItemKind::Skill | ItemKind::Tool => file_name(&path),
ItemKind::Agent | ItemKind::Rule => path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(),
};
build_item(
source,
prefix,
kind,
bare,
path,
meta,
ItemOverrides::default(),
)
}
#[cfg(test)]
mod lifecycle_tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
fn tmp() -> PathBuf {
let n = N.fetch_add(1, Ordering::SeqCst);
let p = std::env::temp_dir().join(format!("mind-lifecycle-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
fn write(path: &Path, contents: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn source_for(clone: &Path) -> Source {
use crate::source::Pin;
Source {
name: "local/test/repo".to_string(),
url: clone.to_string_lossy().into_owned(),
host: "local".to_string(),
owner: "test".to_string(),
repo: "repo".to_string(),
commit: None,
description: None,
alias: None,
pin: Pin::default(),
roots: None,
flat_skills: false,
origin: None,
plugin_version: None,
install_hooks: Vec::new(),
install_hook: None,
install_hook_commit: None,
}
}
#[test]
fn item_install_uninstall_hooks_from_mind_toml_on_any_kind() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("guidelines/style.md"),
"---\ndescription: style\n---\n# style\n",
);
write(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"rule\"\n",
"name = \"style\"\n",
"path = \"guidelines/style.md\"\n",
"install = \"echo set-up\"\n",
"uninstall = \"echo tear-down\"\n",
),
);
let paths = Paths {
mind_home: base.clone(),
claude_home: base.join("claude"),
};
let mut items = Vec::new();
scan_source(&paths, &source_for(&clone), &mut items).unwrap();
let rule = items.iter().find(|i| i.name == "style").unwrap();
assert_eq!(rule.install.as_deref(), Some("echo set-up"));
assert_eq!(rule.uninstall.as_deref(), Some("echo tear-down"));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn item_hooks_from_tool_md_frontmatter() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("tools/helper/TOOL.md"),
"---\ndescription: helper\ninstall: make setup\nuninstall: make cleanup\n---\n# helper\n",
);
write(&clone.join("tools/helper/helper"), "#!/bin/sh\n");
let paths = Paths {
mind_home: base.clone(),
claude_home: base.join("claude"),
};
let mut items = Vec::new();
scan_source(&paths, &source_for(&clone), &mut items).unwrap();
let tool = items.iter().find(|i| i.name == "helper").unwrap();
assert_eq!(tool.install.as_deref(), Some("make setup"));
assert_eq!(tool.uninstall.as_deref(), Some("make cleanup"));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn empty_item_hook_is_treated_as_absent() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("guidelines/style.md"),
"---\ndescription: style\n---\n# style\n",
);
write(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"rule\"\n",
"name = \"style\"\n",
"path = \"guidelines/style.md\"\n",
"install = \" \"\n",
),
);
let paths = Paths {
mind_home: base.clone(),
claude_home: base.join("claude"),
};
let mut items = Vec::new();
scan_source(&paths, &source_for(&clone), &mut items).unwrap();
let rule = items.iter().find(|i| i.name == "style").unwrap();
assert_eq!(rule.install, None, "whitespace install must be absent");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn scalar_item_hooks_populate_both_the_scalar_fields_and_the_list() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("guidelines/style.md"),
"---\ndescription: style\n---\n# style\n",
);
write(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"rule\"\n",
"name = \"style\"\n",
"path = \"guidelines/style.md\"\n",
"install = \"echo set-up\"\n",
"uninstall = \"echo tear-down\"\n",
),
);
let paths = Paths {
mind_home: base.clone(),
claude_home: base.join("claude"),
};
let mut items = Vec::new();
scan_source(&paths, &source_for(&clone), &mut items).unwrap();
let rule = items.iter().find(|i| i.name == "style").unwrap();
assert_eq!(rule.install.as_deref(), Some("echo set-up"));
assert_eq!(rule.uninstall.as_deref(), Some("echo tear-down"));
assert_eq!(rule.hooks.len(), 2);
let ih = rule.install_hooks();
assert_eq!(ih.len(), 1);
assert_eq!(ih[0].run, "echo set-up");
let uh = rule.uninstall_hooks();
assert_eq!(uh.len(), 1);
assert_eq!(uh[0].run, "echo tear-down");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn array_item_hooks_resolve_in_order_with_scalar_folded_ahead() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(&clone.join("tools/helper/helper"), "#!/bin/sh\n");
write(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"helper\"\n",
"path = \"tools/helper\"\n",
"install = \"scalar-install\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"array-install\"\n",
"name = \"Second step\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"array-uninstall\"\n",
"event = \"uninstall\"\n",
),
);
let paths = Paths {
mind_home: base.clone(),
claude_home: base.join("claude"),
};
let mut items = Vec::new();
scan_source(&paths, &source_for(&clone), &mut items).unwrap();
let tool = items.iter().find(|i| i.name == "helper").unwrap();
assert_eq!(tool.install.as_deref(), Some("scalar-install"));
assert_eq!(tool.hooks.len(), 3);
let ih = tool.install_hooks();
assert_eq!(ih.len(), 2);
assert_eq!(ih[0].run, "scalar-install");
assert_eq!(ih[1].run, "array-install");
assert_eq!(ih[1].name.as_deref(), Some("Second step"));
let uh = tool.uninstall_hooks();
assert_eq!(uh.len(), 1);
assert_eq!(uh[0].run, "array-uninstall");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn tool_md_scalar_hooks_fold_into_the_list() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("tools/helper/TOOL.md"),
"---\ndescription: helper\ninstall: make setup\nuninstall: make cleanup\n---\n# helper\n",
);
write(&clone.join("tools/helper/helper"), "#!/bin/sh\n");
let paths = Paths {
mind_home: base.clone(),
claude_home: base.join("claude"),
};
let mut items = Vec::new();
scan_source(&paths, &source_for(&clone), &mut items).unwrap();
let tool = items.iter().find(|i| i.name == "helper").unwrap();
assert_eq!(tool.install.as_deref(), Some("make setup"));
assert_eq!(tool.uninstall.as_deref(), Some("make cleanup"));
assert_eq!(tool.hooks.len(), 2);
assert_eq!(tool.install_hooks()[0].run, "make setup");
assert!(!tool.install_hooks()[0].optional);
assert_eq!(tool.uninstall_hooks()[0].run, "make cleanup");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn item_array_hooks_unknown_event_is_a_scan_error() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(&clone.join("tools/helper/helper"), "#!/bin/sh\n");
write(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"helper\"\n",
"path = \"tools/helper\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"do-it\"\n",
"event = \"build\"\n",
),
);
let paths = Paths {
mind_home: base.clone(),
claude_home: base.join("claude"),
};
let mut items = Vec::new();
let err = scan_source(&paths, &source_for(&clone), &mut items).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"unknown item hook event must be a schema error: {err}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn requires_populated_on_authoritative_mind_toml_item() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("guidelines/style.md"),
"---\ndescription: style\nrequires: agent:linter\n---\n# style\n",
);
write(
&clone.join("agents/linter.md"),
"---\ndescription: linter\n---\n# linter\n",
);
write(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"rule\"\n",
"name = \"style\"\n",
"path = \"guidelines/style.md\"\n",
"[[items]]\n",
"kind = \"agent\"\n",
"name = \"linter\"\n",
"path = \"agents/linter.md\"\n",
),
);
let paths = Paths {
mind_home: base.clone(),
claude_home: base.join("claude"),
};
let mut items = Vec::new();
scan_source(&paths, &source_for(&clone), &mut items).unwrap();
let rule = items.iter().find(|i| i.name == "style").unwrap();
assert_eq!(
rule.requires,
vec!["agent:linter".to_string()],
"requires from the meta-file frontmatter must populate on the authoritative mind.toml path"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn requires_splits_on_arbitrary_whitespace() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("skills/review/SKILL.md"),
"---\ndescription: review\nrequires: agent:a rule:b \n---\n# review\n",
);
let paths = Paths {
mind_home: base.clone(),
claude_home: base.join("claude"),
};
let mut items = Vec::new();
scan_source(&paths, &source_for(&clone), &mut items).unwrap();
let skill = items.iter().find(|i| i.name == "review").unwrap();
assert_eq!(
skill.requires,
vec!["agent:a".to_string(), "rule:b".to_string()],
"extra/leading/trailing whitespace must split into exactly two entries"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn empty_requires_scalar_yields_no_entries() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("skills/review/SKILL.md"),
"---\ndescription: review\nrequires: \n---\n# review\n",
);
let paths = Paths {
mind_home: base.clone(),
claude_home: base.join("claude"),
};
let mut items = Vec::new();
scan_source(&paths, &source_for(&clone), &mut items).unwrap();
let skill = items.iter().find(|i| i.name == "review").unwrap();
assert!(
skill.requires.is_empty(),
"a whitespace-only requires value must yield no entries: {:?}",
skill.requires
);
let _ = std::fs::remove_dir_all(&base);
}
}
fn scan_plugin_components(
plugin_root: &Path,
source: &Source,
prefix: &Option<String>,
out: &mut Vec<CatalogItem>,
) -> Result<()> {
let skills_dir = plugin_root.join(ItemKind::Skill.dir());
for entry in read_dir_opt(&skills_dir)? {
let skill_md = entry.join("SKILL.md");
if entry.is_dir() && skill_md.is_file() {
out.push(make_item(source, prefix, ItemKind::Skill, entry, &skill_md));
}
}
let agents_dir = plugin_root.join(ItemKind::Agent.dir());
for entry in read_dir_opt(&agents_dir)? {
if entry.is_file() && entry.extension().is_some_and(|e| e == "md") {
out.push(make_item(
source,
prefix,
ItemKind::Agent,
entry.clone(),
&entry,
));
}
}
Ok(())
}
pub fn plugin_skipped_components(plugin_root: &Path) -> plugin_manifest::SkippedComponents {
let mut sc = plugin_manifest::SkippedComponents::default();
if plugin_root.join("commands").is_dir() {
sc.commands = 1;
}
if plugin_root.join("hooks").is_dir() {
sc.hooks = 1;
}
if plugin_root.join(".mcp.json").is_file() {
sc.mcp_servers = 1;
}
sc
}
fn meta_file(kind: ItemKind, path: &Path) -> PathBuf {
match kind {
ItemKind::Skill => path.join("SKILL.md"),
ItemKind::Tool => path.join("TOOL.md"),
ItemKind::Agent | ItemKind::Rule => path.to_path_buf(),
}
}
fn glob_paths(root: &Path, pattern: &str) -> Result<Vec<PathBuf>> {
let joined = root.join(pattern);
let full = joined.to_string_lossy();
let paths = glob::glob(&full).map_err(|e| MindError::MindToml {
path: root.join("mind.toml"),
msg: format!("bad discover glob '{pattern}': {e}"),
})?;
let mut out = Vec::new();
for entry in paths {
match entry {
Ok(p) => out.push(p),
Err(e) => {
let path = e.path().to_path_buf();
return Err(MindError::io(path, e.into_error()));
}
}
}
out.sort();
Ok(out)
}
fn read_dir_opt(dir: &Path) -> Result<Vec<PathBuf>> {
match std::fs::read_dir(dir) {
Ok(rd) => {
let mut paths = Vec::new();
for entry in rd {
let entry = entry.map_err(|e| MindError::io(dir, e))?;
paths.push(entry.path());
}
paths.sort();
Ok(paths)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(MindError::io(dir, e)),
}
}
fn file_name(p: &Path) -> String {
p.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ItemKind;
use crate::paths::Paths;
use crate::source::{Pin, Source};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static UNIT_COUNTER: AtomicU32 = AtomicU32::new(0);
struct TmpDir(PathBuf);
impl TmpDir {
fn new() -> Self {
let n = UNIT_COUNTER.fetch_add(1, Ordering::SeqCst);
let p =
std::env::temp_dir().join(format!("mind-catalog-unit-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
TmpDir(p)
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn make_source_for(clone: &std::path::Path) -> Source {
Source {
name: "local/test/repo".to_string(),
url: clone.to_string_lossy().into_owned(),
host: "local".to_string(),
owner: "test".to_string(),
repo: "repo".to_string(),
commit: None,
description: None,
alias: None,
pin: Pin::default(),
roots: None,
flat_skills: false,
origin: None,
plugin_version: None,
install_hooks: Vec::new(),
install_hook: None,
install_hook_commit: None,
}
}
fn paths_for(base: &std::path::Path) -> Paths {
Paths {
mind_home: base.to_path_buf(),
claude_home: base.join("claude"),
}
}
fn write_file(path: &std::path::Path, contents: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
#[test]
fn convention_discovery_under_single_explicit_root() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("tools/skills/meld/SKILL.md"),
"---\ndescription: meld skill\n---\n# meld\n",
);
write_file(
&clone.join("tools/agents/do.md"),
"---\ndescription: do agent\n---\n# do\n",
);
write_file(&clone.join("mind.toml"), "[source]\nroots = [\"tools\"]\n");
let paths = paths_for(base);
let source = make_source_for(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let names: Vec<_> = items.iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"meld"), "expected 'meld': {names:?}");
assert!(names.contains(&"do"), "expected 'do': {names:?}");
assert!(!names.contains(&"review"), "unexpected 'review': {names:?}");
}
#[test]
fn source_roots_override_beats_mindfile_roots() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("a/skills/alpha/SKILL.md"),
"---\ndescription: alpha\n---\n# alpha\n",
);
write_file(
&clone.join("b/skills/beta/SKILL.md"),
"---\ndescription: beta\n---\n# beta\n",
);
write_file(&clone.join("mind.toml"), "[source]\nroots = [\"b\"]\n");
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.roots = Some(vec!["a".to_string()]);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let names: Vec<_> = items.iter().map(|i| i.name.as_str()).collect();
assert!(
names.contains(&"alpha"),
"override root 'a' expected: {names:?}"
);
assert!(
!names.contains(&"beta"),
"toml root 'b' should be ignored: {names:?}"
);
}
#[test]
fn two_roots_are_unioned() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("a/skills/alpha/SKILL.md"),
"---\ndescription: alpha\n---\n# alpha\n",
);
write_file(
&clone.join("b/skills/beta/SKILL.md"),
"---\ndescription: beta\n---\n# beta\n",
);
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.roots = Some(vec!["a".to_string(), "b".to_string()]);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let names: Vec<_> = items.iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"alpha"), "expected alpha: {names:?}");
assert!(names.contains(&"beta"), "expected beta: {names:?}");
}
#[test]
fn duplicate_item_across_roots_is_an_error() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("a/skills/review/SKILL.md"),
"---\ndescription: review a\n---\n# review\n",
);
write_file(
&clone.join("b/skills/review/SKILL.md"),
"---\ndescription: review b\n---\n# review\n",
);
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.roots = Some(vec!["a".to_string(), "b".to_string()]);
let mut items = Vec::new();
let err = scan_source(&paths, &source, &mut items).unwrap_err();
assert!(
matches!(err, MindError::DuplicateItem { ref name, .. } if name == "review"),
"expected DuplicateItem: {err}"
);
}
#[test]
fn flat_skills_discovers_bare_dirs_and_composes_with_roots() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("pkg/alpha/SKILL.md"),
"---\ndescription: alpha\n---\n# alpha\n",
);
write_file(
&clone.join("pkg/beta/SKILL.md"),
"---\ndescription: beta\n---\n# beta\n",
);
write_file(&clone.join("pkg/notaskill/README.md"), "# nope\n");
write_file(
&clone.join("pkg/agents/dev.md"),
"---\ndescription: dev\n---\n# dev\n",
);
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.roots = Some(vec!["pkg".to_string()]);
source.flat_skills = true;
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let skills: Vec<&str> = items
.iter()
.filter(|i| i.kind == ItemKind::Skill)
.map(|i| i.name.as_str())
.collect();
assert!(
skills.contains(&"alpha"),
"expected flat skill alpha: {skills:?}"
);
assert!(
skills.contains(&"beta"),
"expected flat skill beta: {skills:?}"
);
assert!(
!skills.contains(&"notaskill"),
"a dir without SKILL.md must not be a skill: {skills:?}"
);
assert!(
items
.iter()
.any(|i| i.kind == ItemKind::Agent && i.name == "dev"),
"agent discovery must be unchanged under flat-skills"
);
}
#[test]
fn flat_skills_off_requires_skills_container() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("alpha/SKILL.md"),
"---\ndescription: alpha\n---\n# alpha\n",
);
let paths = paths_for(base);
let source = make_source_for(&clone); let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
assert!(
items.is_empty(),
"a root-level skill dir must not be found without flat-skills: {:?}",
items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>()
);
}
#[test]
fn flat_skills_duplicate_across_roots_is_an_error() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("a/alpha/SKILL.md"),
"---\ndescription: alpha a\n---\n# alpha\n",
);
write_file(
&clone.join("b/alpha/SKILL.md"),
"---\ndescription: alpha b\n---\n# alpha\n",
);
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.roots = Some(vec!["a".to_string(), "b".to_string()]);
source.flat_skills = true;
let mut items = Vec::new();
let err = scan_source(&paths, &source, &mut items).unwrap_err();
assert!(
matches!(err, MindError::DuplicateItem { ref name, .. } if name == "alpha"),
"expected DuplicateItem for a flat skill across two roots: {err}"
);
}
#[test]
fn non_directory_root_is_invalid_root_error() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
std::fs::create_dir_all(&clone).unwrap();
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.roots = Some(vec!["nonexistent".to_string()]);
let mut items = Vec::new();
let err = scan_source(&paths, &source, &mut items).unwrap_err();
assert!(
matches!(err, MindError::InvalidRoot { ref root, .. } if root == "nonexistent"),
"expected InvalidRoot: {err}"
);
}
#[test]
fn authoritative_mind_toml_ignores_roots() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("guidelines/style.md"),
"---\ndescription: style rule\n---\n# style\n",
);
write_file(
&clone.join("sub/skills/review/SKILL.md"),
"---\ndescription: review\n---\n# review\n",
);
write_file(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"rule\"\n",
"name = \"style\"\n",
"path = \"guidelines/style.md\"\n",
),
);
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.roots = Some(vec!["sub".to_string()]);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let names: Vec<_> = items.iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"style"), "expected 'style': {names:?}");
assert!(
!names.contains(&"review"),
"convention scan should be ignored: {names:?}"
);
}
#[test]
fn absolute_root_pointing_inside_the_clone_is_still_invalid() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("tools/skills/build/SKILL.md"),
"---\ndescription: build\n---\n# build\n",
);
let abs_inside = clone.join("tools").canonicalize().unwrap();
assert!(abs_inside.is_absolute());
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.roots = Some(vec![abs_inside.to_string_lossy().into_owned()]);
let mut items = Vec::new();
let err = scan_source(&paths, &source, &mut items).unwrap_err();
assert!(
matches!(err, MindError::InvalidRoot { .. }),
"an absolute root, even inside the clone, must be InvalidRoot: {err}"
);
assert!(items.is_empty(), "absolute root must contribute nothing");
}
#[test]
fn absolute_root_outside_the_clone_is_invalid() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
std::fs::create_dir_all(&clone).unwrap();
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.roots = Some(vec!["/tmp".to_string()]);
let mut items = Vec::new();
let err = scan_source(&paths, &source, &mut items).unwrap_err();
assert!(
matches!(err, MindError::InvalidRoot { ref root, .. } if root == "/tmp"),
"absolute root outside the clone must be InvalidRoot: {err}"
);
}
#[test]
fn parent_escaping_root_to_existing_sibling_is_invalid_root() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
let sibling = base.join("sources/local/test/other");
write_file(
&sibling.join("skills/leak/SKILL.md"),
"---\ndescription: leaked\n---\n# leak\n",
);
std::fs::create_dir_all(&clone).unwrap();
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.roots = Some(vec!["../other".to_string()]);
let mut items = Vec::new();
let err = scan_source(&paths, &source, &mut items).unwrap_err();
assert!(
matches!(err, MindError::InvalidRoot { ref root, .. } if root == "../other"),
"escaping root must be InvalidRoot, not a silent read outside the clone: {err}"
);
assert!(
items.is_empty(),
"no items should leak from outside the clone"
);
}
#[test]
fn in_clone_dotdot_root_is_allowed() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("tools/skills/build/SKILL.md"),
"---\ndescription: build\n---\n# build\n",
);
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.roots = Some(vec!["tools/../tools".to_string()]);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let names: Vec<_> = items.iter().map(|i| i.name.as_str()).collect();
assert!(
names.contains(&"build"),
"in-clone .. should resolve: {names:?}"
);
}
#[test]
fn duplicate_item_check_is_scoped_to_one_source() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone_a = base.join("sources/local/test/repo");
write_file(
&clone_a.join("skills/review/SKILL.md"),
"---\ndescription: review a\n---\n# review\n",
);
let clone_b = base.join("sources/local/other/repo");
write_file(
&clone_b.join("skills/review/SKILL.md"),
"---\ndescription: review b\n---\n# review\n",
);
let paths = paths_for(base);
let source_a = make_source_for(&clone_a);
let mut source_b = make_source_for(&clone_b);
source_b.name = "local/other/repo".to_string();
source_b.owner = "other".to_string();
let mut items = Vec::new();
scan_source(&paths, &source_a, &mut items).unwrap();
scan_source(&paths, &source_b, &mut items)
.expect("same name in a different source is not a DuplicateItem");
let reviews = items.iter().filter(|i| i.name == "review").count();
assert_eq!(reviews, 2, "both sources' review items should be present");
}
#[test]
fn duplicate_across_roots_collides_on_bare_name_under_a_prefix() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("a/skills/review/SKILL.md"),
"---\ndescription: review a\n---\n# review\n",
);
write_file(
&clone.join("b/skills/review/SKILL.md"),
"---\ndescription: review b\n---\n# review\n",
);
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.alias = Some("jk".to_string()); source.roots = Some(vec!["a".to_string(), "b".to_string()]);
let mut items = Vec::new();
let err = scan_source(&paths, &source, &mut items).unwrap_err();
assert!(
matches!(err, MindError::DuplicateItem { ref name, .. } if name == "review"),
"bare-name collision must error regardless of prefix: {err}"
);
}
#[test]
fn explicit_empty_roots_list_discovers_nothing() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("skills/review/SKILL.md"),
"---\ndescription: review\n---\n# review\n",
);
write_file(&clone.join("mind.toml"), "[source]\nroots = []\n");
let paths = paths_for(base);
let source = make_source_for(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
assert!(
items.is_empty(),
"an explicit empty roots list scans zero roots: {:?}",
items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>()
);
}
#[test]
fn unset_roots_falls_back_to_implicit_repo_root() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("skills/review/SKILL.md"),
"---\ndescription: review\n---\n# review\n",
);
let paths = paths_for(base);
let source = make_source_for(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let names: Vec<_> = items.iter().map(|i| i.name.as_str()).collect();
assert!(
names.contains(&"review"),
"unset roots scans the repo root: {names:?}"
);
}
fn make_test_item(name: &str, description: Option<&str>) -> CatalogItem {
CatalogItem {
kind: ItemKind::Skill,
name: name.to_string(),
source: "test-source".to_string(),
prefix: None,
path: PathBuf::from("/tmp/fake"),
description: description.map(|s| s.to_string()),
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
hooks: Vec::new(),
}
}
#[test]
fn convention_discovers_bare_tool_dir_without_anchor() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(&clone.join("tools/detect/detect"), "#!/bin/sh\necho hi\n");
write_file(&clone.join("tools/detect/lib.sh"), "helper\n");
let paths = paths_for(base);
let source = make_source_for(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let tool = items
.iter()
.find(|i| i.name == "detect")
.expect("tool 'detect' discovered");
assert_eq!(tool.kind, ItemKind::Tool);
assert_eq!(tool.resolved_bin().as_deref(), Some("detect"));
}
#[test]
fn tool_metadata_comes_from_optional_tool_md() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("tools/shard/TOOL.md"),
"---\ndescription: shard a plan\nbin: shard.py\nbuild: make shard\n---\n# shard\n",
);
write_file(&clone.join("tools/shard/shard.py"), "print('x')\n");
let paths = paths_for(base);
let source = make_source_for(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let tool = items.iter().find(|i| i.name == "shard").unwrap();
assert_eq!(tool.description.as_deref(), Some("shard a plan"));
assert_eq!(tool.resolved_bin().as_deref(), Some("shard.py"));
assert_eq!(tool.build.as_deref(), Some("make shard"));
}
#[test]
fn resolved_bin_convention_default_requires_the_file() {
let tmp = TmpDir::new();
let base = tmp.path();
let dir = base.join("tools/empty");
std::fs::create_dir_all(&dir).unwrap();
let item = CatalogItem {
kind: ItemKind::Tool,
name: "empty".to_string(),
source: "s".to_string(),
prefix: None,
path: dir,
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
hooks: Vec::new(),
};
assert_eq!(item.resolved_bin(), None);
}
#[test]
fn is_safe_item_name_rejects_traversal_and_separators() {
for ok in ["x", "my-skill", "a.b", "review2"] {
assert!(is_safe_item_name(ok), "{ok:?} should be accepted");
}
for bad in ["", ".", "..", "a/b", "../x", "/etc", "a\\b", "x\0y"] {
assert!(!is_safe_item_name(bad), "{bad:?} should be rejected");
}
}
#[test]
fn is_safe_link_rel_rejects_escape() {
for ok in ["rules/x.md", "skills/x", "commands/x.toml", "./a/b.md"] {
assert!(is_safe_link_rel(ok), "{ok:?} should be accepted");
}
for bad in [
"",
"../../.bashrc",
"/etc/passwd",
"~/x",
"a/../../b",
"x\0y",
] {
assert!(!is_safe_link_rel(bad), "{bad:?} should be rejected");
}
}
#[test]
fn from_decl_rejects_unsafe_name() {
let tmp = TmpDir::new();
let root = tmp.path();
let source = make_source_for(root);
let decl = ItemDecl {
kind: "rule".to_string(),
name: "../../evil".to_string(),
path: "rules/x.md".to_string(),
link: None,
description: None,
bin: None,
build: None,
install: None,
uninstall: None,
hooks: Vec::new(),
};
let err = from_decl(root, &source, &None, &decl).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"an unsafe item name must be a schema error: {err}"
);
}
#[test]
fn from_decl_rejects_escaping_link() {
let tmp = TmpDir::new();
let root = tmp.path();
let source = make_source_for(root);
let decl = ItemDecl {
kind: "rule".to_string(),
name: "x".to_string(),
path: "rules/x.md".to_string(),
link: Some("../../.bashrc".to_string()),
description: None,
bin: None,
build: None,
install: None,
uninstall: None,
hooks: Vec::new(),
};
let err = from_decl(root, &source, &None, &decl).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"an escaping link override must be a schema error: {err}"
);
}
#[test]
fn from_decl_rejects_bin_or_build_on_non_tool() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(&root.join("skills/x/SKILL.md"), "---\n---\n# x\n");
let source = make_source_for(root);
let decl = ItemDecl {
kind: "skill".to_string(),
name: "x".to_string(),
path: "skills/x".to_string(),
link: None,
description: None,
bin: Some("x".to_string()),
build: None,
install: None,
uninstall: None,
hooks: Vec::new(),
};
let err = from_decl(root, &source, &None, &decl).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"bin on a non-tool must be a schema error: {err}"
);
}
#[test]
fn discover_tools_glob_matches_the_directory() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(&clone.join("pkgs/detect/tool/detect"), "#!/bin/sh\n");
write_file(
&clone.join("mind.toml"),
"[discover]\ntools = { include = [\"pkgs/*/tool\"] }\n",
);
let paths = paths_for(base);
let source = make_source_for(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let tool = items.iter().find(|i| i.name == "tool").unwrap();
assert_eq!(tool.kind, ItemKind::Tool);
}
#[test]
fn empty_query_matches_all() {
let item = make_test_item("review", Some("Review the diff for bugs"));
assert!(matches_query(&item, ""));
}
#[test]
fn matches_by_effective_name() {
let item = make_test_item("review", Some("Review the diff for bugs"));
assert!(matches_query(&item, "review"));
}
#[test]
fn matches_by_description_when_name_does_not_contain_query() {
let item = make_test_item("review", Some("Review the diff for bugs"));
assert!(!item.effective_name().contains("bugs"));
assert!(matches_query(&item, "bugs"));
}
#[test]
fn match_is_case_insensitive_on_name() {
let item = make_test_item("Review", None);
assert!(matches_query(&item, "REVIEW"));
assert!(matches_query(&item, "review"));
assert!(matches_query(&item, "ReViEw"));
}
#[test]
fn match_is_case_insensitive_on_description() {
let item = make_test_item("x", Some("Implements a Spec with Tests"));
assert!(matches_query(&item, "SPEC"));
assert!(matches_query(&item, "spec"));
}
#[test]
fn no_match_when_query_absent_from_both_name_and_description() {
let item = make_test_item("review", Some("Review the diff for bugs"));
assert!(!matches_query(&item, "python"));
}
#[test]
fn no_match_when_description_is_none_and_name_does_not_match() {
let item = make_test_item("review", None);
assert!(!matches_query(&item, "bugs"));
}
#[test]
fn empty_description_does_not_match_a_nonempty_query() {
let item = make_test_item("x", Some(""));
assert!(matches_query(&item, ""));
assert!(!matches_query(&item, "anything"));
}
#[test]
fn whitespace_query_matches_a_description_that_contains_whitespace() {
let item = make_test_item("review", Some("Review the diff"));
assert!(!item.effective_name().contains(' '));
assert!(matches_query(&item, " "));
}
#[test]
fn substring_in_middle_of_word_matches() {
let by_name = make_test_item("refactor", None);
assert!(matches_query(&by_name, "factor"));
let by_desc = make_test_item("x", Some("Performs refactoring"));
assert!(matches_query(&by_desc, "factor"));
}
#[test]
fn prefix_is_used_in_effective_name_match() {
let mut item = make_test_item("review", None);
item.prefix = Some("jk".to_string());
assert!(matches_query(&item, "jk:review"));
assert!(matches_query(&item, "jk"));
assert!(matches_query(&item, "review"));
}
#[test]
fn requires_field_parsed_from_skill_frontmatter() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("skills/review/SKILL.md"),
"---\ndescription: review\nrequires: skill:plan agent:test\n---\n# review\n",
);
let paths = paths_for(base);
let source = make_source_for(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let skill = items.iter().find(|i| i.name == "review").unwrap();
assert_eq!(
skill.requires,
vec!["skill:plan".to_string(), "agent:test".to_string()],
"requires must be whitespace-split from the frontmatter scalar"
);
}
#[test]
fn requires_field_absent_is_empty_vec() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("skills/review/SKILL.md"),
"---\ndescription: review\n---\n# review\n",
);
let paths = paths_for(base);
let source = make_source_for(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let skill = items.iter().find(|i| i.name == "review").unwrap();
assert!(
skill.requires.is_empty(),
"absent requires must yield empty Vec"
);
}
#[test]
fn requires_field_parsed_from_agent_frontmatter() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("agents/dev.md"),
"---\ndescription: dev\nrequires: rule:style\n---\n# dev\n",
);
let paths = paths_for(base);
let source = make_source_for(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let agent = items.iter().find(|i| i.name == "dev").unwrap();
assert_eq!(agent.requires, vec!["rule:style".to_string()],);
}
#[test]
fn from_decl_rejects_dotdot_path() {
let tmp = TmpDir::new();
let root = tmp.path();
let source = make_source_for(root);
let decl = crate::mindfile::ItemDecl {
kind: "rule".to_string(),
name: "evil".to_string(),
path: "../escape".to_string(),
link: None,
description: None,
bin: None,
build: None,
install: None,
uninstall: None,
hooks: Vec::new(),
};
let err = from_decl(root, &source, &None, &decl).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"a dotdot path must be a schema error: {err}"
);
}
#[test]
fn from_decl_rejects_absolute_path() {
let tmp = TmpDir::new();
let root = tmp.path();
let source = make_source_for(root);
let decl = crate::mindfile::ItemDecl {
kind: "rule".to_string(),
name: "evil".to_string(),
path: "/etc/passwd".to_string(),
link: None,
description: None,
bin: None,
build: None,
install: None,
uninstall: None,
hooks: Vec::new(),
};
let err = from_decl(root, &source, &None, &decl).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"an absolute path must be a schema error: {err}"
);
}
#[test]
fn from_decl_accepts_subdir_path() {
let tmp = TmpDir::new();
let root = tmp.path();
let source = make_source_for(root);
let decl = crate::mindfile::ItemDecl {
kind: "rule".to_string(),
name: "style".to_string(),
path: "sub/dir/style.md".to_string(),
link: None,
description: None,
bin: None,
build: None,
install: None,
uninstall: None,
hooks: Vec::new(),
};
let item = from_decl(root, &source, &None, &decl).unwrap();
assert_eq!(item.name, "style");
assert_eq!(item.path, root.join("sub/dir/style.md"));
}
#[test]
fn authoritative_mind_toml_duplicate_items_is_duplicate_item_error() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("rules/style.md"),
"---\ndescription: style\n---\n",
);
write_file(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"rule\"\n",
"name = \"style\"\n",
"path = \"rules/style.md\"\n",
"[[items]]\n",
"kind = \"rule\"\n",
"name = \"style\"\n",
"path = \"rules/style.md\"\n",
),
);
let paths = paths_for(base);
let source = make_source_for(&clone);
let mut items = Vec::new();
let err = scan_source(&paths, &source, &mut items).unwrap_err();
assert!(
matches!(err, MindError::DuplicateItem { ref name, .. } if name == "style"),
"duplicate [[items]] entries must be DuplicateItem: {err}"
);
}
fn agent_item(path: std::path::PathBuf, bare_name: &str) -> CatalogItem {
CatalogItem {
source: "src".to_string(),
kind: ItemKind::Agent,
name: bare_name.to_string(),
prefix: None,
path,
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
hooks: Vec::new(),
}
}
#[test]
fn agent_harness_name_reads_frontmatter_name() {
let dir = TmpDir::new();
let p = dir.path().join("agents/coder.md");
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, "---\nname: dev\ndescription: d\n---\n# dev\n").unwrap();
let item = agent_item(p, "coder");
assert_eq!(item.agent_harness_name(), Some("dev".to_string()));
}
#[test]
fn agent_harness_name_falls_back_to_bare_name_when_frontmatter_absent() {
let dir = TmpDir::new();
let p = dir.path().join("agents/coder.md");
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, "---\ndescription: d\n---\n# coder\n").unwrap();
let item = agent_item(p, "coder");
assert_eq!(item.agent_harness_name(), Some("coder".to_string()));
}
#[test]
fn agent_harness_name_rejects_unsafe_frontmatter_name() {
let dir = TmpDir::new();
let p = dir.path().join("agents/coder.md");
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, "---\nname: ../evil\ndescription: d\n---\n# coder\n").unwrap();
let item = agent_item(p, "coder");
assert_eq!(item.agent_harness_name(), Some("coder".to_string()));
}
#[test]
fn agent_harness_name_returns_none_for_non_agents() {
let dir = TmpDir::new();
let p = dir.path().join("skills/review/SKILL.md");
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, "---\nname: review\n---\n").unwrap();
let mut item = agent_item(p, "review");
item.kind = ItemKind::Skill;
assert_eq!(item.agent_harness_name(), None);
}
}
#[cfg(test)]
mod plugin_tests {
use super::*;
use crate::paths::Paths;
use crate::source::{Pin, Source};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static PLUGIN_COUNTER: AtomicU32 = AtomicU32::new(0);
struct TmpDir(PathBuf);
impl TmpDir {
fn new() -> Self {
let n = PLUGIN_COUNTER.fetch_add(1, Ordering::SeqCst);
let p = std::env::temp_dir()
.join(format!("mind-catalog-plugin-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
TmpDir(p)
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn write_file(path: &std::path::Path, contents: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn make_plugin_source(clone: &std::path::Path) -> Source {
Source {
name: "local/test/plugin-repo".to_string(),
url: clone.to_string_lossy().into_owned(),
host: "local".to_string(),
owner: "test".to_string(),
repo: "plugin-repo".to_string(),
commit: None,
description: None,
alias: None,
pin: Pin::default(),
roots: None,
flat_skills: false,
origin: None,
plugin_version: None,
install_hooks: Vec::new(),
install_hook: None,
install_hook_commit: None,
}
}
fn paths_for(base: &std::path::Path) -> Paths {
Paths {
mind_home: base.to_path_buf(),
claude_home: base.join("claude"),
}
}
#[test]
fn plugin_json_discovers_skill_and_agent_with_plugin_name_prefix() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/plugin.json"),
r#"{"name":"acme","version":"1.0","description":"Acme plugin"}"#,
);
write_file(
&clone.join("skills/foo/SKILL.md"),
"---\ndescription: foo skill\n---\n# foo\n",
);
write_file(
&clone.join("agents/bar.md"),
"---\nname: bar-agent\ndescription: bar agent\n---\n# bar\n",
);
let paths = paths_for(base);
let source = make_plugin_source(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let skill = items
.iter()
.find(|i| i.kind == ItemKind::Skill && i.name == "foo")
.expect("skill 'foo' must be discovered from plugin");
assert_eq!(
skill.effective_name(),
"acme:foo",
"skill must carry plugin name as prefix (MKT-5)"
);
assert_eq!(skill.prefix.as_deref(), Some("acme"));
let agent = items
.iter()
.find(|i| i.kind == ItemKind::Agent && i.name == "bar")
.expect("agent 'bar' must be discovered from plugin");
assert_eq!(
agent.agent_harness_name(),
Some("bar-agent".to_string()),
"agent harness name must come from frontmatter `name:` field (NS-40)"
);
assert!(
!items.iter().any(|i| i.kind == ItemKind::Rule),
"no rules must be emitted from a plugin (MKT-3)"
);
assert!(
!items.iter().any(|i| i.kind == ItemKind::Tool),
"no tools must be emitted from a plugin (MKT-3)"
);
}
#[test]
fn plugin_rules_and_tools_dirs_present_are_not_emitted() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/plugin.json"),
r#"{"name":"acme"}"#,
);
write_file(
&clone.join("skills/foo/SKILL.md"),
"---\ndescription: foo\n---\n# foo\n",
);
write_file(
&clone.join("rules/housestyle.md"),
"---\ndescription: a rule\n---\n# housestyle\n",
);
write_file(&clone.join("tools/helper/helper"), "#!/bin/sh\n");
write_file(
&clone.join("tools/helper/TOOL.md"),
"---\ndescription: a tool\n---\n# helper\n",
);
let paths = paths_for(base);
let source = make_plugin_source(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
assert!(
items
.iter()
.any(|i| i.kind == ItemKind::Skill && i.name == "foo"),
"the plugin's skill must still be discovered"
);
assert!(
!items.iter().any(|i| i.kind == ItemKind::Rule),
"a rules/ dir at a plugin root must NOT be emitted (MKT-3): {:?}",
items
.iter()
.map(|i| (i.kind, i.name.as_str()))
.collect::<Vec<_>>()
);
assert!(
!items.iter().any(|i| i.kind == ItemKind::Tool),
"a tools/ dir at a plugin root must NOT be emitted (MKT-3): {:?}",
items
.iter()
.map(|i| (i.kind, i.name.as_str()))
.collect::<Vec<_>>()
);
}
#[test]
fn plugin_agent_without_frontmatter_name_flattens_to_file_stem() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/plugin.json"),
r#"{"name":"acme"}"#,
);
write_file(
&clone.join("agents/nameless.md"),
"---\ndescription: an agent with no name field\n---\n# nameless\n",
);
let paths = paths_for(base);
let source = make_plugin_source(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let agent = items
.iter()
.find(|i| i.kind == ItemKind::Agent && i.name == "nameless")
.expect("plugin agent 'nameless' must be discovered");
assert_eq!(
agent.agent_harness_name(),
Some("nameless".to_string()),
"an agent with no frontmatter name must flatten to its file stem (NS-40)"
);
}
#[test]
fn consumer_alias_overrides_plugin_name_as_prefix() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/plugin.json"),
r#"{"name":"acme"}"#,
);
write_file(
&clone.join("skills/foo/SKILL.md"),
"---\ndescription: foo\n---\n# foo\n",
);
let paths = paths_for(base);
let mut source = make_plugin_source(&clone);
source.alias = Some("z".to_string());
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let skill = items.iter().find(|i| i.name == "foo").unwrap();
assert_eq!(
skill.effective_name(),
"z:foo",
"consumer alias must override plugin name as prefix (MKT-5)"
);
}
#[test]
fn cleared_alias_yields_no_prefix_overriding_plugin_name() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/plugin.json"),
r#"{"name":"acme"}"#,
);
write_file(
&clone.join("skills/foo/SKILL.md"),
"---\ndescription: foo\n---\n# foo\n",
);
let paths = paths_for(base);
let mut source = make_plugin_source(&clone);
source.alias = Some(String::new()); let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let skill = items.iter().find(|i| i.name == "foo").unwrap();
assert_eq!(
skill.effective_name(),
"foo",
"an explicitly-cleared alias must suppress the plugin name prefix"
);
assert!(
skill.prefix.is_none(),
"prefix must be None when alias was cleared"
);
}
#[test]
fn source_only_mind_toml_prefix_participates_with_plugin_json() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/plugin.json"),
r#"{"name":"acme"}"#,
);
write_file(
&clone.join("skills/foo/SKILL.md"),
"---\ndescription: foo\n---\n# foo\n",
);
write_file(
&clone.join("agents/bar.md"),
"---\ndescription: bar\n---\n# bar\n",
);
write_file(&clone.join("mind.toml"), "[source]\nprefix = \"mp\"\n");
write_file(
&clone.join("rules/should-not-appear.md"),
"---\ndescription: nope\n---\n",
);
let paths = paths_for(base);
let source = make_plugin_source(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let skill = items
.iter()
.find(|i| i.kind == ItemKind::Skill && i.name == "foo")
.expect("skill from plugin must be present");
assert_eq!(
skill.effective_name(),
"mp:foo",
"mind.toml [source].prefix must win over plugin name"
);
assert!(
!items.iter().any(|i| i.name == "should-not-appear"),
"convention scan must be skipped when plugin.json is present"
);
}
#[test]
fn authoritative_mind_toml_suppresses_plugin_json() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/plugin.json"),
r#"{"name":"acme"}"#,
);
write_file(
&clone.join("skills/plugin-skill/SKILL.md"),
"---\ndescription: from plugin\n---\n",
);
write_file(
&clone.join("rules/my-rule.md"),
"---\ndescription: my rule\n---\n",
);
write_file(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"rule\"\n",
"name = \"my-rule\"\n",
"path = \"rules/my-rule.md\"\n",
),
);
let paths = paths_for(base);
let source = make_plugin_source(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
assert_eq!(
items.len(),
1,
"authoritative mind.toml must suppress plugin.json (MKT-2); got: {:?}",
items
.iter()
.map(|i| (i.kind, i.name.as_str()))
.collect::<Vec<_>>()
);
assert_eq!(items[0].name, "my-rule");
assert!(
items.iter().all(|i| i.kind != ItemKind::Skill),
"plugin skill must not appear when authoritative mind.toml is present"
);
}
#[test]
fn malformed_plugin_json_is_scan_error() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/plugin.json"),
r#"{not valid json"#,
);
let paths = paths_for(base);
let source = make_plugin_source(&clone);
let mut items = Vec::new();
let err = scan_source(&paths, &source, &mut items).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"malformed plugin.json must propagate as MindToml error (MKT-9): {err:?}"
);
}
#[test]
fn plugin_skipped_components_counts_unsupported_dirs() {
let tmp = TmpDir::new();
let plugin_root = tmp.path().join("my-plugin");
std::fs::create_dir_all(plugin_root.join("commands")).unwrap();
std::fs::create_dir_all(plugin_root.join("hooks")).unwrap();
let skipped = plugin_skipped_components(&plugin_root);
assert!(
skipped.commands > 0,
"commands/ dir must be counted as skipped"
);
assert!(skipped.hooks > 0, "hooks/ dir must be counted as skipped");
assert!(
skipped.total() >= 2,
"at least 2 skipped components: {:?}",
skipped
);
}
#[test]
fn plugin_with_reserved_kind_name_falls_back_to_no_prefix() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/plugin.json"),
r#"{"name":"skill"}"#,
);
write_file(
&clone.join("skills/foo/SKILL.md"),
"---\ndescription: foo\n---\n# foo\n",
);
let paths = paths_for(base);
let source = make_plugin_source(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items)
.expect("a plugin named with a reserved kind word must not fail scan");
let skill = items.iter().find(|i| i.name == "foo").unwrap();
assert_eq!(
skill.effective_name(),
"foo",
"reserved kind word as plugin name must fall through to no prefix"
);
assert!(
skill.prefix.is_none(),
"prefix must be None when plugin name is a reserved kind word"
);
}
}