Skip to main content

ralph/queue/repair/
planning.rs

1//! Purpose: Plan queue repairs from on-disk and in-memory queue state.
2//!
3//! Responsibilities:
4//! - Load the active and done queues for repair planning.
5//! - Decide which tasks need missing-field, timestamp, or ID repairs based on
6//!   the requested `RepairScope`.
7//! - Track remapped IDs and rewrite cross-task references in a second pass.
8//! - Surface the resulting `QueueRepairPlan` and `RepairReport` for callers.
9//!
10//! Scope:
11//! - Pure planning only; never validates the planned queue set or writes to disk.
12//! - Apply, validation, and persistence flows live in `apply.rs`.
13//!
14//! Usage:
15//! - `plan_queue_repair` and `plan_queue_maintenance_repair` load from disk and
16//!   delegate to `plan_loaded_queue_repair_with_scope`.
17//! - `plan_loaded_queue_repair` is the in-memory entry point used by callers
18//!   that already have queue state loaded.
19//!
20//! Invariants/Assumptions:
21//! - Maintenance scope only normalizes timestamps; full scope additionally fills
22//!   missing fields and remaps invalid/duplicate IDs.
23//! - Non-UTC RFC3339 timestamps are normalized in both scopes.
24//! - The planner increments `report.fixed_tasks` once per task that was modified
25//!   in the first pass, and again per task whose relationships were rewritten in
26//!   the second pass; this preserves historical operation-count semantics.
27
28use super::relationships::rewrite_task_id_references;
29use super::types::{QueueRepairPlan, RepairReport, RepairScope};
30use crate::contracts::{QueueFile, Task, TaskStatus};
31use crate::queue::{format_id, load_queue_or_default, normalize_prefix, validation};
32use crate::timeutil;
33use anyhow::Result;
34use std::collections::{HashMap, HashSet};
35use std::path::Path;
36use time::UtcOffset;
37
38pub fn plan_queue_repair(
39    queue_path: &Path,
40    done_path: &Path,
41    id_prefix: &str,
42    id_width: usize,
43) -> Result<QueueRepairPlan> {
44    let active = load_queue_or_default(queue_path)?;
45    let done = load_queue_or_default(done_path)?;
46    plan_loaded_queue_repair_with_scope(active, done, id_prefix, id_width, RepairScope::Full)
47}
48
49pub fn plan_queue_maintenance_repair(
50    queue_path: &Path,
51    done_path: &Path,
52    id_prefix: &str,
53    id_width: usize,
54) -> Result<QueueRepairPlan> {
55    let active = load_queue_or_default(queue_path)?;
56    let done = load_queue_or_default(done_path)?;
57    plan_loaded_queue_repair_with_scope(active, done, id_prefix, id_width, RepairScope::Maintenance)
58}
59
60pub fn plan_loaded_queue_repair(
61    active: QueueFile,
62    done: QueueFile,
63    id_prefix: &str,
64    id_width: usize,
65) -> Result<QueueRepairPlan> {
66    plan_loaded_queue_repair_with_scope(active, done, id_prefix, id_width, RepairScope::Full)
67}
68
69pub(super) fn plan_loaded_queue_repair_with_scope(
70    mut active: QueueFile,
71    mut done: QueueFile,
72    id_prefix: &str,
73    id_width: usize,
74    scope: RepairScope,
75) -> Result<QueueRepairPlan> {
76    let mut report = RepairReport::default();
77    let expected_prefix = normalize_prefix(id_prefix);
78    let now = timeutil::now_utc_rfc3339_or_fallback();
79
80    // Determine max existing numeric ID across active and done.
81    let mut max_id_val = 0;
82    for task in active.tasks.iter().chain(done.tasks.iter()) {
83        if let Some(n) = parse_id_number(&task.id, &expected_prefix) {
84            max_id_val = max_id_val.max(n);
85        }
86    }
87    let mut next_id_val = max_id_val + 1;
88    let mut seen_ids = HashSet::new();
89
90    let mut repair_tasks = |tasks: &mut Vec<Task>| -> bool {
91        let mut queue_changed = false;
92        for task in tasks.iter_mut() {
93            let mut modified = false;
94            let mut timestamp_modified = false;
95            let mut id_modified = false;
96
97            if scope == RepairScope::Full {
98                // Fix missing fields
99                if task.title.trim().is_empty() {
100                    task.title = "Untitled".to_string();
101                    modified = true;
102                }
103                if task.tags.is_empty() {
104                    task.tags.push("untagged".to_string());
105                    modified = true;
106                }
107                if task.scope.is_empty() {
108                    task.scope.push("unknown".to_string());
109                    modified = true;
110                }
111                if task.evidence.is_empty() {
112                    task.evidence.push("None provided".to_string());
113                    modified = true;
114                }
115                if task.plan.is_empty() {
116                    task.plan.push("To be determined".to_string());
117                    modified = true;
118                }
119                if task.request.as_ref().is_none_or(|r| r.trim().is_empty()) {
120                    task.request = Some("Imported task".to_string());
121                    modified = true;
122                }
123            }
124
125            // Fix timestamps
126            let terminal = matches!(task.status, TaskStatus::Done | TaskStatus::Rejected);
127            let mut fix_ts = |ts: &mut Option<String>, label: &str| {
128                if let Some(existing) = ts.as_ref() {
129                    match timeutil::parse_rfc3339(existing) {
130                        Ok(dt) => {
131                            if dt.offset() != UtcOffset::UTC {
132                                let normalized =
133                                    timeutil::format_rfc3339(dt).unwrap_or_else(|_| now.clone());
134                                *ts = Some(normalized);
135                                report.fixed_timestamps += 1;
136                                timestamp_modified = true;
137                            }
138                        }
139                        Err(_) => {
140                            if scope == RepairScope::Full {
141                                *ts = Some(now.clone());
142                                report.fixed_timestamps += 1;
143                                timestamp_modified = true;
144                            }
145                        }
146                    }
147                } else {
148                    // Create/Update required
149                    let should_backfill = (scope == RepairScope::Full
150                        && (label == "created_at"
151                            || label == "updated_at"
152                            || label == "completed_at"))
153                        || (scope == RepairScope::Maintenance && label == "completed_at");
154                    if should_backfill {
155                        *ts = Some(now.clone());
156                        report.fixed_timestamps += 1;
157                        timestamp_modified = true;
158                    }
159                }
160            };
161
162            fix_ts(&mut task.created_at, "created_at");
163            fix_ts(&mut task.updated_at, "updated_at");
164            if terminal || task.completed_at.is_some() {
165                fix_ts(&mut task.completed_at, "completed_at");
166            }
167
168            if modified || timestamp_modified {
169                report.fixed_tasks += 1;
170            }
171
172            if scope == RepairScope::Full {
173                // Fix ID
174                // We use a normalized key for collision detection
175                let id_key = task.id.trim().to_uppercase();
176                let is_valid_format =
177                    validation::validate_task_id(0, &task.id, &expected_prefix, id_width).is_ok();
178
179                if !is_valid_format || seen_ids.contains(&id_key) || id_key.is_empty() {
180                    let new_id = format_id(&expected_prefix, next_id_val, id_width);
181                    next_id_val += 1;
182                    report.remapped_ids.push((task.id.clone(), new_id.clone()));
183                    task.id = new_id.clone();
184                    seen_ids.insert(new_id);
185                    id_modified = true;
186                } else {
187                    seen_ids.insert(id_key);
188                }
189            }
190
191            queue_changed |= modified || timestamp_modified || id_modified;
192        }
193        queue_changed
194    };
195
196    let mut queue_changed = repair_tasks(&mut active.tasks);
197    let mut done_changed = repair_tasks(&mut done.tasks);
198
199    // Second pass: update relationship references for remapped IDs.
200    if scope == RepairScope::Full && !report.remapped_ids.is_empty() {
201        let remapped_map: HashMap<String, String> = report.remapped_ids.iter().cloned().collect();
202
203        let mut fix_relationships = |tasks: &mut Vec<Task>| {
204            let mut queue_changed = false;
205            for task in tasks.iter_mut() {
206                if rewrite_task_id_references(task, &remapped_map) {
207                    // Preserve the historical operation count semantics for `fixed_tasks`.
208                    report.fixed_tasks += 1;
209                    queue_changed = true;
210                }
211            }
212            queue_changed
213        };
214
215        queue_changed |= fix_relationships(&mut active.tasks);
216        done_changed |= fix_relationships(&mut done.tasks);
217    }
218
219    Ok(QueueRepairPlan {
220        active,
221        done,
222        report,
223        queue_changed,
224        done_changed,
225    })
226}
227
228fn parse_id_number(id: &str, expected_prefix: &str) -> Option<u32> {
229    let normalized = id.trim().to_uppercase();
230    let prefix = format!("{}-", expected_prefix);
231    let suffix = normalized.strip_prefix(&prefix)?;
232    suffix.parse().ok()
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::contracts::{Task, TaskStatus};
239    use crate::queue::save_queue;
240    use std::collections::HashMap;
241    use tempfile::tempdir;
242
243    fn task(id: &str) -> Task {
244        Task {
245            id: id.to_string(),
246            status: TaskStatus::Todo,
247            title: "Test task".to_string(),
248            description: None,
249            priority: Default::default(),
250            tags: vec!["test".to_string()],
251            scope: vec!["crates/ralph".to_string()],
252            evidence: vec!["evidence".to_string()],
253            plan: vec!["plan".to_string()],
254            notes: vec![],
255            request: Some("request".to_string()),
256            agent: None,
257            created_at: Some("2026-01-18T00:00:00Z".to_string()),
258            updated_at: Some("2026-01-18T00:00:00Z".to_string()),
259            completed_at: None,
260            started_at: None,
261            scheduled_start: None,
262            estimated_minutes: None,
263            actual_minutes: None,
264            depends_on: vec![],
265            blocks: vec![],
266            relates_to: vec![],
267            duplicates: None,
268            custom_fields: HashMap::new(),
269            parent_id: None,
270        }
271    }
272
273    #[test]
274    fn plan_repair_backfills_completed_at_for_done_tasks() {
275        let dir = tempdir().unwrap();
276        let queue_path = dir.path().join("queue.json");
277        let done_path = dir.path().join("done.json");
278
279        let mut t = task("RQ-0001");
280        t.status = TaskStatus::Done;
281        t.completed_at = None;
282
283        let active = QueueFile {
284            version: 1,
285            tasks: vec![t],
286        };
287        save_queue(&queue_path, &active).unwrap();
288        save_queue(
289            &done_path,
290            &QueueFile {
291                version: 1,
292                tasks: vec![],
293            },
294        )
295        .unwrap();
296
297        let plan = plan_queue_repair(&queue_path, &done_path, "RQ", 4).unwrap();
298        let report = plan.report();
299        assert!(report.fixed_timestamps > 0);
300
301        let (repaired, _done, _report) = plan.into_parts();
302        assert!(repaired.tasks[0].completed_at.is_some());
303    }
304
305    #[test]
306    fn plan_repair_normalizes_non_utc_timestamps() {
307        let dir = tempdir().unwrap();
308        let queue_path = dir.path().join("queue.json");
309        let done_path = dir.path().join("done.json");
310
311        let mut t = task("RQ-0001");
312        t.status = TaskStatus::Done;
313        t.created_at = Some("2026-01-18T12:00:00-05:00".to_string());
314        t.updated_at = Some("2026-01-18T12:00:00-05:00".to_string());
315        t.completed_at = Some("2026-01-18T12:00:00-05:00".to_string());
316
317        let active = QueueFile {
318            version: 1,
319            tasks: vec![t],
320        };
321        save_queue(&queue_path, &active).unwrap();
322        save_queue(
323            &done_path,
324            &QueueFile {
325                version: 1,
326                tasks: vec![],
327            },
328        )
329        .unwrap();
330
331        let plan = plan_queue_repair(&queue_path, &done_path, "RQ", 4).unwrap();
332        let report = plan.report();
333        assert!(report.fixed_timestamps > 0);
334
335        let (repaired, _done, _report) = plan.into_parts();
336        let expected = crate::timeutil::format_rfc3339(
337            crate::timeutil::parse_rfc3339("2026-01-18T12:00:00-05:00").unwrap(),
338        )
339        .unwrap();
340        assert_eq!(
341            repaired.tasks[0].created_at.as_deref(),
342            Some(expected.as_str())
343        );
344        assert_eq!(
345            repaired.tasks[0].updated_at.as_deref(),
346            Some(expected.as_str())
347        );
348        assert_eq!(
349            repaired.tasks[0].completed_at.as_deref(),
350            Some(expected.as_str())
351        );
352    }
353}