ralph/queue/operations/batch/
filters.rs1use crate::contracts::{QueueFile, Task, TaskPriority, TaskStatus};
17use anyhow::{Result, bail};
18
19#[derive(Debug, Clone, Default)]
21pub struct BatchTaskFilters {
22 pub status_filter: Vec<TaskStatus>,
23 pub priority_filter: Vec<TaskPriority>,
24 pub scope_filter: Vec<String>,
25 pub older_than: Option<String>,
26}
27
28pub fn filter_tasks_by_tags<'a>(queue: &'a QueueFile, tags: &[String]) -> Vec<&'a Task> {
32 if tags.is_empty() {
33 return Vec::new();
34 }
35
36 let normalized_filter_tags: Vec<String> = tags
37 .iter()
38 .map(|t| t.trim().to_lowercase())
39 .filter(|t| !t.is_empty())
40 .collect();
41
42 queue
43 .tasks
44 .iter()
45 .filter(|task| {
46 task.tags.iter().any(|task_tag| {
47 let normalized_task_tag = task_tag.trim().to_lowercase();
48 normalized_filter_tags
49 .iter()
50 .any(|filter_tag| filter_tag == &normalized_task_tag)
51 })
52 })
53 .collect()
54}
55
56pub fn parse_older_than_cutoff(now_rfc3339: &str, spec: &str) -> Result<String> {
63 let trimmed = spec.trim();
64 if trimmed.is_empty() {
65 bail!("Empty older_than specification");
66 }
67
68 let lower = trimmed.to_lowercase();
69
70 if let Ok(dt) = crate::timeutil::parse_rfc3339(trimmed) {
72 return crate::timeutil::format_rfc3339(dt);
73 }
74
75 if let Some(days) = lower.strip_suffix('d') {
77 let num_days: i64 = days
78 .parse()
79 .map_err(|_| anyhow::anyhow!("Invalid days in older_than: {}", spec))?;
80 let now = crate::timeutil::parse_rfc3339(now_rfc3339)?;
81 let cutoff = now - time::Duration::days(num_days);
82 return crate::timeutil::format_rfc3339(cutoff);
83 }
84
85 if let Some(weeks) = lower.strip_suffix('w') {
86 let num_weeks: i64 = weeks
87 .parse()
88 .map_err(|_| anyhow::anyhow!("Invalid weeks in older_than: {}", spec))?;
89 let now = crate::timeutil::parse_rfc3339(now_rfc3339)?;
90 let cutoff = now - time::Duration::weeks(num_weeks);
91 return crate::timeutil::format_rfc3339(cutoff);
92 }
93
94 if lower.len() == 10 && lower.contains('-') {
96 let date_str = format!("{}T00:00:00Z", lower);
97 if let Ok(dt) = crate::timeutil::parse_rfc3339(&date_str) {
98 return crate::timeutil::format_rfc3339(dt);
99 }
100 }
101
102 bail!(
103 "Unable to parse older_than: '{}'. Supported formats: '7d', '1w', '2026-01-01', RFC3339",
104 spec
105 )
106}
107
108pub fn resolve_task_ids_filtered(
124 queue: &QueueFile,
125 task_ids: &[String],
126 tag_filter: &[String],
127 filters: &BatchTaskFilters,
128 now_rfc3339: &str,
129) -> Result<Vec<String>> {
130 use super::{collect_task_ids, deduplicate_task_ids};
131
132 let has_task_ids = !task_ids.is_empty();
134 let has_tag_filter = !tag_filter.is_empty();
135 let has_other_filters = !filters.status_filter.is_empty()
136 || !filters.priority_filter.is_empty()
137 || !filters.scope_filter.is_empty()
138 || filters.older_than.is_some();
139
140 if !has_task_ids && !has_tag_filter && !has_other_filters {
141 bail!(
142 "No tasks specified. Provide task IDs, use --tag-filter, or use other filters like --status-filter, --priority-filter, --scope-filter, or --older-than."
143 );
144 }
145
146 let base_ids = if has_tag_filter {
148 let matching_tasks = filter_tasks_by_tags(queue, tag_filter);
149 if matching_tasks.is_empty() {
150 let tags_str = tag_filter.join(", ");
151 bail!("No tasks found with tags: {}", tags_str);
152 }
153 collect_task_ids(&matching_tasks)
154 } else if has_task_ids {
155 deduplicate_task_ids(task_ids)
156 } else {
157 queue.tasks.iter().map(|t| t.id.clone()).collect()
160 };
161
162 if filters.status_filter.is_empty()
164 && filters.priority_filter.is_empty()
165 && filters.scope_filter.is_empty()
166 && filters.older_than.is_none()
167 {
168 return Ok(base_ids);
169 }
170
171 let cutoff = filters
173 .older_than
174 .as_ref()
175 .map(|spec| parse_older_than_cutoff(now_rfc3339, spec))
176 .transpose()?;
177
178 let normalized_scope_filters: Vec<String> = filters
179 .scope_filter
180 .iter()
181 .map(|s| s.trim().to_lowercase())
182 .filter(|s| !s.is_empty())
183 .collect();
184
185 let filtered: Vec<String> = base_ids
186 .into_iter()
187 .filter(|id| {
188 let task = match queue.tasks.iter().find(|t| t.id == *id) {
189 Some(t) => t,
190 None => return false,
191 };
192
193 if !filters.status_filter.is_empty() && !filters.status_filter.contains(&task.status) {
195 return false;
196 }
197
198 if !filters.priority_filter.is_empty()
200 && !filters.priority_filter.contains(&task.priority)
201 {
202 return false;
203 }
204
205 if !normalized_scope_filters.is_empty() {
207 let matches_scope = task.scope.iter().any(|s| {
208 let s_lower = s.to_lowercase();
209 normalized_scope_filters.iter().any(|f| s_lower.contains(f))
210 });
211 if !matches_scope {
212 return false;
213 }
214 }
215
216 if let Some(ref cutoff_str) = cutoff
218 && let Some(ref updated_at) = task.updated_at
219 && let Ok(updated) = crate::timeutil::parse_rfc3339(updated_at)
220 && let Ok(cutoff_dt) = crate::timeutil::parse_rfc3339(cutoff_str)
221 && updated > cutoff_dt
222 {
223 return false;
224 }
225
226 true
227 })
228 .collect();
229
230 Ok(filtered)
231}
232
233pub fn resolve_task_ids(
246 queue: &QueueFile,
247 task_ids: &[String],
248 tag_filter: &[String],
249) -> Result<Vec<String>> {
250 let filters = BatchTaskFilters::default();
251 let now = crate::timeutil::now_utc_rfc3339_or_fallback();
252 resolve_task_ids_filtered(queue, task_ids, tag_filter, &filters, &now)
253}