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::sanitize::ItemKey;
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 when_to_use: Option<String>,
pub link_rel: Option<String>,
pub bin: Option<String>,
pub build: Option<String>,
pub requires: Vec<String>,
pub ignore: Option<Vec<String>>,
pub expand: Vec<String>,
pub hooks: Vec<ResolvedHook>,
}
impl CatalogItem {
pub fn effective_name(&self) -> String {
namespace::apply(&self.name, &self.prefix)
}
pub fn forges_namespace(&self) -> bool {
self.prefix.is_none() && self.name.contains(':')
}
pub fn agent_harness_name(&self) -> Option<String> {
if self.kind != ItemKind::Agent {
return None;
}
if let Ok(text) = frontmatter::text_capped(&self.path)
&& let Some(fm_name) = frontmatter::field(&text, "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 update_hooks(&self) -> Vec<&ResolvedHook> {
self.hooks
.iter()
.filter(|h| h.event == HookEvent::Update)
.collect()
}
pub fn install_time_hooks(&self, is_update: bool) -> Vec<&ResolvedHook> {
if is_update {
let update = self.update_hooks();
if !update.is_empty() {
return update;
}
}
self.install_hooks()
}
pub fn uninstall_hooks(&self) -> Vec<&ResolvedHook> {
self.hooks
.iter()
.filter(|h| h.event == HookEvent::Uninstall)
.collect()
}
pub fn key(&self) -> ItemKey {
ItemKey::new(format!("{}:{}", self.kind.as_str(), self.effective_name()))
}
pub fn ignore_set(&self) -> Result<crate::ignore::IgnoreSet> {
crate::ignore::IgnoreSet::new(
self.ignore.as_deref().unwrap_or_default(),
&self.key().display(),
)
}
pub fn content_hash(&self) -> Result<String> {
crate::hash::hash_path_ignoring(&self.path, &self.ignore_set()?)
}
pub fn anchor_file(&self) -> Option<&'static str> {
match self.kind {
ItemKind::Skill => Some("SKILL.md"),
ItemKind::Tool => self.path.join("TOOL.md").is_file().then_some("TOOL.md"),
ItemKind::Agent | ItemKind::Rule | ItemKind::Command | ItemKind::Workflow => None,
ItemKind::Resource => None,
}
}
pub fn display_description(&self) -> Option<String> {
match (&self.description, &self.when_to_use) {
(Some(d), Some(w)) => Some(format!("{d} - {w}")),
(Some(d), None) => Some(d.clone()),
(None, Some(w)) => Some(w.clone()),
(None, None) => None,
}
}
pub fn display_key(&self) -> String {
self.key().display()
}
pub fn display_name(&self) -> String {
crate::sanitize::strip_ansi(&self.name)
}
pub fn display_effective_name(&self) -> String {
crate::sanitize::strip_ansi(&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.display_description()
.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 {
if let Err(e) = scan_source(paths, source, &mut items) {
if matches!(e, MindError::LinkedSourceGone { .. }) {
crate::render::scan_warn(format!("warning: {e}"));
continue;
}
return Err(e);
}
}
Ok(items)
}
static WARNED_RESERVED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::OnceLock::new();
fn first_reserved_warning(source_name: &str) -> bool {
let set = WARNED_RESERVED.get_or_init(Default::default);
let mut guard = set.lock().unwrap_or_else(|e| e.into_inner());
guard.insert(source_name.to_string())
}
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<()> {
scan_source_reporting(clone_root.as_ref(), source, out).map(drop)
}
pub(crate) fn scan_source_reporting(
clone_root: &Path,
source: &Source,
out: &mut Vec<CatalogItem>,
) -> Result<Vec<CatalogItem>> {
if source.is_linked() && !clone_root.is_dir() {
return Err(MindError::LinkedSourceGone {
source_name: source.name.clone(),
path: clone_root.display().to_string(),
});
}
let mindfile = MindToml::load(clone_root).map_err(|e| melded_prefix_error(source, e))?;
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());
if let Some(p) = &prefix
&& matches!(
namespace::validate_prefix(p),
Err(MindError::ReservedPrefix { .. })
)
&& first_reserved_warning(&source.name)
{
crate::render::scan_warn(format!(
"warning: source '{}': its namespace prefix '{}' is a word mind now reserves, so \
'{}:<name>' reads as a kind-qualified ref rather than a namespaced item; unmeld \
it, then re-meld with `--namespace <other>` to rename it",
crate::sanitize::strip_ansi(&source.name),
crate::sanitize::strip_ansi(p),
crate::sanitize::strip_ansi(p),
));
}
let source_ignore: Vec<String> = mindfile
.as_ref()
.and_then(|m| m.source.ignore.clone())
.unwrap_or_default();
let scan_start = out.len();
if let Some(item_path) = &source.item_path {
scan_item_link(clone_root, source, &prefix, item_path, out)?;
resolve_ignores(&mut out[scan_start..], &source_ignore)?;
return Ok(Vec::new());
}
let base_start = out.len();
let addroot_prefix = scan_source_layers(clone_root, source, &mindfile, &prefix, out)?;
let add_start = out.len();
scan_add_roots(clone_root, source, &addroot_prefix, base_start, out)?;
let convention_from = if mindfile.as_ref().is_some_and(|m| m.is_authoritative()) {
add_start
} else {
base_start
};
let dropped = prune_unreferenced_resources(out, base_start, convention_from);
resolve_ignores(&mut out[scan_start..], &source_ignore)?;
Ok(dropped)
}
fn unreferenced_resources(items: &[CatalogItem], convention_from: usize) -> Vec<usize> {
let is_candidate = |i: usize| i >= convention_from && items[i].kind == ItemKind::Resource;
if !(0..items.len()).any(is_candidate) {
return Vec::new();
}
let roots: Vec<usize> = (0..items.len()).filter(|&i| !is_candidate(i)).collect();
let reached = crate::deps::reachable(items, &roots, read_expanding_text);
(0..items.len())
.filter(|&i| is_candidate(i) && !reached.contains(&i))
.collect()
}
fn prune_unreferenced_resources(
out: &mut Vec<CatalogItem>,
start: usize,
convention_from: usize,
) -> Vec<CatalogItem> {
let drop: std::collections::HashSet<usize> =
unreferenced_resources(&out[start..], convention_from - start)
.into_iter()
.map(|i| i + start)
.collect();
if drop.is_empty() {
return Vec::new();
}
let mut dropped = Vec::new();
let mut kept = Vec::with_capacity(out.len() - drop.len());
for (i, item) in std::mem::take(out).into_iter().enumerate() {
if drop.contains(&i) {
dropped.push(item);
} else {
kept.push(item);
}
}
*out = kept;
dropped
}
fn read_expanding_text(item: &CatalogItem) -> String {
let mut buf = String::new();
for file in crate::review::item_files(item) {
if !namespace::item_expands_tokens(item, &item.path, &file) {
continue;
}
if !is_regular_file_nofollow(&file) {
continue;
}
if let Ok(content) = std::fs::read_to_string(&file) {
buf.push_str(&content);
buf.push('\n');
}
}
buf
}
fn melded_prefix_error(source: &Source, e: MindError) -> MindError {
match e {
MindError::ReservedPrefix { prefix } => MindError::MeldedSourceReservedPrefix {
source_name: crate::sanitize::strip_ansi(&source.name),
prefix: crate::sanitize::strip_ansi(&prefix),
},
other => other,
}
}
fn resolve_ignores(items: &mut [CatalogItem], source_ignore: &[String]) -> Result<()> {
const SOURCE_LABEL: &str = "mind.toml [source].ignore";
for item in items.iter_mut() {
let declared_own = item.ignore.is_some();
if !declared_own {
item.ignore = Some(source_ignore.to_vec());
}
let label = if declared_own {
item.display_key()
} else {
SOURCE_LABEL.to_string()
};
let set =
crate::ignore::IgnoreSet::new(item.ignore.as_deref().unwrap_or_default(), &label)?;
if let Some(anchor) = item.anchor_file()
&& set.is_under_ignored(std::path::Path::new(anchor))
{
let entry = item
.ignore
.as_deref()
.unwrap_or_default()
.iter()
.find(|e| {
crate::ignore::IgnoreSet::new(std::slice::from_ref(e), "")
.is_ok_and(|one| one.is_under_ignored(std::path::Path::new(anchor)))
})
.cloned()
.unwrap_or_else(|| anchor.to_string());
return Err(MindError::IgnoresOwnAnchor {
item: label,
entry: crate::sanitize::strip_ansi(&entry),
anchor: anchor.to_string(),
});
}
for file in &item.expand {
let rel = std::path::Path::new(file);
if !set.is_under_ignored(rel) {
continue;
}
let entry = item
.ignore
.as_deref()
.unwrap_or_default()
.iter()
.find(|e| {
crate::ignore::IgnoreSet::new(std::slice::from_ref(e), "")
.is_ok_and(|one| one.is_under_ignored(rel))
})
.cloned()
.or_else(|| builtin_match(rel).map(str::to_string))
.unwrap_or_else(|| "a built-in ignore".to_string());
return Err(MindError::ExpandsIgnoredFile {
item: label,
file: crate::sanitize::strip_ansi(file),
entry: crate::sanitize::strip_ansi(&entry),
});
}
}
Ok(())
}
fn builtin_match(rel: &Path) -> Option<&'static str> {
rel.components().find_map(|c| match c {
std::path::Component::Normal(name) => crate::ignore::BUILTIN_IGNORES
.iter()
.find(|n| **n == name.to_string_lossy())
.copied(),
_ => None,
})
}
pub(crate) fn is_file_link(item_path: &str) -> bool {
item_path.ends_with(".md")
}
fn is_workflow_link_path(item_path: &str) -> bool {
let path = Path::new(item_path);
let parent_is_workflows = path
.parent()
.and_then(|p| p.file_name())
.is_some_and(|n| n == ItemKind::Workflow.dir());
parent_is_workflows && is_js_link_path(item_path)
}
fn is_js_link_path(item_path: &str) -> bool {
Path::new(item_path)
.extension()
.is_some_and(|e| e == kind_extension(ItemKind::Workflow))
}
fn resolve_file_link_kind(source: &Source, item_path: &str, file: &Path) -> Result<ItemKind> {
let mismatch = |kind: ItemKind, reason: &str| MindError::LinkKindMismatch {
source_name: source.name.clone(),
path: item_path.to_string(),
kind: kind.as_str().to_string(),
reason: reason.to_string(),
};
if let Some(kind) = source.item_kind {
if !is_file_kind(kind) {
return Err(mismatch(
kind,
"the link names a file, and a skill or tool is a directory",
));
}
return Ok(kind);
}
if let Some(kind) = Path::new(item_path)
.parent()
.and_then(|p| p.file_name())
.and_then(|n| ItemKind::from_dir(&n.to_string_lossy()))
.filter(|k| is_file_kind(*k))
{
return Ok(kind);
}
let text = crate::frontmatter::text_capped(file)?;
if let Some(declared) = crate::frontmatter::field(&text, "kind") {
let kind = ItemKind::parse(declared.trim()).ok_or_else(|| MindError::LinkKindMismatch {
source_name: source.name.clone(),
path: item_path.to_string(),
kind: crate::sanitize::strip_ansi(declared.trim()),
reason: "not an item kind (expected agent, rule, or command); pass \
--kind <agent|rule|command> to override the frontmatter"
.to_string(),
})?;
if !is_file_kind(kind) {
return Err(mismatch(
kind,
"its frontmatter declares a directory kind, but the link names a file",
));
}
return Ok(kind);
}
Err(MindError::LinkKindUnresolved {
source_name: source.name.clone(),
path: item_path.to_string(),
})
}
fn is_file_kind(kind: ItemKind) -> bool {
matches!(kind, ItemKind::Agent | ItemKind::Rule | ItemKind::Command)
}
fn scan_item_link(
clone_root: &Path,
source: &Source,
prefix: &Option<String>,
item_path: &str,
out: &mut Vec<CatalogItem>,
) -> Result<()> {
let file_link = is_file_link(item_path);
let bad_target = || {
if file_link {
MindError::LinkNotAFile {
source_name: source.name.clone(),
path: item_path.to_string(),
}
} else {
MindError::LinkNotASkill {
source_name: source.name.clone(),
path: item_path.to_string(),
}
}
};
if !plugin_manifest::is_safe_manifest_path(item_path) {
return Err(bad_target());
}
let canon_root = clone_root
.canonicalize()
.unwrap_or_else(|_| clone_root.to_path_buf());
let target = canon_root.join(item_path);
let canon = target.canonicalize().unwrap_or_else(|_| target.clone());
if !canon.starts_with(&canon_root) {
return Err(bad_target());
}
let item_path_abs = clone_root.join(item_path);
let (kind, path, meta) = if file_link {
if !target.is_file() {
return Err(bad_target());
}
let kind = resolve_file_link_kind(source, item_path, &target)?;
(kind, item_path_abs.clone(), item_path_abs)
} else {
if let Some(kind) = source.item_kind.filter(|k| *k != ItemKind::Skill) {
return Err(MindError::LinkKindMismatch {
source_name: source.name.clone(),
path: item_path.to_string(),
kind: kind.as_str().to_string(),
reason: "the link names a directory, which is always a skill".to_string(),
});
}
let skill_md = target.join("SKILL.md");
if !(is_dir_nofollow(&target) && is_regular_file_nofollow(&skill_md)) {
if is_workflow_link_path(item_path) {
return Err(MindError::LinkKindNotSupported {
source_name: source.name.clone(),
path: item_path.to_string(),
});
}
if is_js_link_path(item_path) {
return Err(MindError::LinkNotLinkableFile {
source_name: source.name.clone(),
path: item_path.to_string(),
});
}
return Err(bad_target());
}
let meta = item_path_abs.join("SKILL.md");
(ItemKind::Skill, item_path_abs, meta)
};
if let Some(item) = make_item(clone_root, source, prefix, kind, path, &meta)? {
out.push(item);
}
Ok(())
}
fn scan_source_layers(
clone_root: &Path,
source: &Source,
mindfile: &Option<MindToml>,
prefix: &Option<String>,
out: &mut Vec<CatalogItem>,
) -> Result<Option<String>> {
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 Some(item) = from_decl(clone_root, source, prefix, decl)? else {
continue;
};
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(prefix.clone())
}
mt => {
let suppress_manifest = source.roots.is_some()
|| source.flat_skills
|| mt.as_ref().is_some_and(|m| m.declares_scan_layout());
if !suppress_manifest
&& 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(plugin_prefix);
}
if !suppress_manifest
&& let Some(marketplace_path) =
plugin_manifest::find_marketplace_manifest(clone_root)
{
let manifest = plugin_manifest::load_marketplace_manifest(&marketplace_path)?;
let has_explicit_prefix = source.alias.is_some()
|| mt.as_ref().and_then(|m| m.source.prefix.as_ref()).is_some();
scan_marketplace_in_repo_plugins(
clone_root,
source,
manifest,
prefix,
has_explicit_prefix,
out,
)?;
return Ok(prefix.clone());
}
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 {
validate_scan_root(clone_root, r, &source.name)?;
}
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(prefix.clone())
}
}
}
fn validate_scan_root(clone_root: &Path, root: &str, source_name: &str) -> Result<()> {
let invalid = || MindError::InvalidRoot {
source_name: source_name.to_string(),
root: root.to_string(),
};
if Path::new(root).is_absolute() {
return Err(invalid());
}
let full = clone_root.join(root);
if !full
.canonicalize()
.unwrap_or_else(|_| full.clone())
.starts_with(
clone_root
.canonicalize()
.unwrap_or_else(|_| clone_root.to_path_buf()),
)
{
return Err(invalid());
}
if !full.is_dir() {
return Err(invalid());
}
Ok(())
}
fn scan_add_roots(
clone_root: &Path,
source: &Source,
prefix: &Option<String>,
base_start: usize,
out: &mut Vec<CatalogItem>,
) -> Result<()> {
let Some(add_roots) = source.add_roots.as_ref().filter(|r| !r.is_empty()) else {
return Ok(());
};
for r in add_roots {
validate_scan_root(clone_root, r, &source.name)?;
}
let canon = |p: &Path| -> PathBuf { p.canonicalize().unwrap_or_else(|_| p.to_path_buf()) };
let base_paths: std::collections::HashSet<PathBuf> =
out[base_start..].iter().map(|it| canon(&it.path)).collect();
let add_start = out.len();
for r in add_roots {
let root = clone_root.join(r);
scan_convention(&root, source, prefix, true, out)?;
let skills_dir = root.join(ItemKind::Skill.dir());
for entry in read_container_opt(&skills_dir)? {
let skill_md = entry.join("SKILL.md");
if is_dir_nofollow(&entry)
&& is_regular_file_nofollow(&skill_md)
&& let Some(item) = make_item(
clone_root,
source,
prefix,
ItemKind::Skill,
entry,
&skill_md,
)?
{
out.push(item);
}
}
}
let mut added: Vec<CatalogItem> = out.split_off(add_start);
added.retain(|it| !base_paths.contains(&canon(&it.path)));
let mut seen_add_paths: std::collections::HashSet<(ItemKind, PathBuf)> =
std::collections::HashSet::new();
added.retain(|it| seen_add_paths.insert((it.kind, canon(&it.path))));
out.extend(added);
let mut seen: std::collections::HashSet<(ItemKind, String)> = std::collections::HashSet::new();
for item in &out[base_start..] {
let key = (item.kind, item.effective_name());
if !seen.insert(key.clone()) {
return Err(MindError::DuplicateItem {
source_name: source.name.clone(),
kind: key.0,
name: key.1,
});
}
}
Ok(())
}
fn declared_path_is_own(
root: &Path,
source: &Source,
kind: ItemKind,
rel: &str,
path: &Path,
safe_name: &str,
) -> Result<bool> {
let drop_with = |shown: &str, what: &str| {
crate::render::scan_warn(format!(
"warning: source '{}': [[items]] entry '{safe_name}' path '{}' {what}; a declared \
item must be the repo's own file or directory, so it is not offered",
crate::sanitize::strip_ansi(&source.name),
crate::sanitize::strip_ansi(shown),
));
Ok(false)
};
let Ok(md) = std::fs::symlink_metadata(path) else {
return Ok(true);
};
if md.file_type().is_symlink() {
return drop_with(rel, "is a symlink");
}
let canon_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if !canon.starts_with(&canon_root) {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"item '{safe_name}' path '{}' resolves outside the repo root",
crate::sanitize::strip_ansi(rel)
),
});
}
match kind {
ItemKind::Agent
| ItemKind::Rule
| ItemKind::Command
| ItemKind::Workflow
| ItemKind::Resource => {
let ft = md.file_type();
if !ft.is_file() && !ft.is_dir() {
return drop_with(rel, "is not a regular file");
}
}
ItemKind::Skill | ItemKind::Tool => {
let anchor = meta_file(kind, path);
if let Ok(am) = std::fs::symlink_metadata(&anchor) {
let anchor_rel = format!("{}/{}", rel.trim_end_matches('/'), file_name(&anchor));
if am.file_type().is_symlink() {
return drop_with(&anchor_rel, "is a symlink");
}
if !am.file_type().is_file() {
return drop_with(&anchor_rel, "is not a regular file");
}
}
}
}
Ok(true)
}
fn from_decl(
root: &Path,
source: &Source,
prefix: &Option<String>,
decl: &ItemDecl,
) -> Result<Option<CatalogItem>> {
let safe_name = crate::sanitize::strip_ansi(&decl.name);
let kind = ItemKind::parse(&decl.kind).ok_or_else(|| MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"unknown item kind '{}' for '{}'",
crate::sanitize::strip_ansi(&decl.kind),
safe_name
),
})?;
if !is_safe_item_name(&decl.name) {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"item name '{safe_name}' is unsafe: it must be a single path component (no '/', \
'\\', '.', '..', NUL, a control character, or a bidi/zero-width Unicode code point)"
),
});
}
if kind == ItemKind::Resource && decl.link.is_some() {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"`link` is not valid on a resource item ('{safe_name}'): a resource is store-only \
and reached by {{{{resources:{safe_name}}}}}"
),
});
}
if let Some(link) = &decl.link {
if !is_safe_link_rel(link) {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"item '{safe_name}' has an unsafe link '{}': it must be a relative path inside \
the agent home (no leading '/' or '~', no '..' component, no NUL)",
crate::sanitize::strip_ansi(link)
),
});
}
if !is_confined_link_target(link, kind) {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"item '{safe_name}' has a link '{}' outside a kind directory: it must begin \
with 'skills/', 'agents/', 'rules/', 'commands/', 'workflows/', or 'tools/' (a \
link cannot place a file at the agent-home root or inside a harness's own \
config, e.g. 'settings.json'), and only a command may link into \
'commands/' and only a workflow into 'workflows/', since the harness offers \
or runs what it finds there",
crate::sanitize::strip_ansi(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 '{}' ('{safe_name}')",
crate::sanitize::strip_ansi(&decl.kind)
),
});
}
if !is_safe_link_rel(&decl.path) {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"item '{safe_name}' has an unsafe path '{}': must be a relative path inside the \
clone (no leading '/' or '~', no '..' component, no NUL)",
crate::sanitize::strip_ansi(&decl.path)
),
});
}
let path = root.join(&decl.path);
if kind == ItemKind::Workflow
&& std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_dir())
{
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"workflow item '{safe_name}' has a path '{}' that is a directory; a workflow is a \
single .js file, so the path must name that file",
crate::sanitize::strip_ansi(&decl.path)
),
});
}
if !declared_path_is_own(root, source, kind, &decl.path, &path, &safe_name)? {
return Ok(None);
}
let meta = meta_file(kind, &path);
let hooks = decl
.declares_hooks()
.then(|| decl.resolved_item_hooks(&root.join("mind.toml")))
.transpose()?;
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(),
hooks,
ignore: decl.ignore.clone(),
manifest_dir: Some(root),
},
)
}
pub(crate) 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;
}
if crate::sanitize::has_blocked_chars(name) {
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 is_confined_link_target(rel: &str, kind: ItemKind) -> bool {
use std::path::Component;
let mut comps = Path::new(rel)
.components()
.skip_while(|c| matches!(c, Component::CurDir));
match comps.next() {
Some(Component::Normal(first)) => {
let first = first.to_string_lossy();
for invocable in [ItemKind::Command, ItemKind::Workflow] {
if first == invocable.dir() {
return kind == invocable;
}
}
[
ItemKind::Skill,
ItemKind::Agent,
ItemKind::Rule,
ItemKind::Tool,
]
.iter()
.any(|k| k.dir() == first)
}
_ => false,
}
}
fn lifecycle_frontmatter(meta_text: &str, key: &str) -> Option<String> {
nonempty(frontmatter::field(meta_text, key))
}
fn item_declared_hooks(
manifest_dir: Option<&Path>,
source: &Source,
kind: ItemKind,
name: &str,
path: &Path,
meta_text: &str,
) -> Result<Option<Vec<ResolvedHook>>> {
if matches!(kind, ItemKind::Skill | ItemKind::Tool)
&& path.is_dir()
&& !manifest_dir.is_some_and(|dir| same_dir(path, dir))
{
let manifest_path = path.join("mind.toml");
let loaded = crate::mindfile::ItemToml::load(path)
.and_then(|m| m.map(|m| m.resolved_hooks(&manifest_path)).transpose());
match loaded {
Ok(Some(hooks)) if !hooks.is_empty() => return Ok(Some(hooks)),
Ok(_) => {}
Err(e) => {
crate::render::scan_warn(format!(
"warning: skipping {} '{}' in source '{}': {e}",
kind.as_str(),
crate::sanitize::strip_ansi(name),
crate::sanitize::strip_ansi(&source.name)
));
return Ok(None);
}
}
}
let mut out: Vec<ResolvedHook> = Vec::new();
for (key, event) in [
("install", HookEvent::Install),
("update", HookEvent::Update),
("uninstall", HookEvent::Uninstall),
] {
if let Some(run) = lifecycle_frontmatter(meta_text, key) {
out.push(ResolvedHook {
run,
name: None,
optional: false,
event,
});
}
}
Ok(Some(out))
}
fn same_dir(a: &Path, b: &Path) -> bool {
let canon = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
canon(a) == canon(b)
}
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_text: &str,
key: &str,
) -> Option<String> {
if kind != ItemKind::Tool {
return None;
}
match explicit {
Some(v) => Some(v),
None => frontmatter::field(meta_text, key),
}
}
#[derive(Default)]
struct ItemOverrides<'a> {
description: Option<String>,
link: Option<String>,
bin: Option<String>,
build: Option<String>,
hooks: Option<Vec<ResolvedHook>>,
ignore: Option<Vec<String>>,
manifest_dir: Option<&'a Path>,
}
fn build_item(
source: &Source,
prefix: &Option<String>,
kind: ItemKind,
name: String,
path: PathBuf,
meta: &Path,
ov: ItemOverrides<'_>,
) -> Result<Option<CatalogItem>> {
let meta_text = if kind == ItemKind::Resource {
String::new()
} else {
match frontmatter::text_capped(meta) {
Ok(text) => text,
Err(MindError::MetadataTooLarge { .. }) if kind == ItemKind::Workflow => String::new(),
Err(e) => return Err(e),
}
};
let hooks = match ov.hooks {
Some(hooks) => hooks,
None => {
match item_declared_hooks(ov.manifest_dir, source, kind, &name, &path, &meta_text)? {
Some(hooks) => hooks,
None => return Ok(None),
}
}
};
let requires: Vec<String> = frontmatter::field(&meta_text, "requires")
.map(|s| s.split_whitespace().map(str::to_owned).collect())
.unwrap_or_default();
let expand: Vec<String> = frontmatter::field(&meta_text, "expand")
.map(|s| s.split_whitespace().map(str::to_owned).collect())
.unwrap_or_default();
let wf_meta = if kind == ItemKind::Workflow {
crate::workflow_meta::parse(&meta_text)
} else {
crate::workflow_meta::WorkflowMeta::default()
};
let sanitized = |d: String| crate::sanitize::strip_ansi(&namespace::flatten_display(&d));
let description = match ov.description {
Some(d) => Some(d),
None => match kind {
ItemKind::Workflow => wf_meta.description.filter(|d| !d.trim().is_empty()),
_ => frontmatter::field(&meta_text, "description"),
},
}
.map(sanitized);
let when_to_use = wf_meta
.when_to_use
.filter(|w| !w.trim().is_empty())
.map(sanitized);
Ok(Some(CatalogItem {
kind,
name,
source: source.name.clone(),
prefix: prefix.clone(),
path,
description,
when_to_use,
link_rel: ov.link,
bin: tool_field(kind, ov.bin, &meta_text, "bin"),
build: tool_field(kind, ov.build, &meta_text, "build"),
requires,
ignore: ov.ignore,
expand,
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, ItemKind::Skill)? {
if let Some(dir) = skill_md.parent()
&& let Some(item) = make_item(
root,
source,
prefix,
ItemKind::Skill,
dir.to_path_buf(),
&skill_md,
)?
{
out.push(item);
}
}
for (kind, globs) in [
(ItemKind::Agent, &discover.agents),
(ItemKind::Rule, &discover.rules),
(ItemKind::Command, &discover.commands),
(ItemKind::Workflow, &discover.workflows),
] {
for md in resolve_globs(root, globs, kind)? {
if let Some(item) = make_item(root, source, prefix, kind, md.clone(), &md)? {
out.push(item);
}
}
}
for dir in resolve_globs(root, &discover.tools, ItemKind::Tool)? {
if !tool_anchor_ok(&dir) {
continue;
}
let meta = dir.join("TOOL.md");
if let Some(item) = make_item(root, source, prefix, ItemKind::Tool, dir, &meta)? {
out.push(item);
}
}
for entry in resolve_globs(root, &discover.resources, ItemKind::Resource)? {
if !(is_regular_file_nofollow(&entry) || is_dir_nofollow(&entry)) {
continue;
}
if let Some(item) = make_item(
root,
source,
prefix,
ItemKind::Resource,
entry.clone(),
&entry,
)? {
out.push(item);
}
}
Ok(())
}
fn resolve_globs(root: &Path, globs: &KindGlobs, kind: ItemKind) -> Result<Vec<PathBuf>> {
let mut included = BTreeSet::new();
for pattern in &globs.include {
included.extend(glob_paths(root, pattern, kind)?);
}
let mut excluded = BTreeSet::new();
for pattern in &globs.exclude {
excluded.extend(glob_paths(root, pattern, kind)?);
}
Ok(included.difference(&excluded).cloned().collect())
}
fn kind_extension(kind: ItemKind) -> &'static str {
match kind {
ItemKind::Agent | ItemKind::Rule | ItemKind::Command => "md",
ItemKind::Workflow => "js",
ItemKind::Skill | ItemKind::Tool | ItemKind::Resource => "",
}
}
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())
};
let skill_entries = if flat_skills {
read_dir_opt(&skills_dir)?
} else {
read_container_opt(&skills_dir)?
};
for entry in skill_entries {
let skill_md = entry.join("SKILL.md");
if is_dir_nofollow(&entry)
&& is_regular_file_nofollow(&skill_md)
&& let Some(item) = make_item(root, source, prefix, ItemKind::Skill, entry, &skill_md)?
{
out.push(item);
}
}
for kind in [
ItemKind::Agent,
ItemKind::Rule,
ItemKind::Command,
ItemKind::Workflow,
] {
let ext = kind_extension(kind);
let kind_dir = root.join(kind.dir());
for entry in read_container_opt(&kind_dir)? {
if is_regular_file_nofollow(&entry)
&& entry.extension().is_some_and(|e| e == ext)
&& let Some(item) = make_item(root, source, prefix, kind, entry.clone(), &entry)?
{
out.push(item);
}
}
}
let tools_dir = root.join(ItemKind::Tool.dir());
for entry in read_container_opt(&tools_dir)? {
if is_dir_nofollow(&entry) && tool_anchor_ok(&entry) {
let meta = entry.join("TOOL.md");
if let Some(item) = make_item(root, source, prefix, ItemKind::Tool, entry, &meta)? {
out.push(item);
}
}
}
let resources_dir = root.join(ItemKind::Resource.dir());
for entry in read_container_opt(&resources_dir)? {
if file_name(&entry).starts_with('.') {
continue;
}
if (is_regular_file_nofollow(&entry) || is_dir_nofollow(&entry))
&& let Some(item) = make_item(
root,
source,
prefix,
ItemKind::Resource,
entry.clone(),
&entry,
)?
{
out.push(item);
}
}
Ok(())
}
fn make_item(
root: &Path,
source: &Source,
prefix: &Option<String>,
kind: ItemKind,
path: PathBuf,
meta: &Path,
) -> Result<Option<CatalogItem>> {
let bare = match kind {
ItemKind::Skill | ItemKind::Tool => file_name(&path),
ItemKind::Resource => file_name(&path),
ItemKind::Agent | ItemKind::Rule | ItemKind::Command | ItemKind::Workflow => path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(),
};
if !is_safe_item_name(&bare) {
crate::render::scan_warn(format!(
"warning: skipping {} '{}' in source '{}': unsafe item name (a control character or \
a bidi/zero-width Unicode code point)",
kind.as_str(),
crate::sanitize::strip_ansi(&bare),
crate::sanitize::strip_ansi(&source.name)
));
return Ok(None);
}
build_item(
source,
prefix,
kind,
bare,
path,
meta,
ItemOverrides {
manifest_dir: Some(root),
..ItemOverrides::default()
},
)
}
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_container_opt(&skills_dir)? {
let skill_md = entry.join("SKILL.md");
if is_dir_nofollow(&entry)
&& is_regular_file_nofollow(&skill_md)
&& let Some(item) = make_item(
plugin_root,
source,
prefix,
ItemKind::Skill,
entry,
&skill_md,
)?
{
out.push(item);
}
}
let agents_dir = plugin_root.join(ItemKind::Agent.dir());
for entry in read_container_opt(&agents_dir)? {
if is_regular_file_nofollow(&entry)
&& entry.extension().is_some_and(|e| e == "md")
&& let Some(item) = make_item(
plugin_root,
source,
prefix,
ItemKind::Agent,
entry.clone(),
&entry,
)?
{
out.push(item);
}
}
scan_plugin_flat_items(plugin_root, source, prefix, out)?;
Ok(())
}
fn scan_plugin_flat_items(
plugin_root: &Path,
source: &Source,
prefix: &Option<String>,
out: &mut Vec<CatalogItem>,
) -> Result<()> {
for kind in [ItemKind::Command, ItemKind::Workflow] {
let ext = kind_extension(kind);
let kind_dir = plugin_root.join(kind.dir());
for entry in read_container_opt(&kind_dir)? {
if is_regular_file_nofollow(&entry)
&& entry.extension().is_some_and(|e| e == ext)
&& let Some(item) =
make_item(plugin_root, source, prefix, kind, entry.clone(), &entry)?
{
out.push(item);
}
}
}
Ok(())
}
fn scan_marketplace_in_repo_plugins(
clone_root: &Path,
source: &Source,
manifest: plugin_manifest::MarketplaceManifest,
outer_prefix: &Option<String>,
has_explicit_prefix: bool,
out: &mut Vec<CatalogItem>,
) -> Result<()> {
let canon_clone = clone_root
.canonicalize()
.unwrap_or_else(|_| clone_root.to_path_buf());
for entry in manifest.into_entries() {
let inrepo_path = match &entry.source {
plugin_manifest::PluginSource::InRepo { path } => path.clone(),
plugin_manifest::PluginSource::External { .. } => continue,
};
let plugin_root = clone_root.join(&inrepo_path);
let Ok(canon_plugin) = plugin_root.canonicalize() else {
continue; };
if !canon_plugin.starts_with(&canon_clone) {
continue; }
let entry_name = crate::sanitize::strip_ansi(entry.name.trim());
let plugin_prefix = if has_explicit_prefix {
match outer_prefix {
Some(p) if !entry_name.is_empty() => {
Some(format!("{p}:{entry_name}"))
}
Some(p) => {
Some(p.clone())
}
None => {
if !entry_name.is_empty()
&& namespace::is_safe_prefix_component(&entry_name)
&& !matches!(entry_name.as_str(), "skill" | "agent" | "rule" | "tool")
{
Some(entry_name.clone())
} else {
None
}
}
}
} else {
if !entry_name.is_empty()
&& namespace::is_safe_prefix_component(&entry_name)
&& !matches!(entry_name.as_str(), "skill" | "agent" | "rule" | "tool")
{
Some(entry_name.clone())
} else {
None
}
};
if !entry.skills.is_empty() {
for skill_path in &entry.skills {
let skill_dir = plugin_root.join(skill_path);
let skill_md = skill_dir.join("SKILL.md");
if is_dir_nofollow(&skill_dir)
&& is_regular_file_nofollow(&skill_md)
&& let Some(item) = make_item(
&plugin_root,
source,
&plugin_prefix,
ItemKind::Skill,
skill_dir,
&skill_md,
)?
{
out.push(item);
}
}
} else {
let skills_dir = plugin_root.join(ItemKind::Skill.dir());
for entry_path in read_container_opt(&skills_dir)? {
let skill_md = entry_path.join("SKILL.md");
if is_dir_nofollow(&entry_path)
&& is_regular_file_nofollow(&skill_md)
&& let Some(item) = make_item(
&plugin_root,
source,
&plugin_prefix,
ItemKind::Skill,
entry_path,
&skill_md,
)?
{
out.push(item);
}
}
}
let agents_dir = plugin_root.join(ItemKind::Agent.dir());
for agent_path in read_container_opt(&agents_dir)? {
if is_regular_file_nofollow(&agent_path)
&& agent_path.extension().is_some_and(|e| e == "md")
&& let Some(item) = make_item(
&plugin_root,
source,
&plugin_prefix,
ItemKind::Agent,
agent_path.clone(),
&agent_path,
)?
{
out.push(item);
}
}
scan_plugin_flat_items(&plugin_root, source, &plugin_prefix, out)?;
}
Ok(())
}
pub fn plugin_skipped_components(plugin_root: &Path) -> plugin_manifest::SkippedComponents {
let mut sc = plugin_manifest::SkippedComponents::default();
if plugin_root.join("hooks").is_dir() {
sc.hooks = 1;
}
if plugin_root.join(".mcp.json").is_file() {
sc.mcp_servers = 1;
}
sc.commands = unmapped_flat_entries(
&plugin_root.join(ItemKind::Command.dir()),
kind_extension(ItemKind::Command),
);
sc.workflows = unmapped_flat_entries(
&plugin_root.join(ItemKind::Workflow.dir()),
kind_extension(ItemKind::Workflow),
);
sc
}
fn unmapped_flat_entries(dir: &Path, ext: &str) -> u32 {
if is_symlink(dir) {
return 1;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut n = 0;
for entry in entries.flatten() {
let path = entry.path();
if file_name(&path).starts_with('.') {
continue;
}
let mapped = is_regular_file_nofollow(&path) && path.extension().is_some_and(|e| e == ext);
if !mapped {
n += 1;
}
}
n
}
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 | ItemKind::Command | ItemKind::Workflow => {
path.to_path_buf()
}
ItemKind::Resource => path.to_path_buf(),
}
}
fn glob_paths(root: &Path, pattern: &str, kind: ItemKind) -> Result<Vec<PathBuf>> {
{
let pp = Path::new(pattern);
if pp.is_absolute() || pattern.starts_with('~') {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"discover glob '{}' is absolute; globs must be relative to the repo root",
crate::sanitize::strip_ansi(pattern)
),
});
}
use std::path::Component;
if pp.components().any(|c| c == Component::ParentDir) {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"discover glob '{}' contains '..'; globs must not escape the repo root",
crate::sanitize::strip_ansi(pattern)
),
});
}
}
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let joined = canonical_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 '{}': {e}",
crate::sanitize::strip_ansi(pattern)
),
})?;
let mut out = Vec::new();
for entry in paths {
match entry {
Ok(p) => {
let canonical = p.canonicalize().unwrap_or_else(|_| p.clone());
if !canonical.starts_with(&canonical_root) {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"discover glob '{}' matched '{}' which is outside the repo root",
crate::sanitize::strip_ansi(pattern),
crate::sanitize::strip_ansi(&p.display().to_string())
),
});
}
if kind == ItemKind::Workflow
&& !(is_regular_file_nofollow(&p) && p.extension().is_some_and(|e| e == "js"))
{
continue;
}
let item_name = if kind == ItemKind::Skill {
p.parent()
.and_then(|d| d.file_name())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
} else if kind == ItemKind::Resource {
file_name(&p)
} else {
p.file_stem()
.or_else(|| p.file_name())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
};
if !is_safe_item_name(&item_name) {
return Err(MindError::MindToml {
path: root.join("mind.toml"),
msg: format!(
"discover glob '{}' matched '{}' whose derived name '{}' is unsafe",
crate::sanitize::strip_ansi(pattern),
crate::sanitize::strip_ansi(&p.display().to_string()),
crate::sanitize::strip_ansi(&item_name)
),
});
}
let linked = is_symlink(&p)
|| (kind == ItemKind::Skill && p.parent().is_some_and(is_symlink));
if linked {
continue;
}
out.push(root.join(p.strip_prefix(&canonical_root).unwrap_or(&p)));
}
Err(e) => {
let path = e.path().to_path_buf();
return Err(MindError::io(path, e.into()));
}
}
}
out.sort();
Ok(out)
}
fn is_regular_file_nofollow(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file())
}
fn tool_anchor_ok(dir: &Path) -> bool {
match std::fs::symlink_metadata(dir.join("TOOL.md")) {
Ok(m) => m.file_type().is_file(),
Err(_) => true,
}
}
fn is_dir_nofollow(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_dir())
}
fn is_symlink(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
}
fn read_container_opt(dir: &Path) -> Result<Vec<PathBuf>> {
if is_symlink(dir) {
return Ok(Vec::new());
}
read_dir_opt(dir)
}
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;
#[test]
fn only_a_js_directly_under_workflows_is_a_workflow_link_path() {
for path in [
"workflows/deploy.js",
"pkg/workflows/deploy.js",
"workflows/DEPLOY.js",
] {
assert!(
is_workflow_link_path(path),
"'{path}' names a workflow file"
);
assert!(is_js_link_path(path), "'{path}' is also a JavaScript file");
}
for path in [
"lib/util.js",
"deploy.js",
"workflows/nested/deep.js",
"workflows/deploy.mjs",
"workflows/deploy.ts",
"workflows/deploy.js.md",
"workflows/deploy",
] {
assert!(
!is_workflow_link_path(path),
"'{path}' must not be called a workflow"
);
}
for path in ["lib/util.js", "deploy.js", "workflows/nested/deep.js"] {
assert!(
is_js_link_path(path),
"'{path}' is a JavaScript file, refused as one"
);
}
for path in ["skills/review/SKILL.md", "agents/dev.md", "workflows"] {
assert!(
!is_js_link_path(path),
"'{path}' is not a JavaScript file at all"
);
}
}
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,
as_alias: None,
pin: Pin::default(),
roots: None,
flat_skills: false,
add_roots: None,
item_path: None,
item_kind: None,
curated_by: None,
origin: None,
plugin_version: None,
install_hooks: Vec::new(),
install_hook: None,
install_hook_commit: None,
profiles: None,
profile_snapshot: 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 scan_source_reports_linked_source_gone_when_clone_dir_is_missing() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
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();
match err {
MindError::LinkedSourceGone {
ref source_name,
ref path,
} => {
assert_eq!(source_name, "local/test/repo");
assert!(
path.contains("repo"),
"path must name the vanished dir: {path}"
);
}
other => panic!("expected LinkedSourceGone, got {other:?}"),
}
}
#[test]
fn linked_source_gone_wins_over_the_less_specific_scan_errors() {
let tmp = TmpDir::new();
let base = tmp.path();
let paths = paths_for(base);
let clone = base.join("sources/local/test/repo");
let mut rooted = make_source_for(&clone);
rooted.roots = Some(vec!["packages/agents".to_string()]);
let mut items = Vec::new();
assert!(
matches!(
scan_source(&paths, &rooted, &mut items).unwrap_err(),
MindError::LinkedSourceGone { .. }
),
"a vanished working tree must not be reported as an InvalidRoot"
);
let plain = make_source_for(&clone);
let mut items = Vec::new();
assert!(
scan_source(&paths, &plain, &mut items).is_err(),
"a vanished working tree must not scan as a healthy source with no items"
);
assert!(items.is_empty());
}
#[test]
fn scan_degrades_past_a_gone_linked_source_and_keeps_the_rest() {
let tmp = TmpDir::new();
let base = tmp.path();
let paths = paths_for(base);
let clone_a = base.join("sources/local/a/repo");
write_file(
&clone_a.join("skills/alpha/SKILL.md"),
"---\ndescription: alpha\n---\n# alpha\n",
);
let mut source_a = make_source_for(&clone_a);
source_a.name = "local/a/repo".to_string();
source_a.owner = "a".to_string();
let clone_b = base.join("sources/local/b/repo");
let mut source_b = make_source_for(&clone_b);
source_b.name = "local/b/repo".to_string();
source_b.owner = "b".to_string();
let registry = Registry {
sources: vec![source_a, source_b],
};
let items = scan(&paths, ®istry).expect(
"scan() must degrade past the gone linked source, not hard-fail the whole call",
);
let names: Vec<_> = items.iter().map(|i| i.name.as_str()).collect();
assert_eq!(
names,
vec!["alpha"],
"the gone source must be skipped and the good source's items kept: {names:?}"
);
}
#[test]
fn scan_still_hard_fails_on_a_non_gone_scan_error() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(&clone.join("README.md"), "hi");
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.roots = Some(vec!["does-not-exist".to_string()]);
let registry = Registry {
sources: vec![source],
};
let err = scan(&paths, ®istry).unwrap_err();
assert!(
matches!(err, MindError::InvalidRoot { .. }),
"a non-LinkedSourceGone failure must still abort scan(): {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()),
when_to_use: None,
link_rel: None,
bin: None,
build: None,
requires: Vec::new(),
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
}
}
#[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,
when_to_use: None,
link_rel: None,
bin: None,
build: None,
requires: Vec::new(),
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
};
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", "caf\u{00e9}"] {
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_item_name_rejects_blocked_unicode() {
for bad in [
"revie\u{200B}w", "pay\u{202E}oot", "a\x01b", ] {
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,
update: None,
uninstall: None,
hooks: Vec::new(),
ignore: None,
};
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_bidi_name() {
let tmp = TmpDir::new();
let root = tmp.path();
let source = make_source_for(root);
let decl = ItemDecl {
kind: "rule".to_string(),
name: "revie\u{200B}w".to_string(),
path: "rules/x.md".to_string(),
link: None,
description: None,
bin: None,
build: None,
install: None,
update: None,
uninstall: None,
hooks: Vec::new(),
ignore: None,
};
let err = from_decl(root, &source, &None, &decl).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"a bidi/zero-width name must be a schema error: {err}"
);
}
#[test]
fn convention_scan_skips_item_with_unsafe_name_and_warns() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(&root.join("rules/good.md"), "---\n---\n# good\n");
write_file(&root.join("rules/b\u{200B}ad.md"), "---\n---\n# bad\n");
let paths = paths_for(root);
let source = make_source_for(root);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
assert_eq!(
items.iter().filter(|i| i.kind == ItemKind::Rule).count(),
1,
"only the safely-named item should surface: {items:?}"
);
assert!(items.iter().any(|i| i.name == "good"));
assert!(
!items.iter().any(|i| i.name.contains('\u{200B}')),
"the unsafely-named item must not be in the catalog"
);
}
#[test]
fn is_confined_link_target_requires_a_kind_directory() {
for ok in [
"skills/x",
"agents/x.md",
"rules/x.md",
"tools/x",
"./rules/x.md",
] {
assert!(
is_confined_link_target(ok, ItemKind::Rule),
"{ok:?} should be accepted"
);
}
for bad in ["settings.json", "hooks/x.json", ".claude/settings.json", ""] {
assert!(
!is_confined_link_target(bad, ItemKind::Rule),
"{bad:?} should be rejected"
);
}
}
#[test]
fn only_a_command_may_link_into_the_commands_directory() {
for kind in [
ItemKind::Skill,
ItemKind::Agent,
ItemKind::Rule,
ItemKind::Tool,
] {
assert!(
!is_confined_link_target("commands/deploy.md", kind),
"{kind:?} must not be able to link into commands/"
);
}
assert!(
is_confined_link_target("commands/deploy.md", ItemKind::Command),
"a command's own kind directory must still be allowed"
);
assert!(
is_confined_link_target("commands/frontend/component.md", ItemKind::Command),
"a command may still nest inside its own kind directory (CMD-2)"
);
assert!(is_confined_link_target(
"rules/deploy.md",
ItemKind::Command
));
}
#[test]
fn only_a_workflow_may_link_into_the_workflows_directory() {
for kind in [
ItemKind::Skill,
ItemKind::Agent,
ItemKind::Rule,
ItemKind::Command,
ItemKind::Tool,
] {
assert!(
!is_confined_link_target("workflows/deploy.js", kind),
"{kind:?} must not be able to link into workflows/"
);
}
assert!(
is_confined_link_target("workflows/deploy.js", ItemKind::Workflow),
"a workflow's own kind directory must be allowed"
);
assert!(is_confined_link_target(
"rules/deploy.md",
ItemKind::Workflow
));
assert!(!is_confined_link_target(
"commands/deploy.md",
ItemKind::Workflow
));
}
#[test]
fn a_workflow_takes_its_description_and_when_to_use_from_meta() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(
&root.join("workflows/review.js"),
"export const meta = {\n name: 'review-changes',\n \
description: 'Review changed files',\n whenToUse: 'before a PR',\n}\n",
);
write_file(
&root.join("workflows/nested/deep.js"),
"export const meta = {}",
);
write_file(&root.join("workflows/modern.mjs"), "export const meta = {}");
let paths = paths_for(root);
let source = make_source_for(root);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let wfs: Vec<_> = items
.iter()
.filter(|i| i.kind == ItemKind::Workflow)
.collect();
assert_eq!(wfs.len(), 1, "one flat `.js` workflow only: {items:?}");
let wf = wfs[0];
assert_eq!(wf.name, "review");
assert_eq!(wf.description.as_deref(), Some("Review changed files"));
assert_eq!(wf.when_to_use.as_deref(), Some("before a PR"));
assert_eq!(
wf.display_description().as_deref(),
Some("Review changed files - before a PR"),
"a display surface reads the pair as the harness renders it"
);
}
#[test]
fn a_declared_description_overrides_meta_and_leaves_when_to_use() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(
&root.join("workflows/review.js"),
"export const meta = {\n description: 'from meta',\n \
whenToUse: 'before a PR',\n}\n",
);
write_file(
&root.join("mind.toml"),
"[[items]]\nkind = \"workflow\"\nname = \"review\"\n\
path = \"workflows/review.js\"\ndescription = \"declared\"\n",
);
let paths = paths_for(root);
let source = make_source_for(root);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let wf = items
.iter()
.find(|i| i.kind == ItemKind::Workflow)
.expect("the declared workflow");
assert_eq!(wf.description.as_deref(), Some("declared"));
assert_eq!(wf.when_to_use.as_deref(), Some("before a PR"));
assert_eq!(
wf.display_description().as_deref(),
Some("declared - before a PR")
);
}
#[test]
fn an_unreadable_workflow_meta_lists_without_a_description() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(
&root.join("workflows/odd.js"),
"const meta = buildMeta()\nexport { meta }\n",
);
let paths = paths_for(root);
let source = make_source_for(root);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let wf = items
.iter()
.find(|i| i.kind == ItemKind::Workflow)
.expect("the workflow is still discovered");
assert_eq!(wf.name, "odd");
assert_eq!(wf.description, None);
assert_eq!(wf.when_to_use, None);
assert_eq!(wf.display_description(), None);
}
#[test]
fn from_decl_rejects_a_non_command_linking_into_commands() {
let tmp = TmpDir::new();
let root = tmp.path();
let source = make_source_for(root);
let decl = ItemDecl {
kind: "rule".to_string(),
name: "style".to_string(),
path: "rules/style.md".to_string(),
link: Some("commands/deploy.md".to_string()),
description: None,
bin: None,
build: None,
install: None,
update: None,
uninstall: None,
hooks: Vec::new(),
ignore: None,
};
let err = from_decl(root, &source, &None, &decl).unwrap_err();
match &err {
MindError::MindToml { msg, .. } => assert!(
msg.contains("commands/"),
"the message must name the directory it refused: {msg}"
),
other => panic!("expected a MindToml schema error, got: {other:?}"),
}
let decl = ItemDecl {
kind: "command".to_string(),
path: "commands/deploy.md".to_string(),
..decl
};
assert!(
from_decl(root, &source, &None, &decl).is_ok(),
"a command linking into commands/ must still be accepted"
);
}
#[test]
fn from_decl_rejects_link_outside_kind_directory() {
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("settings.json".to_string()),
description: None,
bin: None,
build: None,
install: None,
update: None,
uninstall: None,
hooks: Vec::new(),
ignore: None,
};
let err = from_decl(root, &source, &None, &decl).unwrap_err();
assert!(
matches!(err, MindError::MindToml { .. }),
"a link outside a kind directory 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,
update: None,
uninstall: None,
hooks: Vec::new(),
ignore: None,
};
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,
update: None,
uninstall: None,
hooks: Vec::new(),
ignore: None,
};
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 expand_field_parsed_and_split_from_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\nexpand: resources/pr.py bin/run.sh \n---\n# review\n",
);
write_file(
&clone.join("skills/plain/SKILL.md"),
"---\ndescription: plain\n---\n# plain\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 review = items.iter().find(|i| i.name == "review").unwrap();
assert_eq!(
review.expand,
vec!["resources/pr.py".to_string(), "bin/run.sh".to_string()],
"expand must be whitespace-split from the frontmatter scalar"
);
let plain = items.iter().find(|i| i.name == "plain").unwrap();
assert!(
plain.expand.is_empty(),
"absent expand 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,
update: None,
uninstall: None,
hooks: Vec::new(),
ignore: None,
};
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,
update: None,
uninstall: None,
hooks: Vec::new(),
ignore: None,
};
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,
update: None,
uninstall: None,
hooks: Vec::new(),
ignore: None,
};
let item = from_decl(root, &source, &None, &decl)
.unwrap()
.expect("a well-formed declaration is not dropped");
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,
when_to_use: None,
link_rel: None,
bin: None,
build: None,
requires: Vec::new(),
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
}
}
#[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_falls_back_when_frontmatter_is_oversized() {
use std::io::Write as _;
let dir = TmpDir::new();
let p = dir.path().join("agents/coder.md");
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
let mut file = std::fs::File::create(&p).unwrap();
file.write_all(b"---\nname: other\n---\n").unwrap();
file.set_len(crate::error::METADATA_SIZE_LIMIT + 1).unwrap();
drop(file);
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);
}
#[test]
fn the_reserved_prefix_warning_is_owed_once_per_source() {
let a = format!("local/test/dsc112-a-{}", std::process::id());
let b = format!("local/test/dsc112-b-{}", std::process::id());
assert!(first_reserved_warning(&a), "first sighting warns");
assert!(!first_reserved_warning(&a), "a repeat sighting does not");
assert!(first_reserved_warning(&b), "another source still warns");
assert!(!first_reserved_warning(&b));
}
#[test]
fn only_a_reserved_prefix_consumes_the_reserved_warning() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(&root.join("rules/style.md"), "---\ndescription: d\n---\n");
let mut unsafe_src = make_source_for(root);
unsafe_src.name = format!("local/test/dsc112-unsafe-{}", std::process::id());
unsafe_src.alias = Some("a:b".to_string());
let mut out = Vec::new();
scan_source_at(root, &unsafe_src, &mut out).unwrap();
assert!(
first_reserved_warning(&unsafe_src.name),
"an unsafe (non-reserved) prefix must not fire the reserved-word warning"
);
let mut reserved_src = make_source_for(root);
reserved_src.name = format!("local/test/dsc112-reserved-{}", std::process::id());
reserved_src.alias = Some("workflow".to_string());
let mut out = Vec::new();
scan_source_at(root, &reserved_src, &mut out).unwrap();
assert!(
!first_reserved_warning(&reserved_src.name),
"a reserved prefix must have fired the warning during the scan"
);
}
#[cfg(unix)]
#[test]
fn workflow_glob_matches_only_regular_js_files() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(&root.join("workflows/ok.js"), "export const meta = {}\n");
write_file(&root.join("workflows/README.md"), "# readme\n");
write_file(&root.join("workflows/dir.js/inner.txt"), "x\n");
std::os::unix::fs::symlink(root.join("workflows/ok.js"), root.join("workflows/link.js"))
.unwrap();
let matches = glob_paths(root, "workflows/*", ItemKind::Workflow).unwrap();
let names: Vec<String> = matches.iter().map(|p| file_name(p)).collect();
assert_eq!(names, vec!["ok.js".to_string()], "got {names:?}");
let md = glob_paths(root, "workflows/*", ItemKind::Rule).unwrap();
assert!(md.len() > 1, "non-workflow globs keep matching: {md:?}");
}
#[cfg(unix)]
#[test]
fn item_link_skill_requires_a_regular_anchor() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(
&root.join("skills/good/SKILL.md"),
"---\ndescription: g\n---\n",
);
write_file(
&root.join("elsewhere/real.md"),
"---\ndescription: SECRET\n---\n",
);
std::fs::create_dir_all(root.join("skills/linked")).unwrap();
std::os::unix::fs::symlink(
root.join("elsewhere/real.md"),
root.join("skills/linked/SKILL.md"),
)
.unwrap();
std::os::unix::fs::symlink(root.join("skills/good"), root.join("skills/dirlink")).unwrap();
let scan = |item_path: &str| {
let mut src = make_source_for(root);
src.item_path = Some(item_path.to_string());
let mut out = Vec::new();
scan_source_at(root, &src, &mut out).map(|_| out)
};
let ok = scan("skills/good").unwrap();
assert_eq!(ok.len(), 1, "the regular skill is still linkable");
assert!(matches!(
scan("skills/linked"),
Err(MindError::LinkNotASkill { .. })
));
assert!(matches!(
scan("skills/dirlink"),
Err(MindError::LinkNotASkill { .. })
));
}
#[cfg(unix)]
#[test]
fn a_tool_with_a_symlinked_anchor_is_not_offered() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(
&root.join("elsewhere/real.md"),
"---\ndescription: SECRET\n---\n",
);
write_file(&root.join("tools/bare/run.sh"), "#!/bin/sh\n");
write_file(
&root.join("tools/plain/TOOL.md"),
"---\ndescription: p\n---\n",
);
write_file(&root.join("tools/linked/run.sh"), "#!/bin/sh\n");
std::os::unix::fs::symlink(
root.join("elsewhere/real.md"),
root.join("tools/linked/TOOL.md"),
)
.unwrap();
let names = |out: &Vec<CatalogItem>| {
let mut v: Vec<String> = out.iter().map(|i| i.name.clone()).collect();
v.sort();
v
};
let src = make_source_for(root);
let mut out = Vec::new();
scan_source_at(root, &src, &mut out).unwrap();
assert_eq!(names(&out), vec!["bare", "plain"], "convention scan");
write_file(
&root.join("mind.toml"),
"[discover]\ntools = { include = [\"tools/*\"] }\n",
);
let mut out = Vec::new();
scan_source_at(root, &src, &mut out).unwrap();
assert_eq!(names(&out), vec!["bare", "plain"], "glob scan");
assert!(
out.iter()
.all(|i| i.description.as_deref() != Some("SECRET"))
);
}
#[test]
fn a_blank_workflow_description_and_when_to_use_are_none() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(
&root.join("workflows/blank.js"),
"export const meta = { name: 'blank', description: ' ', whenToUse: '' }\n",
);
write_file(
&root.join("workflows/full.js"),
"export const meta = { name: 'full', description: 'd', whenToUse: 'w' }\n",
);
let src = make_source_for(root);
let mut out = Vec::new();
scan_source_at(root, &src, &mut out).unwrap();
let blank = out.iter().find(|i| i.name == "blank").unwrap();
assert_eq!(blank.description, None);
assert_eq!(blank.when_to_use, None);
let full = out.iter().find(|i| i.name == "full").unwrap();
assert_eq!(full.description.as_deref(), Some("d"));
assert_eq!(full.when_to_use.as_deref(), Some("w"));
}
fn bare_decl(kind: &str, name: &str, path: &str, link: Option<&str>) -> ItemDecl {
ItemDecl {
kind: kind.to_string(),
name: name.to_string(),
path: path.to_string(),
link: link.map(str::to_string),
description: None,
bin: None,
build: None,
install: None,
update: None,
uninstall: None,
hooks: Vec::new(),
ignore: None,
}
}
#[test]
fn from_decl_link_refusal_names_the_workflow_only_rule() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(&root.join("rules/style.md"), "---\ndescription: d\n---\n");
let source = make_source_for(root);
let decl = bare_decl(
"rule",
"style",
"rules/style.md",
Some("workflows/deploy.js"),
);
match from_decl(root, &source, &None, &decl) {
Err(MindError::MindToml { msg, .. }) => {
assert!(
msg.contains("only a workflow into 'workflows/'"),
"the message must state the workflow-only rule: {msg}"
);
assert!(
msg.contains("'commands/', 'workflows/', or 'tools/'"),
"the message must list every kind directory: {msg}"
);
}
other => panic!("expected a MindToml schema error, got: {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn from_decl_drops_a_declared_fifo_without_opening_it() {
let tmp = TmpDir::new();
let root = tmp.path().to_path_buf();
std::fs::create_dir_all(root.join("workflows")).unwrap();
let fifo = root.join("workflows/pipe.js");
let made = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.is_ok_and(|s| s.success());
if !made {
return;
}
let source = make_source_for(&root);
let decl = bare_decl("workflow", "pipe", "workflows/pipe.js", None);
let (tx, rx) = std::sync::mpsc::channel();
let thread_root = root.clone();
std::thread::spawn(move || {
let r = from_decl(&thread_root, &source, &None, &decl).map(|o| o.is_none());
let _ = tx.send(r);
});
let got = rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("from_decl must not hang on a fifo");
assert!(
matches!(got, Ok(true)),
"a declared fifo must be dropped (Ok(None)): {got:?}"
);
let _ = std::fs::remove_file(&fifo);
}
#[cfg(unix)]
#[test]
fn discover_match_path_keeps_the_non_canonical_root() {
let tmp = TmpDir::new();
let real = tmp.path().join("real");
write_file(
&real.join("agents/coder.md"),
"---\ndescription: coder\n---\n",
);
write_file(
&real.join("skills/review/SKILL.md"),
"---\ndescription: review\n---\n",
);
write_file(
&real.join("mind.toml"),
"[discover]\nagents = { include = [\"agents/*.md\"] }\n\
skills = { include = [\"skills/*/SKILL.md\"] }\n",
);
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let source = make_source_for(&link);
let mut items = Vec::new();
scan_source_at(&link, &source, &mut items).unwrap();
assert_eq!(items.len(), 2, "both matches are offered: {items:?}");
for it in &items {
assert!(
it.path.starts_with(&link),
"the item path must be under the non-canonical root {}: {}",
link.display(),
it.path.display()
);
}
}
#[cfg(unix)]
#[test]
fn item_link_path_keeps_the_non_canonical_root() {
let tmp = TmpDir::new();
let real = tmp.path().join("real");
write_file(
&real.join("skills/review/SKILL.md"),
"---\ndescription: review\n---\n",
);
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let mut source = make_source_for(&link);
source.item_path = Some("skills/review".to_string());
let mut items = Vec::new();
scan_source_at(&link, &source, &mut items).unwrap();
assert_eq!(items.len(), 1, "the linked skill is offered: {items:?}");
assert_eq!(items[0].path, link.join("skills/review"));
}
#[test]
fn glob_paths_rejects_absolute_pattern() {
let tmp = TmpDir::new();
let root = tmp.path();
let err = glob_paths(root, "/etc/passwd", ItemKind::Agent).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("absolute"),
"error must mention 'absolute': {msg}"
);
}
#[test]
fn glob_paths_rejects_parent_dir_in_pattern() {
let tmp = TmpDir::new();
let root = tmp.path();
let err = glob_paths(root, "../outside/*.md", ItemKind::Agent).unwrap_err();
let msg = err.to_string();
assert!(msg.contains(".."), "error must mention '..': {msg}");
}
#[test]
fn glob_paths_accepts_normal_relative_pattern() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(
&root.join("agents/coder.md"),
"---\ndescription: coder\n---\n",
);
let matches = glob_paths(root, "agents/*.md", ItemKind::Agent).unwrap();
assert_eq!(matches.len(), 1, "should find one match: {matches:?}");
assert!(
matches[0].ends_with("coder.md"),
"matched the wrong file: {matches:?}"
);
}
#[test]
fn glob_paths_skill_rejects_hostile_parent_dir_name() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(
&root.join("packages").join("evil\\skill").join("SKILL.md"),
"---\ndescription: hostile\n---\n",
);
let err = glob_paths(root, "packages/*/SKILL.md", ItemKind::Skill).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("unsafe"),
"error must mention 'unsafe' for hostile parent dir name: {msg}"
);
}
#[test]
fn glob_paths_skill_accepts_normal_parent_dir_name() {
let tmp = TmpDir::new();
let root = tmp.path();
write_file(
&root.join("packages/good-skill/SKILL.md"),
"---\ndescription: fine\n---\n",
);
let matches = glob_paths(root, "packages/*/SKILL.md", ItemKind::Skill).unwrap();
assert_eq!(matches.len(), 1, "should find one match: {matches:?}");
assert!(
matches[0].ends_with("SKILL.md"),
"matched the wrong file: {matches:?}"
);
}
#[test]
fn overlapping_add_roots_same_canonical_item_dedupes_instead_of_erroring() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("skills/foo/SKILL.md"),
"---\ndescription: foo\n---\n# foo\n",
);
write_file(
&clone.join("guidelines/style.md"),
"---\ndescription: style\n---\n# style\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.add_roots = Some(vec![".".to_string(), "skills".to_string()]);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let foos: Vec<_> = items
.iter()
.filter(|i| i.kind == ItemKind::Skill && i.name == "foo")
.collect();
assert_eq!(
foos.len(),
1,
"overlapping add-roots surfacing the same on-disk item must dedupe to one entry: {items:?}"
);
}
#[test]
fn add_roots_same_path_different_kind_surfaces_both_items() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("tools/foo/SKILL.md"),
"---\ndescription: foo\n---\n# foo\n",
);
write_file(
&clone.join("guidelines/style.md"),
"---\ndescription: style\n---\n# style\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.add_roots = Some(vec![".".to_string(), "tools".to_string()]);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let foo_tool = items
.iter()
.any(|i| i.kind == ItemKind::Tool && i.name == "foo");
let foo_skill = items
.iter()
.any(|i| i.kind == ItemKind::Skill && i.name == "foo");
assert!(
foo_tool,
"the tools/ pass must still surface foo as a Tool: {items:?}"
);
assert!(
foo_skill,
"the flat-skills pass over the `tools` add-root must still surface \
foo as a Skill, alongside the Tool at the same path: {items:?}"
);
}
#[test]
fn distinct_path_name_collision_between_add_root_items_still_errors() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("foo/SKILL.md"),
"---\ndescription: foo at root\n---\n# foo\n",
);
write_file(
&clone.join("nested/foo/SKILL.md"),
"---\ndescription: foo nested\n---\n# foo\n",
);
write_file(
&clone.join("guidelines/style.md"),
"---\ndescription: style\n---\n# style\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.add_roots = Some(vec![".".to_string(), "nested".to_string()]);
let mut items = Vec::new();
let err = scan_source(&paths, &source, &mut items).unwrap_err();
assert!(
matches!(err, MindError::DuplicateItem { .. }),
"a distinct-path name collision must still error as DuplicateItem: {err}"
);
}
#[test]
fn overlapping_add_roots_dedup_keeps_first_occurrence_with_correct_metadata() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("skills/foo/SKILL.md"),
"---\ndescription: the real foo\n---\n# foo\n",
);
write_file(
&clone.join("guidelines/style.md"),
"---\ndescription: style\n---\n# style\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.add_roots = Some(vec![".".to_string(), "skills".to_string()]);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let foos: Vec<_> = items
.iter()
.filter(|i| i.kind == ItemKind::Skill && i.name == "foo")
.collect();
assert_eq!(
foos.len(),
1,
"expected exactly one deduped item: {items:?}"
);
let kept = foos[0];
assert_eq!(kept.kind, ItemKind::Skill);
assert_eq!(kept.name, "foo");
let canon_kept = kept.path.canonicalize().unwrap();
let canon_expected = clone.join("skills/foo").canonicalize().unwrap();
assert_eq!(
canon_kept, canon_expected,
"the kept item's on-disk path must be the real skills/foo directory: {kept:?}"
);
assert_eq!(
kept.description.as_deref(),
Some("the real foo"),
"the kept item must carry the real item's frontmatter description, not a \
corrupted/empty stand-in: {kept:?}"
);
}
#[test]
fn three_add_roots_overlapping_same_canonical_path_dedup_to_one() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("skills/foo/SKILL.md"),
"---\ndescription: foo\n---\n# foo\n",
);
write_file(
&clone.join("guidelines/style.md"),
"---\ndescription: style\n---\n# style\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.add_roots = Some(vec![
".".to_string(),
"skills".to_string(),
"./skills".to_string(),
]);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let foos: Vec<_> = items
.iter()
.filter(|i| i.kind == ItemKind::Skill && i.name == "foo")
.collect();
assert_eq!(
foos.len(),
1,
"three add-roots overlapping on the same canonical path must dedupe to \
exactly one entry: {items:?}"
);
}
#[test]
fn symlinked_add_root_resolving_to_same_canonical_path_dedups() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("skills/foo/SKILL.md"),
"---\ndescription: foo\n---\n# foo\n",
);
write_file(
&clone.join("guidelines/style.md"),
"---\ndescription: style\n---\n# style\n",
);
write_file(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"rule\"\n",
"name = \"style\"\n",
"path = \"guidelines/style.md\"\n",
),
);
std::os::unix::fs::symlink(clone.join("skills"), clone.join("mirror")).unwrap();
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.add_roots = Some(vec!["skills".to_string(), "mirror".to_string()]);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let foos: Vec<_> = items
.iter()
.filter(|i| i.kind == ItemKind::Skill && i.name == "foo")
.collect();
assert_eq!(
foos.len(),
1,
"a symlinked add-root resolving to the same canonical path as another \
add-root must dedupe to one entry, not error or double-count: {items:?}"
);
}
#[test]
fn add_root_item_colliding_with_authoritative_declared_item_at_distinct_path_errors() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("declared/foo/SKILL.md"),
"---\ndescription: declared foo\n---\n# foo\n",
);
write_file(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"foo\"\n",
"path = \"declared/foo\"\n",
),
);
write_file(
&clone.join("extra/skills/foo/SKILL.md"),
"---\ndescription: extra foo\n---\n# foo\n",
);
let paths = paths_for(base);
let mut source = make_source_for(&clone);
source.add_roots = Some(vec!["extra".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 == "foo"),
"an add-root item colliding by name with an authoritative [[items]] \
declaration at a distinct path must still error as DuplicateItem: {err}"
);
}
#[test]
fn missing_convention_directories_yield_no_items_not_an_error() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
std::fs::create_dir_all(&clone).unwrap();
write_file(&clone.join("README.md"), "# empty repo\n");
let paths = paths_for(base);
let source = make_source_for(&clone);
let mut items = Vec::new();
let result = scan_source(&paths, &source, &mut items);
assert!(
result.is_ok(),
"scan_source must not error when convention directories are absent (DSC-13): {result:?}"
);
assert!(
items.is_empty(),
"no items must be discovered when convention directories are absent (DSC-13): {items:?}"
);
}
#[test]
fn missing_individual_convention_directories_yield_no_items_for_that_kind() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/repo");
write_file(
&clone.join("agents/helper.md"),
"---\ndescription: helper\n---\n# helper\n",
);
let paths = paths_for(base);
let source = make_source_for(&clone);
let mut items = Vec::new();
let result = scan_source(&paths, &source, &mut items);
assert!(
result.is_ok(),
"scan_source must not error when only some convention directories exist (DSC-13): {result:?}"
);
let kinds: Vec<_> = items.iter().map(|i| i.kind).collect();
assert_eq!(
kinds,
vec![ItemKind::Agent],
"only the agent from the present agents/ dir must be discovered; \
missing skill/rule/tool dirs must not cause an error (DSC-13): {items:?}"
);
}
}
#[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,
as_alias: None,
pin: Pin::default(),
roots: None,
flat_skills: false,
add_roots: None,
item_path: None,
item_kind: None,
curated_by: None,
origin: None,
plugin_version: None,
install_hooks: Vec::new(),
install_hook: None,
install_hook_commit: None,
profiles: None,
profile_snapshot: 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::Manifest { .. }),
"malformed plugin.json must propagate as a Manifest error (MKT-9): {err:?}"
);
}
#[test]
fn plugin_skipped_components_counts_unsupported_dirs() {
let tmp = TmpDir::new();
let plugin_root = tmp.path().join("my-plugin");
write_file(
&plugin_root.join("commands/review.md"),
"---\ndescription: review\n---\n",
);
std::fs::create_dir_all(plugin_root.join("hooks")).unwrap();
std::fs::write(plugin_root.join(".mcp.json"), "{}").unwrap();
let skipped = plugin_skipped_components(&plugin_root);
assert!(skipped.hooks > 0, "hooks/ dir must be counted as skipped");
assert!(
skipped.mcp_servers > 0,
".mcp.json must be counted as skipped"
);
assert_eq!(
skipped.total(),
2,
"a mapped commands/<name>.md must NOT be counted as skipped: {skipped:?}"
);
assert!(
!skipped.summary().unwrap().contains("command"),
"the skipped summary must not name commands: {skipped:?}"
);
}
#[test]
fn plugin_skipped_components_counts_nested_and_non_md_commands() {
let tmp = TmpDir::new();
let plugin_root = tmp.path().join("my-plugin");
write_file(
&plugin_root.join("commands/review.md"),
"---\ndescription: review\n---\n",
);
write_file(
&plugin_root.join("commands/frontend/component.md"),
"---\ndescription: component\n---\n",
);
write_file(&plugin_root.join("commands/do.sh"), "#!/bin/sh\n");
write_file(&plugin_root.join("commands/.gitkeep"), "");
let skipped = plugin_skipped_components(&plugin_root);
assert_eq!(
skipped.commands, 2,
"the nested dir and the .sh must both count; the .md and the \
dotfile must not: {skipped:?}"
);
assert_eq!(
skipped.total(),
2,
"nothing else is present to count: {skipped:?}"
);
let summary = skipped.summary().expect("a drop must be reported");
assert!(
summary.contains("2 unmapped commands/ entries"),
"the summary must name what was left behind: {summary}"
);
}
#[test]
fn plugin_skipped_components_counts_nested_and_non_js_workflows() {
let tmp = TmpDir::new();
let plugin_root = tmp.path().join("my-plugin");
write_file(
&plugin_root.join("workflows/deploy.js"),
"export const meta = { name: 'deploy', description: 'd' }\n",
);
write_file(&plugin_root.join("workflows/nested/deep.js"), "");
write_file(&plugin_root.join("workflows/deploy.ts"), "");
write_file(&plugin_root.join("workflows/.gitkeep"), "");
let skipped = plugin_skipped_components(&plugin_root);
assert_eq!(
skipped.workflows, 2,
"the nested dir and the .ts must both count; the .js and the \
dotfile must not: {skipped:?}"
);
assert_eq!(skipped.total(), 2, "nothing else is present: {skipped:?}");
let summary = skipped.summary().expect("a drop must be reported");
assert!(
summary.contains("2 unmapped workflows/ entries"),
"the summary must name what was left behind: {summary}"
);
}
#[cfg(unix)]
#[test]
fn plugin_skipped_components_counts_a_symlinked_workflow_as_unmapped() {
let tmp = TmpDir::new();
let base = tmp.path();
let plugin_root = base.join("my-plugin");
write_file(
&base.join("outside/private.js"),
"export const meta = { name: 'private', description: 'd' }\n",
);
std::fs::create_dir_all(plugin_root.join("workflows")).unwrap();
std::os::unix::fs::symlink(
base.join("outside/private.js"),
plugin_root.join("workflows/leak.js"),
)
.unwrap();
let skipped = plugin_skipped_components(&plugin_root);
assert_eq!(
skipped.workflows, 1,
"a symlinked workflow entry is not installed, so it must be reported \
as an unmapped entry rather than counted as mapped: {skipped:?}"
);
let source = make_plugin_source(&plugin_root);
let mut items = Vec::new();
scan_plugin_components(&plugin_root, &source, &None, &mut items).unwrap();
assert!(
items.is_empty(),
"the symlinked entry must not be discovered: {items:?}"
);
}
#[cfg(unix)]
#[test]
fn plugin_skipped_components_counts_a_symlinked_workflows_dir_once() {
let tmp = TmpDir::new();
let base = tmp.path();
let plugin_root = base.join("my-plugin");
write_file(
&base.join("outside/a.js"),
"export const meta = { name: 'a', description: 'd' }\n",
);
write_file(
&base.join("outside/b.js"),
"export const meta = { name: 'b', description: 'd' }\n",
);
std::fs::create_dir_all(&plugin_root).unwrap();
std::os::unix::fs::symlink(base.join("outside"), plugin_root.join("workflows")).unwrap();
let skipped = plugin_skipped_components(&plugin_root);
assert_eq!(
skipped.workflows, 1,
"a symlinked workflows/ folder is one unmapped entry, not a listing of \
the directory it points at: {skipped:?}"
);
let source = make_plugin_source(&plugin_root);
let mut items = Vec::new();
scan_plugin_components(&plugin_root, &source, &None, &mut items).unwrap();
assert!(
!items.iter().any(|i| i.kind == ItemKind::Workflow),
"no workflow may be discovered through a symlinked folder: {items:?}"
);
}
#[cfg(unix)]
#[test]
fn plugin_component_scan_classifies_symlinks_no_follow() {
let tmp = TmpDir::new();
let base = tmp.path();
let plugin_root = base.join("my-plugin");
write_file(
&base.join("outside/realskill/SKILL.md"),
"---\ndescription: outside skill\n---\n",
);
write_file(
&base.join("outside/agent.md"),
"---\ndescription: outside agent\n---\n",
);
write_file(
&base.join("outside/anchor.md"),
"---\ndescription: outside anchor\n---\n",
);
std::fs::create_dir_all(plugin_root.join("skills/anchored")).unwrap();
std::fs::create_dir_all(plugin_root.join("agents")).unwrap();
std::os::unix::fs::symlink(
base.join("outside/realskill"),
plugin_root.join("skills/linked-dir"),
)
.unwrap();
std::os::unix::fs::symlink(
base.join("outside/anchor.md"),
plugin_root.join("skills/anchored/SKILL.md"),
)
.unwrap();
std::os::unix::fs::symlink(
base.join("outside/agent.md"),
plugin_root.join("agents/linked.md"),
)
.unwrap();
write_file(
&plugin_root.join("skills/real/SKILL.md"),
"---\ndescription: the plugin's own skill\n---\n",
);
let source = make_plugin_source(&plugin_root);
let mut items = Vec::new();
scan_plugin_components(&plugin_root, &source, &None, &mut items).unwrap();
let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
assert_eq!(
names,
vec!["real"],
"only the plugin's own non-symlinked component may be discovered: {items:?}"
);
assert!(
!items
.iter()
.any(|i| i.description.as_deref().unwrap_or("").contains("outside")),
"no linked-to file's description may reach the catalog: {items:?}"
);
}
#[cfg(unix)]
#[test]
fn marketplace_in_repo_scan_classifies_symlinks_no_follow() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&base.join("outside/realskill/SKILL.md"),
"---\ndescription: outside skill\n---\n",
);
std::fs::create_dir_all(clone.join("plug/skills")).unwrap();
std::os::unix::fs::symlink(
base.join("outside/realskill"),
clone.join("plug/skills/listed"),
)
.unwrap();
write_file(
&clone.join(".claude-plugin/marketplace.json"),
r#"{
"name": "Market",
"plugins": [
{"name": "listedplug", "source": "./plug", "skills": ["./skills/listed"]},
{"name": "scannedplug", "source": "./plug"}
]
}"#,
);
let paths = paths_for(base);
let source = make_plugin_source(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).expect("the scan must not fail");
assert!(
items.is_empty(),
"neither marketplace branch may discover a symlinked skill dir: {items:?}"
);
}
#[test]
fn plugin_with_no_commands_dir_reports_nothing_skipped() {
let tmp = TmpDir::new();
let plugin_root = tmp.path().join("my-plugin");
write_file(
&plugin_root.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n",
);
let skipped = plugin_skipped_components(&plugin_root);
assert_eq!(skipped.commands, 0, "no commands/ dir -> nothing unmapped");
assert!(
skipped.summary().is_none(),
"a plugin with only supported components reports nothing: {skipped:?}"
);
}
#[test]
fn marketplace_in_repo_with_skills_array_scans_explicit_skill_dirs() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/marketplace.json"),
r#"{
"name": "Acme Market",
"plugins": [
{"name": "p1", "source": "./", "skills": ["./skills/foo"]},
{"name": "p2", "source": "./", "skills": ["./skills/bar"]}
]
}"#,
);
write_file(
&clone.join("skills/foo/SKILL.md"),
"---\ndescription: foo skill\n---\n# foo\n",
);
write_file(
&clone.join("skills/bar/SKILL.md"),
"---\ndescription: bar skill\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 foo = items
.iter()
.find(|i| i.kind == ItemKind::Skill && i.name == "foo")
.expect("skill 'foo' must be discovered from marketplace entry p1");
assert_eq!(
foo.effective_name(),
"p1:foo",
"foo must carry entry name 'p1' as prefix (MKT-14)"
);
let bar = items
.iter()
.find(|i| i.kind == ItemKind::Skill && i.name == "bar")
.expect("skill 'bar' must be discovered from marketplace entry p2");
assert_eq!(
bar.effective_name(),
"p2:bar",
"bar must carry entry name 'p2' as prefix (MKT-14)"
);
}
#[test]
fn marketplace_in_repo_without_skills_array_falls_back_to_convention_scan() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/marketplace.json"),
r#"{
"name": "Acme Market",
"plugins": [
{"name": "p1", "source": "./"}
]
}"#,
);
write_file(
&clone.join("skills/baz/SKILL.md"),
"---\ndescription: baz skill\n---\n# baz\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 baz = items
.iter()
.find(|i| i.kind == ItemKind::Skill && i.name == "baz")
.expect(
"skill 'baz' must be discovered via conventional scan when skills array is absent",
);
assert_eq!(
baz.effective_name(),
"p1:baz",
"baz must carry entry name 'p1' as prefix (MKT-14)"
);
}
#[test]
fn marketplace_in_repo_skills_array_does_not_double_count() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/marketplace.json"),
r#"{
"name": "Acme Market",
"plugins": [
{"name": "p1", "source": "./", "skills": ["./skills/foo"]}
]
}"#,
);
write_file(
&clone.join("skills/foo/SKILL.md"),
"---\ndescription: foo skill\n---\n# foo\n",
);
write_file(
&clone.join("skills/qux/SKILL.md"),
"---\ndescription: qux skill\n---\n# qux\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"),
"skill 'foo' must be discovered (it is in the skills array)"
);
assert!(
!items
.iter()
.any(|i| i.kind == ItemKind::Skill && i.name == "qux"),
"skill 'qux' must NOT be discovered when it is absent from the skills array (MKT-14)"
);
}
#[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"
);
}
#[test]
fn marketplace_symlink_outside_clone_is_skipped() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
let outside = base.join("outside-clone");
write_file(
&outside.join("skills/secret/SKILL.md"),
"---\ndescription: secret\n---\n",
);
std::fs::create_dir_all(clone.join(".claude-plugin")).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, clone.join("external")).unwrap();
#[cfg(not(unix))]
{
return;
}
write_file(
&clone.join(".claude-plugin/marketplace.json"),
r#"{
"name": "Acme Market",
"plugins": [
{"name": "escape", "source": "external"}
]
}"#,
);
let paths = paths_for(base);
let source = make_plugin_source(&clone);
let mut items = Vec::new();
scan_source(&paths, &source, &mut items)
.expect("path-traversal via symlink must be skipped, not errored");
assert!(
!items.iter().any(|i| i.name == "secret"),
"skill behind a symlink escaping the clone must NOT be discovered (MKT-14, MKT-9)"
);
}
#[test]
fn marketplace_no_override_uses_entry_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/marketplace.json"),
r#"{
"name": "Market",
"plugins": [
{"name": "myplugin", "source": "./", "skills": ["./skills/alpha"]}
]
}"#,
);
write_file(
&clone.join("skills/alpha/SKILL.md"),
"---\ndescription: alpha\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 == "alpha")
.expect("skill 'alpha' must be discovered");
assert_eq!(
skill.effective_name(),
"myplugin:alpha",
"entry name must be the default prefix when no consumer override is set (MKT-8)"
);
}
#[test]
fn marketplace_outer_prefix_combines_with_entry_name() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/marketplace.json"),
r#"{
"name": "Market",
"plugins": [
{"name": "myplugin", "source": "./", "skills": ["./skills/beta"]}
]
}"#,
);
write_file(
&clone.join("skills/beta/SKILL.md"),
"---\ndescription: beta\n---\n",
);
let paths = paths_for(base);
let mut source = make_plugin_source(&clone);
source.alias = Some("outer".to_string());
let mut items = Vec::new();
scan_source(&paths, &source, &mut items).unwrap();
let skill = items
.iter()
.find(|i| i.kind == ItemKind::Skill && i.name == "beta")
.expect("skill 'beta' must be discovered");
assert_eq!(
skill.effective_name(),
"outer:myplugin:beta",
"outer prefix and entry name must combine (MKT-13): outer:entry:item"
);
}
#[test]
fn marketplace_cleared_outer_prefix_keeps_entry_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/marketplace.json"),
r#"{
"name": "Market",
"plugins": [
{"name": "myplugin", "source": "./", "skills": ["./skills/gamma"]}
]
}"#,
);
write_file(
&clone.join("skills/gamma/SKILL.md"),
"---\ndescription: gamma\n---\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.kind == ItemKind::Skill && i.name == "gamma")
.expect("skill 'gamma' must be discovered");
assert_eq!(
skill.effective_name(),
"myplugin:gamma",
"cleared outer prefix must leave per-plugin entry-name prefix intact (MKT-13)"
);
}
#[test]
fn marketplace_entry_name_with_ansi_produces_clean_prefix() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/marketplace.json"),
"{\"name\":\"Market\",\"plugins\":[{\"name\":\"\\u001b[1mplugin\\u001b[0m\",\"source\":\"./\",\"skills\":[\"./skills/delta\"]}]}",
);
write_file(
&clone.join("skills/delta/SKILL.md"),
"---\ndescription: delta\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 == "delta")
.expect("skill 'delta' must be discovered");
assert_eq!(
skill.effective_name(),
"plugin:delta",
"ANSI sequences in entry.name must be stripped before use as prefix (MKT-14)"
);
assert!(
!skill.prefix.as_deref().unwrap_or("").contains('\x1b'),
"prefix must not contain raw ANSI escape (\\x1b) after stripping"
);
assert_eq!(
skill.prefix.as_deref(),
Some("plugin"),
"prefix must be 'plugin' after stripping ANSI escapes"
);
}
#[test]
fn marketplace_in_repo_entry_discovers_agents() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/marketplace.json"),
r#"{
"name": "Acme Market",
"plugins": [
{"name": "acme", "source": "./"}
]
}"#,
);
write_file(
&clone.join("agents/myagent.md"),
"---\ndescription: my agent\n---\n# myagent\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 == "myagent")
.expect("agent 'myagent' must be discovered from marketplace in-repo entry");
assert_eq!(
agent.effective_name(),
"acme:myagent",
"agent must be namespaced under its marketplace entry name (MKT-14)"
);
}
#[test]
fn mkt15_mind_toml_roots_suppress_plugin_manifest_own_items() {
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"),
"{\n \"name\": \"acme-tools\",\n \"version\": \"1.0.0\"\n}\n",
);
write_file(
&clone.join("skills/greet/SKILL.md"),
"---\nname: greet\ndescription: plugin greet\n---\n# greet\n",
);
write_file(&clone.join("mind.toml"), "[source]\nroots = [\"pkg\"]\n");
write_file(
&clone.join("pkg/skills/own/SKILL.md"),
"---\nname: own\ndescription: convention own skill\n---\n# own\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 names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
assert!(
names.contains(&"own"),
"convention skill under the declared root must be discovered: {names:?}"
);
assert!(
!names.contains(&"greet"),
"the plugin manifest's own items must be suppressed by the roots layout (MKT-15): {names:?}"
);
}
#[test]
fn mkt15_mind_toml_flat_skills_suppress_marketplace_own_items() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/marketplace.json"),
r#"{
"name": "Acme Market",
"plugins": [
{"name": "acme", "source": "./plugins/alpha"}
]
}"#,
);
write_file(
&clone.join("plugins/alpha/skills/mkt/SKILL.md"),
"---\nname: mkt\ndescription: marketplace skill\n---\n# mkt\n",
);
write_file(&clone.join("mind.toml"), "[source]\nflat-skills = true\n");
write_file(
&clone.join("own/SKILL.md"),
"---\nname: own\ndescription: flat own skill\n---\n# own\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 names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
assert!(
names.contains(&"own"),
"flat convention skill at the repo root must be discovered: {names:?}"
);
assert!(
!names.contains(&"mkt"),
"the marketplace's in-repo plugin items must be suppressed by the flat layout (MKT-15): {names:?}"
);
}
#[test]
fn mkt16_bare_discover_sources_does_not_suppress_marketplace_own_items() {
let tmp = TmpDir::new();
let base = tmp.path();
let clone = base.join("sources/local/test/plugin-repo");
write_file(
&clone.join(".claude-plugin/marketplace.json"),
r#"{
"name": "Acme Market",
"plugins": [
{"name": "acme", "source": "./plugins/alpha"}
]
}"#,
);
write_file(
&clone.join("plugins/alpha/skills/mkt/SKILL.md"),
"---\nname: mkt\ndescription: marketplace skill\n---\n# mkt\n",
);
write_file(
&clone.join("mind.toml"),
"[source]\ndescription = \"marketplace + curator\"\n\n[discover]\nsources = [{ source = \"owner/repo\" }]\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 mkt = items
.iter()
.find(|i| i.kind == ItemKind::Skill && i.name == "mkt")
.expect("the marketplace's in-repo plugin skill must still be discovered (MKT-16)");
assert_eq!(
mkt.effective_name(),
"acme:mkt",
"the in-repo plugin skill keeps its marketplace-entry namespace (MKT-8)"
);
}
}
#[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,
as_alias: None,
pin: Pin::default(),
roots: None,
flat_skills: false,
add_roots: None,
item_path: None,
item_kind: None,
curated_by: None,
origin: None,
plugin_version: None,
install_hooks: Vec::new(),
install_hook: None,
install_hook_commit: None,
profiles: None,
profile_snapshot: 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_hooks()[0].run, "echo set-up");
assert_eq!(rule.uninstall_hooks()[0].run, "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_hooks()[0].run, "make setup");
assert_eq!(tool.uninstall_hooks()[0].run, "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!(
rule.install_hooks().is_empty(),
"whitespace install must be absent"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn scalar_item_hooks_resolve_into_the_hook_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.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.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.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 convention_scan_finds_commands_flat_and_reads_their_description() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("commands/review.md"),
"---\ndescription: Review the diff\nargument-hint: [pr]\n---\n# review\n",
);
write(
&clone.join("commands/frontend/component.md"),
"---\ndescription: nested\n---\n# component\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 cmd = items
.iter()
.find(|i| i.kind == ItemKind::Command)
.expect("the command must be discovered");
assert_eq!(cmd.name, "review", "the name is the file stem");
assert_eq!(cmd.description.as_deref(), Some("Review the diff"));
assert_eq!(cmd.key().as_str(), "command:review");
assert_eq!(
items.iter().filter(|i| i.kind == ItemKind::Command).count(),
1,
"the convention scan must not descend into commands/ subdirectories"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn commands_are_declarable_by_items_and_by_discover_globs() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("commands/frontend/component.md"),
"---\ndescription: component\n---\n# component\n",
);
write(
&clone.join("packages/api/commands/deploy.md"),
"---\ndescription: deploy\n---\n# deploy\n",
);
write(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"command\"\n",
"name = \"component\"\n",
"path = \"commands/frontend/component.md\"\n",
"link = \"commands/frontend/component.md\"\n",
"\n",
"[discover]\n",
"commands = { include = [\"packages/*/commands/*.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 declared = items
.iter()
.find(|i| i.name == "component")
.expect("the declared command must be offered");
assert_eq!(declared.kind, ItemKind::Command);
assert_eq!(
declared.link_rel.as_deref(),
Some("commands/frontend/component.md"),
"a nested link target is confined to the commands/ kind directory"
);
let globbed = items
.iter()
.find(|i| i.name == "deploy")
.expect("the globbed command must be offered");
assert_eq!(globbed.kind, ItemKind::Command);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn skill_frontmatter_declares_its_own_lifecycle_hooks() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("skills/scanner/SKILL.md"),
"---\ndescription: scanner\ninstall: setup.sh\nupdate: migrate.sh\nuninstall: teardown.sh\n---\n# scanner\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 == "scanner").unwrap();
assert_eq!(skill.install_hooks()[0].run, "setup.sh");
assert_eq!(skill.update_hooks()[0].run, "migrate.sh");
assert_eq!(skill.uninstall_hooks()[0].run, "teardown.sh");
assert!(
!skill.install_hooks()[0].optional,
"a frontmatter scalar is one required hook"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn agent_frontmatter_declares_its_own_lifecycle_hooks() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("agents/dev.md"),
"---\ndescription: dev\ninstall: setup.sh\n---\n# dev\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 agent = items.iter().find(|i| i.name == "dev").unwrap();
assert_eq!(agent.install_hooks()[0].run, "setup.sh");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn item_directory_manifest_declares_hooks_and_beats_frontmatter() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("skills/scanner/SKILL.md"),
"---\ndescription: scanner\ninstall: frontmatter-install\n---\n# scanner\n",
);
write(
&clone.join("skills/scanner/mind.toml"),
"[[hooks]]\nrun = \"manifest-install\"\nname = \"Set up\"\noptional = true\n\n[[hooks]]\nrun = \"manifest-update\"\nevent = \"update\"\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 == "scanner").unwrap();
assert_eq!(skill.hooks.len(), 2, "the two sites must not merge");
let ih = skill.install_hooks();
assert_eq!(ih.len(), 1);
assert_eq!(ih[0].run, "manifest-install");
assert!(ih[0].optional, "the manifest form carries `optional`");
assert_eq!(ih[0].label(), "Set up");
assert_eq!(skill.update_hooks()[0].run, "manifest-update");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn root_items_declaration_beats_the_item_directory_manifest() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("skills/scanner/SKILL.md"),
"---\ndescription: scanner\n---\n# scanner\n",
);
write(
&clone.join("skills/scanner/mind.toml"),
"[[hooks]]\nrun = \"manifest-install\"\n",
);
write(
&clone.join("skills/quiet/SKILL.md"),
"---\ndescription: quiet\n---\n# quiet\n",
);
write(
&clone.join("skills/quiet/mind.toml"),
"[[hooks]]\nrun = \"quiet-install\"\n",
);
write(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"root-install\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"quiet\"\n",
"path = \"skills/quiet\"\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 scanner = items.iter().find(|i| i.name == "scanner").unwrap();
assert_eq!(scanner.hooks.len(), 1);
assert_eq!(scanner.install_hooks()[0].run, "root-install");
let quiet = items.iter().find(|i| i.name == "quiet").unwrap();
assert_eq!(
quiet.install_hooks()[0].run,
"quiet-install",
"a declaration silent about hooks must not suppress the item's own"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn source_root_mind_toml_is_not_read_as_an_item_manifest() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("SKILL.md"),
"---\ndescription: root skill\n---\n# root\n",
);
write(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"root-skill\"\n",
"path = \".\"\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).expect("the scan must succeed");
let skill = items.iter().find(|i| i.name == "root-skill").unwrap();
assert!(
skill.hooks.is_empty(),
"the source manifest must not contribute item hooks"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn item_directory_manifest_schema_error_drops_only_that_item() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("skills/scanner/SKILL.md"),
"---\ndescription: scanner\ninstall: frontmatter-install\n---\n# scanner\n",
);
write(
&clone.join("skills/scanner/mind.toml"),
"[[hooks]]\nrun = \"setup.sh\"\nevent = \"whenever\"\n",
);
write(
&clone.join("skills/pasted/SKILL.md"),
"---\ndescription: pasted\n---\n# pasted\n",
);
write(
&clone.join("skills/pasted/mind.toml"),
"[source]\ndescription = \"a source manifest copied into an item dir\"\n",
);
write(
&clone.join("skills/healthy/SKILL.md"),
"---\ndescription: healthy\n---\n# healthy\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)
.expect("one unreadable item manifest must not fail the scan");
let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
assert_eq!(
names,
vec!["healthy"],
"both items with an unreadable manifest must be dropped, and only those: {names:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_declared_item_with_an_unreadable_manifest_is_dropped_too() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("skills/scanner/SKILL.md"),
"---\ndescription: scanner\n---\n# scanner\n",
);
write(
&clone.join("skills/scanner/mind.toml"),
"[discover]\nskills = { include = [\"*/SKILL.md\"] }\n",
);
write(
&clone.join("skills/other/SKILL.md"),
"---\ndescription: other\n---\n# other\n",
);
write(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"other\"\n",
"path = \"skills/other\"\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)
.expect("the declared inventory must still scan");
let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
assert_eq!(names, vec!["other"], "got: {names:?}");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn tool_directory_manifest_declares_hooks_and_beats_frontmatter() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("tools/helper/TOOL.md"),
"---\ndescription: helper\ninstall: frontmatter-install\nuninstall: frontmatter-uninstall\n---\n# helper\n",
);
write(&clone.join("tools/helper/helper"), "#!/bin/sh\n");
write(
&clone.join("tools/helper/mind.toml"),
"[[hooks]]\nrun = \"manifest-build\"\nname = \"Build it\"\noptional = true\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.kind, ItemKind::Tool);
assert_eq!(
tool.hooks.len(),
1,
"the manifest replaces the TOOL.md scalars: {:?}",
tool.hooks
);
assert_eq!(tool.install_hooks()[0].run, "manifest-build");
assert!(tool.install_hooks()[0].optional);
assert_eq!(tool.install_hooks()[0].label(), "Build it");
assert!(
tool.uninstall_hooks().is_empty(),
"the frontmatter uninstall: must not survive the replacement"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_declared_tool_falls_through_to_its_tool_md_scalars() {
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");
write(
&clone.join("mind.toml"),
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"helper\"\n",
"path = \"tools/helper\"\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_hooks()[0].run, "make setup");
assert_eq!(tool.uninstall_hooks()[0].run, "make cleanup");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn update_hooks_replace_install_hooks_only_on_a_reinstall() {
let base = tmp();
let clone = base.join("sources/local/test/repo");
write(
&clone.join("skills/scanner/SKILL.md"),
"---\ndescription: scanner\ninstall: setup.sh\nupdate: migrate.sh\n---\n# scanner\n",
);
write(
&clone.join("skills/plain/SKILL.md"),
"---\ndescription: plain\ninstall: setup.sh\n---\n# plain\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 scanner = items.iter().find(|i| i.name == "scanner").unwrap();
assert_eq!(scanner.install_time_hooks(false)[0].run, "setup.sh");
assert_eq!(
scanner.install_time_hooks(true)[0].run,
"migrate.sh",
"a re-install runs the update hook in place of the install hook"
);
let plain = items.iter().find(|i| i.name == "plain").unwrap();
assert_eq!(
plain.install_time_hooks(true)[0].run,
"setup.sh",
"an item with no update hook re-runs its install hook, unchanged"
);
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);
}
}