ralph/queue/search/
fuzzy.rs1use crate::contracts::Task;
19use crate::queue::search::fields::for_each_searchable_text;
20use anyhow::Result;
21
22pub 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 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 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 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 let results = fuzzy_search_tasks(tasks.iter().copied(), "login", true)?;
131 assert_eq!(results.len(), 0);
132
133 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 assert_eq!(results.len(), 2);
175
176 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 let results = fuzzy_search_tasks(tasks.iter().copied(), "authentication", false)?;
199 assert_eq!(results.len(), 1);
200
201 let results = fuzzy_search_tasks(tasks.iter().copied(), "login fails", false)?;
203 assert_eq!(results.len(), 1);
204
205 let results = fuzzy_search_tasks(tasks.iter().copied(), "debug token", false)?;
207 assert_eq!(results.len(), 1);
208
209 let results = fuzzy_search_tasks(tasks.iter().copied(), "checked logs", false)?;
211 assert_eq!(results.len(), 1);
212
213 let results = fuzzy_search_tasks(tasks.iter().copied(), "user request", false)?;
215 assert_eq!(results.len(), 1);
216
217 let results = fuzzy_search_tasks(tasks.iter().copied(), "auth", false)?;
219 assert_eq!(results.len(), 1);
220
221 let results = fuzzy_search_tasks(tasks.iter().copied(), "crates/auth", false)?;
223 assert_eq!(results.len(), 1);
224
225 let results = fuzzy_search_tasks(tasks.iter().copied(), "severity", false)?;
227 assert_eq!(results.len(), 1);
228
229 let results = fuzzy_search_tasks(tasks.iter().copied(), "high", false)?;
231 assert_eq!(results.len(), 1);
232
233 Ok(())
234 }
235}