use nucleo::{
Config, Matcher, Utf32Str,
pattern::{CaseMatching, Normalization, Pattern},
};
use crate::model::SearchEntry;
pub const DEFAULT_LIMIT: usize = 50;
#[derive(Debug, Clone)]
pub struct SearchResult {
pub entry: SearchEntry,
pub score: u32,
}
pub struct Searcher {
matcher: Matcher,
entries: Vec<SearchEntry>,
}
impl Searcher {
pub fn new(entries: Vec<SearchEntry>) -> Self {
Self {
matcher: Matcher::new(Config::DEFAULT),
entries,
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn entries(&self) -> &[SearchEntry] {
&self.entries
}
pub fn search(&mut self, query: &str, limit: usize) -> Vec<SearchResult> {
if query.is_empty() {
return Vec::new();
}
let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
let entries = &self.entries;
let matcher = &mut self.matcher;
let mut buf: Vec<char> = Vec::new();
let mut hits: Vec<(usize, u32)> = Vec::new();
for (idx, entry) in entries.iter().enumerate() {
buf.clear();
let haystack = Utf32Str::new(&entry.name, &mut buf);
if let Some(score) = pattern.score(haystack, matcher) {
hits.push((idx, score));
}
}
hits.sort_unstable_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| entries[b.0].usage_count.cmp(&entries[a.0].usage_count))
});
hits.truncate(limit);
hits.into_iter()
.map(|(idx, score)| SearchResult {
entry: entries[idx].clone(),
score,
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_entry(id: i64, docset_id: i64, name: &str, entry_type: &str) -> SearchEntry {
SearchEntry {
id,
docset_id,
name: name.to_string(),
entry_type: entry_type.to_string(),
path: format!("{}.html", name.to_lowercase()),
usage_count: 0,
docset_name: "TestDocset".to_string(),
docset_version: None,
}
}
#[test]
fn empty_query_returns_nothing() {
let mut s = Searcher::new(vec![make_entry(1, 1, "Vec", "Struct")]);
assert!(s.search("", 10).is_empty());
}
#[test]
fn empty_index_returns_nothing() {
let mut s = Searcher::new(vec![]);
assert!(s.search("vec", 10).is_empty());
}
#[test]
fn exact_name_matches() {
let entries = vec![
make_entry(1, 1, "Vec", "Struct"),
make_entry(2, 1, "HashMap", "Struct"),
];
let mut s = Searcher::new(entries);
let results = s.search("Vec", 10);
assert!(!results.is_empty());
assert_eq!(results[0].entry.name, "Vec");
}
#[test]
fn partial_match_returns_results() {
let entries = vec![
make_entry(1, 1, "Vec", "Struct"),
make_entry(2, 1, "VecDeque", "Struct"),
make_entry(3, 1, "HashMap", "Struct"),
];
let mut s = Searcher::new(entries);
let results = s.search("vec", DEFAULT_LIMIT);
assert!(results.len() >= 2);
let names: Vec<&str> = results.iter().map(|r| r.entry.name.as_str()).collect();
assert!(names.contains(&"Vec"));
assert!(names.contains(&"VecDeque"));
}
#[test]
fn limit_caps_result_count() {
let entries: Vec<_> = (0..20)
.map(|i| make_entry(i, 1, &format!("func_{i}"), "Function"))
.collect();
let mut s = Searcher::new(entries);
let results = s.search("func", 5);
assert!(results.len() <= 5);
}
#[test]
fn results_ordered_by_score_desc() {
let entries = vec![
make_entry(1, 1, "spawn_blocking", "Function"),
make_entry(2, 1, "spawn", "Function"),
];
let mut s = Searcher::new(entries);
let results = s.search("spawn", DEFAULT_LIMIT);
assert!(!results.is_empty());
for w in results.windows(2) {
assert!(w[0].score >= w[1].score, "results must be score-descending");
}
}
#[test]
fn usage_count_breaks_score_ties() {
let mut e1 = make_entry(1, 1, "run", "Function");
e1.usage_count = 5;
let mut e2 = make_entry(2, 2, "run", "Function");
e2.usage_count = 20;
let mut s = Searcher::new(vec![e1, e2]);
let results = s.search("run", DEFAULT_LIMIT);
assert_eq!(results.len(), 2);
assert!(results[0].entry.usage_count >= results[1].entry.usage_count);
}
#[test]
fn len_and_is_empty() {
let s = Searcher::new(vec![]);
assert!(s.is_empty());
assert_eq!(s.len(), 0);
let s2 = Searcher::new(vec![make_entry(1, 1, "A", "Struct")]);
assert!(!s2.is_empty());
assert_eq!(s2.len(), 1);
}
#[test]
fn multiple_searches_are_independent() {
let entries = vec![
make_entry(1, 1, "Vec", "Struct"),
make_entry(2, 1, "HashMap", "Struct"),
];
let mut s = Searcher::new(entries);
let r1 = s.search("Vec", DEFAULT_LIMIT);
let r2 = s.search("HashMap", DEFAULT_LIMIT);
assert_eq!(r1[0].entry.name, "Vec");
assert_eq!(r2[0].entry.name, "HashMap");
}
#[test]
fn case_insensitive_smart_matching() {
let entries = vec![make_entry(1, 1, "HashMap", "Struct")];
let mut s = Searcher::new(entries);
assert!(!s.search("hashmap", DEFAULT_LIMIT).is_empty());
}
#[test]
fn unrelated_query_returns_no_results() {
let entries = vec![
make_entry(1, 1, "Vec", "Struct"),
make_entry(2, 1, "HashMap", "Struct"),
];
let mut s = Searcher::new(entries);
let results = s.search("zzzzz", DEFAULT_LIMIT);
for r in &results {
assert!(r.score > 0);
}
}
#[test]
fn large_corpus_small_limit_still_returns_top_ranked() {
let mut entries: Vec<_> = (0..10_000)
.map(|i| make_entry(i, 1, &format!("func_{i:05}"), "Function"))
.collect();
entries[42].usage_count = 999;
let mut s = Searcher::new(entries);
let results = s.search("func", 5);
assert_eq!(results.len(), 5);
assert!(
results.iter().any(|r| r.entry.usage_count == 999),
"usage-boosted entry should tie-break into the top-5"
);
}
#[test]
fn limit_zero_returns_empty() {
let entries = vec![make_entry(1, 1, "Vec", "Struct")];
let mut s = Searcher::new(entries);
assert!(s.search("Vec", 0).is_empty());
}
#[test]
fn results_are_deduplicated_by_index_not_shared_state() {
let entries = vec![
make_entry(1, 1, "Vec", "Struct"),
make_entry(2, 1, "VecDeque", "Struct"),
make_entry(3, 1, "HashMap", "Struct"),
];
let mut s = Searcher::new(entries);
let r1 = s.search("vec", DEFAULT_LIMIT);
let r2 = s.search("vec", DEFAULT_LIMIT);
let ids1: Vec<i64> = r1.iter().map(|r| r.entry.id).collect();
let ids2: Vec<i64> = r2.iter().map(|r| r.entry.id).collect();
assert_eq!(ids1, ids2);
}
}