Skip to main content

ralph/queue/
prune.rs

1//! Queue pruning submodule.
2//!
3//! This module contains the pruning entry points and internal helpers used to
4//! remove old tasks from the done archive (.ralph/done.jsonc) according to
5//! configurable filters (age, status, keep-last) while preserving the original
6//! order of remaining tasks.
7//!
8//! The parent `queue` module re-exports the public API so callers can continue
9//! using `crate::queue::prune_done_tasks`, `crate::queue::PruneOptions`, and
10//! `crate::queue::PruneReport`.
11
12use 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/// Result of a prune operation on the done archive.
21#[derive(Debug, Clone, Default)]
22pub struct PruneReport {
23    /// IDs of tasks that were pruned (or would be pruned in dry-run).
24    pub pruned_ids: Vec<String>,
25    /// IDs of tasks that were kept (protected by keep-last or didn't match filters).
26    pub kept_ids: Vec<String>,
27}
28
29/// Options for pruning tasks from the done archive.
30#[derive(Debug, Clone)]
31pub struct PruneOptions {
32    /// Minimum age in days for a task to be pruned (None = no age filter).
33    pub age_days: Option<u32>,
34    /// Statuses to prune (empty = all statuses).
35    pub statuses: HashSet<TaskStatus>,
36    /// Keep the N most recently completed tasks regardless of other filters.
37    pub keep_last: Option<u32>,
38    /// If true, report what would be pruned without writing to disk.
39    pub dry_run: bool,
40}
41
42/// Prune tasks from the done archive based on age, status, and keep-last rules.
43///
44/// This function loads the done archive, applies pruning rules, and optionally
45/// saves the result. Pruning preserves the original order of remaining tasks.
46///
47/// # Arguments
48/// * `done_path` - Path to the done archive file
49/// * `options` - Pruning options (age filter, status filter, keep-last, dry-run)
50///
51/// # Returns
52/// A `PruneReport` containing the IDs of pruned and kept tasks.
53pub 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
64/// Core pruning logic for a task list.
65///
66/// Tasks are sorted by completion date (most recent first), then keep-last
67/// protection is applied, then age and status filters. The original order of
68/// remaining tasks is preserved.
69fn prune_done_queue(tasks: &mut Vec<Task>, options: &PruneOptions) -> Result<PruneReport> {
70    // Use OffsetDateTime directly instead of formatting/parsing a string
71    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    // Sort indices by completion date descending (most recent first)
83    let mut indices: Vec<usize> = (0..tasks.len()).collect();
84    indices.sort_by(|&i, &j| compare_completed_desc(&tasks[i], &j, tasks));
85
86    // Apply keep-last protection by index to avoid duplicate-ID inflation
87    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    // Filter tasks - iterate by index to avoid borrow issues
98    let mut keep_mask = vec![false; tasks.len()];
99    for (idx, task) in tasks.iter().enumerate() {
100        // Check keep-last protection first
101        if keep_set.contains(&idx) {
102            keep_mask[idx] = true;
103            kept_ids.push(task.id.clone());
104            continue;
105        }
106
107        // Check status filter
108        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        // Check age filter
115        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                    // Calculate age: now - task_dt
119                    // Use checked_sub to handle potential underflow gracefully
120                    let age = if now_dt >= task_dt {
121                        now_dt - task_dt
122                    } else {
123                        // Task is in the future (clock skew), treat as 0 age
124                        Duration::ZERO
125                    };
126                    if age < age_dur {
127                        // Too recent to prune
128                        keep_mask[idx] = true;
129                        kept_ids.push(task.id.clone());
130                        continue;
131                    }
132                }
133            } else {
134                // Invalid completed_at - keep for safety (don't prune by age)
135                keep_mask[idx] = true;
136                kept_ids.push(task.id.clone());
137                continue;
138            }
139        } else {
140            // Missing completed_at - keep for safety (don't prune by age)
141            keep_mask[idx] = true;
142            kept_ids.push(task.id.clone());
143            continue;
144        }
145
146        // Task passes all filters - mark for pruning
147        pruned_ids.push(task.id.clone());
148    }
149
150    // Remove pruned tasks while preserving order
151    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
181/// Parse an RFC3339 timestamp into OffsetDateTime.
182/// Returns None if the timestamp is invalid.
183fn parse_completed_at(ts: &str) -> Option<OffsetDateTime> {
184    timeutil::parse_rfc3339_opt(ts)
185}
186
187/// Compare two tasks by completion date for descending sort.
188/// Tasks with valid completed_at come first (most recent), then tasks with
189/// missing or invalid timestamps (treated as oldest).
190fn 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    //! Pruning behavior tests (age/status/keep-last, safety, and order preservation).
211
212    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    /// Create a task with a specific completion timestamp.
223    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    /// Create a task without a completion timestamp.
230    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        // now = 2026-01-20T12:00:00Z
269        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}