pub fn score(query: &str, text: &str) -> Option<i32> {
if query.is_empty() {
return None;
}
let q: Vec<char> = query.chars().flat_map(char::to_lowercase).collect();
let mut qi = 0;
let mut total = 0i32;
let mut prev_match: Option<usize> = None;
let mut first = true;
let mut prev_char: Option<char> = None;
let mut t_len = 0usize;
for (ti, tc) in text.chars().flat_map(char::to_lowercase).enumerate() {
t_len = ti + 1;
if qi < q.len() && tc == q[qi] {
let mut points = 1;
if ti == 0 {
points += 8;
} else if let Some(prev) = prev_char
&& matches!(prev, ' ' | '-' | '_' | '/' | '.' | ':')
{
points += 6;
}
if let Some(p) = prev_match {
if ti == p + 1 {
points += 10;
} else {
points -= (ti - p - 1).min(5) as i32;
}
}
if first {
total += 4i32.saturating_sub((ti as i32).min(4));
first = false;
}
total += points;
prev_match = Some(ti);
qi += 1;
}
prev_char = Some(tc);
}
if qi < q.len() {
return None;
}
total += 2i32.saturating_sub((t_len as i32 / 20).min(2));
Some(total)
}
pub fn best_index<'a, I>(query: &str, titles: I) -> Option<usize>
where
I: IntoIterator<Item = &'a str>,
{
let mut best: Option<(i32, usize)> = None;
for (i, title) in titles.into_iter().enumerate() {
let Some(s) = score(query, title) else {
continue;
};
match best {
Some((bs, _)) if s <= bs => {}
_ => best = Some((s, i)),
}
}
best.map(|(_, i)| i)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_prefix_beats_later_match() {
let titles = ["later milk", "milk", "milkshake"];
assert_eq!(best_index("milk", titles), Some(1));
}
#[test]
fn subsequence_matches() {
assert!(score("mlk", "milk").is_some());
assert!(score("xyz", "milk").is_none());
}
#[test]
fn case_insensitive() {
assert!(score("Mi", "milk").is_some());
assert_eq!(best_index("MI", ["alpha", "Milk", "beta"]), Some(1));
}
#[test]
fn empty_query_matches_nothing() {
assert_eq!(best_index("", ["a", "b"]), None);
assert!(score("", "a").is_none());
}
#[test]
fn consecutive_beats_scattered() {
let a = score("ab", "ab x").unwrap();
let b = score("ab", "a x b").unwrap();
assert!(a > b);
}
}