use std::collections::BTreeMap;
use std::path::PathBuf;
use crate::error::{ItemKind, MindError, Result};
use crate::manifest::Manifest;
use crate::paths::Paths;
use crate::resolve::ItemRef;
use crate::sanitize::ItemKey;
#[derive(Debug, Clone)]
pub struct UnmanagedItem {
pub kind: ItemKind,
pub name: String,
pub paths: Vec<PathBuf>,
}
impl UnmanagedItem {
pub fn key(&self) -> ItemKey {
ItemKey::new(format!("{}:{}", self.kind.as_str(), self.name))
}
pub fn display_key(&self) -> String {
self.key().display()
}
}
pub fn scan(paths: &Paths, manifest: &Manifest) -> Result<Vec<UnmanagedItem>> {
let managed: std::collections::HashSet<PathBuf> = manifest
.items
.values()
.flat_map(|it| it.links.iter())
.map(PathBuf::from)
.collect();
let mut found: BTreeMap<(ItemKind, String), Vec<PathBuf>> = BTreeMap::new();
for lobe in paths.agent_homes()? {
let home = &lobe.path;
for kind in ItemKind::LINKABLE {
let Ok(rd) = std::fs::read_dir(home.join(kind.dir())) else {
continue;
};
for entry in rd.flatten() {
let path = entry.path();
if managed.contains(&path) {
continue; }
match item_name(kind, &entry) {
Ok(Some(name)) => {
found.entry((kind, name)).or_default().push(path);
}
Ok(None) => {}
Err(MindError::UnsafeName { name }) => {
crate::render::scan_warn(format!(
"warning: skipping unmanaged {} '{}': unsafe item name (a control \
character, a path separator, or a bidi/zero-width Unicode code \
point)",
kind.as_str(),
crate::sanitize::strip_ansi(&name),
));
}
Err(e) => return Err(e),
}
}
}
}
Ok(found
.into_iter()
.map(|((kind, name), mut paths)| {
paths.sort();
UnmanagedItem { kind, name, paths }
})
.collect())
}
fn item_name(kind: ItemKind, entry: &std::fs::DirEntry) -> Result<Option<String>> {
let raw = entry.file_name();
let Some(name) = raw.to_str() else {
return Ok(None);
};
let derived = match kind {
ItemKind::Skill => Some(name.to_string()),
ItemKind::Agent | ItemKind::Rule | ItemKind::Command => {
name.strip_suffix(".md").map(str::to_string)
}
ItemKind::Workflow => name
.strip_suffix(".js")
.filter(|_| entry.path().is_file())
.map(str::to_string),
ItemKind::Tool => None,
};
let Some(derived) = derived else {
return Ok(None);
};
if crate::catalog::is_safe_item_name(&derived) {
Ok(Some(derived))
} else {
Err(MindError::UnsafeName { name: derived })
}
}
pub fn select<'a>(items: &'a [UnmanagedItem], r: Option<&ItemRef>) -> Vec<&'a UnmanagedItem> {
let Some(r) = r else {
return items.iter().collect();
};
if r.source.is_some() {
return vec![];
}
if crate::resolve::is_glob(&r.name) {
let pattern = match glob::Pattern::new(&r.name) {
Ok(p) => p,
Err(_) => return vec![],
};
items
.iter()
.filter(|it| r.kind.is_none_or(|k| it.kind == k) && pattern.matches(&it.name))
.collect()
} else {
items
.iter()
.filter(|it| r.kind.is_none_or(|k| it.kind == k) && it.name == r.name)
.collect()
}
}
pub fn resolve<'a>(items: &'a [UnmanagedItem], r: &ItemRef) -> Result<&'a UnmanagedItem> {
if r.source.is_some() {
return Err(MindError::NotInstalled {
name: r.name.clone(),
});
}
let matches: Vec<&UnmanagedItem> = items
.iter()
.filter(|it| it.name == r.name && r.kind.is_none_or(|k| it.kind == k))
.collect();
match matches.as_slice() {
[] => Err(MindError::NotInstalled {
name: r.name.clone(),
}),
[only] => Ok(only),
many => Err(MindError::AmbiguousItem {
query: r.name.clone(),
candidates: many.iter().map(|it| it.key().display()).collect(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::parse_item_ref;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn make_items() -> Vec<UnmanagedItem> {
vec![
UnmanagedItem {
kind: ItemKind::Skill,
name: "review".to_string(),
paths: vec![],
},
UnmanagedItem {
kind: ItemKind::Skill,
name: "style".to_string(),
paths: vec![],
},
UnmanagedItem {
kind: ItemKind::Agent,
name: "dev".to_string(),
paths: vec![],
},
]
}
#[test]
fn select_none_returns_all() {
let items = make_items();
let result = select(&items, None);
assert_eq!(result.len(), 3);
}
#[test]
fn select_glob_star_matches_all() {
let items = make_items();
let r = parse_item_ref("*").unwrap();
let result = select(&items, Some(&r));
assert_eq!(result.len(), 3);
}
#[test]
fn select_kind_glob_filters_by_kind() {
let items = make_items();
let r = parse_item_ref("skill:*").unwrap();
let result = select(&items, Some(&r));
assert_eq!(result.len(), 2);
assert!(result.iter().all(|it| it.kind == ItemKind::Skill));
}
#[test]
fn select_exact_name() {
let items = make_items();
let r = parse_item_ref("review").unwrap();
let result = select(&items, Some(&r));
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "review");
}
#[test]
fn select_kind_exact_name() {
let items = make_items();
let r = parse_item_ref("agent:dev").unwrap();
let result = select(&items, Some(&r));
assert_eq!(result.len(), 1);
assert_eq!(result[0].kind, ItemKind::Agent);
assert_eq!(result[0].name, "dev");
}
#[test]
fn select_source_qualified_returns_empty() {
let items = make_items();
let r = parse_item_ref("owner/repo#skill:review").unwrap();
let result = select(&items, Some(&r));
assert!(result.is_empty());
}
#[test]
fn select_no_match_returns_empty() {
let items = make_items();
let r = parse_item_ref("nope").unwrap();
let result = select(&items, Some(&r));
assert!(result.is_empty());
}
#[test]
fn select_bare_name_matches_all_kinds() {
let items = vec![
UnmanagedItem {
kind: ItemKind::Skill,
name: "shared".to_string(),
paths: vec![],
},
UnmanagedItem {
kind: ItemKind::Agent,
name: "shared".to_string(),
paths: vec![],
},
UnmanagedItem {
kind: ItemKind::Rule,
name: "other".to_string(),
paths: vec![],
},
];
let r = parse_item_ref("shared").unwrap();
let result = select(&items, Some(&r));
assert_eq!(result.len(), 2, "both `shared` items must match");
assert!(result.iter().all(|it| it.name == "shared"));
}
#[test]
fn select_kind_glob_excludes_other_kinds() {
let items = make_items(); let r = parse_item_ref("agent:*e*").unwrap();
let result = select(&items, Some(&r));
assert_eq!(result.len(), 1);
assert_eq!(result[0].kind, ItemKind::Agent);
assert_eq!(result[0].name, "dev");
}
#[test]
fn select_glob_no_match_returns_empty() {
let items = make_items();
let r = parse_item_ref("nope*").unwrap();
let result = select(&items, Some(&r));
assert!(result.is_empty());
}
#[test]
fn key_and_name_forms() {
let u = UnmanagedItem {
kind: ItemKind::Agent,
name: "dev".to_string(),
paths: vec![],
};
assert_eq!(u.key(), "agent:dev");
assert_eq!(
UnmanagedItem {
kind: ItemKind::Skill,
name: "review".to_string(),
paths: vec![]
}
.key(),
"skill:review"
);
}
#[test]
fn resolve_candidates_are_sanitized_for_a_hostile_unmanaged_name() {
let items = vec![
UnmanagedItem {
kind: ItemKind::Skill,
name: "evil\x1b[31mname".to_string(),
paths: vec![],
},
UnmanagedItem {
kind: ItemKind::Agent,
name: "evil\x1b[31mname".to_string(),
paths: vec![],
},
];
let r = parse_item_ref("evil\x1b[31mname").unwrap();
let err = resolve(&items, &r).unwrap_err();
match err {
MindError::AmbiguousItem { candidates, .. } => {
assert!(
candidates.iter().all(|c| !c.contains('\x1b')),
"candidates must not carry a raw ESC byte: {candidates:?}"
);
assert!(
candidates.iter().any(|c| c.contains("evil")),
"candidates must still name the item: {candidates:?}"
);
}
other => panic!("expected AmbiguousItem, got {other:?}"),
}
}
#[test]
fn resolve_matches_kind_and_rejects_source() {
let items = vec![
UnmanagedItem {
kind: ItemKind::Skill,
name: "x".to_string(),
paths: vec![],
},
UnmanagedItem {
kind: ItemKind::Agent,
name: "x".to_string(),
paths: vec![],
},
];
assert!(matches!(
resolve(&items, &parse_item_ref("x").unwrap()),
Err(MindError::AmbiguousItem { .. })
));
assert_eq!(
resolve(&items, &parse_item_ref("agent:x").unwrap())
.unwrap()
.kind,
ItemKind::Agent
);
assert!(matches!(
resolve(&items, &parse_item_ref("owner/repo#skill:x").unwrap()),
Err(MindError::NotInstalled { .. })
));
assert!(matches!(
resolve(&items, &parse_item_ref("nope").unwrap()),
Err(MindError::NotInstalled { .. })
));
}
#[test]
fn scan_skips_a_workflow_whose_derived_name_is_unsafe() {
let _guard = crate::paths::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let home = std::env::temp_dir().join(format!("mind-unm-unsafe-{}-{n}", std::process::id()));
let workflows = home.join("workflows");
std::fs::create_dir_all(&workflows).unwrap();
std::fs::write(workflows.join("...js"), b"export const meta = {};").unwrap();
std::fs::write(workflows.join("deploy.js"), b"export const meta = {};").unwrap();
let saved_agent_homes = std::env::var_os("MIND_AGENT_HOMES");
let saved_policy = std::env::var_os("MIND_POLICY_FILE");
unsafe {
std::env::set_var("MIND_AGENT_HOMES", home.to_str().unwrap());
std::env::remove_var("MIND_POLICY_FILE");
}
let paths = Paths {
mind_home: home.join("mind-home-unused"),
claude_home: home.clone(),
};
let result = scan(&paths, &Manifest::default());
unsafe {
match saved_agent_homes {
Some(v) => std::env::set_var("MIND_AGENT_HOMES", v),
None => std::env::remove_var("MIND_AGENT_HOMES"),
}
if let Some(v) = saved_policy {
std::env::set_var("MIND_POLICY_FILE", v);
}
}
let _ = std::fs::remove_dir_all(&home);
let items = result.expect("scan must succeed despite the one unsafe entry");
assert_eq!(
items.len(),
1,
"the unsafe entry must be skipped, not listed: {items:?}"
);
assert_eq!(items[0].kind, ItemKind::Workflow);
assert_eq!(
items[0].name, "deploy",
"the well-formed sibling must still be listed: {items:?}"
);
}
fn entry_named(dir: &std::path::Path, name: &str) -> std::fs::DirEntry {
std::fs::read_dir(dir)
.unwrap()
.flatten()
.find(|e| e.file_name().to_str() == Some(name))
.unwrap_or_else(|| panic!("no dir entry named {name:?} in {dir:?}"))
}
#[test]
fn item_name_refuses_every_unsafe_derivation_for_every_kind() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("mind-unm-derive-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for f in [
"...md",
"..md",
".md",
"...js",
"..js",
".js",
"ok.md",
"ok.js",
"plain.txt",
] {
std::fs::write(dir.join(f), b"x").unwrap();
}
std::fs::create_dir(dir.join("okdir")).unwrap();
std::fs::create_dir(dir.join("ha\u{202E}ck")).unwrap();
let unsafe_name = |kind: ItemKind, file: &str, want: &str| {
let entry = entry_named(&dir, file);
match item_name(kind, &entry) {
Err(MindError::UnsafeName { name }) => assert_eq!(
name, want,
"{kind:?} {file:?} must be refused with the derived name {want:?}"
),
other => panic!("{kind:?} {file:?}: expected Err(UnsafeName), got {other:?}"),
}
};
for kind in [ItemKind::Agent, ItemKind::Rule, ItemKind::Command] {
unsafe_name(kind, "...md", "..");
unsafe_name(kind, "..md", ".");
unsafe_name(kind, ".md", "");
assert_eq!(
item_name(kind, &entry_named(&dir, "ok.md")).unwrap(),
Some("ok".to_string()),
"{kind:?}: a well-formed sibling must still derive its name"
);
assert_eq!(
item_name(kind, &entry_named(&dir, "plain.txt")).unwrap(),
None,
"{kind:?}: a non-matching suffix must be skipped, not refused"
);
}
unsafe_name(ItemKind::Workflow, "...js", "..");
unsafe_name(ItemKind::Workflow, "..js", ".");
unsafe_name(ItemKind::Workflow, ".js", "");
assert_eq!(
item_name(ItemKind::Workflow, &entry_named(&dir, "ok.js")).unwrap(),
Some("ok".to_string())
);
std::fs::create_dir(dir.join("dirflow.js")).unwrap();
assert_eq!(
item_name(ItemKind::Workflow, &entry_named(&dir, "dirflow.js")).unwrap(),
None
);
assert_eq!(
item_name(ItemKind::Workflow, &entry_named(&dir, "...md")).unwrap(),
None
);
assert_eq!(
item_name(ItemKind::Agent, &entry_named(&dir, "...js")).unwrap(),
None
);
assert_eq!(
item_name(ItemKind::Skill, &entry_named(&dir, "okdir")).unwrap(),
Some("okdir".to_string())
);
unsafe_name(ItemKind::Skill, "ha\u{202E}ck", "ha\u{202E}ck");
assert_eq!(
item_name(ItemKind::Tool, &entry_named(&dir, "...md")).unwrap(),
None
);
let _ = std::fs::remove_dir_all(&dir);
}
}