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