//! The merged agent catalog: lookup, aliases, groups and search. use crate::config::AgentDef; use std::collections::{BTreeMap, HashMap}; #[derive(Debug, Clone, Default)] pub struct Catalog { agents: Vec, by_name: HashMap, aliases: BTreeMap, groups: BTreeMap>, } impl Catalog { pub fn new( agents: Vec, aliases: BTreeMap, groups: BTreeMap>, ) -> Self { let mut by_name = HashMap::new(); for (i, a) in agents.iter().enumerate() { by_name.insert(a.name.clone(), i); } Self { agents, by_name, aliases, groups, } } pub fn agents(&self) -> &[AgentDef] { &self.agents } pub fn len(&self) -> usize { self.agents.len() } pub fn is_empty(&self) -> bool { self.agents.is_empty() } pub fn get(&self, name: &str) -> Option<&AgentDef> { self.by_name.get(name).map(|&i| &self.agents[i]) } /// Resolve an agent name or an alias to the agent definition. pub fn resolve(&self, name_or_alias: &str) -> Option<&AgentDef> { if let Some(target) = self.aliases.get(name_or_alias) { return self.get(target); } self.get(name_or_alias) } pub fn alias_target(&self, alias: &str) -> Option<&str> { self.aliases.get(alias).map(|s| s.as_str()) } /// Members of a group, in definition order (missing names are skipped). pub fn group_members(&self, group: &str) -> Vec<&AgentDef> { let mut out = Vec::new(); if let Some(names) = self.groups.get(group) { for n in names { if let Some(a) = self.get(n) { out.push(a); } } } out } pub fn has_group(&self, group: &str) -> bool { self.groups.contains_key(group) } /// Is this token a group selector ("group:")? pub fn parse_group_selector(s: &str) -> Option<&str> { s.strip_prefix("group:") } /// Case-insensitive search over name, display name, description, /// category, tags and note — typo-tolerant, ranked by relevance. pub fn search(&self, keyword: &str, category: Option<&str>) -> Vec<&AgentDef> { let kw = keyword.to_lowercase(); let mut scored: Vec<(u32, &AgentDef)> = self .agents .iter() .filter(|a| !a.hidden) .filter(|a| category.map_or(true, |c| a.category.as_deref() == Some(c))) .filter_map(|a| { let name = a.name.to_lowercase(); let title = a.title().to_lowercase(); let rest = format!( "{} {} {}", a.description.as_deref().unwrap_or("").to_lowercase(), a.category.as_deref().unwrap_or("").to_lowercase(), a.tags.join(" ").to_lowercase() ); let score = score_keyword(&kw, &name) .saturating_mul(3) .saturating_add(score_keyword(&kw, &title).saturating_mul(2)) .saturating_add(score_keyword(&kw, &rest)); (score > 0).then_some((score, a)) }) .collect(); scored.sort_by(|(sa, a), (sb, b)| sb.cmp(sa).then(a.name.cmp(&b.name))); scored.into_iter().map(|(_, a)| a).collect() } /// "Did you mean" candidates for a keyword with no results: closest /// agent names and display names by edit distance. pub fn suggest(&self, keyword: &str) -> Vec { let kw = keyword.to_lowercase(); let mut out: Vec<(usize, String)> = Vec::new(); for a in self.agents.iter().filter(|a| !a.hidden) { for field in [&a.name, &a.title().to_string()] { let f = field.to_lowercase(); if f.is_empty() { continue; } let d = levenshtein(&kw, &f); if d <= 3 && !out.iter().any(|(_, n)| n == &a.name) { out.push((d, a.name.clone())); } } } out.sort_by(|(da, na), (db, nb)| da.cmp(db).then(na.cmp(nb))); out.truncate(4); out.into_iter().map(|(_, n)| n).collect() } } /// Score a keyword against one haystack field: 100 for a full hit, /// then subsequence and typo bonuses; 0 = no relation at all. fn score_keyword(keyword: &str, hay: &str) -> u32 { if keyword.is_empty() { return 0; } let tokens: Vec<&str> = keyword.split_whitespace().collect(); let mut total = 0u32; for token in &tokens { if token.is_empty() { continue; } let s = score_token(token, hay); if s == 0 { return 0; } total += s; } total } fn score_token(token: &str, hay: &str) -> u32 { if hay.contains(token) { return 100; } if subsequence(token, hay) { return 40; } // Typo tolerance: compare the token with every word of the field. let mut best = usize::MAX; for word in hay.split(|c: char| !c.is_alphanumeric()) { if word.is_empty() { continue; } best = best.min(levenshtein(token, word)); } match best { 0..=1 => 25, 2 => 15, 3 => 5, _ => 0, } } /// Do the characters of needle appear in order inside hay? fn subsequence(needle: &str, hay: &str) -> bool { let mut it = hay.chars(); needle.chars().all(|c| it.any(|h| h == c)) } /// Classic Levenshtein distance on characters. fn levenshtein(a: &str, b: &str) -> usize { let a: Vec = a.chars().collect(); let b: Vec = b.chars().collect(); let mut prev: Vec = (0..=b.len()).collect(); let mut cur = vec![0usize; b.len() + 1]; for (i, ca) in a.iter().enumerate() { cur[0] = i + 1; for (j, cb) in b.iter().enumerate() { let cost = if ca == cb { 0 } else { 1 }; cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1); } std::mem::swap(&mut prev, &mut cur); } prev[b.len()] } impl Catalog { /// Sorted list of all categories present in the catalog. pub fn categories(&self) -> Vec { let mut cats: Vec = self .agents .iter() .filter_map(|a| a.category.clone()) .collect(); cats.sort(); cats.dedup(); cats } } #[cfg(test)] mod tests { use super::*; use crate::config::Config; fn catalog() -> Catalog { let cfg: Config = serde_yaml::from_str(crate::config::DEFAULT_CONFIG).unwrap(); Catalog::new(cfg.agents, cfg.aliases, cfg.groups) } #[test] fn resolves_aliases() { let cat = catalog(); let via_alias = cat.resolve("cc").map(|a| a.name.clone()); assert_eq!(via_alias.as_deref(), Some("claude-code")); } #[test] fn resolves_groups() { let cat = catalog(); assert!(cat.has_group("dev")); let members = cat.group_members("dev"); assert!(!members.is_empty()); assert!(members.iter().any(|a| a.name == "claude-code")); } #[test] fn searches() { let cat = catalog(); let hits = cat.search("anthropic", None); assert!(hits.iter().any(|a| a.name == "claude-code")); let cats = cat.categories(); assert!(cats.contains(&"coding-agent".to_string())); } #[test] fn fuzzy_search_tolerates_missing_separator() { let cat = catalog(); let hits = cat.search("claude cod", None); assert!( hits.iter().any(|a| a.name == "claude-code"), "expected claude-code in {:?}", hits.iter().map(|a| &a.name).collect::>() ); } #[test] fn fuzzy_search_tolerates_typos() { let cat = catalog(); let hits = cat.search("cludecode", None); assert!(hits.iter().any(|a| a.name == "claude-code")); } #[test] fn exact_matches_rank_first() { let cat = catalog(); let hits = cat.search("claude", None); assert!(!hits.is_empty()); assert_eq!( hits[0].name, "claude-code", "exact name match must rank first: {:?}", hits.iter().map(|a| &a.name).collect::>() ); } #[test] fn suggest_proposes_close_names() { let cat = catalog(); let sug = cat.suggest("aiderr"); assert!(sug.contains(&"aider".to_string()), "suggestions: {sug:?}"); assert!(sug.len() <= 4); } #[test] fn levenshtein_distance() { assert_eq!(levenshtein("aider", "aider"), 0); assert_eq!(levenshtein("aider", "aiderr"), 1); assert_eq!(levenshtein("abc", "axc"), 1); assert_eq!(levenshtein("abc", "xyz"), 3); } }