Skip to main content

ralph/
queue.rs

1//! Task queue persistence, validation, and pruning.
2//!
3//! Responsibilities:
4//! - Load, save, and validate queue files in JSON format.
5//! - Provide operations for moving completed tasks and pruning history.
6//! - Own queue-level helpers such as ID generation and validation.
7//!
8//! Not handled here:
9//! - Directory lock acquisition (see `crate::lock`).
10//! - CLI parsing or user interaction.
11//! - Runner integration or external command execution.
12//!
13//! Invariants/assumptions:
14//! - Queue files conform to the schema in `crate::contracts`.
15//! - Callers hold locks when mutating queue state on disk.
16
17// Existing submodules (unchanged)
18pub mod graph;
19pub mod hierarchy;
20pub mod operations;
21pub mod prune;
22pub mod repair;
23pub mod search;
24pub mod size_check;
25pub mod validation;
26
27// New split modules
28mod backup;
29mod id;
30mod json_repair;
31mod loader;
32mod save;
33
34// Re-exports from existing submodules
35pub use graph::*;
36pub use operations::*;
37pub use prune::{PruneOptions, PruneReport, prune_done_tasks};
38pub use repair::*;
39pub use search::{
40    SearchOptions, filter_tasks, fuzzy_search_tasks, search_tasks, search_tasks_with_options,
41};
42pub use size_check::{
43    SizeCheckResult, check_queue_size, count_threshold_or_default, print_size_warning_if_needed,
44    size_threshold_or_default,
45};
46pub use validation::{ValidationWarning, log_warnings, validate_queue, validate_queue_set};
47
48// Re-exports from new modules.
49pub use backup::backup_queue;
50pub use id::next_id_across;
51pub use id::{format_id, normalize_prefix};
52pub use json_repair::attempt_json_repair;
53pub use loader::{
54    load_and_validate_queues, load_queue, load_queue_or_default, load_queue_with_repair,
55    load_queue_with_repair_and_validate, repair_and_validate_queues,
56};
57pub use save::save_queue;
58
59use crate::config::Resolved;
60use crate::contracts::QueueFile;
61use crate::lock;
62use anyhow::Result;
63use std::path::Path;
64
65pub fn acquire_queue_lock(repo_root: &Path, label: &str, force: bool) -> Result<lock::DirLock> {
66    let lock_dir = lock::queue_lock_dir(repo_root);
67    lock::acquire_dir_lock(&lock_dir, label, force)
68}
69
70pub fn optional_done_queue<'a>(
71    done_file: &'a QueueFile,
72    done_path: &Path,
73) -> Option<&'a QueueFile> {
74    (!done_file.tasks.is_empty() || done_path.exists()).then_some(done_file)
75}
76
77pub fn with_locked_queue_mutation<T>(
78    resolved: &Resolved,
79    label: &str,
80    operation: impl AsRef<str>,
81    force: bool,
82    mutate: impl FnOnce() -> Result<T>,
83) -> Result<T> {
84    let _queue_lock = acquire_queue_lock(&resolved.repo_root, label, force)?;
85    crate::undo::create_undo_snapshot(resolved, operation.as_ref())?;
86    mutate()
87}
88
89// Tests that exercise the facade re-exports
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::contracts::{Task, TaskStatus};
94    use std::collections::HashMap;
95
96    fn task(id: &str) -> Task {
97        Task {
98            id: id.to_string(),
99            status: TaskStatus::Todo,
100            title: "Test task".to_string(),
101            description: None,
102            priority: Default::default(),
103            tags: vec!["code".to_string()],
104            scope: vec!["crates/ralph".to_string()],
105            evidence: vec!["observed".to_string()],
106            plan: vec!["do thing".to_string()],
107            notes: vec![],
108            request: Some("test request".to_string()),
109            agent: None,
110            created_at: Some("2026-01-18T00:00:00Z".to_string()),
111            updated_at: Some("2026-01-18T00:00:00Z".to_string()),
112            completed_at: None,
113            started_at: None,
114            scheduled_start: None,
115            depends_on: vec![],
116            blocks: vec![],
117            relates_to: vec![],
118            duplicates: None,
119            custom_fields: HashMap::new(),
120            parent_id: None,
121            estimated_minutes: None,
122            actual_minutes: None,
123        }
124    }
125
126    #[test]
127    fn task_defaults_to_medium_priority() {
128        use crate::contracts::TaskPriority;
129        let task = task("RQ-0001");
130        assert_eq!(task.priority, TaskPriority::Medium);
131    }
132
133    #[test]
134    fn priority_ord_implements_correct_ordering() {
135        use crate::contracts::TaskPriority;
136        assert!(TaskPriority::Critical > TaskPriority::High);
137        assert!(TaskPriority::High > TaskPriority::Medium);
138        assert!(TaskPriority::Medium > TaskPriority::Low);
139    }
140
141    #[test]
142    fn attempt_json_repair_fixes_trailing_comma_in_array() {
143        let input = r#"{"tasks": [{"id": "RQ-0001", "tags": ["a", "b",]}]}"#;
144        let repaired = attempt_json_repair(input).expect("should repair");
145        assert!(repaired.contains("\"tags\": [\"a\", \"b\"]"));
146        assert!(!repaired.contains("\"b\","));
147    }
148
149    #[test]
150    fn attempt_json_repair_fixes_trailing_comma_in_object() {
151        let input = r#"{"tasks": [{"id": "RQ-0001", "title": "Test",}]}"#;
152        let repaired = attempt_json_repair(input).expect("should repair");
153        assert!(repaired.contains("\"title\": \"Test\"}"));
154        assert!(!repaired.contains("\"Test\","));
155    }
156
157    #[test]
158    fn attempt_json_repair_returns_none_for_valid_json() {
159        let input = r#"{"tasks": [{"id": "RQ-0001", "title": "Test"}]}"#;
160        assert!(attempt_json_repair(input).is_none());
161    }
162}