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 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;
#[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 { .. })
));
}
}