1use crate::config::Resolved;
19use crate::contracts::{QueueFile, Task};
20use crate::queue::json_repair::attempt_json_repair;
21use crate::queue::validation::{self, ValidationWarning};
22use anyhow::{Context, Result};
23use std::path::Path;
24use time::UtcOffset;
25
26#[derive(Debug, Default, Clone, Copy)]
27struct QueueMaintenanceReport {
28 normalized_timestamps: usize,
29 backfilled_completed_at: usize,
30 queue_changed: bool,
31 done_changed: bool,
32}
33
34impl QueueMaintenanceReport {
35 fn has_changes(self) -> bool {
36 self.normalized_timestamps > 0 || self.backfilled_completed_at > 0
37 }
38}
39
40#[derive(Debug, Default, Clone, Copy)]
41struct SingleQueueMaintenance {
42 normalized_timestamps: usize,
43 backfilled_completed_at: usize,
44 changed: bool,
45}
46
47fn normalize_timestamp_field(field: &mut Option<String>) -> Result<bool> {
48 let Some(raw) = field.as_ref() else {
49 return Ok(false);
50 };
51 let trimmed = raw.trim();
52 if trimmed.is_empty() {
53 return Ok(false);
54 }
55
56 let dt = match crate::timeutil::parse_rfc3339(trimmed) {
57 Ok(dt) => dt,
58 Err(_) => return Ok(false),
59 };
60
61 if dt.offset() == UtcOffset::UTC {
62 return Ok(false);
63 }
64
65 let normalized = crate::timeutil::format_rfc3339(dt)?;
66 if normalized == *raw {
67 return Ok(false);
68 }
69 *field = Some(normalized);
70 Ok(true)
71}
72
73fn normalize_task_timestamps(task: &mut Task) -> Result<usize> {
74 let mut normalized = 0usize;
75
76 if normalize_timestamp_field(&mut task.created_at)? {
77 normalized += 1;
78 }
79 if normalize_timestamp_field(&mut task.updated_at)? {
80 normalized += 1;
81 }
82 if normalize_timestamp_field(&mut task.completed_at)? {
83 normalized += 1;
84 }
85 if normalize_timestamp_field(&mut task.started_at)? {
86 normalized += 1;
87 }
88 if normalize_timestamp_field(&mut task.scheduled_start)? {
89 normalized += 1;
90 }
91
92 Ok(normalized)
93}
94
95fn maintain_single_queue_timestamps(
96 queue: &mut QueueFile,
97 now_utc: &str,
98) -> Result<SingleQueueMaintenance> {
99 let mut normalized_timestamps = 0usize;
100 for task in &mut queue.tasks {
101 normalized_timestamps += normalize_task_timestamps(task)?;
102 }
103
104 let backfilled_completed_at = super::backfill_terminal_completed_at(queue, now_utc);
105 let changed = normalized_timestamps > 0 || backfilled_completed_at > 0;
106
107 Ok(SingleQueueMaintenance {
108 normalized_timestamps,
109 backfilled_completed_at,
110 changed,
111 })
112}
113
114fn log_maintenance_report(report: QueueMaintenanceReport, queue_path: &Path, done_path: &Path) {
115 if !report.has_changes() {
116 return;
117 }
118
119 log::warn!(
120 "Queue repair applied: normalized {} non-UTC timestamp(s), backfilled {} terminal completed_at value(s). Saved queue={}, done={} (queue_path={}, done_path={}).",
121 report.normalized_timestamps,
122 report.backfilled_completed_at,
123 report.queue_changed,
124 report.done_changed,
125 queue_path.display(),
126 done_path.display()
127 );
128}
129
130fn maintain_and_save_loaded_queues(
131 queue_path: &Path,
132 queue_file: &mut QueueFile,
133 done_path: &Path,
134 done_path_exists: bool,
135 done_file: &mut QueueFile,
136) -> Result<QueueMaintenanceReport> {
137 let now = crate::timeutil::now_utc_rfc3339()?;
138
139 let queue_report = maintain_single_queue_timestamps(queue_file, &now)?;
140 let done_report = maintain_single_queue_timestamps(done_file, &now)?;
141
142 if queue_report.changed {
143 super::save_queue(queue_path, queue_file)
144 .with_context(|| format!("save auto-repaired queue {}", queue_path.display()))?;
145 }
146 if done_report.changed && (done_path_exists || !done_file.tasks.is_empty()) {
147 super::save_queue(done_path, done_file)
148 .with_context(|| format!("save auto-repaired done {}", done_path.display()))?;
149 }
150
151 let report = QueueMaintenanceReport {
152 normalized_timestamps: queue_report.normalized_timestamps
153 + done_report.normalized_timestamps,
154 backfilled_completed_at: queue_report.backfilled_completed_at
155 + done_report.backfilled_completed_at,
156 queue_changed: queue_report.changed,
157 done_changed: done_report.changed,
158 };
159
160 log_maintenance_report(report, queue_path, done_path);
161 Ok(report)
162}
163
164pub fn load_queue_or_default(path: &Path) -> Result<QueueFile> {
166 if !path.exists() {
167 return Ok(QueueFile::default());
168 }
169 load_queue(path)
170}
171
172pub fn load_queue(path: &Path) -> Result<QueueFile> {
174 let raw = std::fs::read_to_string(path)
175 .with_context(|| format!("read queue file {}", path.display()))?;
176 let queue = crate::jsonc::parse_jsonc::<QueueFile>(&raw, &format!("queue {}", path.display()))?;
177 Ok(queue)
178}
179
180pub fn load_queue_with_repair(path: &Path) -> Result<QueueFile> {
183 let raw = std::fs::read_to_string(path)
184 .with_context(|| format!("read queue file {}", path.display()))?;
185
186 match crate::jsonc::parse_jsonc::<QueueFile>(&raw, &format!("queue {}", path.display())) {
188 Ok(queue) => Ok(queue),
189 Err(parse_err) => {
190 log::warn!("Queue JSON parse error, attempting repair: {}", parse_err);
192
193 if let Some(repaired) = attempt_json_repair(&raw) {
194 match crate::jsonc::parse_jsonc::<QueueFile>(
195 &repaired,
196 &format!("repaired queue {}", path.display()),
197 ) {
198 Ok(queue) => {
199 log::info!("Successfully repaired queue JSON");
200 Ok(queue)
201 }
202 Err(repair_err) => {
203 Err(parse_err).with_context(|| {
205 format!(
206 "parse queue {} as JSON/JSONC (repair also failed: {})",
207 path.display(),
208 repair_err
209 )
210 })?
211 }
212 }
213 } else {
214 Err(parse_err)
216 }
217 }
218 }
219}
220
221pub fn load_queue_with_repair_and_validate(
228 path: &Path,
229 done: Option<&crate::contracts::QueueFile>,
230 id_prefix: &str,
231 id_width: usize,
232 max_dependency_depth: u8,
233) -> Result<(QueueFile, Vec<ValidationWarning>)> {
234 let queue = load_queue_with_repair(path)?;
235
236 let warnings = if let Some(d) = done {
237 validation::validate_queue_set(&queue, Some(d), id_prefix, id_width, max_dependency_depth)
238 .with_context(|| format!("validate repaired queue {}", path.display()))?
239 } else {
240 validation::validate_queue(&queue, id_prefix, id_width)
241 .with_context(|| format!("validate repaired queue {}", path.display()))?;
242 Vec::new()
243 };
244
245 Ok((queue, warnings))
246}
247
248fn validate_loaded_queues(
249 resolved: &Resolved,
250 queue_file: &QueueFile,
251 done_file: &QueueFile,
252) -> Result<Vec<ValidationWarning>> {
253 let done_ref = if !done_file.tasks.is_empty() || resolved.done_path.exists() {
254 Some(done_file)
255 } else {
256 None
257 };
258
259 let max_depth = resolved.config.queue.max_dependency_depth.unwrap_or(10);
260 let warnings = validation::validate_queue_set(
261 queue_file,
262 done_ref,
263 &resolved.id_prefix,
264 resolved.id_width,
265 max_depth,
266 )?;
267 validation::log_warnings(&warnings);
268 Ok(warnings)
269}
270
271fn load_queue_set_with_repair(
272 resolved: &Resolved,
273 include_done: bool,
274) -> Result<(QueueFile, QueueFile, bool)> {
275 let queue_file = load_queue_with_repair(&resolved.queue_path)?;
276 let done_path_exists = resolved.done_path.exists();
277 let done_file = if done_path_exists {
278 load_queue_with_repair(&resolved.done_path)?
279 } else {
280 QueueFile::default()
281 };
282
283 let done_file = if include_done || done_path_exists {
284 done_file
285 } else {
286 QueueFile::default()
287 };
288
289 Ok((queue_file, done_file, done_path_exists))
290}
291
292pub fn load_and_validate_queues(
297 resolved: &Resolved,
298 include_done: bool,
299) -> Result<(QueueFile, Option<QueueFile>)> {
300 let (queue_file, done_for_validation, _done_path_exists) =
301 load_queue_set_with_repair(resolved, include_done)?;
302 validate_loaded_queues(resolved, &queue_file, &done_for_validation)?;
303
304 let done_file = if include_done {
305 Some(done_for_validation)
306 } else {
307 None
308 };
309
310 Ok((queue_file, done_file))
311}
312
313pub fn repair_and_validate_queues(
318 resolved: &Resolved,
319 include_done: bool,
320) -> Result<(QueueFile, Option<QueueFile>)> {
321 let (mut queue_file, mut done_for_validation, done_path_exists) =
322 load_queue_set_with_repair(resolved, true)?;
323
324 maintain_and_save_loaded_queues(
325 &resolved.queue_path,
326 &mut queue_file,
327 &resolved.done_path,
328 done_path_exists,
329 &mut done_for_validation,
330 )?;
331
332 validate_loaded_queues(resolved, &queue_file, &done_for_validation)?;
333
334 let done_file = if include_done {
335 Some(done_for_validation)
336 } else {
337 None
338 };
339
340 Ok((queue_file, done_file))
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::contracts::{QueueFile, Task, TaskStatus};
347 use crate::fsutil;
348 use std::collections::HashMap;
349 use tempfile::TempDir;
350
351 fn task(id: &str) -> Task {
352 Task {
353 id: id.to_string(),
354 status: TaskStatus::Todo,
355 title: "Test task".to_string(),
356 description: None,
357 priority: Default::default(),
358 tags: vec!["code".to_string()],
359 scope: vec!["crates/ralph".to_string()],
360 evidence: vec!["observed".to_string()],
361 plan: vec!["do thing".to_string()],
362 notes: vec![],
363 request: Some("test request".to_string()),
364 agent: None,
365 created_at: Some("2026-01-18T00:00:00Z".to_string()),
366 updated_at: Some("2026-01-18T00:00:00Z".to_string()),
367 completed_at: None,
368 started_at: None,
369 scheduled_start: None,
370 depends_on: vec![],
371 blocks: vec![],
372 relates_to: vec![],
373 duplicates: None,
374 custom_fields: HashMap::new(),
375 parent_id: None,
376 estimated_minutes: None,
377 actual_minutes: None,
378 }
379 }
380
381 fn save_queue(path: &Path, queue: &QueueFile) -> Result<()> {
382 let rendered = serde_json::to_string_pretty(queue).context("serialize queue JSON")?;
383 fsutil::write_atomic(path, rendered.as_bytes())
384 .with_context(|| format!("write queue JSON {}", path.display()))?;
385 Ok(())
386 }
387
388 #[test]
389 fn load_and_validate_queues_allows_missing_done_file() -> Result<()> {
390 let temp = TempDir::new()?;
391 let repo_root = temp.path();
392 let ralph_dir = repo_root.join(".ralph");
393 std::fs::create_dir_all(&ralph_dir)?;
394 let queue_path = ralph_dir.join("queue.json");
395 save_queue(
396 &queue_path,
397 &QueueFile {
398 version: 1,
399 tasks: vec![task("RQ-0001")],
400 },
401 )?;
402 let done_path = ralph_dir.join("done.json");
403
404 let resolved = Resolved {
405 config: crate::contracts::Config::default(),
406 repo_root: repo_root.to_path_buf(),
407 queue_path,
408 done_path,
409 id_prefix: "RQ".to_string(),
410 id_width: 4,
411 global_config_path: None,
412 project_config_path: None,
413 };
414
415 let (queue, done) = load_and_validate_queues(&resolved, true)?;
416 assert_eq!(queue.tasks.len(), 1);
417 assert!(done.is_some());
418 assert!(done.unwrap().tasks.is_empty());
419 Ok(())
420 }
421
422 #[test]
423 fn load_and_validate_queues_rejects_duplicate_ids_across_done() -> Result<()> {
424 let temp = TempDir::new()?;
425 let repo_root = temp.path();
426 let ralph_dir = repo_root.join(".ralph");
427 std::fs::create_dir_all(&ralph_dir)?;
428 let queue_path = ralph_dir.join("queue.json");
429 save_queue(
430 &queue_path,
431 &QueueFile {
432 version: 1,
433 tasks: vec![task("RQ-0001")],
434 },
435 )?;
436 let done_path = ralph_dir.join("done.json");
437 save_queue(
438 &done_path,
439 &QueueFile {
440 version: 1,
441 tasks: vec![{
442 let mut t = task("RQ-0001");
443 t.status = TaskStatus::Done;
444 t.completed_at = Some("2026-01-18T00:00:00Z".to_string());
445 t
446 }],
447 },
448 )?;
449
450 let resolved = Resolved {
451 config: crate::contracts::Config::default(),
452 repo_root: repo_root.to_path_buf(),
453 queue_path,
454 done_path,
455 id_prefix: "RQ".to_string(),
456 id_width: 4,
457 global_config_path: None,
458 project_config_path: None,
459 };
460
461 let err =
462 load_and_validate_queues(&resolved, true).expect_err("expected duplicate id error");
463 assert!(
464 err.to_string()
465 .contains("Duplicate task ID detected across queue and done")
466 );
467 Ok(())
468 }
469
470 #[test]
471 fn load_and_validate_queues_rejects_invalid_deps_when_include_done_false() -> Result<()> {
472 let temp = TempDir::new()?;
473 let repo_root = temp.path();
474 let ralph_dir = repo_root.join(".ralph");
475 std::fs::create_dir_all(&ralph_dir)?;
476
477 let queue_path = ralph_dir.join("queue.json");
479 save_queue(
480 &queue_path,
481 &QueueFile {
482 version: 1,
483 tasks: vec![{
484 let mut t = task("RQ-0001");
485 t.depends_on = vec!["RQ-9999".to_string()]; t
487 }],
488 },
489 )?;
490
491 let done_path = ralph_dir.join("done.json");
492
493 let resolved = Resolved {
494 config: crate::contracts::Config::default(),
495 repo_root: repo_root.to_path_buf(),
496 queue_path,
497 done_path,
498 id_prefix: "RQ".to_string(),
499 id_width: 4,
500 global_config_path: None,
501 project_config_path: None,
502 };
503
504 let err = load_and_validate_queues(&resolved, false)
507 .expect_err("should fail on invalid dependency");
508 assert!(
509 err.to_string().contains("Invalid dependency"),
510 "Error should mention invalid dependency: {}",
511 err
512 );
513
514 Ok(())
515 }
516
517 #[test]
518 fn load_and_validate_queues_rejects_non_utc_timestamps_without_persisting() -> Result<()> {
519 let temp = TempDir::new()?;
520 let repo_root = temp.path();
521 let ralph_dir = repo_root.join(".ralph");
522 std::fs::create_dir_all(&ralph_dir)?;
523
524 let queue_path = ralph_dir.join("queue.json");
525 let done_path = ralph_dir.join("done.json");
526
527 let mut active_task = task("RQ-0001");
528 active_task.created_at = Some("2026-01-18T12:00:00-05:00".to_string());
529 active_task.updated_at = Some("2026-01-18T13:00:00-05:00".to_string());
530 save_queue(
531 &queue_path,
532 &QueueFile {
533 version: 1,
534 tasks: vec![active_task],
535 },
536 )?;
537
538 let mut done_task = task("RQ-0002");
539 done_task.status = TaskStatus::Done;
540 done_task.created_at = Some("2026-01-18T10:00:00-07:00".to_string());
541 done_task.updated_at = Some("2026-01-18T11:00:00-07:00".to_string());
542 done_task.completed_at = Some("2026-01-18T12:00:00-07:00".to_string());
543 save_queue(
544 &done_path,
545 &QueueFile {
546 version: 1,
547 tasks: vec![done_task],
548 },
549 )?;
550
551 let resolved = Resolved {
552 config: crate::contracts::Config::default(),
553 repo_root: repo_root.to_path_buf(),
554 queue_path: queue_path.clone(),
555 done_path: done_path.clone(),
556 id_prefix: "RQ".to_string(),
557 id_width: 4,
558 global_config_path: None,
559 project_config_path: None,
560 };
561
562 let err = load_and_validate_queues(&resolved, true)
563 .expect_err("non-UTC timestamps should fail without explicit repair");
564 let err_msg = format!("{err:#}");
565 assert!(
566 err_msg.contains("must be a valid RFC3339 UTC timestamp"),
567 "unexpected error message: {err_msg}"
568 );
569
570 let persisted_queue = load_queue(&queue_path)?;
571 let persisted_done = load_queue(&done_path)?;
572 assert_eq!(
573 persisted_queue.tasks[0].created_at.as_deref(),
574 Some("2026-01-18T12:00:00-05:00")
575 );
576 assert_eq!(
577 persisted_done.tasks[0].completed_at.as_deref(),
578 Some("2026-01-18T12:00:00-07:00")
579 );
580
581 Ok(())
582 }
583
584 #[test]
585 fn load_and_validate_queues_rejects_missing_terminal_completed_at_without_persisting()
586 -> Result<()> {
587 let temp = TempDir::new()?;
588 let repo_root = temp.path();
589 let ralph_dir = repo_root.join(".ralph");
590 std::fs::create_dir_all(&ralph_dir)?;
591
592 let queue_path = ralph_dir.join("queue.json");
593 let done_path = ralph_dir.join("done.json");
594
595 let mut queue_task = task("RQ-0001");
596 queue_task.status = TaskStatus::Done;
597 queue_task.completed_at = None;
598 save_queue(
599 &queue_path,
600 &QueueFile {
601 version: 1,
602 tasks: vec![queue_task],
603 },
604 )?;
605 save_queue(&done_path, &QueueFile::default())?;
606
607 let resolved = Resolved {
608 config: crate::contracts::Config::default(),
609 repo_root: repo_root.to_path_buf(),
610 queue_path: queue_path.clone(),
611 done_path,
612 id_prefix: "RQ".to_string(),
613 id_width: 4,
614 global_config_path: None,
615 project_config_path: None,
616 };
617
618 let err = load_and_validate_queues(&resolved, true)
619 .expect_err("missing completed_at should fail without explicit repair");
620 let err_msg = format!("{err:#}");
621 assert!(
622 err_msg.contains("Missing completed_at"),
623 "unexpected error message: {err_msg}"
624 );
625
626 let persisted_queue = load_queue(&queue_path)?;
627 assert!(
628 persisted_queue.tasks[0].completed_at.is_none(),
629 "read-only validation must not backfill completed_at"
630 );
631
632 Ok(())
633 }
634
635 #[test]
636 fn repair_and_validate_queues_normalizes_non_utc_timestamps_and_persists() -> Result<()> {
637 let temp = TempDir::new()?;
638 let repo_root = temp.path();
639 let ralph_dir = repo_root.join(".ralph");
640 std::fs::create_dir_all(&ralph_dir)?;
641
642 let queue_path = ralph_dir.join("queue.json");
643 let done_path = ralph_dir.join("done.json");
644
645 let mut active_task = task("RQ-0001");
646 active_task.created_at = Some("2026-01-18T12:00:00-05:00".to_string());
647 active_task.updated_at = Some("2026-01-18T13:00:00-05:00".to_string());
648 save_queue(
649 &queue_path,
650 &QueueFile {
651 version: 1,
652 tasks: vec![active_task],
653 },
654 )?;
655
656 let mut done_task = task("RQ-0002");
657 done_task.status = TaskStatus::Done;
658 done_task.created_at = Some("2026-01-18T10:00:00-07:00".to_string());
659 done_task.updated_at = Some("2026-01-18T11:00:00-07:00".to_string());
660 done_task.completed_at = Some("2026-01-18T12:00:00-07:00".to_string());
661 save_queue(
662 &done_path,
663 &QueueFile {
664 version: 1,
665 tasks: vec![done_task],
666 },
667 )?;
668
669 let resolved = Resolved {
670 config: crate::contracts::Config::default(),
671 repo_root: repo_root.to_path_buf(),
672 queue_path: queue_path.clone(),
673 done_path: done_path.clone(),
674 id_prefix: "RQ".to_string(),
675 id_width: 4,
676 global_config_path: None,
677 project_config_path: None,
678 };
679
680 let (queue, done) = repair_and_validate_queues(&resolved, true)?;
681 let done = done.expect("done file should be present");
682
683 let expected_active_created = crate::timeutil::format_rfc3339(
684 crate::timeutil::parse_rfc3339("2026-01-18T12:00:00-05:00")?,
685 )?;
686 let expected_done_completed = crate::timeutil::format_rfc3339(
687 crate::timeutil::parse_rfc3339("2026-01-18T12:00:00-07:00")?,
688 )?;
689
690 assert_eq!(
691 queue.tasks[0].created_at.as_deref(),
692 Some(expected_active_created.as_str())
693 );
694 assert_eq!(
695 done.tasks[0].completed_at.as_deref(),
696 Some(expected_done_completed.as_str())
697 );
698
699 let persisted_queue = load_queue(&queue_path)?;
700 let persisted_done = load_queue(&done_path)?;
701 assert_eq!(
702 persisted_queue.tasks[0].created_at.as_deref(),
703 Some(expected_active_created.as_str())
704 );
705 assert_eq!(
706 persisted_done.tasks[0].completed_at.as_deref(),
707 Some(expected_done_completed.as_str())
708 );
709
710 Ok(())
711 }
712
713 #[test]
714 fn repair_and_validate_queues_backfills_terminal_completed_at_and_persists() -> Result<()> {
715 let temp = TempDir::new()?;
716 let repo_root = temp.path();
717 let ralph_dir = repo_root.join(".ralph");
718 std::fs::create_dir_all(&ralph_dir)?;
719
720 let queue_path = ralph_dir.join("queue.json");
721 let done_path = ralph_dir.join("done.json");
722
723 let mut queue_task = task("RQ-0001");
724 queue_task.status = TaskStatus::Done;
725 queue_task.completed_at = None;
726 save_queue(
727 &queue_path,
728 &QueueFile {
729 version: 1,
730 tasks: vec![queue_task],
731 },
732 )?;
733 save_queue(&done_path, &QueueFile::default())?;
734
735 let resolved = Resolved {
736 config: crate::contracts::Config::default(),
737 repo_root: repo_root.to_path_buf(),
738 queue_path: queue_path.clone(),
739 done_path,
740 id_prefix: "RQ".to_string(),
741 id_width: 4,
742 global_config_path: None,
743 project_config_path: None,
744 };
745
746 let (queue, _done) = repair_and_validate_queues(&resolved, true)?;
747 let completed_at = queue.tasks[0]
748 .completed_at
749 .as_deref()
750 .expect("completed_at should be backfilled");
751 crate::timeutil::parse_rfc3339(completed_at)?;
752
753 let persisted_queue = load_queue(&queue_path)?;
754 let persisted_completed = persisted_queue.tasks[0]
755 .completed_at
756 .as_deref()
757 .expect("completed_at should be saved");
758 crate::timeutil::parse_rfc3339(persisted_completed)?;
759
760 Ok(())
761 }
762
763 #[test]
764 fn load_and_validate_queues_rejects_malformed_timestamps_without_rewrite() -> Result<()> {
765 let temp = TempDir::new()?;
766 let repo_root = temp.path();
767 let ralph_dir = repo_root.join(".ralph");
768 std::fs::create_dir_all(&ralph_dir)?;
769
770 let queue_path = ralph_dir.join("queue.json");
771 let done_path = ralph_dir.join("done.json");
772
773 let mut bad_task = task("RQ-0001");
774 bad_task.created_at = Some("not-a-timestamp".to_string());
775 save_queue(
776 &queue_path,
777 &QueueFile {
778 version: 1,
779 tasks: vec![bad_task],
780 },
781 )?;
782
783 let resolved = Resolved {
784 config: crate::contracts::Config::default(),
785 repo_root: repo_root.to_path_buf(),
786 queue_path: queue_path.clone(),
787 done_path,
788 id_prefix: "RQ".to_string(),
789 id_width: 4,
790 global_config_path: None,
791 project_config_path: None,
792 };
793
794 let err = load_and_validate_queues(&resolved, false)
795 .expect_err("expected malformed timestamp to fail validation");
796 let err_msg = format!("{:#}", err);
797 assert!(
798 err_msg.contains("must be a valid RFC3339 UTC timestamp"),
799 "unexpected error message: {err_msg}"
800 );
801
802 let persisted = std::fs::read_to_string(&queue_path)?;
803 assert!(
804 persisted.contains("not-a-timestamp"),
805 "malformed timestamp should not be rewritten during conservative repair"
806 );
807
808 Ok(())
809 }
810
811 #[test]
812 fn load_queue_with_repair_fixes_malformed_json() -> Result<()> {
813 let temp = TempDir::new()?;
814 let queue_path = temp.path().join("queue.json");
815
816 let malformed = r#"{"version": 1, "tasks": [{"id": "RQ-0001", "title": "Test", "status": "todo", "tags": ["bug",],}]}"#;
818 std::fs::write(&queue_path, malformed)?;
819
820 let queue = load_queue_with_repair(&queue_path)?;
822 assert_eq!(queue.tasks.len(), 1);
823 assert_eq!(queue.tasks[0].id, "RQ-0001");
824 assert_eq!(queue.tasks[0].tags, vec!["bug"]);
825
826 Ok(())
827 }
828
829 #[test]
830 fn load_queue_with_repair_fixes_complex_malformed_json() -> Result<()> {
831 let temp = TempDir::new()?;
832 let queue_path = temp.path().join("queue.json");
833
834 let malformed = r#"{'version': 1, tasks: [{'id': 'RQ-0001', 'title': 'Test task', 'status': 'todo', 'tags': ['bug',], 'scope': ['file',],}]}"#;
836 std::fs::write(&queue_path, malformed)?;
837
838 let queue = load_queue_with_repair(&queue_path)?;
840 assert_eq!(queue.tasks.len(), 1);
841 assert_eq!(queue.tasks[0].id, "RQ-0001");
842 assert_eq!(queue.tasks[0].title, "Test task");
843 assert_eq!(queue.tasks[0].tags, vec!["bug"]);
844
845 Ok(())
846 }
847
848 #[test]
851 fn load_queue_with_repair_and_validate_rejects_missing_timestamps() -> Result<()> {
852 let temp = TempDir::new()?;
853 let queue_path = temp.path().join("queue.json");
854
855 let malformed = r#"{'version': 1, 'tasks': [{'id': 'RQ-0001', 'title': 'Test task', 'status': 'todo', 'tags': ['bug',], 'scope': ['file',], 'evidence': [], 'plan': [],}]}"#;
857 std::fs::write(&queue_path, malformed)?;
858
859 let result = load_queue_with_repair_and_validate(&queue_path, None, "RQ", 4, 10);
861
862 let err = result.expect_err("should fail validation due to missing timestamps");
863 let err_msg = err
865 .chain()
866 .map(|e| e.to_string())
867 .collect::<Vec<_>>()
868 .join(" | ");
869 assert!(
870 err_msg.contains("created_at") || err_msg.contains("updated_at"),
871 "Error should mention missing timestamp: {}",
872 err_msg
873 );
874
875 Ok(())
876 }
877
878 #[test]
879 fn load_queue_with_repair_and_validate_accepts_valid_repair() -> Result<()> {
880 let temp = TempDir::new()?;
881 let queue_path = temp.path().join("queue.json");
882
883 let malformed = r#"{'version': 1, 'tasks': [{'id': 'RQ-0001', 'title': 'Test task', 'status': 'todo', 'tags': ['bug',], 'scope': ['file',], 'evidence': ['observed',], 'plan': ['do thing',], 'created_at': '2026-01-18T00:00:00Z', 'updated_at': '2026-01-18T00:00:00Z',}]}"#;
885 std::fs::write(&queue_path, malformed)?;
886
887 let (queue, warnings) =
889 load_queue_with_repair_and_validate(&queue_path, None, "RQ", 4, 10)?;
890
891 assert_eq!(queue.tasks.len(), 1);
892 assert_eq!(queue.tasks[0].id, "RQ-0001");
893 assert_eq!(queue.tasks[0].title, "Test task");
894 assert_eq!(queue.tasks[0].tags, vec!["bug"]);
895 assert!(warnings.is_empty());
896
897 Ok(())
898 }
899
900 #[test]
901 fn load_queue_with_repair_and_validate_detects_done_queue_issues() -> Result<()> {
902 let temp = TempDir::new()?;
903 let queue_path = temp.path().join("queue.json");
904 let done_path = temp.path().join("done.json");
905
906 let active_malformed = r#"{'version': 1, 'tasks': [{'id': 'RQ-0002', 'title': 'Second task', 'status': 'todo', 'tags': ['bug',], 'scope': ['file',], 'evidence': [], 'plan': [], 'created_at': '2026-01-18T00:00:00Z', 'updated_at': '2026-01-18T00:00:00Z', 'depends_on': ['RQ-0001',],}]}"#;
908 std::fs::write(&queue_path, active_malformed)?;
909
910 let done_queue = QueueFile {
912 version: 1,
913 tasks: vec![{
914 let mut t = task("RQ-0001");
915 t.status = TaskStatus::Done;
916 t.completed_at = Some("2026-01-18T00:00:00Z".to_string());
917 t
918 }],
919 };
920 save_queue(&done_path, &done_queue)?;
921
922 let (queue, warnings) =
924 load_queue_with_repair_and_validate(&queue_path, Some(&done_queue), "RQ", 4, 10)?;
925
926 assert_eq!(queue.tasks.len(), 1);
927 assert_eq!(queue.tasks[0].id, "RQ-0002");
928 assert!(warnings.is_empty());
929
930 Ok(())
931 }
932
933 #[test]
934 fn load_queue_accepts_scalar_custom_fields_and_save_normalizes_to_strings() -> Result<()> {
935 let temp = TempDir::new()?;
936 let queue_path = temp.path().join("queue.json");
937
938 std::fs::write(
940 &queue_path,
941 r#"{"version":1,"tasks":[{"id":"RQ-0001","title":"t","created_at":"2026-01-18T00:00:00Z","updated_at":"2026-01-18T00:00:00Z","custom_fields":{"n":1411,"b":false}}]}"#,
942 )?;
943
944 let queue = load_queue(&queue_path)?;
946 assert_eq!(
947 queue.tasks[0].custom_fields.get("n").map(String::as_str),
948 Some("1411")
949 );
950 assert_eq!(
951 queue.tasks[0].custom_fields.get("b").map(String::as_str),
952 Some("false")
953 );
954
955 save_queue(&queue_path, &queue)?;
957 let rendered = std::fs::read_to_string(&queue_path)?;
958 assert!(rendered.contains("\"n\": \"1411\""));
959 assert!(rendered.contains("\"b\": \"false\""));
960
961 Ok(())
962 }
963
964 #[test]
965 fn load_queue_malformed_json_returns_error() -> Result<()> {
966 let temp = TempDir::new()?;
967 let queue_path = temp.path().join("queue.json");
968
969 let malformed = r#"{"version": 1, "tasks": [{"id": "RQ-0001", "title": }]}"#;
971 std::fs::write(&queue_path, malformed)?;
972
973 let result = load_queue(&queue_path);
975 assert!(result.is_err(), "Should error on malformed JSON");
976 let err = result.unwrap_err();
977 let err_msg = err.to_string();
978 assert!(
979 err_msg.contains("parse") || err_msg.contains("JSON"),
980 "Error should mention parsing/JSON: {}",
981 err_msg
982 );
983
984 Ok(())
985 }
986
987 #[test]
988 fn load_queue_with_repair_fails_on_unrepairable_json() -> Result<()> {
989 let temp = TempDir::new()?;
990 let queue_path = temp.path().join("queue.json");
991
992 let unrepairable = r#"{this is not valid json at all"#;
994 std::fs::write(&queue_path, unrepairable)?;
995
996 let result = load_queue_with_repair(&queue_path);
998 assert!(result.is_err(), "Should error on unrepairable JSON");
999 let err = result.unwrap_err();
1000 let err_msg = format!("{:#}", err);
1001 assert!(
1002 err_msg.contains("parse") || err_msg.contains("JSON") || err_msg.contains("repair"),
1003 "Error should mention parsing or repair failure: {}",
1004 err_msg
1005 );
1006
1007 Ok(())
1008 }
1009
1010 #[test]
1011 fn load_queue_handles_empty_file() -> Result<()> {
1012 let temp = TempDir::new()?;
1013 let queue_path = temp.path().join("queue.json");
1014
1015 std::fs::write(&queue_path, "")?;
1017
1018 let result = load_queue(&queue_path);
1020 assert!(result.is_err(), "Should error on empty file");
1021 let err_msg = format!("{:#}", result.unwrap_err());
1022 assert!(
1023 err_msg.contains("EOF") || err_msg.contains("parse") || err_msg.contains("empty"),
1024 "Error should indicate empty or unparseable file: {}",
1025 err_msg
1026 );
1027
1028 Ok(())
1029 }
1030
1031 #[test]
1035 fn load_queue_detects_truncated_file() -> Result<()> {
1036 let temp = TempDir::new()?;
1037 let queue_path = temp.path().join("queue.json");
1038
1039 let truncated = r#"{"version": 1, "tasks": [{"id": "RQ-0001", "title": "Test""#;
1041 std::fs::write(&queue_path, truncated)?;
1042
1043 let result = load_queue(&queue_path);
1044 assert!(result.is_err(), "Should error on truncated JSON");
1045 let err_msg = format!("{:#}", result.unwrap_err());
1046 assert!(
1047 err_msg.contains("EOF")
1048 || err_msg.contains("unexpected end")
1049 || err_msg.contains("parse"),
1050 "Error should indicate truncated file or EOF: {}",
1051 err_msg
1052 );
1053
1054 Ok(())
1055 }
1056}