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