Skip to main content

ralph/queue/
loader.rs

1//! Queue file loading functionality with explicit read-vs-repair semantics.
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 without mutating files.
7//! - Provide explicit queue-set repair helpers that persist normalized timestamp maintenance.
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//! - Pure load/validate APIs never write to disk.
16//! - Callers must hold locks before invoking explicit repair-and-save APIs.
17
18use 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
164/// Load queue from path, returning default if file doesn't exist.
165pub 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
172/// Load queue from path with standard JSONC parsing.
173pub 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
180/// Load queue with automatic repair for common JSON errors.
181/// Attempts to fix trailing commas and other common agent-induced mistakes.
182pub 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    // Try JSONC parsing first (handles both valid JSON and JSONC with comments)
187    match crate::jsonc::parse_jsonc::<QueueFile>(&raw, &format!("queue {}", path.display())) {
188        Ok(queue) => Ok(queue),
189        Err(parse_err) => {
190            // Attempt to repair common JSON errors
191            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                        // Repair failed, return original error with context
204                        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                // No repair possible, return original error
215                Err(parse_err)
216            }
217        }
218    }
219}
220
221/// Load queue with JSON repair and semantic validation.
222///
223/// This API is pure with respect to the filesystem: it may repair parseable JSON
224/// mistakes in memory, but it never rewrites the queue file on disk.
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 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
292/// Load the active queue and optionally the done queue, validating both.
293///
294/// This API is pure with respect to the filesystem: it may repair parseable JSON
295/// in memory, but it never rewrites queue/done files during the read.
296pub 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
313/// Explicitly repair queue/done timestamp maintenance and persist the result before validation.
314///
315/// Unlike [`load_and_validate_queues`], this API mutates queue/done files on disk when it
316/// normalizes non-UTC timestamps or backfills missing terminal `completed_at` values.
317pub 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        // Queue with invalid dependency (depends on non-existent task)
478        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()]; // Non-existent task!
486                    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        // With include_done=false, should STILL fail on invalid dependency
505        // This is the regression test for RQ-0881
506        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        // Write malformed JSON with trailing comma
817        let malformed = r#"{"version": 1, "tasks": [{"id": "RQ-0001", "title": "Test", "status": "todo", "tags": ["bug",],}]}"#;
818        std::fs::write(&queue_path, malformed)?;
819
820        // Should load with repair
821        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        // Write malformed JSON with multiple issues
835        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        // Should load with repair
839        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    // Tests for load_queue_with_repair_and_validate (RQ-0502)
849
850    #[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        // Write malformed JSON with trailing comma but missing required timestamps
856        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        // Should fail validation due to missing created_at/updated_at
860        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        // Traverse the error chain to find the root cause
864        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        // Write malformed JSON with trailing commas but all required fields present
884        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        // Should load with repair and pass validation
888        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        // Active queue: valid but with dependency on done task
907        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        // Done queue: contains the dependency target
911        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        // Should load with repair and validate successfully
923        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        // Write queue with numeric and boolean custom_fields values
939        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        // Load queue - should accept numeric/boolean values and coerce to strings
945        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 - should serialize as strings
956        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        // Write unrecoverably malformed JSON (not fixable by repair)
970        let malformed = r#"{"version": 1, "tasks": [{"id": "RQ-0001", "title": }]}"#;
971        std::fs::write(&queue_path, malformed)?;
972
973        // Should fail with descriptive error
974        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        // Write JSON that is too corrupted to repair (structurally invalid)
993        let unrepairable = r#"{this is not valid json at all"#;
994        std::fs::write(&queue_path, unrepairable)?;
995
996        // Should fail even with repair attempt
997        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        // Write empty file
1016        std::fs::write(&queue_path, "")?;
1017
1018        // Should fail gracefully with meaningful error
1019        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: Truncated JSON file (simulating partial write or crash during write)
1032    /// Scenario: File ends mid-object due to external corruption or power loss
1033    /// Expected: load_queue should detect and report a parsing/EOF error
1034    #[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        // Simulate truncated write - valid JSON cut off mid-stream
1040        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}