Skip to main content

ralph/queue/prune/
core.rs

1//! Purpose: Core done-queue pruning logic and timestamp ordering helpers.
2//!
3//! Responsibilities:
4//! - Load and save the done queue for prune operations.
5//! - Apply keep-last, status, and age pruning rules.
6//! - Preserve order of retained tasks after pruning.
7//! - Provide timestamp parsing and completion-order helpers.
8//!
9//! Scope:
10//! - Prune execution only; CLI parsing and queue facade re-exports live elsewhere.
11//!
12//! Usage:
13//! - Called by `crate::queue::prune_done_tasks` via the prune facade.
14//! - Test helpers inject time through `prune_done_queue_at` and `prune_done_tasks_at`.
15//!
16//! Invariants/Assumptions:
17//! - Keep-last protection is index-based to avoid duplicate-ID inflation.
18//! - Missing or invalid `completed_at` values are kept for safety in age-based pruning.
19//! - Remaining tasks preserve their original relative order after pruning.
20
21use super::super::{load_queue_or_default, save_queue};
22use super::types::{PruneOptions, PruneReport};
23use crate::contracts::Task;
24use crate::timeutil;
25use anyhow::Result;
26use std::cmp::Ordering;
27use std::collections::HashSet;
28use std::path::Path;
29use time::{Duration, OffsetDateTime};
30
31/// Prune tasks from the done archive based on age, status, and keep-last rules.
32///
33/// This function loads the done archive, applies pruning rules, and optionally
34/// saves the result. Pruning preserves the original order of remaining tasks.
35///
36/// # Arguments
37/// * `done_path` - Path to the done archive file
38/// * `options` - Pruning options (age filter, status filter, keep-last, dry-run)
39///
40/// # Returns
41/// A `PruneReport` containing the IDs of pruned and kept tasks.
42pub fn prune_done_tasks(done_path: &Path, options: PruneOptions) -> Result<PruneReport> {
43    let mut done = load_queue_or_default(done_path)?;
44    let report = prune_done_queue(&mut done.tasks, &options)?;
45
46    if !options.dry_run && !report.pruned_ids.is_empty() {
47        save_queue(done_path, &done)?;
48    }
49
50    Ok(report)
51}
52
53/// Core pruning logic for a task list.
54///
55/// Tasks are sorted by completion date (most recent first), then keep-last
56/// protection is applied, then age and status filters. The original order of
57/// remaining tasks is preserved.
58pub(crate) fn prune_done_queue(
59    tasks: &mut Vec<Task>,
60    options: &PruneOptions,
61) -> Result<PruneReport> {
62    let now_dt = OffsetDateTime::now_utc();
63    prune_done_queue_at(tasks, options, now_dt)
64}
65
66pub(crate) fn prune_done_queue_at(
67    tasks: &mut Vec<Task>,
68    options: &PruneOptions,
69    now_dt: OffsetDateTime,
70) -> Result<PruneReport> {
71    let age_duration = options.age_days.map(|days| Duration::days(days as i64));
72
73    let mut indices: Vec<usize> = (0..tasks.len()).collect();
74    indices.sort_by(|&idx_a, &idx_b| compare_completed_desc(&tasks[idx_a], &idx_b, tasks));
75
76    let mut keep_set: HashSet<usize> = HashSet::new();
77    if let Some(keep_n) = options.keep_last {
78        for &idx in indices.iter().take(keep_n as usize) {
79            keep_set.insert(idx);
80        }
81    }
82
83    let mut pruned_ids = Vec::new();
84    let mut kept_ids = Vec::new();
85    let mut keep_mask = vec![false; tasks.len()];
86
87    for (idx, task) in tasks.iter().enumerate() {
88        if keep_set.contains(&idx) {
89            keep_mask[idx] = true;
90            kept_ids.push(task.id.clone());
91            continue;
92        }
93
94        if !options.statuses.is_empty() && !options.statuses.contains(&task.status) {
95            keep_mask[idx] = true;
96            kept_ids.push(task.id.clone());
97            continue;
98        }
99
100        if let Some(ref completed_at) = task.completed_at {
101            if let Some(task_dt) = parse_completed_at(completed_at) {
102                if let Some(age_dur) = age_duration {
103                    let age = if now_dt >= task_dt {
104                        now_dt - task_dt
105                    } else {
106                        Duration::ZERO
107                    };
108                    if age < age_dur {
109                        keep_mask[idx] = true;
110                        kept_ids.push(task.id.clone());
111                        continue;
112                    }
113                }
114            } else {
115                keep_mask[idx] = true;
116                kept_ids.push(task.id.clone());
117                continue;
118            }
119        } else {
120            keep_mask[idx] = true;
121            kept_ids.push(task.id.clone());
122            continue;
123        }
124
125        pruned_ids.push(task.id.clone());
126    }
127
128    let mut new_tasks = Vec::new();
129    for (idx, task) in tasks.drain(..).enumerate() {
130        if keep_mask[idx] {
131            new_tasks.push(task);
132        }
133    }
134    *tasks = new_tasks;
135
136    Ok(PruneReport {
137        pruned_ids,
138        kept_ids,
139    })
140}
141
142#[cfg(test)]
143pub(crate) fn prune_done_tasks_at(
144    done_path: &Path,
145    options: PruneOptions,
146    now_dt: OffsetDateTime,
147) -> Result<PruneReport> {
148    let mut done = load_queue_or_default(done_path)?;
149    let report = prune_done_queue_at(&mut done.tasks, &options, now_dt)?;
150
151    if !options.dry_run && !report.pruned_ids.is_empty() {
152        save_queue(done_path, &done)?;
153    }
154
155    Ok(report)
156}
157
158/// Parse an RFC3339 timestamp into `OffsetDateTime`.
159/// Returns `None` if the timestamp is invalid.
160fn parse_completed_at(ts: &str) -> Option<OffsetDateTime> {
161    timeutil::parse_rfc3339_opt(ts)
162}
163
164/// Compare two tasks by completion date for descending sort.
165/// Tasks with valid completed_at come first (most recent), then tasks with
166/// missing or invalid timestamps (treated as oldest).
167fn compare_completed_desc(a: &Task, idx_b: &usize, tasks: &[Task]) -> Ordering {
168    let b = &tasks[*idx_b];
169    let a_ts = parse_completed_at;
170    let b_ts = parse_completed_at;
171
172    match (a.completed_at.as_deref(), b.completed_at.as_deref()) {
173        (Some(ts_a), Some(ts_b)) => match (a_ts(ts_a), b_ts(ts_b)) {
174            (Some(dt_a), Some(dt_b)) => dt_a.cmp(&dt_b).reverse(),
175            (Some(_), None) => Ordering::Less,
176            (None, Some(_)) => Ordering::Greater,
177            (None, None) => Ordering::Equal,
178        },
179        (Some(_), None) => Ordering::Less,
180        (None, Some(_)) => Ordering::Greater,
181        (None, None) => Ordering::Equal,
182    }
183}