1use crate::contracts::Task;
18use crate::queue::search::fields::for_each_searchable_text;
19use anyhow::{Context, Result};
20use regex::{Regex, RegexBuilder};
21
22pub fn search_tasks<'a>(
23 tasks: impl IntoIterator<Item = &'a Task>,
24 query: &str,
25 use_regex: bool,
26 case_sensitive: bool,
27) -> Result<Vec<&'a Task>> {
28 let query = query.trim();
29 if query.is_empty() {
30 return Ok(Vec::new());
31 }
32
33 let matcher = if use_regex {
34 let regex = RegexBuilder::new(query)
35 .case_insensitive(!case_sensitive)
36 .build()
37 .with_context(|| {
38 format!(
39 "Invalid regular expression pattern '{}'. Provide a valid regex pattern or use substring search without --regex.",
40 query
41 )
42 })?;
43 SearchMatcher::Regex(regex)
44 } else {
45 let pattern = if case_sensitive {
46 query.to_string()
47 } else {
48 query.to_lowercase()
49 };
50 SearchMatcher::Substring {
51 pattern,
52 case_sensitive,
53 }
54 };
55
56 let mut results = Vec::new();
57 for task in tasks {
58 let mut matched = false;
59 for_each_searchable_text(task, |text| {
60 if !matched && matcher.matches(text) {
61 matched = true;
62 }
63 });
64 if matched {
65 results.push(task);
66 }
67 }
68
69 Ok(results)
70}
71
72enum SearchMatcher {
73 Regex(Regex),
74 Substring {
75 pattern: String,
76 case_sensitive: bool,
77 },
78}
79
80impl SearchMatcher {
81 fn matches(&self, text: &str) -> bool {
82 let haystack = text.trim();
83 if haystack.is_empty() {
84 return false;
85 }
86 match self {
87 SearchMatcher::Regex(re) => re.is_match(haystack),
88 SearchMatcher::Substring {
89 pattern,
90 case_sensitive,
91 } => {
92 if *case_sensitive {
93 haystack.contains(pattern)
94 } else {
95 haystack.to_lowercase().contains(pattern)
96 }
97 }
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use crate::queue::search::test_support::task;
106
107 #[test]
108 fn search_tasks_substring_case_insensitive() -> Result<()> {
109 let mut t1 = task("RQ-0001");
110 t1.title = "Fix login bug".to_string();
111 t1.evidence = vec!["Users report authentication failure".to_string()];
112 t1.plan = vec!["Debug auth service".to_string()];
113 t1.notes = vec!["Check token expiration".to_string()];
114
115 let mut t2 = task("RQ-0002");
116 t2.title = "Update docs".to_string();
117 t2.evidence = vec!["Documentation needs refresh".to_string()];
118
119 let tasks: Vec<&Task> = vec![&t1, &t2];
120 let results = search_tasks(tasks, "LOGIN", false, false)?;
121 assert_eq!(results.len(), 1);
122 assert_eq!(results[0].id, "RQ-0001");
123 Ok(())
124 }
125
126 #[test]
127 fn search_tasks_substring_case_sensitive() -> Result<()> {
128 let mut t1 = task("RQ-0001");
129 t1.title = "Fix Login bug".to_string();
130
131 let mut t2 = task("RQ-0002");
132 t2.title = "Fix login bug".to_string();
133
134 let tasks: Vec<&Task> = vec![&t1, &t2];
135 let results = search_tasks(tasks, "Login", false, true)?;
136 assert_eq!(results.len(), 1);
137 assert_eq!(results[0].id, "RQ-0001");
138 Ok(())
139 }
140
141 #[test]
142 fn search_tasks_regex_valid_pattern() -> Result<()> {
143 let mut t1 = task("RQ-0001");
144 t1.title = "Fix RQ-1234 bug".to_string();
145
146 let mut t2 = task("RQ-0002");
147 t2.title = "Update docs".to_string();
148
149 let tasks: Vec<&Task> = vec![&t1, &t2];
150 let results = search_tasks(tasks, r"RQ-\d{4}", true, false)?;
151 assert_eq!(results.len(), 1);
152 assert_eq!(results[0].id, "RQ-0001");
153 Ok(())
154 }
155
156 #[test]
157 fn search_tasks_regex_invalid_pattern() {
158 let t1 = task("RQ-0001");
159 let tasks: Vec<&Task> = vec![&t1];
160 let err = search_tasks(tasks, r"(?P<unclosed", true, false).unwrap_err();
161 let msg = format!("{err}");
162 assert!(msg.contains("Invalid regular expression"));
163 }
164
165 #[test]
166 fn search_tasks_matches_all_fields() -> Result<()> {
167 let mut t1 = task("RQ-0001");
168 t1.title = "Fix authentication".to_string();
169 t1.evidence = vec!["Login fails".to_string()];
170 t1.plan = vec!["Debug token".to_string()];
171 t1.notes = vec!["Checked logs".to_string()];
172 t1.request = Some("User request to fix login".to_string());
173 t1.tags = vec!["auth".to_string(), "bug".to_string()];
174 t1.scope = vec!["crates/auth".to_string()];
175 t1.custom_fields
176 .insert("severity".to_string(), "high".to_string());
177 t1.custom_fields
178 .insert("owner".to_string(), "team-security".to_string());
179
180 let tasks: Vec<&Task> = vec![&t1];
181
182 let results = search_tasks(tasks.iter().copied(), "authentication", false, false)?;
184 assert_eq!(results.len(), 1);
185
186 let results = search_tasks(tasks.iter().copied(), "login fails", false, false)?;
188 assert_eq!(results.len(), 1);
189
190 let results = search_tasks(tasks.iter().copied(), "debug token", false, false)?;
192 assert_eq!(results.len(), 1);
193
194 let results = search_tasks(tasks.iter().copied(), "checked logs", false, false)?;
196 assert_eq!(results.len(), 1);
197
198 let results = search_tasks(tasks.iter().copied(), "user request", false, false)?;
200 assert_eq!(results.len(), 1);
201
202 let results = search_tasks(tasks.iter().copied(), "bug", false, false)?;
204 assert_eq!(results.len(), 1);
205
206 let results = search_tasks(tasks.iter().copied(), "crates/auth", false, false)?;
208 assert_eq!(results.len(), 1);
209
210 let results = search_tasks(tasks.iter().copied(), "severity", false, false)?;
212 assert_eq!(results.len(), 1);
213
214 let results = search_tasks(tasks.iter().copied(), "team-security", false, false)?;
216 assert_eq!(results.len(), 1);
217
218 Ok(())
219 }
220
221 #[test]
222 fn search_tasks_empty_query_returns_empty() -> Result<()> {
223 let t1 = task("RQ-0001");
224 let tasks: Vec<&Task> = vec![&t1];
225 let results = search_tasks(tasks.iter().copied(), "", false, false)?;
226 assert_eq!(results.len(), 0);
227 Ok(())
228 }
229
230 #[test]
231 fn search_tasks_no_match_returns_empty() -> Result<()> {
232 let mut t1 = task("RQ-0001");
233 t1.title = "Fix authentication".to_string();
234
235 let tasks: Vec<&Task> = vec![&t1];
236 let results = search_tasks(tasks.iter().copied(), "database", false, false)?;
237 assert_eq!(results.len(), 0);
238 Ok(())
239 }
240
241 #[test]
242 fn search_tasks_regex_case_sensitive_flag() -> Result<()> {
243 let mut t1 = task("RQ-0001");
244 t1.title = "Fix LOGIN bug".to_string();
245
246 let tasks: Vec<&Task> = vec![&t1];
247 let results = search_tasks(tasks.iter().copied(), "LOGIN", true, false)?;
248 assert_eq!(results.len(), 1);
249
250 let results = search_tasks(tasks.iter().copied(), "login", true, false)?;
251 assert_eq!(results.len(), 1);
252
253 let results = search_tasks(tasks.iter().copied(), "login", true, true)?;
254 assert_eq!(results.len(), 0);
255 Ok(())
256 }
257}