Skip to main content

ralph/queue/search/
fuzzy.rs

1//! Fuzzy search for tasks with relevance scoring.
2//!
3//! Responsibilities:
4//! - Search tasks using fuzzy matching with relevance scoring
5//! - Return results sorted by score (highest first)
6//!
7//! Not handled here:
8//! - Substring or regex matching (see substring.rs)
9//! - Status/tag/scope filtering (see filter.rs)
10//!
11//! Invariants/assumptions:
12//! - Empty/whitespace query returns empty results
13//! - Case matching respects the case_sensitive parameter
14//! - Best field score per task is used (excludes tasks with score == 0)
15//! - Results are sorted by score descending using stable sort
16//! - All text fields are searched (title, evidence, plan, notes, request, tags, scope, custom_fields)
17
18use crate::contracts::Task;
19use crate::queue::search::fields::for_each_searchable_text;
20use anyhow::Result;
21
22/// Search tasks using fuzzy matching with relevance scoring.
23///
24/// Returns tasks sorted by match score (highest first). Each task is searched
25/// across all text fields (title, evidence, plan, notes, request, tags, scope,
26/// custom fields). The best matching field's score is used for the task.
27pub fn fuzzy_search_tasks<'a>(
28    tasks: impl IntoIterator<Item = &'a Task>,
29    query: &str,
30    case_sensitive: bool,
31) -> Result<Vec<(u32, &'a Task)>> {
32    use nucleo_matcher::pattern::{CaseMatching, Normalization};
33    use nucleo_matcher::{Config, Matcher, Utf32String};
34
35    let query = query.trim();
36    if query.is_empty() {
37        return Ok(Vec::new());
38    }
39
40    let case_matching = if case_sensitive {
41        CaseMatching::Respect
42    } else {
43        CaseMatching::Ignore
44    };
45
46    let mut matcher = Matcher::new(Config::DEFAULT);
47    let pattern =
48        nucleo_matcher::pattern::Pattern::parse(query, case_matching, Normalization::Smart);
49
50    let mut results = Vec::new();
51
52    for task in tasks {
53        let mut best_score: u32 = 0;
54
55        // Check all searchable fields
56        for_each_searchable_text(task, |text| {
57            if text.is_empty() {
58                return;
59            }
60            let haystack: Utf32String = text.into();
61            if let Some(score) = pattern.score(haystack.slice(..), &mut matcher)
62                && score > best_score
63            {
64                best_score = score;
65            }
66        });
67
68        if best_score > 0 {
69            results.push((best_score, task));
70        }
71    }
72
73    // Sort by score descending (highest first)
74    results.sort_by(|a, b| b.0.cmp(&a.0));
75
76    Ok(results)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::queue::search::test_support::task;
83
84    #[test]
85    fn fuzzy_search_basic_match() -> Result<()> {
86        let mut t1 = task("RQ-0001");
87        t1.title = "Fix authentication bug".to_string();
88
89        let mut t2 = task("RQ-0002");
90        t2.title = "Update documentation".to_string();
91
92        let tasks: Vec<&Task> = vec![&t1, &t2];
93        let results = fuzzy_search_tasks(tasks.iter().copied(), "auth bug", false)?;
94        assert_eq!(results.len(), 1);
95        assert_eq!(results[0].1.id, "RQ-0001");
96        Ok(())
97    }
98
99    #[test]
100    fn fuzzy_search_typo_tolerance() -> Result<()> {
101        let mut t1 = task("RQ-0001");
102        t1.title = "Implement fuzzy search".to_string();
103
104        let tasks: Vec<&Task> = vec![&t1];
105        // Typo: "fzy" should still match "fuzzy"
106        let results = fuzzy_search_tasks(tasks.iter().copied(), "fzy srch", false)?;
107        assert_eq!(results.len(), 1);
108        assert_eq!(results[0].1.id, "RQ-0001");
109        Ok(())
110    }
111
112    #[test]
113    fn fuzzy_search_case_insensitive() -> Result<()> {
114        let mut t1 = task("RQ-0001");
115        t1.title = "Fix LOGIN Bug".to_string();
116
117        let tasks: Vec<&Task> = vec![&t1];
118        let results = fuzzy_search_tasks(tasks.iter().copied(), "login", false)?;
119        assert_eq!(results.len(), 1);
120        Ok(())
121    }
122
123    #[test]
124    fn fuzzy_search_case_sensitive() -> Result<()> {
125        let mut t1 = task("RQ-0001");
126        t1.title = "Fix LOGIN Bug".to_string();
127
128        let tasks: Vec<&Task> = vec![&t1];
129        // Case sensitive search for "login" should not match "LOGIN"
130        let results = fuzzy_search_tasks(tasks.iter().copied(), "login", true)?;
131        assert_eq!(results.len(), 0);
132
133        // Case sensitive search for "LOGIN" should match
134        let results = fuzzy_search_tasks(tasks.iter().copied(), "LOGIN", true)?;
135        assert_eq!(results.len(), 1);
136        Ok(())
137    }
138
139    #[test]
140    fn fuzzy_search_empty_query_returns_empty() -> Result<()> {
141        let t1 = task("RQ-0001");
142        let tasks: Vec<&Task> = vec![&t1];
143        let results = fuzzy_search_tasks(tasks.iter().copied(), "", false)?;
144        assert_eq!(results.len(), 0);
145        Ok(())
146    }
147
148    #[test]
149    fn fuzzy_search_no_match_returns_empty() -> Result<()> {
150        let mut t1 = task("RQ-0001");
151        t1.title = "Fix authentication".to_string();
152
153        let tasks: Vec<&Task> = vec![&t1];
154        let results = fuzzy_search_tasks(tasks.iter().copied(), "xyz123", false)?;
155        assert_eq!(results.len(), 0);
156        Ok(())
157    }
158
159    #[test]
160    fn fuzzy_search_scores_sorted() -> Result<()> {
161        let mut t1 = task("RQ-0001");
162        t1.title = "fuzzy search implementation".to_string();
163
164        let mut t2 = task("RQ-0002");
165        t2.title = "something else entirely".to_string();
166
167        let mut t3 = task("RQ-0003");
168        t3.title = "fuzzy search and more".to_string();
169
170        let tasks: Vec<&Task> = vec![&t1, &t2, &t3];
171        let results = fuzzy_search_tasks(tasks.iter().copied(), "fuzzy search", false)?;
172
173        // Should find 2 matches (t1 and t3)
174        assert_eq!(results.len(), 2);
175
176        // Results should be sorted by score descending
177        // Exact "fuzzy search" at start of t1 should score higher than t3
178        assert!(results[0].0 >= results[1].0);
179        Ok(())
180    }
181
182    #[test]
183    fn fuzzy_search_matches_all_fields() -> Result<()> {
184        let mut t1 = task("RQ-0001");
185        t1.title = "Fix authentication".to_string();
186        t1.evidence = vec!["Login fails".to_string()];
187        t1.plan = vec!["Debug token".to_string()];
188        t1.notes = vec!["Checked logs".to_string()];
189        t1.request = Some("User request to fix login".to_string());
190        t1.tags = vec!["auth".to_string(), "bug".to_string()];
191        t1.scope = vec!["crates/auth".to_string()];
192        t1.custom_fields
193            .insert("severity".to_string(), "high".to_string());
194
195        let tasks: Vec<&Task> = vec![&t1];
196
197        // Title match
198        let results = fuzzy_search_tasks(tasks.iter().copied(), "authentication", false)?;
199        assert_eq!(results.len(), 1);
200
201        // Evidence match
202        let results = fuzzy_search_tasks(tasks.iter().copied(), "login fails", false)?;
203        assert_eq!(results.len(), 1);
204
205        // Plan match
206        let results = fuzzy_search_tasks(tasks.iter().copied(), "debug token", false)?;
207        assert_eq!(results.len(), 1);
208
209        // Notes match
210        let results = fuzzy_search_tasks(tasks.iter().copied(), "checked logs", false)?;
211        assert_eq!(results.len(), 1);
212
213        // Request match
214        let results = fuzzy_search_tasks(tasks.iter().copied(), "user request", false)?;
215        assert_eq!(results.len(), 1);
216
217        // Tag match
218        let results = fuzzy_search_tasks(tasks.iter().copied(), "auth", false)?;
219        assert_eq!(results.len(), 1);
220
221        // Scope match
222        let results = fuzzy_search_tasks(tasks.iter().copied(), "crates/auth", false)?;
223        assert_eq!(results.len(), 1);
224
225        // Custom field key match
226        let results = fuzzy_search_tasks(tasks.iter().copied(), "severity", false)?;
227        assert_eq!(results.len(), 1);
228
229        // Custom field value match
230        let results = fuzzy_search_tasks(tasks.iter().copied(), "high", false)?;
231        assert_eq!(results.len(), 1);
232
233        Ok(())
234    }
235}