1use crate::contracts::{Task, TaskStatus};
13use crate::timeutil;
14use anyhow::Result;
15use std::cmp::Ordering;
16use std::collections::HashSet;
17use std::path::Path;
18use time::{Duration, OffsetDateTime};
19
20#[derive(Debug, Clone, Default)]
22pub struct PruneReport {
23 pub pruned_ids: Vec<String>,
25 pub kept_ids: Vec<String>,
27}
28
29#[derive(Debug, Clone)]
31pub struct PruneOptions {
32 pub age_days: Option<u32>,
34 pub statuses: HashSet<TaskStatus>,
36 pub keep_last: Option<u32>,
38 pub dry_run: bool,
40}
41
42pub fn prune_done_tasks(done_path: &Path, options: PruneOptions) -> Result<PruneReport> {
54 let mut done = super::load_queue_or_default(done_path)?;
55 let report = prune_done_queue(&mut done.tasks, &options)?;
56
57 if !options.dry_run && !report.pruned_ids.is_empty() {
58 super::save_queue(done_path, &done)?;
59 }
60
61 Ok(report)
62}
63
64fn prune_done_queue(tasks: &mut Vec<Task>, options: &PruneOptions) -> Result<PruneReport> {
70 let now_dt = OffsetDateTime::now_utc();
72 prune_done_queue_at(tasks, options, now_dt)
73}
74
75fn prune_done_queue_at(
76 tasks: &mut Vec<Task>,
77 options: &PruneOptions,
78 now_dt: OffsetDateTime,
79) -> Result<PruneReport> {
80 let age_duration = options.age_days.map(|d| Duration::days(d as i64));
81
82 let mut indices: Vec<usize> = (0..tasks.len()).collect();
84 indices.sort_by(|&i, &j| compare_completed_desc(&tasks[i], &j, tasks));
85
86 let mut keep_set: HashSet<usize> = HashSet::new();
88 if let Some(keep_n) = options.keep_last {
89 for &idx in indices.iter().take(keep_n as usize) {
90 keep_set.insert(idx);
91 }
92 }
93
94 let mut pruned_ids = Vec::new();
95 let mut kept_ids = Vec::new();
96
97 let mut keep_mask = vec![false; tasks.len()];
99 for (idx, task) in tasks.iter().enumerate() {
100 if keep_set.contains(&idx) {
102 keep_mask[idx] = true;
103 kept_ids.push(task.id.clone());
104 continue;
105 }
106
107 if !options.statuses.is_empty() && !options.statuses.contains(&task.status) {
109 keep_mask[idx] = true;
110 kept_ids.push(task.id.clone());
111 continue;
112 }
113
114 if let Some(ref completed_at) = task.completed_at {
116 if let Some(task_dt) = parse_completed_at(completed_at) {
117 if let Some(age_dur) = age_duration {
118 let age = if now_dt >= task_dt {
121 now_dt - task_dt
122 } else {
123 Duration::ZERO
125 };
126 if age < age_dur {
127 keep_mask[idx] = true;
129 kept_ids.push(task.id.clone());
130 continue;
131 }
132 }
133 } else {
134 keep_mask[idx] = true;
136 kept_ids.push(task.id.clone());
137 continue;
138 }
139 } else {
140 keep_mask[idx] = true;
142 kept_ids.push(task.id.clone());
143 continue;
144 }
145
146 pruned_ids.push(task.id.clone());
148 }
149
150 let mut new_tasks = Vec::new();
152 for (idx, task) in tasks.drain(..).enumerate() {
153 if keep_mask[idx] {
154 new_tasks.push(task);
155 }
156 }
157 *tasks = new_tasks;
158
159 Ok(PruneReport {
160 pruned_ids,
161 kept_ids,
162 })
163}
164
165#[cfg(test)]
166fn prune_done_tasks_at(
167 done_path: &Path,
168 options: PruneOptions,
169 now_dt: OffsetDateTime,
170) -> Result<PruneReport> {
171 let mut done = super::load_queue_or_default(done_path)?;
172 let report = prune_done_queue_at(&mut done.tasks, &options, now_dt)?;
173
174 if !options.dry_run && !report.pruned_ids.is_empty() {
175 super::save_queue(done_path, &done)?;
176 }
177
178 Ok(report)
179}
180
181fn parse_completed_at(ts: &str) -> Option<OffsetDateTime> {
184 timeutil::parse_rfc3339_opt(ts)
185}
186
187fn compare_completed_desc(a: &Task, idx_b: &usize, tasks: &[Task]) -> Ordering {
191 let b = &tasks[*idx_b];
192 let a_ts = parse_completed_at;
193 let b_ts = parse_completed_at;
194
195 match (a.completed_at.as_deref(), b.completed_at.as_deref()) {
196 (Some(ts_a), Some(ts_b)) => match (a_ts(ts_a), b_ts(ts_b)) {
197 (Some(dt_a), Some(dt_b)) => dt_a.cmp(&dt_b).reverse(),
198 (Some(_), None) => Ordering::Less,
199 (None, Some(_)) => Ordering::Greater,
200 (None, None) => Ordering::Equal,
201 },
202 (Some(_), None) => Ordering::Less,
203 (None, Some(_)) => Ordering::Greater,
204 (None, None) => Ordering::Equal,
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::super::{load_queue, save_queue};
213 use super::*;
214 use crate::contracts::{QueueFile, Task, TaskStatus};
215 use std::collections::{HashMap, HashSet};
216 use tempfile::TempDir;
217
218 fn fixed_now() -> OffsetDateTime {
219 timeutil::parse_rfc3339("2026-01-20T12:00:00Z").expect("fixed timestamp should parse")
220 }
221
222 fn done_task_with_completed(id: &str, completed_at: &str) -> Task {
224 let mut t = task_with(id, TaskStatus::Done, vec!["done".to_string()]);
225 t.completed_at = Some(completed_at.to_string());
226 t
227 }
228
229 fn done_task_missing_completed(id: &str) -> Task {
231 let mut t = task_with(id, TaskStatus::Done, vec!["done".to_string()]);
232 t.completed_at = None;
233 t
234 }
235
236 fn task_with(id: &str, status: TaskStatus, tags: Vec<String>) -> Task {
237 Task {
238 id: id.to_string(),
239 status,
240 title: "Test task".to_string(),
241 description: None,
242 priority: Default::default(),
243 tags,
244 scope: vec!["crates/ralph".to_string()],
245 evidence: vec!["observed".to_string()],
246 plan: vec!["do thing".to_string()],
247 notes: vec![],
248 request: Some("test request".to_string()),
249 agent: None,
250 created_at: Some("2026-01-18T00:00:00Z".to_string()),
251 updated_at: Some("2026-01-18T00:00:00Z".to_string()),
252 completed_at: None,
253 started_at: None,
254 scheduled_start: None,
255 estimated_minutes: None,
256 actual_minutes: None,
257 depends_on: vec![],
258 blocks: vec![],
259 relates_to: vec![],
260 duplicates: None,
261 custom_fields: HashMap::new(),
262 parent_id: None,
263 }
264 }
265
266 #[test]
267 fn prune_by_age_only() {
268 let tasks = vec![
270 done_task_with_completed("RQ-0001", "2026-01-01T12:00:00Z"),
271 done_task_with_completed("RQ-0002", "2026-01-10T12:00:00Z"),
272 done_task_with_completed("RQ-0003", "2026-01-19T12:00:00Z"),
273 ];
274
275 let temp_dir = TempDir::new().unwrap();
276 let done_path = temp_dir.path().join("done.json");
277 let queue_file = QueueFile {
278 version: 1,
279 tasks: tasks.clone(),
280 };
281 save_queue(&done_path, &queue_file).unwrap();
282
283 let options = PruneOptions {
284 age_days: Some(15),
285 statuses: HashSet::new(),
286 keep_last: None,
287 dry_run: false,
288 };
289
290 let mut done = load_queue(&done_path).unwrap();
291 let report = prune_done_queue_at(&mut done.tasks, &options, fixed_now()).unwrap();
292
293 assert_eq!(report.pruned_ids, vec!["RQ-0001"]);
294 assert_eq!(report.kept_ids.len(), 2);
295 assert!(report.kept_ids.contains(&"RQ-0002".to_string()));
296 assert!(report.kept_ids.contains(&"RQ-0003".to_string()));
297 assert_eq!(done.tasks.len(), 2);
298 }
299
300 #[test]
301 fn prune_by_status_only() {
302 let mut tasks = vec![
303 done_task_with_completed("RQ-0001", "2026-01-01T12:00:00Z"),
304 done_task_with_completed("RQ-0002", "2026-01-10T12:00:00Z"),
305 task_with("RQ-0003", TaskStatus::Rejected, vec!["done".to_string()]),
306 ];
307 tasks[2].completed_at = Some("2026-01-15T12:00:00Z".to_string());
308
309 let temp_dir = TempDir::new().unwrap();
310 let done_path = temp_dir.path().join("done.json");
311 let queue_file = QueueFile {
312 version: 1,
313 tasks: tasks.clone(),
314 };
315 save_queue(&done_path, &queue_file).unwrap();
316
317 let options = PruneOptions {
318 age_days: None,
319 statuses: vec![TaskStatus::Rejected].into_iter().collect(),
320 keep_last: None,
321 dry_run: false,
322 };
323
324 let mut done = load_queue(&done_path).unwrap();
325 let report = prune_done_queue_at(&mut done.tasks, &options, fixed_now()).unwrap();
326
327 assert_eq!(report.pruned_ids, vec!["RQ-0003"]);
328 assert_eq!(report.kept_ids.len(), 2);
329 assert_eq!(done.tasks.len(), 2);
330 }
331
332 #[test]
333 fn prune_keep_last_protects_recent() {
334 let tasks = vec![
335 done_task_with_completed("RQ-0001", "2026-01-01T12:00:00Z"),
336 done_task_with_completed("RQ-0002", "2026-01-10T12:00:00Z"),
337 done_task_with_completed("RQ-0003", "2026-01-15T12:00:00Z"),
338 done_task_with_completed("RQ-0004", "2026-01-19T12:00:00Z"),
339 ];
340
341 let temp_dir = TempDir::new().unwrap();
342 let done_path = temp_dir.path().join("done.json");
343 let queue_file = QueueFile {
344 version: 1,
345 tasks: tasks.clone(),
346 };
347 save_queue(&done_path, &queue_file).unwrap();
348
349 let options = PruneOptions {
350 age_days: None,
351 statuses: HashSet::new(),
352 keep_last: Some(2),
353 dry_run: false,
354 };
355
356 let mut done = load_queue(&done_path).unwrap();
357 let report = prune_done_queue_at(&mut done.tasks, &options, fixed_now()).unwrap();
358
359 assert_eq!(report.kept_ids.len(), 2);
360 assert!(report.kept_ids.contains(&"RQ-0003".to_string()));
361 assert!(report.kept_ids.contains(&"RQ-0004".to_string()));
362 assert_eq!(report.pruned_ids.len(), 2);
363 assert!(report.pruned_ids.contains(&"RQ-0001".to_string()));
364 assert!(report.pruned_ids.contains(&"RQ-0002".to_string()));
365 assert_eq!(done.tasks.len(), 2);
366 }
367
368 #[test]
369 fn prune_keep_last_with_duplicate_ids() {
370 let tasks = vec![
371 done_task_with_completed("RQ-0001", "2026-01-01T12:00:00Z"),
372 done_task_with_completed("RQ-0002", "2026-01-10T12:00:00Z"),
373 done_task_with_completed("RQ-0003", "2026-01-15T12:00:00Z"),
374 done_task_with_completed("RQ-0003", "2026-01-19T12:00:00Z"),
375 ];
376
377 let temp_dir = TempDir::new().unwrap();
378 let done_path = temp_dir.path().join("done.json");
379 let queue_file = QueueFile {
380 version: 1,
381 tasks: tasks.clone(),
382 };
383 save_queue(&done_path, &queue_file).unwrap();
384
385 let options = PruneOptions {
386 age_days: None,
387 statuses: HashSet::new(),
388 keep_last: Some(2),
389 dry_run: false,
390 };
391
392 let mut done = load_queue(&done_path).unwrap();
393 let report = prune_done_queue_at(&mut done.tasks, &options, fixed_now()).unwrap();
394
395 assert_eq!(report.kept_ids.len(), 2);
396 assert_eq!(report.pruned_ids.len(), 2);
397 assert_eq!(done.tasks.len(), 2);
398 assert_eq!(done.tasks[0].id, "RQ-0003");
399 assert_eq!(done.tasks[1].id, "RQ-0003");
400 assert_eq!(report.kept_ids, vec!["RQ-0003", "RQ-0003"]);
401 assert_eq!(report.pruned_ids, vec!["RQ-0001", "RQ-0002"]);
402 }
403
404 #[test]
405 fn prune_combined_age_and_status() {
406 let mut tasks = vec![
407 done_task_with_completed("RQ-0001", "2026-01-01T12:00:00Z"),
408 done_task_with_completed("RQ-0002", "2026-01-10T12:00:00Z"),
409 task_with("RQ-0003", TaskStatus::Rejected, vec!["done".to_string()]),
410 task_with("RQ-0004", TaskStatus::Rejected, vec!["done".to_string()]),
411 ];
412 tasks[2].completed_at = Some("2026-01-05T12:00:00Z".to_string());
413 tasks[3].completed_at = Some("2026-01-15T12:00:00Z".to_string());
414
415 let temp_dir = TempDir::new().unwrap();
416 let done_path = temp_dir.path().join("done.json");
417 let queue_file = QueueFile {
418 version: 1,
419 tasks: tasks.clone(),
420 };
421 save_queue(&done_path, &queue_file).unwrap();
422
423 let options = PruneOptions {
424 age_days: Some(10),
425 statuses: vec![TaskStatus::Rejected].into_iter().collect(),
426 keep_last: None,
427 dry_run: false,
428 };
429
430 let mut done = load_queue(&done_path).unwrap();
431 let report = prune_done_queue_at(&mut done.tasks, &options, fixed_now()).unwrap();
432
433 assert_eq!(report.pruned_ids, vec!["RQ-0003"]);
434 assert_eq!(report.kept_ids.len(), 3);
435 assert_eq!(done.tasks.len(), 3);
436 }
437
438 #[test]
439 fn prune_missing_completed_at_kept_for_safety() {
440 let tasks = vec![
441 done_task_with_completed("RQ-0001", "2026-01-01T12:00:00Z"),
442 done_task_missing_completed("RQ-0002"),
443 done_task_with_completed("RQ-0003", "2026-01-18T12:00:00Z"),
444 ];
445
446 let temp_dir = TempDir::new().unwrap();
447 let done_path = temp_dir.path().join("done.json");
448 let queue_file = QueueFile {
449 version: 1,
450 tasks: tasks.clone(),
451 };
452 save_queue(&done_path, &queue_file).unwrap();
453
454 let options = PruneOptions {
455 age_days: Some(5),
456 statuses: HashSet::new(),
457 keep_last: None,
458 dry_run: false,
459 };
460
461 let mut done = load_queue(&done_path).unwrap();
462 let report = prune_done_queue_at(&mut done.tasks, &options, fixed_now()).unwrap();
463
464 assert_eq!(report.pruned_ids, vec!["RQ-0001"]);
465 assert_eq!(report.kept_ids.len(), 2);
466 assert!(report.kept_ids.contains(&"RQ-0002".to_string()));
467 assert!(report.kept_ids.contains(&"RQ-0003".to_string()));
468 assert_eq!(done.tasks.len(), 2);
469 }
470
471 #[test]
472 fn prune_dry_run_does_not_write_to_disk() {
473 let tasks = vec![
474 done_task_with_completed("RQ-0001", "2026-01-01T12:00:00Z"),
475 done_task_with_completed("RQ-0002", "2026-01-18T12:00:00Z"),
476 ];
477
478 let temp_dir = TempDir::new().unwrap();
479 let done_path = temp_dir.path().join("done.json");
480 let queue_file = QueueFile {
481 version: 1,
482 tasks: tasks.clone(),
483 };
484 save_queue(&done_path, &queue_file).unwrap();
485
486 let options = PruneOptions {
487 age_days: Some(5),
488 statuses: HashSet::new(),
489 keep_last: None,
490 dry_run: true,
491 };
492
493 let report = prune_done_tasks_at(&done_path, options, fixed_now()).unwrap();
494
495 assert_eq!(report.pruned_ids, vec!["RQ-0001"]);
496
497 let done_after = load_queue(&done_path).unwrap();
498 assert_eq!(done_after.tasks.len(), 2);
499 }
500
501 #[test]
502 fn prune_preserves_original_order() {
503 let tasks = vec![
504 done_task_with_completed("RQ-0001", "2026-01-01T12:00:00Z"),
505 done_task_with_completed("RQ-0002", "2026-01-16T12:00:00Z"),
506 done_task_with_completed("RQ-0003", "2026-01-18T12:00:00Z"),
507 ];
508
509 let temp_dir = TempDir::new().unwrap();
510 let done_path = temp_dir.path().join("done.json");
511 let queue_file = QueueFile {
512 version: 1,
513 tasks: tasks.clone(),
514 };
515 save_queue(&done_path, &queue_file).unwrap();
516
517 let options = PruneOptions {
518 age_days: Some(5),
519 statuses: HashSet::new(),
520 keep_last: None,
521 dry_run: false,
522 };
523
524 prune_done_tasks_at(&done_path, options, fixed_now()).unwrap();
525
526 let done_after = load_queue(&done_path).unwrap();
527 assert_eq!(done_after.tasks.len(), 2);
528 assert_eq!(done_after.tasks[0].id, "RQ-0002");
529 assert_eq!(done_after.tasks[1].id, "RQ-0003");
530 }
531
532 #[test]
533 fn prune_with_keep_last_and_age_combines_filters() {
534 let tasks = vec![
535 done_task_with_completed("RQ-0001", "2026-01-01T12:00:00Z"),
536 done_task_with_completed("RQ-0002", "2026-01-10T12:00:00Z"),
537 done_task_with_completed("RQ-0003", "2026-01-15T12:00:00Z"),
538 ];
539
540 let temp_dir = TempDir::new().unwrap();
541 let done_path = temp_dir.path().join("done.json");
542 let queue_file = QueueFile {
543 version: 1,
544 tasks: tasks.clone(),
545 };
546 save_queue(&done_path, &queue_file).unwrap();
547
548 let options = PruneOptions {
549 age_days: Some(5),
550 statuses: HashSet::new(),
551 keep_last: Some(1),
552 dry_run: false,
553 };
554
555 let mut done = load_queue(&done_path).unwrap();
556 let report = prune_done_queue_at(&mut done.tasks, &options, fixed_now()).unwrap();
557
558 assert_eq!(report.pruned_ids.len(), 2);
559 assert!(report.pruned_ids.contains(&"RQ-0001".to_string()));
560 assert!(report.pruned_ids.contains(&"RQ-0002".to_string()));
561 assert_eq!(report.kept_ids, vec!["RQ-0003"]);
562 assert_eq!(done.tasks.len(), 1);
563 }
564
565 #[test]
566 fn prune_invalid_completed_at_kept_for_safety() {
567 let mut tasks = vec![
568 done_task_with_completed("RQ-0001", "2026-01-01T12:00:00Z"),
569 task_with("RQ-0002", TaskStatus::Done, vec!["done".to_string()]),
570 ];
571 tasks[1].completed_at = Some("not-a-valid-timestamp".to_string());
572
573 let temp_dir = TempDir::new().unwrap();
574 let done_path = temp_dir.path().join("done.json");
575 let queue_file = QueueFile {
576 version: 1,
577 tasks: tasks.clone(),
578 };
579 save_queue(&done_path, &queue_file).unwrap();
580
581 let options = PruneOptions {
582 age_days: Some(5),
583 statuses: HashSet::new(),
584 keep_last: None,
585 dry_run: false,
586 };
587
588 let mut done = load_queue(&done_path).unwrap();
589 let report = prune_done_queue_at(&mut done.tasks, &options, fixed_now()).unwrap();
590
591 assert_eq!(report.pruned_ids, vec!["RQ-0001"]);
592 assert_eq!(report.kept_ids, vec!["RQ-0002"]);
593 assert_eq!(done.tasks.len(), 1);
594 }
595}