1use 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 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 let mut repair_tasks = |tasks: &mut Vec<Task>| {
59 for task in tasks.iter_mut() {
60 let mut modified = false;
61
62 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 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 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 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 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 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
184pub 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 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(¤t_id.to_string()) {
208 dependents.push(current_id.to_string());
209 }
210 collect_dependents(current_id, active, done, dependents, visited);
211 }
212 }
213
214 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(¤t_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}