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;
26
27pub use delete::{batch_archive_tasks, batch_delete_tasks};
28pub use display::print_batch_results;
29pub use filters::{
30    BatchTaskFilters, filter_tasks_by_tags, parse_older_than_cutoff, resolve_task_ids,
31    resolve_task_ids_filtered,
32};
33pub use generate::{batch_clone_tasks, batch_split_tasks};
34pub use plan::{batch_plan_append, batch_plan_prepend};
35pub use update::{batch_apply_edit, batch_set_field, batch_set_status};
36
37/// Result of a batch operation on a single task.
38#[derive(Debug, Clone)]
39pub struct BatchTaskResult {
40    pub task_id: String,
41    pub success: bool,
42    pub error: Option<String>,
43    pub created_task_ids: Vec<String>,
44}
45
46/// Overall result of a batch operation.
47#[derive(Debug, Clone)]
48pub struct BatchOperationResult {
49    pub total: usize,
50    pub succeeded: usize,
51    pub failed: usize,
52    pub results: Vec<BatchTaskResult>,
53}
54
55impl BatchOperationResult {
56    pub fn all_succeeded(&self) -> bool {
57        self.failed == 0
58    }
59
60    pub fn has_failures(&self) -> bool {
61        self.failed > 0
62    }
63}
64
65/// Collect unique task IDs from a list of tasks.
66pub fn collect_task_ids(tasks: &[&crate::contracts::Task]) -> Vec<String> {
67    tasks.iter().map(|t| t.id.clone()).collect()
68}
69
70/// Deduplicate task IDs while preserving order.
71pub(crate) fn deduplicate_task_ids(task_ids: &[String]) -> Vec<String> {
72    let mut seen = std::collections::HashSet::new();
73    let mut result = Vec::new();
74    for id in task_ids {
75        let trimmed = id.trim().to_string();
76        if !trimmed.is_empty() && seen.insert(trimmed.clone()) {
77            result.push(trimmed);
78        }
79    }
80    result
81}
82
83/// Validate that all task IDs exist in the queue.
84///
85/// Returns an error if any task ID is not found.
86pub(crate) fn validate_task_ids_exist(
87    queue: &crate::contracts::QueueFile,
88    task_ids: &[String],
89) -> anyhow::Result<()> {
90    use anyhow::bail;
91
92    for task_id in task_ids {
93        let needle = task_id.trim();
94        if needle.is_empty() {
95            bail!("Empty task ID provided");
96        }
97        if !queue.tasks.iter().any(|t| t.id.trim() == needle) {
98            bail!(
99                "{}",
100                crate::error_messages::task_not_found_batch_failure(needle)
101            );
102        }
103    }
104    Ok(())
105}
106
107/// Collector for batch operation results with standardized error handling.
108///
109/// Responsibilities:
110/// - Track success/failure counts
111/// - Collect individual task results
112/// - Handle continue-on-error semantics
113///
114/// Does not handle:
115/// - Task ID deduplication (use preprocess_batch_ids)
116/// - Actual operation execution (caller responsibility)
117pub(crate) struct BatchResultCollector {
118    total: usize,
119    results: Vec<BatchTaskResult>,
120    succeeded: usize,
121    failed: usize,
122    continue_on_error: bool,
123    op_name: &'static str,
124}
125
126impl BatchResultCollector {
127    /// Create a new collector for a batch operation.
128    pub fn new(total: usize, continue_on_error: bool, op_name: &'static str) -> Self {
129        Self {
130            total,
131            results: Vec::with_capacity(total),
132            succeeded: 0,
133            failed: 0,
134            continue_on_error,
135            op_name,
136        }
137    }
138
139    /// Record a successful operation on a task.
140    pub fn record_success(&mut self, task_id: String, created_task_ids: Vec<String>) {
141        self.results.push(BatchTaskResult {
142            task_id,
143            success: true,
144            error: None,
145            created_task_ids,
146        });
147        self.succeeded += 1;
148    }
149
150    /// Record a failed operation on a task.
151    ///
152    /// Returns an error if not in continue-on-error mode, allowing caller to propagate.
153    pub fn record_failure(&mut self, task_id: String, error: String) -> anyhow::Result<()> {
154        self.results.push(BatchTaskResult {
155            task_id: task_id.clone(),
156            success: false,
157            error: Some(error.clone()),
158            created_task_ids: Vec::new(),
159        });
160        self.failed += 1;
161
162        if !self.continue_on_error {
163            anyhow::bail!(
164                "Batch {} failed at task {}: {}. Use --continue-on-error to process remaining tasks.",
165                self.op_name,
166                task_id,
167                error
168            );
169        }
170        Ok(())
171    }
172
173    /// Consume the collector and return the final result.
174    pub fn finish(self) -> BatchOperationResult {
175        BatchOperationResult {
176            total: self.total,
177            succeeded: self.succeeded,
178            failed: self.failed,
179            results: self.results,
180        }
181    }
182}
183
184/// Preprocess task IDs for batch operations.
185///
186/// Deduplicates task IDs and validates the list is not empty.
187pub(crate) fn preprocess_batch_ids(
188    task_ids: &[String],
189    op_name: &str,
190) -> anyhow::Result<Vec<String>> {
191    let unique_ids = deduplicate_task_ids(task_ids);
192    if unique_ids.is_empty() {
193        anyhow::bail!("No task IDs provided for batch {}", op_name);
194    }
195    Ok(unique_ids)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::{
201        BatchResultCollector, batch_delete_tasks, batch_plan_append, batch_plan_prepend,
202        parse_older_than_cutoff, preprocess_batch_ids, validate_task_ids_exist,
203    };
204    use crate::contracts::{QueueFile, Task};
205
206    #[test]
207    fn parse_older_than_cutoff_parses_days() {
208        let now = "2026-02-05T00:00:00Z";
209        let result = parse_older_than_cutoff(now, "7d").unwrap();
210        assert!(result.contains("2026-01-29"));
211    }
212
213    #[test]
214    fn parse_older_than_cutoff_parses_weeks() {
215        let now = "2026-02-05T00:00:00Z";
216        let result = parse_older_than_cutoff(now, "2w").unwrap();
217        assert!(result.contains("2026-01-22"));
218    }
219
220    #[test]
221    fn parse_older_than_cutoff_parses_date() {
222        let result = parse_older_than_cutoff("2026-02-05T00:00:00Z", "2026-01-01").unwrap();
223        assert!(result.contains("2026-01-01"));
224    }
225
226    #[test]
227    fn parse_older_than_cutoff_parses_rfc3339() {
228        let result =
229            parse_older_than_cutoff("2026-02-05T00:00:00Z", "2026-01-15T12:00:00Z").unwrap();
230        assert!(result.contains("2026-01-15"));
231    }
232
233    #[test]
234    fn parse_older_than_cutoff_rejects_invalid() {
235        let result = parse_older_than_cutoff("2026-02-05T00:00:00Z", "invalid");
236        assert!(result.is_err());
237    }
238
239    #[test]
240    fn batch_delete_tasks_removes_tasks() {
241        let mut queue = QueueFile {
242            version: 1,
243            tasks: vec![
244                Task {
245                    id: "RQ-0001".to_string(),
246                    title: "Task 1".to_string(),
247                    ..Default::default()
248                },
249                Task {
250                    id: "RQ-0002".to_string(),
251                    title: "Task 2".to_string(),
252                    ..Default::default()
253                },
254                Task {
255                    id: "RQ-0003".to_string(),
256                    title: "Task 3".to_string(),
257                    ..Default::default()
258                },
259            ],
260        };
261
262        let result = batch_delete_tasks(
263            &mut queue,
264            &["RQ-0001".to_string(), "RQ-0002".to_string()],
265            false,
266        )
267        .unwrap();
268
269        assert_eq!(result.succeeded, 2);
270        assert_eq!(result.failed, 0);
271        assert_eq!(queue.tasks.len(), 1);
272        assert_eq!(queue.tasks[0].id, "RQ-0003");
273    }
274
275    #[test]
276    fn batch_delete_tasks_atomic_fails_on_missing() {
277        let mut queue = QueueFile {
278            version: 1,
279            tasks: vec![Task {
280                id: "RQ-0001".to_string(),
281                title: "Task 1".to_string(),
282                ..Default::default()
283            }],
284        };
285
286        let result = batch_delete_tasks(
287            &mut queue,
288            &["RQ-0001".to_string(), "RQ-9999".to_string()],
289            false,
290        );
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn batch_plan_append_adds_items() {
296        let mut queue = QueueFile {
297            version: 1,
298            tasks: vec![Task {
299                id: "RQ-0001".to_string(),
300                title: "Task 1".to_string(),
301                plan: vec!["Step 1".to_string()],
302                ..Default::default()
303            }],
304        };
305
306        let result = batch_plan_append(
307            &mut queue,
308            &["RQ-0001".to_string()],
309            &["Step 2".to_string(), "Step 3".to_string()],
310            "2026-02-05T00:00:00Z",
311            false,
312        )
313        .unwrap();
314
315        assert_eq!(result.succeeded, 1);
316        assert_eq!(queue.tasks[0].plan.len(), 3);
317        assert_eq!(queue.tasks[0].plan[0], "Step 1");
318        assert_eq!(queue.tasks[0].plan[1], "Step 2");
319        assert_eq!(queue.tasks[0].plan[2], "Step 3");
320    }
321
322    #[test]
323    fn batch_plan_prepend_adds_items_first() {
324        let mut queue = QueueFile {
325            version: 1,
326            tasks: vec![Task {
327                id: "RQ-0001".to_string(),
328                title: "Task 1".to_string(),
329                plan: vec!["Step 2".to_string()],
330                ..Default::default()
331            }],
332        };
333
334        let result = batch_plan_prepend(
335            &mut queue,
336            &["RQ-0001".to_string()],
337            &["Step 1".to_string()],
338            "2026-02-05T00:00:00Z",
339            false,
340        )
341        .unwrap();
342
343        assert_eq!(result.succeeded, 1);
344        assert_eq!(queue.tasks[0].plan.len(), 2);
345        assert_eq!(queue.tasks[0].plan[0], "Step 1");
346        assert_eq!(queue.tasks[0].plan[1], "Step 2");
347    }
348
349    // Tests for BatchResultCollector
350
351    #[test]
352    fn batch_result_collector_records_success() {
353        let mut collector = BatchResultCollector::new(2, false, "test");
354        collector.record_success("RQ-0001".to_string(), Vec::new());
355        collector.record_success("RQ-0002".to_string(), vec!["RQ-0003".to_string()]);
356        let result = collector.finish();
357        assert_eq!(result.total, 2);
358        assert_eq!(result.succeeded, 2);
359        assert_eq!(result.failed, 0);
360        assert!(result.all_succeeded());
361    }
362
363    #[test]
364    fn batch_result_collector_records_failure() {
365        let mut collector = BatchResultCollector::new(1, true, "test");
366        collector
367            .record_failure("RQ-0001".to_string(), "error msg".to_string())
368            .expect("record_failure should succeed with continue_on_error=true");
369        let result = collector.finish();
370        assert_eq!(result.total, 1);
371        assert_eq!(result.succeeded, 0);
372        assert_eq!(result.failed, 1);
373        assert!(result.has_failures());
374    }
375
376    #[test]
377    fn batch_result_collector_atomic_mode_fails_on_error() {
378        let mut collector = BatchResultCollector::new(1, false, "test");
379        let result = collector.record_failure("RQ-0001".to_string(), "error".to_string());
380        assert!(result.is_err());
381    }
382
383    #[test]
384    fn preprocess_batch_ids_deduplicates() {
385        let ids = vec![
386            "RQ-0001".to_string(),
387            "RQ-0001".to_string(),
388            "RQ-0002".to_string(),
389        ];
390        let result = preprocess_batch_ids(&ids, "test").unwrap();
391        assert_eq!(result, vec!["RQ-0001", "RQ-0002"]);
392    }
393
394    #[test]
395    fn preprocess_batch_ids_rejects_empty() {
396        let result = preprocess_batch_ids(&[], "test");
397        assert!(result.is_err());
398    }
399
400    #[test]
401    fn batch_result_collector_mixed_results() {
402        let mut collector = BatchResultCollector::new(3, true, "test");
403        collector.record_success("RQ-0001".to_string(), Vec::new());
404        collector
405            .record_failure("RQ-0002".to_string(), "error".to_string())
406            .expect("record_failure should succeed with continue_on_error=true");
407        collector.record_success("RQ-0003".to_string(), vec!["RQ-0004".to_string()]);
408        let result = collector.finish();
409        assert_eq!(result.total, 3);
410        assert_eq!(result.succeeded, 2);
411        assert_eq!(result.failed, 1);
412        assert!(result.has_failures());
413        assert!(!result.all_succeeded());
414    }
415
416    #[test]
417    fn batch_result_collector_error_message_content() {
418        let mut collector = BatchResultCollector::new(1, true, "test");
419        collector
420            .record_failure("RQ-0001".to_string(), "task not found".to_string())
421            .expect("record_failure should succeed with continue_on_error=true");
422        let result = collector.finish();
423        assert_eq!(result.results[0].task_id, "RQ-0001");
424        assert_eq!(result.results[0].error.as_ref().unwrap(), "task not found");
425    }
426
427    #[test]
428    fn preprocess_batch_ids_trims_whitespace() {
429        let ids = vec!["  RQ-0001  ".to_string(), "RQ-0002".to_string()];
430        let result = preprocess_batch_ids(&ids, "test").unwrap();
431        assert_eq!(result, vec!["RQ-0001", "RQ-0002"]);
432    }
433
434    #[test]
435    fn preprocess_batch_ids_preserves_order() {
436        let ids = vec![
437            "RQ-0003".to_string(),
438            "RQ-0001".to_string(),
439            "RQ-0003".to_string(),
440            "RQ-0002".to_string(),
441        ];
442        let result = preprocess_batch_ids(&ids, "test").unwrap();
443        assert_eq!(result, vec!["RQ-0003", "RQ-0001", "RQ-0002"]);
444    }
445
446    #[test]
447    fn batch_plan_append_atomic_fails_on_missing() {
448        let mut queue = QueueFile {
449            version: 1,
450            tasks: vec![Task {
451                id: "RQ-0001".to_string(),
452                title: "Task 1".to_string(),
453                plan: vec!["Step 1".to_string()],
454                ..Default::default()
455            }],
456        };
457
458        let result = batch_plan_append(
459            &mut queue,
460            &["RQ-0001".to_string(), "RQ-9999".to_string()],
461            &["Step 2".to_string()],
462            "2026-02-05T00:00:00Z",
463            false,
464        );
465        assert!(result.is_err());
466        // Queue should remain unchanged in atomic mode
467        assert_eq!(queue.tasks[0].plan.len(), 1);
468    }
469
470    #[test]
471    fn batch_plan_prepend_atomic_fails_on_missing() {
472        let mut queue = QueueFile {
473            version: 1,
474            tasks: vec![Task {
475                id: "RQ-0001".to_string(),
476                title: "Task 1".to_string(),
477                plan: vec!["Step 1".to_string()],
478                ..Default::default()
479            }],
480        };
481
482        let result = batch_plan_prepend(
483            &mut queue,
484            &["RQ-0001".to_string(), "RQ-9999".to_string()],
485            &["Step 0".to_string()],
486            "2026-02-05T00:00:00Z",
487            false,
488        );
489        assert!(result.is_err());
490        // Queue should remain unchanged in atomic mode
491        assert_eq!(queue.tasks[0].plan.len(), 1);
492        assert_eq!(queue.tasks[0].plan[0], "Step 1");
493    }
494
495    #[test]
496    fn validate_task_ids_exist_rejects_missing() {
497        let queue = QueueFile {
498            version: 1,
499            tasks: vec![Task {
500                id: "RQ-0001".to_string(),
501                title: "Task 1".to_string(),
502                ..Default::default()
503            }],
504        };
505
506        let result = validate_task_ids_exist(&queue, &["RQ-0001".to_string()]);
507        assert!(result.is_ok());
508
509        let result = validate_task_ids_exist(&queue, &["RQ-9999".to_string()]);
510        assert!(result.is_err());
511    }
512}