#[derive(Debug, Clone, PartialEq)]
pub struct FuzzyMatch {
pub matches: bool,
pub score: f64,
}
pub fn fuzzy_match(query: &str, text: &str) -> FuzzyMatch {
let query_lower = query.to_lowercase();
let text_lower = text.to_lowercase();
let match_query = |normalized_query: &str| -> FuzzyMatch {
if normalized_query.is_empty() {
return FuzzyMatch {
matches: true,
score: 0.0,
};
}
let query_chars: Vec<char> = normalized_query.chars().collect();
let text_chars: Vec<char> = text_lower.chars().collect();
if query_chars.len() > text_chars.len() {
return FuzzyMatch {
matches: false,
score: 0.0,
};
}
let mut query_idx = 0;
let mut score: f64 = 0.0;
let mut last_match_idx: i32 = -1;
let mut consecutive = 0;
for (i, ch) in text_chars.iter().enumerate() {
if query_idx >= query_chars.len() {
break;
}
if *ch == query_chars[query_idx] {
let is_word_boundary = i == 0
|| matches!(
text_lower.as_bytes().get(i - 1),
Some(b' ') | Some(b'-') | Some(b'_') | Some(b'.') | Some(b'/') | Some(b':')
);
if last_match_idx == (i as i32) - 1 {
consecutive += 1;
score -= (consecutive as f64) * 5.0;
} else {
consecutive = 0;
if last_match_idx >= 0 {
score += ((i as i32) - last_match_idx - 1) as f64 * 2.0;
}
}
if is_word_boundary {
score -= 10.0;
}
score += (i as f64) * 0.1;
last_match_idx = i as i32;
query_idx += 1;
}
}
if query_idx < query_chars.len() {
return FuzzyMatch {
matches: false,
score: 0.0,
};
}
if normalized_query == text_lower.as_str() {
score -= 100.0;
}
FuzzyMatch {
matches: true,
score,
}
};
let primary = match_query(&query_lower);
if primary.matches {
return primary;
}
let swapped = swap_alphanumeric(&query_lower);
if swapped.is_empty() {
return primary;
}
let swapped_match = match_query(&swapped);
if swapped_match.matches {
FuzzyMatch {
matches: true,
score: swapped_match.score + 5.0,
}
} else {
primary
}
}
fn swap_alphanumeric(query: &str) -> String {
if let Some(pos) = query.find(|c: char| c.is_ascii_digit())
&& pos > 0
&& query[..pos].chars().all(|c| c.is_ascii_alphabetic())
{
let alpha = &query[..pos];
let rest = &query[pos..];
return format!("{}{}", rest, alpha);
}
if let Some(pos) = query.find(|c: char| c.is_ascii_alphabetic())
&& pos > 0
&& query[..pos].chars().all(|c| c.is_ascii_digit())
{
let digits = &query[..pos];
let rest = &query[pos..];
return format!("{}{}", rest, digits);
}
String::new()
}
pub fn fuzzy_filter<T>(items: &[T], query: &str, get_text: impl Fn(&T) -> &str) -> Vec<usize> {
if query.trim().is_empty() {
return (0..items.len()).collect();
}
let tokens: Vec<&str> = query
.trim()
.split(|c: char| c.is_whitespace() || c == '/')
.filter(|t| !t.is_empty())
.collect();
if tokens.is_empty() {
return (0..items.len()).collect();
}
let mut results: Vec<(usize, f64)> = Vec::new();
for (idx, item) in items.iter().enumerate() {
let text = get_text(item);
let mut total_score = 0.0;
let mut all_match = true;
for token in &tokens {
let m = fuzzy_match(token, text);
if m.matches {
total_score += m.score;
} else {
all_match = false;
break;
}
}
if all_match {
results.push((idx, total_score));
}
}
results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
results.into_iter().map(|(idx, _)| idx).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_query_matches_all() {
let result = fuzzy_match("", "anything");
assert!(result.matches);
assert_eq!(result.score, 0.0);
}
#[test]
fn test_query_longer_than_text() {
let result = fuzzy_match("longquery", "short");
assert!(!result.matches);
}
#[test]
fn test_characters_must_appear_in_order() {
let in_order = fuzzy_match("abc", "aXbXc");
assert!(in_order.matches);
let out_of_order = fuzzy_match("abc", "cba");
assert!(!out_of_order.matches);
}
#[test]
fn test_case_insensitive() {
assert!(fuzzy_match("ABC", "abc").matches);
assert!(fuzzy_match("abc", "ABC").matches);
}
#[test]
fn test_consecutive_better_than_scattered() {
let consecutive = fuzzy_match("foo", "foobar");
let scattered = fuzzy_match("foo", "f_o_o_bar");
assert!(consecutive.matches);
assert!(scattered.matches);
assert!(consecutive.score < scattered.score);
}
#[test]
fn test_word_boundary_better() {
let at_boundary = fuzzy_match("fb", "foo-bar");
let not_boundary = fuzzy_match("fb", "afbx");
assert!(at_boundary.matches);
assert!(not_boundary.matches);
assert!(at_boundary.score < not_boundary.score);
}
#[test]
fn test_swapped_alphanumeric() {
let result = fuzzy_match("codex52", "gpt-5.2-codex");
assert!(result.matches);
}
#[test]
fn test_fuzzy_filter_empty_query_returns_all() {
let items = vec!["apple", "banana", "cherry"];
let result = fuzzy_filter(&items, "", |s| s);
assert_eq!(result, vec![0, 1, 2]);
}
#[test]
fn test_fuzzy_filter_filters_non_matching() {
let items = vec!["apple", "banana", "cherry"];
let result = fuzzy_filter(&items, "an", |s| s);
assert!(result.contains(&1)); assert!(!result.contains(&0)); assert!(!result.contains(&2)); }
#[test]
fn test_fuzzy_filter_sorts_by_quality() {
let items = vec!["a_p_p", "app", "application"];
let result = fuzzy_filter(&items, "app", |s| s);
assert_eq!(items[result[0]], "app");
}
#[test]
fn test_fuzzy_filter_slash_separated() {
let items = vec!["gpt-5.5 openai-codex"];
let result = fuzzy_filter(&items, "openai-codex/gpt-5.5", |s| s);
assert_eq!(result.len(), 1);
}
}