Skip to main content

ralph/queue/operations/batch/
mod.rs

1//! Batch task operations for efficient multi-task updates.
2//!
3//! Responsibilities:
4//! - Apply operations to multiple tasks atomically or with partial success.
5//! - Filter tasks by tags, status, priority, scope, and age for batch selection.
6//! - Batch delete, archive, clone, split, and plan operations.
7//! - Provide detailed progress and error reporting.
8//!
9//! Does not handle:
10//! - CLI argument parsing or user interaction.
11//! - Individual task validation beyond what's in the single-task operations.
12//! - Persistence to disk (callers save the queue after batch operations).
13//!
14//! Assumptions/invariants:
15//! - Callers provide a loaded QueueFile and valid RFC3339 timestamp.
16//! - Tag filtering is case-insensitive and OR-based (any tag matches).
17//! - Status/priority/scope filters use OR logic within each filter type.
18//! - Task IDs are unique within the queue.
19
20mod delete;
21mod display;
22mod filters;
23mod generate;
24mod plan;
25mod update;
26mod validation;
27
28pub use delete::{batch_archive_tasks, batch_delete_tasks};
29pub use display::print_batch_results;
30pub use filters::{
31    BatchTaskFilters, filter_tasks_by_tags, parse_older_than_cutoff, resolve_task_ids,
32    resolve_task_ids_filtered,
33};
34pub use generate::{batch_clone_tasks, batch_split_tasks};
35pub use plan::{batch_plan_append, batch_plan_prepend};
36pub use update::{batch_apply_edit, batch_set_field, batch_set_status};
37pub use validation::collect_task_ids;
38pub(crate) use validation::{
39    BatchResultCollector, deduplicate_task_ids, preprocess_batch_ids, validate_task_ids_exist,
40};
41
42/// Result of a batch operation on a single task.
43#[derive(Debug, Clone)]
44pub struct BatchTaskResult {
45    pub task_id: String,
46    pub success: bool,
47    pub error: Option<String>,
48    pub created_task_ids: Vec<String>,
49}
50
51/// Overall result of a batch operation.
52#[derive(Debug, Clone)]
53pub struct BatchOperationResult {
54    pub total: usize,
55    pub succeeded: usize,
56    pub failed: usize,
57    pub results: Vec<BatchTaskResult>,
58}
59
60impl BatchOperationResult {
61    pub fn all_succeeded(&self) -> bool {
62        self.failed == 0
63    }
64
65    pub fn has_failures(&self) -> bool {
66        self.failed > 0
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::{
73        BatchResultCollector, batch_delete_tasks, batch_plan_append, batch_plan_prepend,
74        parse_older_than_cutoff, preprocess_batch_ids, validate_task_ids_exist,
75    };
76    use crate::contracts::{QueueFile, Task};
77
78    #[test]
79    fn parse_older_than_cutoff_parses_days() {
80        let now = "2026-02-05T00:00:00Z";
81        let result = parse_older_than_cutoff(now, "7d").unwrap();
82        assert!(result.contains("2026-01-29"));
83    }
84
85    #[test]
86    fn parse_older_than_cutoff_parses_weeks() {
87        let now = "2026-02-05T00:00:00Z";
88        let result = parse_older_than_cutoff(now, "2w").unwrap();
89        assert!(result.contains("2026-01-22"));
90    }
91
92    #[test]
93    fn parse_older_than_cutoff_parses_date() {
94        let result = parse_older_than_cutoff("2026-02-05T00:00:00Z", "2026-01-01").unwrap();
95        assert!(result.contains("2026-01-01"));
96    }
97
98    #[test]
99    fn parse_older_than_cutoff_parses_rfc3339() {
100        let result =
101            parse_older_than_cutoff("2026-02-05T00:00:00Z", "2026-01-15T12:00:00Z").unwrap();
102        assert!(result.contains("2026-01-15"));
103    }
104
105    #[test]
106    fn parse_older_than_cutoff_rejects_invalid() {
107        let result = parse_older_than_cutoff("2026-02-05T00:00:00Z", "invalid");
108        assert!(result.is_err());
109    }
110
111    #[test]
112    fn batch_delete_tasks_removes_tasks() {
113        let mut queue = QueueFile {
114            version: 1,
115            tasks: vec![
116                Task {
117                    id: "RQ-0001".to_string(),
118                    title: "Task 1".to_string(),
119                    ..Default::default()
120                },
121                Task {
122                    id: "RQ-0002".to_string(),
123                    title: "Task 2".to_string(),
124                    ..Default::default()
125                },
126                Task {
127                    id: "RQ-0003".to_string(),
128                    title: "Task 3".to_string(),
129                    ..Default::default()
130                },
131            ],
132        };
133
134        let result = batch_delete_tasks(
135            &mut queue,
136            &["RQ-0001".to_string(), "RQ-0002".to_string()],
137            false,
138        )
139        .unwrap();
140
141        assert_eq!(result.succeeded, 2);
142        assert_eq!(result.failed, 0);
143        assert_eq!(queue.tasks.len(), 1);
144        assert_eq!(queue.tasks[0].id, "RQ-0003");
145    }
146
147    #[test]
148    fn batch_delete_tasks_atomic_fails_on_missing() {
149        let mut queue = QueueFile {
150            version: 1,
151            tasks: vec![Task {
152                id: "RQ-0001".to_string(),
153                title: "Task 1".to_string(),
154                ..Default::default()
155            }],
156        };
157
158        let result = batch_delete_tasks(
159            &mut queue,
160            &["RQ-0001".to_string(), "RQ-9999".to_string()],
161            false,
162        );
163        assert!(result.is_err());
164    }
165
166    #[test]
167    fn batch_plan_append_adds_items() {
168        let mut queue = QueueFile {
169            version: 1,
170            tasks: vec![Task {
171                id: "RQ-0001".to_string(),
172                title: "Task 1".to_string(),
173                plan: vec!["Step 1".to_string()],
174                ..Default::default()
175            }],
176        };
177
178        let result = batch_plan_append(
179            &mut queue,
180            &["RQ-0001".to_string()],
181            &["Step 2".to_string(), "Step 3".to_string()],
182            "2026-02-05T00:00:00Z",
183            false,
184        )
185        .unwrap();
186
187        assert_eq!(result.succeeded, 1);
188        assert_eq!(queue.tasks[0].plan.len(), 3);
189        assert_eq!(queue.tasks[0].plan[0], "Step 1");
190        assert_eq!(queue.tasks[0].plan[1], "Step 2");
191        assert_eq!(queue.tasks[0].plan[2], "Step 3");
192    }
193
194    #[test]
195    fn batch_plan_prepend_adds_items_first() {
196        let mut queue = QueueFile {
197            version: 1,
198            tasks: vec![Task {
199                id: "RQ-0001".to_string(),
200                title: "Task 1".to_string(),
201                plan: vec!["Step 2".to_string()],
202                ..Default::default()
203            }],
204        };
205
206        let result = batch_plan_prepend(
207            &mut queue,
208            &["RQ-0001".to_string()],
209            &["Step 1".to_string()],
210            "2026-02-05T00:00:00Z",
211            false,
212        )
213        .unwrap();
214
215        assert_eq!(result.succeeded, 1);
216        assert_eq!(queue.tasks[0].plan.len(), 2);
217        assert_eq!(queue.tasks[0].plan[0], "Step 1");
218        assert_eq!(queue.tasks[0].plan[1], "Step 2");
219    }
220
221    // Tests for BatchResultCollector
222
223    #[test]
224    fn batch_result_collector_records_success() {
225        let mut collector = BatchResultCollector::new(2, false, "test");
226        collector.record_success("RQ-0001".to_string(), Vec::new());
227        collector.record_success("RQ-0002".to_string(), vec!["RQ-0003".to_string()]);
228        let result = collector.finish();
229        assert_eq!(result.total, 2);
230        assert_eq!(result.succeeded, 2);
231        assert_eq!(result.failed, 0);
232        assert!(result.all_succeeded());
233    }
234
235    #[test]
236    fn batch_result_collector_records_failure() {
237        let mut collector = BatchResultCollector::new(1, true, "test");
238        collector
239            .record_failure("RQ-0001".to_string(), "error msg".to_string())
240            .expect("record_failure should succeed with continue_on_error=true");
241        let result = collector.finish();
242        assert_eq!(result.total, 1);
243        assert_eq!(result.succeeded, 0);
244        assert_eq!(result.failed, 1);
245        assert!(result.has_failures());
246    }
247
248    #[test]
249    fn batch_result_collector_atomic_mode_fails_on_error() {
250        let mut collector = BatchResultCollector::new(1, false, "test");
251        let result = collector.record_failure("RQ-0001".to_string(), "error".to_string());
252        assert!(result.is_err());
253    }
254
255    #[test]
256    fn preprocess_batch_ids_deduplicates() {
257        let ids = vec![
258            "RQ-0001".to_string(),
259            "RQ-0001".to_string(),
260            "RQ-0002".to_string(),
261        ];
262        let result = preprocess_batch_ids(&ids, "test").unwrap();
263        assert_eq!(result, vec!["RQ-0001", "RQ-0002"]);
264    }
265
266    #[test]
267    fn preprocess_batch_ids_rejects_empty() {
268        let result = preprocess_batch_ids(&[], "test");
269        assert!(result.is_err());
270    }
271
272    #[test]
273    fn batch_result_collector_mixed_results() {
274        let mut collector = BatchResultCollector::new(3, true, "test");
275        collector.record_success("RQ-0001".to_string(), Vec::new());
276        collector
277            .record_failure("RQ-0002".to_string(), "error".to_string())
278            .expect("record_failure should succeed with continue_on_error=true");
279        collector.record_success("RQ-0003".to_string(), vec!["RQ-0004".to_string()]);
280        let result = collector.finish();
281        assert_eq!(result.total, 3);
282        assert_eq!(result.succeeded, 2);
283        assert_eq!(result.failed, 1);
284        assert!(result.has_failures());
285        assert!(!result.all_succeeded());
286    }
287
288    #[test]
289    fn batch_result_collector_error_message_content() {
290        let mut collector = BatchResultCollector::new(1, true, "test");
291        collector
292            .record_failure("RQ-0001".to_string(), "task not found".to_string())
293            .expect("record_failure should succeed with continue_on_error=true");
294        let result = collector.finish();
295        assert_eq!(result.results[0].task_id, "RQ-0001");
296        assert_eq!(result.results[0].error.as_ref().unwrap(), "task not found");
297    }
298
299    #[test]
300    fn preprocess_batch_ids_trims_whitespace() {
301        let ids = vec!["  RQ-0001  ".to_string(), "RQ-0002".to_string()];
302        let result = preprocess_batch_ids(&ids, "test").unwrap();
303        assert_eq!(result, vec!["RQ-0001", "RQ-0002"]);
304    }
305
306    #[test]
307    fn preprocess_batch_ids_preserves_order() {
308        let ids = vec![
309            "RQ-0003".to_string(),
310            "RQ-0001".to_string(),
311            "RQ-0003".to_string(),
312            "RQ-0002".to_string(),
313        ];
314        let result = preprocess_batch_ids(&ids, "test").unwrap();
315        assert_eq!(result, vec!["RQ-0003", "RQ-0001", "RQ-0002"]);
316    }
317
318    #[test]
319    fn batch_plan_append_atomic_fails_on_missing() {
320        let mut queue = QueueFile {
321            version: 1,
322            tasks: vec![Task {
323                id: "RQ-0001".to_string(),
324                title: "Task 1".to_string(),
325                plan: vec!["Step 1".to_string()],
326                ..Default::default()
327            }],
328        };
329
330        let result = batch_plan_append(
331            &mut queue,
332            &["RQ-0001".to_string(), "RQ-9999".to_string()],
333            &["Step 2".to_string()],
334            "2026-02-05T00:00:00Z",
335            false,
336        );
337        assert!(result.is_err());
338        // Queue should remain unchanged in atomic mode
339        assert_eq!(queue.tasks[0].plan.len(), 1);
340    }
341
342    #[test]
343    fn batch_plan_prepend_atomic_fails_on_missing() {
344        let mut queue = QueueFile {
345            version: 1,
346            tasks: vec![Task {
347                id: "RQ-0001".to_string(),
348                title: "Task 1".to_string(),
349                plan: vec!["Step 1".to_string()],
350                ..Default::default()
351            }],
352        };
353
354        let result = batch_plan_prepend(
355            &mut queue,
356            &["RQ-0001".to_string(), "RQ-9999".to_string()],
357            &["Step 0".to_string()],
358            "2026-02-05T00:00:00Z",
359            false,
360        );
361        assert!(result.is_err());
362        // Queue should remain unchanged in atomic mode
363        assert_eq!(queue.tasks[0].plan.len(), 1);
364        assert_eq!(queue.tasks[0].plan[0], "Step 1");
365    }
366
367    #[test]
368    fn validate_task_ids_exist_rejects_missing() {
369        let queue = QueueFile {
370            version: 1,
371            tasks: vec![Task {
372                id: "RQ-0001".to_string(),
373                title: "Task 1".to_string(),
374                ..Default::default()
375            }],
376        };
377
378        let result = validate_task_ids_exist(&queue, &["RQ-0001".to_string()]);
379        assert!(result.is_ok());
380
381        let result = validate_task_ids_exist(&queue, &["RQ-9999".to_string()]);
382        assert!(result.is_err());
383    }
384}