1use 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 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 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 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 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 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 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 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}