Skip to main content

ralph/queue/search/
substring.rs

1//! Substring and regex search for tasks.
2//!
3//! Responsibilities:
4//! - Search tasks by substring or regex pattern across all text fields
5//! - Handle regex compilation with descriptive error messages
6//!
7//! Not handled here:
8//! - Fuzzy matching (see fuzzy.rs)
9//! - Status/tag/scope filtering (see filter.rs)
10//!
11//! Invariants/assumptions:
12//! - Empty/whitespace query returns empty results
13//! - Regex mode uses RegexBuilder with case_insensitive(!case_sensitive)
14//! - Invalid regex returns error with context containing "Invalid regular expression"
15//! - All text fields are searched (title, evidence, plan, notes, request, tags, scope, custom_fields)
16
17use 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        // Title match
183        let results = search_tasks(tasks.iter().copied(), "authentication", false, false)?;
184        assert_eq!(results.len(), 1);
185
186        // Evidence match
187        let results = search_tasks(tasks.iter().copied(), "login fails", false, false)?;
188        assert_eq!(results.len(), 1);
189
190        // Plan match
191        let results = search_tasks(tasks.iter().copied(), "debug token", false, false)?;
192        assert_eq!(results.len(), 1);
193
194        // Notes match
195        let results = search_tasks(tasks.iter().copied(), "checked logs", false, false)?;
196        assert_eq!(results.len(), 1);
197
198        // Request match
199        let results = search_tasks(tasks.iter().copied(), "user request", false, false)?;
200        assert_eq!(results.len(), 1);
201
202        // Tag match
203        let results = search_tasks(tasks.iter().copied(), "bug", false, false)?;
204        assert_eq!(results.len(), 1);
205
206        // Scope match
207        let results = search_tasks(tasks.iter().copied(), "crates/auth", false, false)?;
208        assert_eq!(results.len(), 1);
209
210        // Custom field key match
211        let results = search_tasks(tasks.iter().copied(), "severity", false, false)?;
212        assert_eq!(results.len(), 1);
213
214        // Custom field value match
215        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}