Skip to main content

ralph/config/validation/
queue.rs

1//! Queue-config validation rules.
2//!
3//! Responsibilities:
4//! - Validate queue override fields and threshold ranges.
5//! - Enforce queue aging-threshold ordering rules.
6//!
7//! Not handled here:
8//! - Active/done queue file contents.
9//! - Agent or trust validation.
10//!
11//! Invariants/assumptions:
12//! - Queue overrides are optional but must be valid when specified.
13//! - Aging thresholds must be strictly increasing when paired.
14
15use crate::contracts::{QueueAgingThresholds, QueueConfig};
16use anyhow::{Result, bail};
17use std::path::Path;
18
19pub const ERR_EMPTY_QUEUE_ID_PREFIX: &str = "Empty queue.id_prefix: prefix is required if specified. Set a non-empty prefix (e.g., 'RQ') in .ralph/config.jsonc or via --id-prefix.";
20pub const ERR_INVALID_QUEUE_ID_WIDTH: &str = "Invalid queue.id_width: width must be greater than 0. Set a valid width (e.g., 4) in .ralph/config.jsonc or via --id-width.";
21pub const ERR_EMPTY_QUEUE_FILE: &str = "Empty queue.file: path is required if specified. Specify a valid path (e.g., '.ralph/queue.jsonc') in .ralph/config.jsonc or via --queue-file.";
22pub const ERR_EMPTY_QUEUE_DONE_FILE: &str = "Empty queue.done_file: path is required if specified. Specify a valid path (e.g., '.ralph/done.jsonc') in .ralph/config.jsonc or via --done-file.";
23
24pub fn validate_queue_id_prefix_override(id_prefix: Option<&str>) -> Result<()> {
25    if let Some(prefix) = id_prefix
26        && prefix.trim().is_empty()
27    {
28        bail!(ERR_EMPTY_QUEUE_ID_PREFIX);
29    }
30    Ok(())
31}
32
33pub fn validate_queue_id_width_override(id_width: Option<u8>) -> Result<()> {
34    if let Some(width) = id_width
35        && width == 0
36    {
37        bail!(ERR_INVALID_QUEUE_ID_WIDTH);
38    }
39    Ok(())
40}
41
42pub fn validate_queue_file_override(file: Option<&Path>) -> Result<()> {
43    if let Some(path) = file
44        && path.as_os_str().is_empty()
45    {
46        bail!(ERR_EMPTY_QUEUE_FILE);
47    }
48    Ok(())
49}
50
51pub fn validate_queue_done_file_override(done_file: Option<&Path>) -> Result<()> {
52    if let Some(path) = done_file
53        && path.as_os_str().is_empty()
54    {
55        bail!(ERR_EMPTY_QUEUE_DONE_FILE);
56    }
57    Ok(())
58}
59
60pub fn validate_queue_overrides(queue: &QueueConfig) -> Result<()> {
61    validate_queue_id_prefix_override(queue.id_prefix.as_deref())?;
62    validate_queue_id_width_override(queue.id_width)?;
63    validate_queue_file_override(queue.file.as_deref())?;
64    validate_queue_done_file_override(queue.done_file.as_deref())?;
65    validate_queue_thresholds(queue)?;
66    Ok(())
67}
68
69pub fn validate_queue_thresholds(queue: &QueueConfig) -> Result<()> {
70    if let Some(threshold) = queue.size_warning_threshold_kb
71        && !(100..=10000).contains(&threshold)
72    {
73        bail!(
74            "Invalid queue.size_warning_threshold_kb: {}. Value must be between 100 and 10000 (inclusive). Update .ralph/config.jsonc.",
75            threshold
76        );
77    }
78
79    if let Some(threshold) = queue.task_count_warning_threshold
80        && !(50..=5000).contains(&threshold)
81    {
82        bail!(
83            "Invalid queue.task_count_warning_threshold: {}. Value must be between 50 and 5000 (inclusive). Update .ralph/config.jsonc.",
84            threshold
85        );
86    }
87
88    if let Some(depth) = queue.max_dependency_depth
89        && !(1..=100).contains(&depth)
90    {
91        bail!(
92            "Invalid queue.max_dependency_depth: {}. Value must be between 1 and 100 (inclusive). Update .ralph/config.jsonc.",
93            depth
94        );
95    }
96
97    if let Some(days) = queue.auto_archive_terminal_after_days
98        && days > 3650
99    {
100        bail!(
101            "Invalid queue.auto_archive_terminal_after_days: {}. Value must be between 0 and 3650 (inclusive). Update .ralph/config.jsonc.",
102            days
103        );
104    }
105
106    Ok(())
107}
108
109pub fn validate_queue_aging_thresholds(thresholds: &Option<QueueAgingThresholds>) -> Result<()> {
110    let Some(thresholds) = thresholds else {
111        return Ok(());
112    };
113
114    let warning = thresholds.warning_days;
115    let stale = thresholds.stale_days;
116    let rotten = thresholds.rotten_days;
117
118    if let (Some(w), Some(s)) = (warning, stale)
119        && w >= s
120    {
121        bail!(format_aging_threshold_error(Some(w), Some(s), rotten));
122    }
123    if let (Some(s), Some(r)) = (stale, rotten)
124        && s >= r
125    {
126        bail!(format_aging_threshold_error(warning, Some(s), Some(r)));
127    }
128    if let (Some(w), Some(r)) = (warning, rotten)
129        && w >= r
130    {
131        bail!(format_aging_threshold_error(Some(w), stale, Some(r)));
132    }
133
134    Ok(())
135}
136
137fn format_aging_threshold_error(
138    warning: Option<u32>,
139    stale: Option<u32>,
140    rotten: Option<u32>,
141) -> String {
142    format!(
143        "Invalid queue.aging_thresholds ordering: require warning_days < stale_days < rotten_days (got warning_days={}, stale_days={}, rotten_days={}). Update .ralph/config.jsonc.",
144        warning
145            .map(|value| value.to_string())
146            .unwrap_or_else(|| "unset".to_string()),
147        stale
148            .map(|value| value.to_string())
149            .unwrap_or_else(|| "unset".to_string()),
150        rotten
151            .map(|value| value.to_string())
152            .unwrap_or_else(|| "unset".to_string()),
153    )
154}