search : recherche fuzzy + suggestions (issue #26) #43
+159
-23
@@ -80,35 +80,125 @@ impl Catalog {
|
||||
}
|
||||
|
||||
/// Case-insensitive search over name, display name, description,
|
||||
/// category, tags and note.
|
||||
/// 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();
|
||||
self.agents
|
||||
let mut scored: Vec<(u32, &AgentDef)> = self
|
||||
.agents
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
if a.hidden {
|
||||
return false;
|
||||
}
|
||||
if let Some(cat) = category {
|
||||
if a.category.as_deref() != Some(cat) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let hay = format!(
|
||||
"{} {} {} {} {} {}",
|
||||
a.name,
|
||||
a.title(),
|
||||
a.description.as_deref().unwrap_or(""),
|
||||
a.category.as_deref().unwrap_or(""),
|
||||
a.tags.join(" "),
|
||||
a.note.as_deref().unwrap_or("")
|
||||
)
|
||||
.to_lowercase();
|
||||
hay.contains(&kw)
|
||||
.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()
|
||||
.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<String> {
|
||||
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<char> = a.chars().collect();
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
let mut prev: Vec<usize> = (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<String> {
|
||||
let mut cats: Vec<String> = self
|
||||
@@ -156,4 +246,50 @@ mod tests {
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,16 @@ pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result<i32> {
|
||||
)
|
||||
);
|
||||
if app.catalog.search(keyword, category).is_empty() {
|
||||
let suggestions = app.catalog.suggest(keyword);
|
||||
if suggestions.is_empty() {
|
||||
app.log
|
||||
.info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", ")));
|
||||
} else {
|
||||
app.log.info(&format!(
|
||||
"no agent matches '{keyword}' — vouliez-vous dire : {} ?",
|
||||
suggestions.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user