use crate::contracts::Task;
use crate::queue::search::fields::for_each_searchable_text;
use anyhow::Result;
pub fn fuzzy_search_tasks<'a>(
tasks: impl IntoIterator<Item = &'a Task>,
query: &str,
case_sensitive: bool,
) -> Result<Vec<(u32, &'a Task)>> {
use nucleo_matcher::pattern::{CaseMatching, Normalization};
use nucleo_matcher::{Config, Matcher, Utf32String};
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let case_matching = if case_sensitive {
CaseMatching::Respect
} else {
CaseMatching::Ignore
};
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern =
nucleo_matcher::pattern::Pattern::parse(query, case_matching, Normalization::Smart);
let mut results = Vec::new();
for task in tasks {
let mut best_score: u32 = 0;
for_each_searchable_text(task, |text| {
if text.is_empty() {
return;
}
let haystack: Utf32String = text.into();
if let Some(score) = pattern.score(haystack.slice(..), &mut matcher)
&& score > best_score
{
best_score = score;
}
});
if best_score > 0 {
results.push((best_score, task));
}
}
results.sort_by(|a, b| b.0.cmp(&a.0));
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::queue::search::test_support::task;
#[test]
fn fuzzy_search_basic_match() -> Result<()> {
let mut t1 = task("RQ-0001");
t1.title = "Fix authentication bug".to_string();
let mut t2 = task("RQ-0002");
t2.title = "Update documentation".to_string();
let tasks: Vec<&Task> = vec![&t1, &t2];
let results = fuzzy_search_tasks(tasks.iter().copied(), "auth bug", false)?;
assert_eq!(results.len(), 1);
assert_eq!(results[0].1.id, "RQ-0001");
Ok(())
}
#[test]
fn fuzzy_search_typo_tolerance() -> Result<()> {
let mut t1 = task("RQ-0001");
t1.title = "Implement fuzzy search".to_string();
let tasks: Vec<&Task> = vec![&t1];
let results = fuzzy_search_tasks(tasks.iter().copied(), "fzy srch", false)?;
assert_eq!(results.len(), 1);
assert_eq!(results[0].1.id, "RQ-0001");
Ok(())
}
#[test]
fn fuzzy_search_case_insensitive() -> Result<()> {
let mut t1 = task("RQ-0001");
t1.title = "Fix LOGIN Bug".to_string();
let tasks: Vec<&Task> = vec![&t1];
let results = fuzzy_search_tasks(tasks.iter().copied(), "login", false)?;
assert_eq!(results.len(), 1);
Ok(())
}
#[test]
fn fuzzy_search_case_sensitive() -> Result<()> {
let mut t1 = task("RQ-0001");
t1.title = "Fix LOGIN Bug".to_string();
let tasks: Vec<&Task> = vec![&t1];
let results = fuzzy_search_tasks(tasks.iter().copied(), "login", true)?;
assert_eq!(results.len(), 0);
let results = fuzzy_search_tasks(tasks.iter().copied(), "LOGIN", true)?;
assert_eq!(results.len(), 1);
Ok(())
}
#[test]
fn fuzzy_search_empty_query_returns_empty() -> Result<()> {
let t1 = task("RQ-0001");
let tasks: Vec<&Task> = vec![&t1];
let results = fuzzy_search_tasks(tasks.iter().copied(), "", false)?;
assert_eq!(results.len(), 0);
Ok(())
}
#[test]
fn fuzzy_search_no_match_returns_empty() -> Result<()> {
let mut t1 = task("RQ-0001");
t1.title = "Fix authentication".to_string();
let tasks: Vec<&Task> = vec![&t1];
let results = fuzzy_search_tasks(tasks.iter().copied(), "xyz123", false)?;
assert_eq!(results.len(), 0);
Ok(())
}
#[test]
fn fuzzy_search_scores_sorted() -> Result<()> {
let mut t1 = task("RQ-0001");
t1.title = "fuzzy search implementation".to_string();
let mut t2 = task("RQ-0002");
t2.title = "something else entirely".to_string();
let mut t3 = task("RQ-0003");
t3.title = "fuzzy search and more".to_string();
let tasks: Vec<&Task> = vec![&t1, &t2, &t3];
let results = fuzzy_search_tasks(tasks.iter().copied(), "fuzzy search", false)?;
assert_eq!(results.len(), 2);
assert!(results[0].0 >= results[1].0);
Ok(())
}
#[test]
fn fuzzy_search_matches_all_fields() -> Result<()> {
let mut t1 = task("RQ-0001");
t1.title = "Fix authentication".to_string();
t1.evidence = vec!["Login fails".to_string()];
t1.plan = vec!["Debug token".to_string()];
t1.notes = vec!["Checked logs".to_string()];
t1.request = Some("User request to fix login".to_string());
t1.tags = vec!["auth".to_string(), "bug".to_string()];
t1.scope = vec!["crates/auth".to_string()];
t1.custom_fields
.insert("severity".to_string(), "high".to_string());
let tasks: Vec<&Task> = vec![&t1];
let results = fuzzy_search_tasks(tasks.iter().copied(), "authentication", false)?;
assert_eq!(results.len(), 1);
let results = fuzzy_search_tasks(tasks.iter().copied(), "login fails", false)?;
assert_eq!(results.len(), 1);
let results = fuzzy_search_tasks(tasks.iter().copied(), "debug token", false)?;
assert_eq!(results.len(), 1);
let results = fuzzy_search_tasks(tasks.iter().copied(), "checked logs", false)?;
assert_eq!(results.len(), 1);
let results = fuzzy_search_tasks(tasks.iter().copied(), "user request", false)?;
assert_eq!(results.len(), 1);
let results = fuzzy_search_tasks(tasks.iter().copied(), "auth", false)?;
assert_eq!(results.len(), 1);
let results = fuzzy_search_tasks(tasks.iter().copied(), "crates/auth", false)?;
assert_eq!(results.len(), 1);
let results = fuzzy_search_tasks(tasks.iter().copied(), "severity", false)?;
assert_eq!(results.len(), 1);
let results = fuzzy_search_tasks(tasks.iter().copied(), "high", false)?;
assert_eq!(results.len(), 1);
Ok(())
}
}