use std::sync::LazyLock;
use frizbee::{Config, match_list};
use crate::Sort;
use crate::corpus::{Entry, entries};
use crate::frecency::Frecency;
#[derive(Debug, Clone)]
pub struct Match<'a> {
pub entry: &'a Entry<'static>,
pub score: u16,
pub freq: u16,
}
static NAMES: LazyLock<Vec<&'static str>> =
LazyLock::new(|| entries().iter().map(|e| e.name).collect());
const MAX_QUERY_LEN: usize = 3275;
pub fn search<'a>(
query: &str,
entries: &'a [Entry<'static>],
frecency: &Frecency,
limit: usize,
max_typos: Option<u16>,
sort: Sort,
) -> Vec<Match<'a>> {
if query.is_empty() {
let mut ranked: Vec<Match<'a>> = entries
.iter()
.filter_map(|e| {
let freq = frecency.get(e.codepoint);
(freq > 0).then_some(Match {
entry: e,
score: 0,
freq: u32::min(freq, u16::MAX as u32) as u16,
})
})
.collect();
sort_matches(&mut ranked, sort);
ranked.truncate(limit);
return ranked;
}
if query.len() > MAX_QUERY_LEN {
return Vec::new();
}
let max_allowed = query.len().saturating_sub(1);
let max_allowed = u16::try_from(max_allowed).unwrap_or(u16::MAX);
let max_typos = max_typos.map(|t| t.min(max_allowed));
let query_upper = query.to_uppercase();
let config = Config {
max_typos,
..Config::default()
};
let raw = match_list(&query_upper, &NAMES, &config);
let mut matches: Vec<Match<'a>> = raw
.into_iter()
.map(|m| {
let entry = &entries[m.index as usize];
Match {
entry,
score: m.score,
freq: u32::min(frecency.get(entry.codepoint), u16::MAX as u32) as u16,
}
})
.collect();
sort_matches(&mut matches, sort);
matches.truncate(limit);
matches
}
fn sort_matches(matches: &mut [Match], sort: Sort) {
match sort {
Sort::Relevance => {
matches.sort_unstable_by(|a, b| b.freq.cmp(&a.freq).then_with(|| b.score.cmp(&a.score)))
}
Sort::Name => matches.sort_unstable_by(|a, b| a.entry.name.cmp(b.entry.name)),
Sort::Codepoint => {
matches.sort_unstable_by(|a, b| a.entry.codepoint.cmp(&b.entry.codepoint))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_exact_finds_result() {
let frec = Frecency::empty_for_testing();
let results = search(
"LATIN CAPITAL LETTER A",
entries(),
&frec,
10,
Some(0),
Sort::Relevance,
);
assert!(!results.is_empty());
assert_eq!(results[0].entry.name, "LATIN CAPITAL LETTER A");
}
#[test]
fn search_fuzzy_finds_result() {
let frec = Frecency::empty_for_testing();
let results = search("GRINNING", entries(), &frec, 10, Some(2), Sort::Relevance);
assert!(!results.is_empty());
assert!(results.iter().any(|m| m.entry.name == "GRINNING FACE"));
}
#[test]
fn search_empty_query_returns_empty_with_cold_frecency() {
let frec = Frecency::empty_for_testing();
let results = search("", entries(), &frec, 10, None, Sort::Relevance);
assert!(results.is_empty());
}
#[test]
fn search_empty_query_returns_frecency_hits() {
let mut frec = Frecency::empty_for_testing();
frec.record(0x0041);
let results = search("", entries(), &frec, 10, None, Sort::Relevance);
assert_eq!(results.len(), 1);
assert_eq!(results[0].entry.codepoint, 0x0041);
}
#[test]
fn search_empty_query_respects_limit() {
let mut frec = Frecency::empty_for_testing();
frec.record(0x0041);
frec.record(0x0042);
let results = search("", entries(), &frec, 1, None, Sort::Relevance);
assert_eq!(results.len(), 1);
}
#[test]
fn search_too_long_query_returns_empty() {
let frec = Frecency::empty_for_testing();
let long_query = "A".repeat(MAX_QUERY_LEN + 1);
let results = search(&long_query, entries(), &frec, 10, None, Sort::Relevance);
assert!(results.is_empty());
}
#[test]
fn search_no_results_returns_empty() {
let frec = Frecency::empty_for_testing();
let results = search(
"XYZZY_NONEXISTENT",
entries(),
&frec,
10,
Some(0),
Sort::Relevance,
);
assert!(results.is_empty());
}
#[test]
fn search_sort_by_name() {
let mut frec = Frecency::empty_for_testing();
frec.record(0x0041);
frec.record(0x0042);
let results = search("", entries(), &frec, 10, None, Sort::Name);
assert_eq!(results.len(), 2);
assert!(results[0].entry.name <= results[1].entry.name);
}
#[test]
fn search_sort_by_codepoint() {
let mut frec = Frecency::empty_for_testing();
frec.record(0x0042);
frec.record(0x0041);
let results = search("", entries(), &frec, 10, None, Sort::Codepoint);
assert_eq!(results.len(), 2);
assert_eq!(results[0].entry.codepoint, 0x0041);
assert_eq!(results[1].entry.codepoint, 0x0042);
}
#[test]
fn search_freq_clamped_to_u16() {
let mut frec = Frecency::empty_for_testing();
frec.record(0x0041);
let results = search(
"LATIN CAPITAL LETTER A",
entries(),
&frec,
10,
Some(0),
Sort::Relevance,
);
assert!(!results.is_empty());
assert!(results[0].freq <= u16::MAX);
}
#[test]
fn search_index_within_bounds() {
let frec = Frecency::empty_for_testing();
let results = search("A", entries(), &frec, 10, Some(1), Sort::Relevance);
for m in &results {
assert!((m.entry.codepoint as usize) > 0);
}
}
}