Skip to main content

ralph/queue/search/
filter.rs

1//! Task filtering by status, tag, and scope.
2//!
3//! Responsibilities:
4//! - Filter tasks by status, tags, and scope with AND across categories
5//! - Support limiting results to N matches
6//!
7//! Not handled here:
8//! - Text search (see substring.rs and fuzzy.rs)
9//! - Sorting or ordering beyond input order
10//!
11//! Invariants/assumptions:
12//! - Empty filter lists mean "no filtering" for that category
13//! - Within-category matching is OR (any status, any tag, any scope token)
14//! - Tag matching is case-insensitive exact match after trim/lowercase
15//! - Scope matching is case-insensitive substring match
16//! - Limit stops after N matches (preserves input order)
17
18use crate::contracts::{QueueFile, Task, TaskStatus};
19use crate::queue::search::normalize::normalize;
20use std::collections::HashSet;
21
22pub fn filter_tasks<'a>(
23    queue: &'a QueueFile,
24    statuses: &[TaskStatus],
25    tags: &[String],
26    scopes: &[String],
27    limit: Option<usize>,
28) -> Vec<&'a Task> {
29    let status_filter: HashSet<TaskStatus> = statuses.iter().copied().collect();
30    let tag_filter: HashSet<String> = tags
31        .iter()
32        .map(|tag| normalize(tag))
33        .filter(|tag| !tag.is_empty())
34        .collect();
35    let scope_filter: Vec<String> = scopes
36        .iter()
37        .map(|scope| normalize(scope))
38        .filter(|scope| !scope.is_empty())
39        .collect();
40
41    let has_status_filter = !status_filter.is_empty();
42    let has_tag_filter = !tag_filter.is_empty();
43    let has_scope_filter = !scope_filter.is_empty();
44
45    let mut out = Vec::new();
46    for task in &queue.tasks {
47        if has_status_filter && !status_filter.contains(&task.status) {
48            continue;
49        }
50        if has_tag_filter
51            && !task
52                .tags
53                .iter()
54                .any(|tag| tag_filter.contains(&normalize(tag)))
55        {
56            continue;
57        }
58        if has_scope_filter
59            && !task.scope.iter().any(|scope| {
60                let hay = normalize(scope);
61                scope_filter.iter().any(|needle| hay.contains(needle))
62            })
63        {
64            continue;
65        }
66
67        out.push(task);
68        if let Some(limit) = limit
69            && out.len() >= limit
70        {
71            break;
72        }
73    }
74    out
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use crate::contracts::{QueueFile, TaskStatus};
81    use crate::queue::search::test_support::{task_with_scope, task_with_tags_scope_status};
82
83    #[test]
84    fn filter_tasks_with_scope_filter() {
85        let queue = QueueFile {
86            version: 1,
87            tasks: vec![
88                task_with_scope("RQ-0001", vec!["crates/ralph".to_string()]),
89                task_with_scope("RQ-0002", vec!["docs/cli".to_string()]),
90                task_with_scope("RQ-0003", vec!["crates/auth".to_string()]),
91            ],
92        };
93
94        let results = filter_tasks(&queue, &[], &[], &["crates/ralph".to_string()], None);
95        assert_eq!(results.len(), 1);
96        assert_eq!(results[0].id, "RQ-0001");
97    }
98
99    #[test]
100    fn filter_tasks_scope_filter_case_insensitive() {
101        let queue = QueueFile {
102            version: 1,
103            tasks: vec![
104                task_with_scope("RQ-0001", vec!["CRATES/ralph".to_string()]),
105                task_with_scope("RQ-0002", vec!["docs/cli".to_string()]),
106            ],
107        };
108
109        let results = filter_tasks(&queue, &[], &[], &["crates/ralph".to_string()], None);
110        assert_eq!(results.len(), 1);
111        assert_eq!(results[0].id, "RQ-0001");
112    }
113
114    #[test]
115    fn filter_tasks_scope_filter_substring() {
116        let queue = QueueFile {
117            version: 1,
118            tasks: vec![
119                task_with_scope("RQ-0001", vec!["crates/ralph/src/cli".to_string()]),
120                task_with_scope("RQ-0002", vec!["docs/cli".to_string()]),
121                task_with_scope("RQ-0003", vec!["crates/auth".to_string()]),
122            ],
123        };
124
125        let results = filter_tasks(&queue, &[], &[], &["crates/ralph".to_string()], None);
126        assert_eq!(results.len(), 1);
127        assert_eq!(results[0].id, "RQ-0001");
128    }
129
130    #[test]
131    fn filter_tasks_with_multiple_scopes_or_logic() {
132        let queue = QueueFile {
133            version: 1,
134            tasks: vec![
135                task_with_scope("RQ-0001", vec!["crates/ralph".to_string()]),
136                task_with_scope("RQ-0002", vec!["docs".to_string()]),
137                task_with_scope("RQ-0003", vec!["crates/auth".to_string()]),
138            ],
139        };
140
141        let results = filter_tasks(
142            &queue,
143            &[],
144            &[],
145            &["crates/ralph".to_string(), "docs".to_string()],
146            None,
147        );
148        assert_eq!(results.len(), 2);
149        assert!(results.iter().any(|t| t.id == "RQ-0001"));
150        assert!(results.iter().any(|t| t.id == "RQ-0002"));
151    }
152
153    #[test]
154    fn filter_tasks_with_no_scope_filter() {
155        let queue = QueueFile {
156            version: 1,
157            tasks: vec![
158                task_with_scope("RQ-0001", vec!["crates/ralph".to_string()]),
159                task_with_scope("RQ-0002", vec!["docs/cli".to_string()]),
160            ],
161        };
162
163        let results = filter_tasks(&queue, &[], &[], &[], None);
164        assert_eq!(results.len(), 2);
165    }
166
167    #[test]
168    fn filter_tasks_combined_filters() {
169        let queue = QueueFile {
170            version: 1,
171            tasks: vec![
172                task_with_tags_scope_status(
173                    "RQ-0001",
174                    vec!["rust".to_string()],
175                    vec!["crates/ralph".to_string()],
176                    TaskStatus::Todo,
177                ),
178                task_with_tags_scope_status(
179                    "RQ-0002",
180                    vec!["docs".to_string()],
181                    vec!["docs".to_string()],
182                    TaskStatus::Done,
183                ),
184                task_with_tags_scope_status(
185                    "RQ-0003",
186                    vec!["rust".to_string()],
187                    vec!["crates".to_string()],
188                    TaskStatus::Doing,
189                ),
190                task_with_tags_scope_status(
191                    "RQ-0004",
192                    vec!["rust".to_string()],
193                    vec!["crates/ralph".to_string()],
194                    TaskStatus::Todo,
195                ),
196            ],
197        };
198
199        let results = filter_tasks(
200            &queue,
201            &[TaskStatus::Todo],
202            &["rust".to_string()],
203            &["crates/ralph".to_string()],
204            None,
205        );
206        assert_eq!(results.len(), 2);
207        assert!(results.iter().any(|t| t.id == "RQ-0001"));
208        assert!(results.iter().any(|t| t.id == "RQ-0004"));
209    }
210
211    #[test]
212    fn filter_tasks_status_only() {
213        let queue = QueueFile {
214            version: 1,
215            tasks: vec![
216                task_with_tags_scope_status("RQ-0001", vec![], vec![], TaskStatus::Todo),
217                task_with_tags_scope_status("RQ-0002", vec![], vec![], TaskStatus::Doing),
218                task_with_tags_scope_status("RQ-0003", vec![], vec![], TaskStatus::Todo),
219            ],
220        };
221
222        let results = filter_tasks(&queue, &[TaskStatus::Todo], &[], &[], None);
223        assert_eq!(results.len(), 2);
224        assert!(results.iter().all(|t| t.status == TaskStatus::Todo));
225    }
226
227    #[test]
228    fn filter_tasks_tag_only() {
229        let queue = QueueFile {
230            version: 1,
231            tasks: vec![
232                task_with_tags_scope_status(
233                    "RQ-0001",
234                    vec!["rust".to_string()],
235                    vec![],
236                    TaskStatus::Todo,
237                ),
238                task_with_tags_scope_status(
239                    "RQ-0002",
240                    vec!["docs".to_string()],
241                    vec![],
242                    TaskStatus::Todo,
243                ),
244                task_with_tags_scope_status(
245                    "RQ-0003",
246                    vec!["RUST".to_string()],
247                    vec![],
248                    TaskStatus::Doing,
249                ),
250            ],
251        };
252
253        let results = filter_tasks(&queue, &[], &["rust".to_string()], &[], None);
254        assert_eq!(results.len(), 2);
255        assert!(results.iter().any(|t| t.id == "RQ-0001"));
256        assert!(results.iter().any(|t| t.id == "RQ-0003"));
257    }
258
259    #[test]
260    fn filter_tasks_with_limit() {
261        let queue = QueueFile {
262            version: 1,
263            tasks: vec![
264                task_with_tags_scope_status(
265                    "RQ-0001",
266                    vec!["rust".to_string()],
267                    vec!["crates/ralph".to_string()],
268                    TaskStatus::Todo,
269                ),
270                task_with_tags_scope_status(
271                    "RQ-0002",
272                    vec!["rust".to_string()],
273                    vec!["crates/ralph".to_string()],
274                    TaskStatus::Todo,
275                ),
276                task_with_tags_scope_status(
277                    "RQ-0003",
278                    vec!["rust".to_string()],
279                    vec!["crates/ralph".to_string()],
280                    TaskStatus::Todo,
281                ),
282            ],
283        };
284
285        let results = filter_tasks(
286            &queue,
287            &[TaskStatus::Todo],
288            &["rust".to_string()],
289            &["crates/ralph".to_string()],
290            Some(2),
291        );
292        assert_eq!(results.len(), 2);
293    }
294}