1mod 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#[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#[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
65pub fn collect_task_ids(tasks: &[&crate::contracts::Task]) -> Vec<String> {
67 tasks.iter().map(|t| t.id.clone()).collect()
68}
69
70pub(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
83pub(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
107pub(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 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 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 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 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
184pub(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 #[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 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 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}