1mod 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#[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#[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 #[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 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 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}