From 4ef89cde7219edb8461dfc3065c34f4c45b0c5b0 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 17 Aug 2026 08:01:39 -0400 Subject: [PATCH] search : recherche fuzzy avec scoring + suggestions vouliez-vous-dire (issue #26) - sous-chaine, subsequence, tolerance de frappe (Levenshtein), tri par pertinence --- src/catalog.rs | 182 ++++++++++++++++++++++++++++++++----- src/commands/search_cmd.rs | 12 ++- 2 files changed, 169 insertions(+), 25 deletions(-) diff --git a/src/catalog.rs b/src/catalog.rs index a048db3..5fc5d88 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -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 { + 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 @@ -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::>() + ); + } + + #[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); + } } diff --git a/src/commands/search_cmd.rs b/src/commands/search_cmd.rs index f748aba..de01a96 100644 --- a/src/commands/search_cmd.rs +++ b/src/commands/search_cmd.rs @@ -53,8 +53,16 @@ pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result { ) ); if app.catalog.search(keyword, category).is_empty() { - app.log - .info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", "))); + 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) } -- 2.54.0