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