Skip to main content

ralph/queue/operations/batch/
filters.rs

1//! Batch task filtering and ID resolution.
2//!
3//! Responsibilities:
4//! - Filter tasks by tags, status, priority, scope, and age.
5//! - Resolve task IDs from explicit lists or tag filters.
6//! - Parse "older than" specifications into RFC3339 cutoffs.
7//!
8//! Does not handle:
9//! - Actual task mutations (see update.rs, delete.rs, etc.).
10//! - Display/output formatting (see display.rs).
11//!
12//! Assumptions/invariants:
13//! - Tag filtering is case-insensitive and OR-based (any tag matches).
14//! - Status/priority/scope filters use OR logic within each filter type.
15
16use crate::contracts::{QueueFile, Task, TaskPriority, TaskStatus};
17use anyhow::{Result, bail};
18
19/// Filters for batch task selection.
20#[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
28/// Filter tasks by tags (case-insensitive, OR-based).
29///
30/// Returns tasks where ANY of the task's tags match ANY of the filter tags (case-insensitive).
31pub 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
56/// Parse an "older than" specification into an RFC3339 cutoff.
57///
58/// Supports:
59/// - Duration expressions: "7d", "1w" (weeks), "30d" (days)
60/// - Date: "2026-01-01"
61/// - RFC3339: "2026-01-01T00:00:00Z"
62pub 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    // Try to parse as RFC3339 first
71    if let Ok(dt) = crate::timeutil::parse_rfc3339(trimmed) {
72        return crate::timeutil::format_rfc3339(dt);
73    }
74
75    // Try duration patterns like "7d", "1w"
76    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    // Try date-only format "YYYY-MM-DD"
95    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
108/// Resolve task IDs from explicit list or tag filter, then apply additional filters.
109///
110/// If `tag_filter` is provided, returns tasks matching any of the tags.
111/// Otherwise, returns the explicit task IDs (after deduplication).
112/// Then applies status, priority, scope, and older_than filters if provided.
113///
114/// # Arguments
115/// * `queue` - The queue file to search
116/// * `task_ids` - Explicit list of task IDs
117/// * `tag_filter` - Optional list of tags to filter by
118/// * `filters` - Additional filters to apply
119/// * `now_rfc3339` - Current timestamp for age-based filtering
120///
121/// # Returns
122/// A deduplicated list of task IDs to operate on.
123pub 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    // Check if any selection criteria is provided
133    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    // First resolve base IDs via existing logic
147    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        // No tag filter and no explicit IDs - use all tasks from the queue
158        // (other filters will be applied below)
159        queue.tasks.iter().map(|t| t.id.clone()).collect()
160    };
161
162    // If no additional filters, return early
163    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    // Apply additional filters
172    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            // Status filter (OR logic)
194            if !filters.status_filter.is_empty() && !filters.status_filter.contains(&task.status) {
195                return false;
196            }
197
198            // Priority filter (OR logic)
199            if !filters.priority_filter.is_empty()
200                && !filters.priority_filter.contains(&task.priority)
201            {
202                return false;
203            }
204
205            // Scope filter (OR logic, case-insensitive substring match)
206            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            // Age filter
217            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
233/// Resolve task IDs from either explicit list or tag filter (legacy, without additional filters).
234///
235/// If `tag_filter` is provided, returns tasks matching any of the tags.
236/// Otherwise, returns the explicit task IDs (after deduplication).
237///
238/// # Arguments
239/// * `queue` - The queue file to search
240/// * `task_ids` - Explicit list of task IDs
241/// * `tag_filter` - Optional list of tags to filter by
242///
243/// # Returns
244/// A deduplicated list of task IDs to operate on.
245pub 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}