Merge pull request 'search : recherche fuzzy + suggestions (issue #26)' (#43) from feat/fuzzy-search into main

This commit was merged in pull request #43.
This commit is contained in:
2026-08-17 08:01:56 -04:00
2 changed files with 169 additions and 25 deletions
+159 -23
View File
@@ -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);
}
}
+10 -2
View File
@@ -53,8 +53,16 @@ pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result<i32> {
)
);
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)
}