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;
#[derive(Debug, Clone)]
pub struct UnmanagedItem {
pub kind: ItemKind,
pub name: String,
pub paths: Vec<PathBuf>,
}
impl UnmanagedItem {
pub fn key(&self) -> String {
format!("{}:{}", self.kind.as_str(), self.name)
}
}
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 home in paths.agent_homes()? {
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; }
let Some(name) = item_name(kind, &entry) else {
continue;
};
found.entry((kind, name)).or_default().push(path);
}
}
}
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) -> Option<String> {
let raw = entry.file_name();
let name = raw.to_str()?;
match kind {
ItemKind::Skill => Some(name.to_string()),
ItemKind::Agent | ItemKind::Rule => name.strip_suffix(".md").map(str::to_string),
ItemKind::Tool => None,
}
}
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()).collect(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::parse_item_ref;
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_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 { .. })
));
}
}