Skip to main content

ralph/queue/
repair.rs

1//! Queue repair and dependency traversal.
2//!
3//! This module consolidates logic for repairing queue inconsistencies (missing fields,
4//! duplicate IDs, invalid/missing timestamps) and for traversing task dependency graphs
5//! (e.g., computing all tasks that depend on a given task ID).
6
7use super::{format_id, load_queue_or_default, normalize_prefix, save_queue, validation};
8use crate::contracts::{QueueFile, Task, TaskStatus};
9use crate::timeutil;
10use anyhow::Result;
11use std::collections::HashSet;
12use std::path::Path;
13use time::UtcOffset;
14
15#[derive(Debug, Default, Clone)]
16pub struct RepairReport {
17    pub fixed_tasks: usize,
18    pub remapped_ids: Vec<(String, String)>,
19    pub fixed_timestamps: usize,
20}
21
22impl RepairReport {
23    pub fn is_empty(&self) -> bool {
24        self.fixed_tasks == 0 && self.remapped_ids.is_empty() && self.fixed_timestamps == 0
25    }
26}
27
28pub fn repair_queue(
29    queue_path: &Path,
30    done_path: &Path,
31    id_prefix: &str,
32    id_width: usize,
33    dry_run: bool,
34) -> Result<RepairReport> {
35    let mut active = load_queue_or_default(queue_path)?;
36    let mut done = load_queue_or_default(done_path)?;
37
38    let mut report = RepairReport::default();
39    let expected_prefix = normalize_prefix(id_prefix);
40    let now = timeutil::now_utc_rfc3339_or_fallback();
41
42    // 1. Scan for max ID to ensure new IDs don't collide
43    let mut max_id_val: u32 = 0;
44    let mut scan_max = |tasks: &[Task]| {
45        for task in tasks {
46            if let Ok(val) = validation::validate_task_id(0, &task.id, &expected_prefix, id_width) {
47                max_id_val = max_id_val.max(val);
48            }
49        }
50    };
51    scan_max(&active.tasks);
52    scan_max(&done.tasks);
53
54    let mut next_id_val = max_id_val + 1;
55    let mut seen_ids = HashSet::new();
56
57    // Helper to repair a list of tasks
58    let mut repair_tasks = |tasks: &mut Vec<Task>| {
59        for task in tasks.iter_mut() {
60            let mut modified = false;
61
62            // Fix missing fields
63            if task.title.trim().is_empty() {
64                task.title = "Untitled".to_string();
65                modified = true;
66            }
67            if task.tags.is_empty() {
68                task.tags.push("untagged".to_string());
69                modified = true;
70            }
71            if task.scope.is_empty() {
72                task.scope.push("unknown".to_string());
73                modified = true;
74            }
75            if task.evidence.is_empty() {
76                task.evidence.push("None provided".to_string());
77                modified = true;
78            }
79            if task.plan.is_empty() {
80                task.plan.push("To be determined".to_string());
81                modified = true;
82            }
83            if task.request.as_ref().is_none_or(|r| r.trim().is_empty()) {
84                task.request = Some("Imported task".to_string());
85                modified = true;
86            }
87
88            // Fix timestamps
89            let mut fix_ts = |ts: &mut Option<String>, label: &str| {
90                if let Some(val) = ts {
91                    match timeutil::parse_rfc3339(val) {
92                        Ok(dt) => {
93                            if dt.offset() != UtcOffset::UTC {
94                                let normalized =
95                                    timeutil::format_rfc3339(dt).unwrap_or_else(|_| now.clone());
96                                *ts = Some(normalized);
97                                report.fixed_timestamps += 1;
98                            }
99                        }
100                        Err(_) => {
101                            *ts = Some(now.clone());
102                            report.fixed_timestamps += 1;
103                        }
104                    }
105                } else {
106                    // Create/Update required
107                    if label == "created_at" || label == "updated_at" || label == "completed_at" {
108                        *ts = Some(now.clone());
109                        report.fixed_timestamps += 1;
110                    }
111                }
112            };
113            fix_ts(&mut task.created_at, "created_at");
114            fix_ts(&mut task.updated_at, "updated_at");
115            if task.status == TaskStatus::Done || task.status == TaskStatus::Rejected {
116                fix_ts(&mut task.completed_at, "completed_at");
117            }
118
119            if modified {
120                report.fixed_tasks += 1;
121            }
122
123            // Fix ID
124            // We use a normalized key for collision detection
125            let id_key = task.id.trim().to_uppercase();
126            let is_valid_format =
127                validation::validate_task_id(0, &task.id, &expected_prefix, id_width).is_ok();
128
129            if !is_valid_format || seen_ids.contains(&id_key) || id_key.is_empty() {
130                let new_id = format_id(&expected_prefix, next_id_val, id_width);
131                next_id_val += 1;
132                report.remapped_ids.push((task.id.clone(), new_id.clone()));
133                task.id = new_id.clone();
134                seen_ids.insert(new_id);
135            } else {
136                seen_ids.insert(id_key);
137            }
138        }
139    };
140
141    repair_tasks(&mut active.tasks);
142    repair_tasks(&mut done.tasks);
143
144    // Second pass: Update dependencies for remapped IDs
145    if !report.remapped_ids.is_empty() {
146        let remapped_map: std::collections::HashMap<String, String> =
147            report.remapped_ids.iter().cloned().collect();
148
149        let mut fix_dependencies = |tasks: &mut Vec<Task>| {
150            for task in tasks.iter_mut() {
151                let mut deps_modified = false;
152                for dep in task.depends_on.iter_mut() {
153                    if let Some(new_id) = remapped_map.get(dep) {
154                        *dep = new_id.clone();
155                        deps_modified = true;
156                    }
157                }
158                if deps_modified {
159                    // Only count as fixed if we haven't already counted it (simpler: just increment,
160                    // as 'fixed_tasks' is a count of tasks touched, strictly speaking we might want to track set of unique modified tasks
161                    // but usually a simple counter is enough for the report.
162                    // However, if we want to be precise: "Fixed missing fields in X tasks" vs "Fixed dependencies".
163                    // The report struct just has `fixed_tasks`.
164                    // If we modified fields in pass 1 AND deps in pass 2, it's the same task being fixed.
165                    // But `repair_tasks` already incremented if fields/ID changed.
166                    // To avoid double counting, we could assume `fixed_tasks` is just "operations performed" or track unique indices.
167                    // Given the current implementation just increments, let's just increment.
168                    report.fixed_tasks += 1;
169                }
170            }
171        };
172
173        fix_dependencies(&mut active.tasks);
174        fix_dependencies(&mut done.tasks);
175    }
176
177    if !dry_run && !report.is_empty() {
178        save_queue(queue_path, &active)?;
179        save_queue(done_path, &done)?;
180    }
181    Ok(report)
182}
183
184/// Get all tasks that depend on the given task ID (recursively).
185/// Returns a list of task IDs that depend on the root task.
186pub fn get_dependents(root_id: &str, active: &QueueFile, done: Option<&QueueFile>) -> Vec<String> {
187    let mut dependents = Vec::new();
188    let mut visited = std::collections::HashSet::new();
189    let root_id = root_id.trim();
190
191    fn collect_dependents(
192        task_id: &str,
193        active: &QueueFile,
194        done: Option<&QueueFile>,
195        dependents: &mut Vec<String>,
196        visited: &mut std::collections::HashSet<String>,
197    ) {
198        if visited.contains(task_id) {
199            return;
200        }
201        visited.insert(task_id.to_string());
202
203        // Check all tasks in active queue
204        for task in &active.tasks {
205            let current_id = task.id.trim();
206            if task.depends_on.iter().any(|d| d.trim() == task_id) {
207                if !dependents.contains(&current_id.to_string()) {
208                    dependents.push(current_id.to_string());
209                }
210                collect_dependents(current_id, active, done, dependents, visited);
211            }
212        }
213
214        // Check all tasks in done archive
215        if let Some(done_file) = done {
216            for task in &done_file.tasks {
217                let current_id = task.id.trim();
218                if task.depends_on.iter().any(|d| d.trim() == task_id) {
219                    if !dependents.contains(&current_id.to_string()) {
220                        dependents.push(current_id.to_string());
221                    }
222                    collect_dependents(current_id, active, done, dependents, visited);
223                }
224            }
225        }
226    }
227
228    collect_dependents(root_id, active, done, &mut dependents, &mut visited);
229    dependents.retain(|id| id != root_id);
230    dependents
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::contracts::{Task, TaskStatus};
237    use std::collections::HashMap;
238
239    fn task(id: &str, depends_on: Vec<&str>) -> Task {
240        Task {
241            id: id.to_string(),
242            status: TaskStatus::Todo,
243            title: "Test task".to_string(),
244            description: None,
245            priority: Default::default(),
246            tags: vec!["test".to_string()],
247            scope: vec!["crates/ralph".to_string()],
248            evidence: vec!["evidence".to_string()],
249            plan: vec!["plan".to_string()],
250            notes: vec![],
251            request: Some("request".to_string()),
252            agent: None,
253            created_at: Some("2026-01-18T00:00:00Z".to_string()),
254            updated_at: Some("2026-01-18T00:00:00Z".to_string()),
255            completed_at: None,
256            started_at: None,
257            scheduled_start: None,
258            estimated_minutes: None,
259            actual_minutes: None,
260            depends_on: depends_on.into_iter().map(|s| s.to_string()).collect(),
261            blocks: vec![],
262            relates_to: vec![],
263            duplicates: None,
264            custom_fields: HashMap::new(),
265            parent_id: None,
266        }
267    }
268
269    #[test]
270    fn get_dependents_traverses_active_and_done_recursively() {
271        let active = QueueFile {
272            version: 1,
273            tasks: vec![
274                task("RQ-0001", vec![]),
275                task("RQ-0002", vec!["RQ-0001"]),
276                task("RQ-0003", vec!["RQ-0002"]),
277            ],
278        };
279        let done = QueueFile {
280            version: 1,
281            tasks: vec![task("RQ-0004", vec!["RQ-0003"])],
282        };
283
284        let got = get_dependents("RQ-0001", &active, Some(&done));
285        let set: std::collections::HashSet<String> = got.into_iter().collect();
286
287        assert!(set.contains("RQ-0002"));
288        assert!(set.contains("RQ-0003"));
289        assert!(set.contains("RQ-0004"));
290        assert_eq!(set.len(), 3);
291    }
292
293    #[test]
294    fn get_dependents_handles_cycles_without_infinite_recursion() {
295        let active = QueueFile {
296            version: 1,
297            tasks: vec![
298                task("RQ-0001", vec!["RQ-0002"]),
299                task("RQ-0002", vec!["RQ-0001"]),
300            ],
301        };
302
303        let got = get_dependents("RQ-0001", &active, None);
304        let set: std::collections::HashSet<String> = got.into_iter().collect();
305
306        assert!(set.contains("RQ-0002"));
307        assert_eq!(set.len(), 1);
308    }
309
310    #[test]
311    fn repair_backfills_completed_at_for_done_tasks() {
312        use crate::queue::save_queue;
313        use tempfile::tempdir;
314
315        let dir = tempdir().unwrap();
316        let queue_path = dir.path().join("queue.json");
317        let done_path = dir.path().join("done.json");
318
319        let mut t = task("RQ-0001", vec![]);
320        t.status = TaskStatus::Done;
321        t.completed_at = None;
322
323        let active = QueueFile {
324            version: 1,
325            tasks: vec![t],
326        };
327        save_queue(&queue_path, &active).unwrap();
328        save_queue(
329            &done_path,
330            &QueueFile {
331                version: 1,
332                tasks: vec![],
333            },
334        )
335        .unwrap();
336
337        let report = repair_queue(&queue_path, &done_path, "RQ", 4, false).unwrap();
338        assert!(report.fixed_timestamps > 0);
339
340        let repaired = crate::queue::load_queue_or_default(&queue_path).unwrap();
341        assert!(repaired.tasks[0].completed_at.is_some());
342    }
343
344    #[test]
345    fn repair_normalizes_non_utc_timestamps() {
346        use crate::queue::save_queue;
347        use tempfile::tempdir;
348
349        let dir = tempdir().unwrap();
350        let queue_path = dir.path().join("queue.json");
351        let done_path = dir.path().join("done.json");
352
353        let mut t = task("RQ-0001", vec![]);
354        t.status = TaskStatus::Done;
355        t.created_at = Some("2026-01-18T12:00:00-05:00".to_string());
356        t.updated_at = Some("2026-01-18T12:00:00-05:00".to_string());
357        t.completed_at = Some("2026-01-18T12:00:00-05:00".to_string());
358
359        let active = QueueFile {
360            version: 1,
361            tasks: vec![t],
362        };
363        save_queue(&queue_path, &active).unwrap();
364        save_queue(
365            &done_path,
366            &QueueFile {
367                version: 1,
368                tasks: vec![],
369            },
370        )
371        .unwrap();
372
373        let report = repair_queue(&queue_path, &done_path, "RQ", 4, false).unwrap();
374        assert!(report.fixed_timestamps > 0);
375
376        let repaired = crate::queue::load_queue_or_default(&queue_path).unwrap();
377        let expected = crate::timeutil::format_rfc3339(
378            crate::timeutil::parse_rfc3339("2026-01-18T12:00:00-05:00").unwrap(),
379        )
380        .unwrap();
381        assert_eq!(
382            repaired.tasks[0].created_at.as_deref(),
383            Some(expected.as_str())
384        );
385        assert_eq!(
386            repaired.tasks[0].updated_at.as_deref(),
387            Some(expected.as_str())
388        );
389        assert_eq!(
390            repaired.tasks[0].completed_at.as_deref(),
391            Some(expected.as_str())
392        );
393    }
394}