Skip to main content

ralph/queue/
loader.rs

1//! Queue file loading functionality with various options.
2//!
3//! Responsibilities:
4//! - Load queue files from disk with standard JSONC parsing.
5//! - Load with automatic repair for common JSON errors.
6//! - Load with repair and semantic validation.
7//! - Load active and done queues together with conservative timestamp maintenance + validation.
8//!
9//! Not handled here:
10//! - Queue file saving (see `queue::save`).
11//! - ID generation or backup management.
12//!
13//! Invariants/assumptions:
14//! - Missing queue files return default empty queues.
15//! - Callers must hold locks when loading mutable state.
16
17use 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
163/// Load queue from path, returning default if file doesn't exist.
164pub 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
171/// Load queue from path with standard JSONC parsing.
172pub 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
179/// Load queue with automatic repair for common JSON errors.
180/// Attempts to fix trailing commas and other common agent-induced mistakes.
181pub 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    // Try JSONC parsing first (handles both valid JSON and JSONC with comments)
186    match crate::jsonc::parse_jsonc::<QueueFile>(&raw, &format!("queue {}", path.display())) {
187        Ok(queue) => Ok(queue),
188        Err(parse_err) => {
189            // Attempt to repair common JSON errors
190            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                        // Repair failed, return original error with context
203                        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                // No repair possible, return original error
214                Err(parse_err)
215            }
216        }
217    }
218}
219
220/// Load queue with repair and semantic validation.
221///
222/// JSON repair is followed by semantic validation via `validate_queue_set`. Callers
223/// should log warnings if needed. This ensures repaired-but-invalid queues fail
224/// early with descriptive errors.
225///
226/// Returns the queue file and any validation warnings (non-blocking issues).
227pub 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
261/// Load the active queue and optionally the done queue, validating both.
262pub 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    // Always load done file for validation context (dependency checks need it)
269    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    // Build reference for validation (same logic as before)
285    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    // Always run full validation (includes dependency checks)
292    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    // Return done_file only if caller requested it (maintains API contract)
303    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        // Queue with invalid dependency (depends on non-existent task)
447        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()]; // Non-existent task!
455                    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        // With include_done=false, should STILL fail on invalid dependency
474        // This is the regression test for RQ-0881
475        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        // Write malformed JSON with trailing comma
668        let malformed = r#"{"version": 1, "tasks": [{"id": "RQ-0001", "title": "Test", "status": "todo", "tags": ["bug",],}]}"#;
669        std::fs::write(&queue_path, malformed)?;
670
671        // Should load with repair
672        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        // Write malformed JSON with multiple issues
686        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        // Should load with repair
690        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    // Tests for load_queue_with_repair_and_validate (RQ-0502)
700
701    #[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        // Write malformed JSON with trailing comma but missing required timestamps
707        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        // Should fail validation due to missing created_at/updated_at
711        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        // Traverse the error chain to find the root cause
715        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        // Write malformed JSON with trailing commas but all required fields present
735        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        // Should load with repair and pass validation
739        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        // Active queue: valid but with dependency on done task
758        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        // Done queue: contains the dependency target
762        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        // Should load with repair and validate successfully
774        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        // Write queue with numeric and boolean custom_fields values
790        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        // Load queue - should accept numeric/boolean values and coerce to strings
796        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 - should serialize as strings
807        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        // Write unrecoverably malformed JSON (not fixable by repair)
821        let malformed = r#"{"version": 1, "tasks": [{"id": "RQ-0001", "title": }]}"#;
822        std::fs::write(&queue_path, malformed)?;
823
824        // Should fail with descriptive error
825        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        // Write JSON that is too corrupted to repair (structurally invalid)
844        let unrepairable = r#"{this is not valid json at all"#;
845        std::fs::write(&queue_path, unrepairable)?;
846
847        // Should fail even with repair attempt
848        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        // Write empty file
867        std::fs::write(&queue_path, "")?;
868
869        // Should fail gracefully with meaningful error
870        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: Truncated JSON file (simulating partial write or crash during write)
883    /// Scenario: File ends mid-object due to external corruption or power loss
884    /// Expected: load_queue should detect and report a parsing/EOF error
885    #[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        // Simulate truncated write - valid JSON cut off mid-stream
891        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}