Skip to main content

ralph/config/
validation.rs

1//! Configuration validation functions for Ralph.
2//!
3//! Responsibilities:
4//! - Validate config values (version, paths, numeric ranges, runner binaries).
5//! - Validate queue config overrides (prefix, width, file paths).
6//! - Validate git branch names for parallel execution.
7//! - Validate agent config patches (for profiles).
8//!
9//! Not handled here:
10//! - Config file loading/parsing (see `super::layer`).
11//! - Config resolution from multiple sources (see `super::resolution`).
12//! - Profile application logic (see `super::resolution`).
13//!
14//! Invariants/assumptions:
15//! - Validation errors are returned as `anyhow::Error` with descriptive messages.
16//! - Queue validation uses shared error messages for consistency.
17
18use crate::constants::runner::{MAX_PHASES, MIN_ITERATIONS, MIN_PARALLEL_WORKERS, MIN_PHASES};
19use crate::contracts::{AgentConfig, Config, QueueAgingThresholds, QueueConfig};
20use anyhow::{Result, bail};
21use std::path::{Component, Path};
22
23/// Helper to format the aging threshold ordering error message.
24fn format_aging_threshold_error(
25    warning: Option<u32>,
26    stale: Option<u32>,
27    rotten: Option<u32>,
28) -> String {
29    format!(
30        "Invalid queue.aging_thresholds ordering: require warning_days < stale_days < rotten_days (got warning_days={}, stale_days={}, rotten_days={}). Update .ralph/config.jsonc.",
31        warning
32            .map(|w| w.to_string())
33            .unwrap_or_else(|| "unset".to_string()),
34        stale
35            .map(|s| s.to_string())
36            .unwrap_or_else(|| "unset".to_string()),
37        rotten
38            .map(|r| r.to_string())
39            .unwrap_or_else(|| "unset".to_string()),
40    )
41}
42
43// Canonical error messages for queue config validation (single source of truth)
44pub(crate) 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.";
45pub(crate) 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.";
46pub(crate) 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.";
47pub(crate) 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.";
48
49/// Validate queue.id_prefix override (if specified, must be non-empty after trim).
50pub fn validate_queue_id_prefix_override(id_prefix: Option<&str>) -> Result<()> {
51    if let Some(prefix) = id_prefix
52        && prefix.trim().is_empty()
53    {
54        bail!(ERR_EMPTY_QUEUE_ID_PREFIX);
55    }
56    Ok(())
57}
58
59/// Validate queue.id_width override (if specified, must be greater than 0).
60pub fn validate_queue_id_width_override(id_width: Option<u8>) -> Result<()> {
61    if let Some(width) = id_width
62        && width == 0
63    {
64        bail!(ERR_INVALID_QUEUE_ID_WIDTH);
65    }
66    Ok(())
67}
68
69/// Validate queue.file override (if specified, must be non-empty).
70pub fn validate_queue_file_override(file: Option<&Path>) -> Result<()> {
71    if let Some(path) = file
72        && path.as_os_str().is_empty()
73    {
74        bail!(ERR_EMPTY_QUEUE_FILE);
75    }
76    Ok(())
77}
78
79/// Validate queue.done_file override (if specified, must be non-empty).
80pub fn validate_queue_done_file_override(done_file: Option<&Path>) -> Result<()> {
81    if let Some(path) = done_file
82        && path.as_os_str().is_empty()
83    {
84        bail!(ERR_EMPTY_QUEUE_DONE_FILE);
85    }
86    Ok(())
87}
88
89/// Validate all queue config overrides in a single call.
90pub fn validate_queue_overrides(queue: &QueueConfig) -> Result<()> {
91    validate_queue_id_prefix_override(queue.id_prefix.as_deref())?;
92    validate_queue_id_width_override(queue.id_width)?;
93    validate_queue_file_override(queue.file.as_deref())?;
94    validate_queue_done_file_override(queue.done_file.as_deref())?;
95    validate_queue_thresholds(queue)?;
96    Ok(())
97}
98
99/// Validate queue threshold values are within schema-defined ranges.
100///
101/// Validates:
102/// - size_warning_threshold_kb: 100..=10000
103/// - task_count_warning_threshold: 50..=5000
104/// - max_dependency_depth: 1..=100
105/// - auto_archive_terminal_after_days: 0..=3650
106pub fn validate_queue_thresholds(queue: &QueueConfig) -> Result<()> {
107    if let Some(threshold) = queue.size_warning_threshold_kb
108        && !(100..=10000).contains(&threshold)
109    {
110        bail!(
111            "Invalid queue.size_warning_threshold_kb: {}. Value must be between 100 and 10000 (inclusive). Update .ralph/config.jsonc.",
112            threshold
113        );
114    }
115
116    if let Some(threshold) = queue.task_count_warning_threshold
117        && !(50..=5000).contains(&threshold)
118    {
119        bail!(
120            "Invalid queue.task_count_warning_threshold: {}. Value must be between 50 and 5000 (inclusive). Update .ralph/config.jsonc.",
121            threshold
122        );
123    }
124
125    if let Some(depth) = queue.max_dependency_depth
126        && !(1..=100).contains(&depth)
127    {
128        bail!(
129            "Invalid queue.max_dependency_depth: {}. Value must be between 1 and 100 (inclusive). Update .ralph/config.jsonc.",
130            depth
131        );
132    }
133
134    if let Some(days) = queue.auto_archive_terminal_after_days
135        && days > 3650
136    {
137        bail!(
138            "Invalid queue.auto_archive_terminal_after_days: {}. Value must be between 0 and 3650 (inclusive). Update .ralph/config.jsonc.",
139            days
140        );
141    }
142
143    Ok(())
144}
145
146/// Validate queue.aging_thresholds ordering (if specified).
147///
148/// When any thresholds are specified, validates that:
149/// - warning_days < stale_days (when both are set)
150/// - stale_days < rotten_days (when both are set)
151/// - warning_days < rotten_days (when both are set, transitive check)
152pub fn validate_queue_aging_thresholds(thresholds: &Option<QueueAgingThresholds>) -> Result<()> {
153    let Some(t) = thresholds else {
154        return Ok(());
155    };
156
157    let warning = t.warning_days;
158    let stale = t.stale_days;
159    let rotten = t.rotten_days;
160
161    // Check ordering when pairs are specified
162    if let (Some(w), Some(s)) = (warning, stale)
163        && w >= s
164    {
165        bail!(format_aging_threshold_error(Some(w), Some(s), rotten));
166    }
167
168    if let (Some(s), Some(r)) = (stale, rotten)
169        && s >= r
170    {
171        bail!(format_aging_threshold_error(warning, Some(s), Some(r)));
172    }
173
174    // Transitive check for warning < rotten (catches cases where middle value is unset)
175    if let (Some(w), Some(r)) = (warning, rotten)
176        && w >= r
177    {
178        bail!(format_aging_threshold_error(Some(w), stale, Some(r)));
179    }
180
181    Ok(())
182}
183
184/// Validate that all configured binary paths are non-empty strings.
185///
186/// Checks each binary path field in AgentConfig - if specified, it must be
187/// non-empty after trimming whitespace.
188///
189/// # Arguments
190/// * `agent` - The agent config to validate
191/// * `label` - Context label for error messages (e.g., "agent", "profiles.dev")
192///
193/// # Binary fields validated
194/// - codex_bin, opencode_bin, gemini_bin, claude_bin, cursor_bin, kimi_bin, pi_bin
195pub fn validate_agent_binary_paths(agent: &AgentConfig, label: &str) -> Result<()> {
196    macro_rules! check_bin {
197        ($field:ident) => {
198            if let Some(bin) = &agent.$field
199                && bin.trim().is_empty()
200            {
201                bail!(
202                    "Empty {label}.{}: binary path is required if specified.",
203                    stringify!($field)
204                );
205            }
206        };
207    }
208
209    check_bin!(codex_bin);
210    check_bin!(opencode_bin);
211    check_bin!(gemini_bin);
212    check_bin!(claude_bin);
213    check_bin!(cursor_bin);
214    check_bin!(kimi_bin);
215    check_bin!(pi_bin);
216
217    Ok(())
218}
219
220/// Validate the full configuration.
221pub fn validate_config(cfg: &Config) -> Result<()> {
222    if cfg.version != 1 {
223        bail!(
224            "Unsupported config version: {}. Ralph requires version 1. Update the 'version' field in your config file.",
225            cfg.version
226        );
227    }
228
229    // Validate queue overrides using shared validators (single source of truth)
230    validate_queue_overrides(&cfg.queue)?;
231    validate_queue_aging_thresholds(&cfg.queue.aging_thresholds)?;
232
233    if let Some(phases) = cfg.agent.phases
234        && !(MIN_PHASES..=MAX_PHASES).contains(&phases)
235    {
236        bail!(
237            "Invalid agent.phases: {}. Supported values are {}, {}, or {}. Update .ralph/config.jsonc or CLI flags.",
238            phases,
239            MIN_PHASES,
240            MIN_PHASES + 1,
241            MAX_PHASES
242        );
243    }
244
245    if let Some(iterations) = cfg.agent.iterations
246        && iterations < MIN_ITERATIONS
247    {
248        bail!(
249            "Invalid agent.iterations: {}. Iterations must be at least {}. Update .ralph/config.jsonc.",
250            iterations,
251            MIN_ITERATIONS
252        );
253    }
254
255    if let Some(workers) = cfg.parallel.workers
256        && workers < MIN_PARALLEL_WORKERS
257    {
258        bail!(
259            "Invalid parallel.workers: {}. Parallel workers must be >= {}. Update .ralph/config.jsonc or CLI flags.",
260            workers,
261            MIN_PARALLEL_WORKERS
262        );
263    }
264
265    // Validate workspace_root does not contain '..' components for security/predictability
266    if let Some(root) = &cfg.parallel.workspace_root {
267        if root.as_os_str().is_empty() {
268            bail!(
269                "Empty parallel.workspace_root: path is required if specified. Set a valid path or remove the field."
270            );
271        }
272        if root.components().any(|c| matches!(c, Component::ParentDir)) {
273            bail!(
274                "Invalid parallel.workspace_root: path must not contain '..' components (got {}). Use a normalized path.",
275                root.display()
276            );
277        }
278    }
279
280    if let Some(timeout) = cfg.agent.session_timeout_hours
281        && timeout == 0
282    {
283        bail!(
284            "Invalid agent.session_timeout_hours: {}. Session timeout must be greater than 0. Update .ralph/config.jsonc.",
285            timeout
286        );
287    }
288
289    // Validate all agent binary paths using shared helper
290    validate_agent_binary_paths(&cfg.agent, "agent")?;
291
292    let ci_gate_enabled = cfg.agent.ci_gate_enabled.unwrap_or(true);
293    if ci_gate_enabled
294        && let Some(command) = &cfg.agent.ci_gate_command
295        && command.trim().is_empty()
296    {
297        bail!(
298            "Empty agent.ci_gate_command: CI gate command must be non-empty when enabled. Set a command (e.g., 'make ci') or disable the gate with agent.ci_gate_enabled=false."
299        );
300    }
301
302    // Validate profile agent configs
303    if let Some(profiles) = cfg.profiles.as_ref() {
304        for (name, patch) in profiles {
305            validate_agent_patch(patch, &format!("profiles.{name}"))?;
306        }
307    }
308
309    Ok(())
310}
311
312/// Validate an AgentConfig patch (used for base agent and profile agents).
313pub fn validate_agent_patch(agent: &AgentConfig, label: &str) -> Result<()> {
314    if let Some(phases) = agent.phases
315        && !(MIN_PHASES..=MAX_PHASES).contains(&phases)
316    {
317        bail!(
318            "Invalid {label}.phases: {phases}. Supported values are {MIN_PHASES}, {}, or {MAX_PHASES}.",
319            MIN_PHASES + 1
320        );
321    }
322
323    if let Some(iterations) = agent.iterations
324        && iterations < MIN_ITERATIONS
325    {
326        bail!(
327            "Invalid {label}.iterations: {iterations}. Iterations must be at least {MIN_ITERATIONS}."
328        );
329    }
330
331    if let Some(timeout) = agent.session_timeout_hours
332        && timeout == 0
333    {
334        bail!(
335            "Invalid {label}.session_timeout_hours: {timeout}. Session timeout must be greater than 0."
336        );
337    }
338
339    // Validate all agent binary paths using shared helper
340    validate_agent_binary_paths(agent, label)?;
341
342    Ok(())
343}
344
345/// Check if a string is a valid git branch name.
346/// Returns None if valid, or Some(reason) if invalid.
347/// Based on git's check-ref-format rules:
348/// - Cannot contain spaces, tabs, or control characters
349/// - Cannot contain .. (dotdot)
350/// - Cannot contain @{ (at brace)
351/// - Cannot start with . or end with .lock
352/// - Cannot contain /./ or // or end with /
353/// - Cannot be @ or contain @{ (reflog syntax)
354pub fn git_ref_invalid_reason(branch: &str) -> Option<String> {
355    // Empty check
356    if branch.is_empty() {
357        return Some("branch name cannot be empty".to_string());
358    }
359
360    // Check for spaces and control characters
361    if branch.chars().any(|c| c.is_ascii_control() || c == ' ') {
362        return Some("branch name cannot contain spaces or control characters".to_string());
363    }
364
365    // Check for double dots
366    if branch.contains("..") {
367        return Some("branch name cannot contain '..'".to_string());
368    }
369
370    // Check for @{ (reflog syntax)
371    if branch.contains("@{") {
372        return Some("branch name cannot contain '@{{'".to_string());
373    }
374
375    // Check for invalid dot patterns
376    if branch.starts_with('.') {
377        return Some("branch name cannot start with '.'".to_string());
378    }
379
380    if branch.ends_with(".lock") {
381        return Some("branch name cannot end with '.lock'".to_string());
382    }
383
384    // Check for invalid slash patterns
385    if branch.contains("//") || branch.contains("/.") || branch.ends_with('/') {
386        return Some("branch name contains invalid slash/dot pattern".to_string());
387    }
388
389    // Check for @ as entire name or component
390    if branch == "@" || branch.starts_with("@/") || branch.contains("/@/") || branch.ends_with("/@")
391    {
392        return Some("branch name cannot be '@' or contain '@' as a path component".to_string());
393    }
394
395    // Check for tilde expansion issues (~ is special in git)
396    if branch.contains('~') {
397        return Some("branch name cannot contain '~'".to_string());
398    }
399
400    // Check for caret (revision suffix)
401    if branch.contains('^') {
402        return Some("branch name cannot contain '^'".to_string());
403    }
404
405    // Check for colon (used for object names)
406    if branch.contains(':') {
407        return Some("branch name cannot contain ':'".to_string());
408    }
409
410    // Check for backslash
411    if branch.contains('\\') {
412        return Some("branch name cannot contain '\\'".to_string());
413    }
414
415    None
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    // =========================================================================
423    // validate_queue_thresholds tests
424    // =========================================================================
425
426    #[test]
427    fn test_validate_queue_thresholds_size_warning_ok() {
428        let queue = QueueConfig {
429            size_warning_threshold_kb: Some(500),
430            ..Default::default()
431        };
432        assert!(validate_queue_thresholds(&queue).is_ok());
433    }
434
435    #[test]
436    fn test_validate_queue_thresholds_size_warning_min_boundary() {
437        let queue = QueueConfig {
438            size_warning_threshold_kb: Some(100),
439            ..Default::default()
440        };
441        assert!(validate_queue_thresholds(&queue).is_ok());
442    }
443
444    #[test]
445    fn test_validate_queue_thresholds_size_warning_max_boundary() {
446        let queue = QueueConfig {
447            size_warning_threshold_kb: Some(10000),
448            ..Default::default()
449        };
450        assert!(validate_queue_thresholds(&queue).is_ok());
451    }
452
453    #[test]
454    fn test_validate_queue_thresholds_size_warning_too_low() {
455        let queue = QueueConfig {
456            size_warning_threshold_kb: Some(50),
457            ..Default::default()
458        };
459        let result = validate_queue_thresholds(&queue);
460        assert!(result.is_err());
461        let err = result.unwrap_err().to_string();
462        assert!(err.contains("size_warning_threshold_kb"));
463        assert!(err.contains("100"));
464        assert!(err.contains("10000"));
465    }
466
467    #[test]
468    fn test_validate_queue_thresholds_size_warning_too_high() {
469        let queue = QueueConfig {
470            size_warning_threshold_kb: Some(50000),
471            ..Default::default()
472        };
473        let result = validate_queue_thresholds(&queue);
474        assert!(result.is_err());
475        let err = result.unwrap_err().to_string();
476        assert!(err.contains("size_warning_threshold_kb"));
477    }
478
479    #[test]
480    fn test_validate_queue_thresholds_task_count_ok() {
481        let queue = QueueConfig {
482            task_count_warning_threshold: Some(500),
483            ..Default::default()
484        };
485        assert!(validate_queue_thresholds(&queue).is_ok());
486    }
487
488    #[test]
489    fn test_validate_queue_thresholds_task_count_min_boundary() {
490        let queue = QueueConfig {
491            task_count_warning_threshold: Some(50),
492            ..Default::default()
493        };
494        assert!(validate_queue_thresholds(&queue).is_ok());
495    }
496
497    #[test]
498    fn test_validate_queue_thresholds_task_count_max_boundary() {
499        let queue = QueueConfig {
500            task_count_warning_threshold: Some(5000),
501            ..Default::default()
502        };
503        assert!(validate_queue_thresholds(&queue).is_ok());
504    }
505
506    #[test]
507    fn test_validate_queue_thresholds_task_count_too_low() {
508        let queue = QueueConfig {
509            task_count_warning_threshold: Some(10),
510            ..Default::default()
511        };
512        let result = validate_queue_thresholds(&queue);
513        assert!(result.is_err());
514        let err = result.unwrap_err().to_string();
515        assert!(err.contains("task_count_warning_threshold"));
516        assert!(err.contains("50"));
517        assert!(err.contains("5000"));
518    }
519
520    #[test]
521    fn test_validate_queue_thresholds_task_count_too_high() {
522        let queue = QueueConfig {
523            task_count_warning_threshold: Some(10000),
524            ..Default::default()
525        };
526        let result = validate_queue_thresholds(&queue);
527        assert!(result.is_err());
528        let err = result.unwrap_err().to_string();
529        assert!(err.contains("task_count_warning_threshold"));
530    }
531
532    #[test]
533    fn test_validate_queue_thresholds_max_dependency_depth_ok() {
534        let queue = QueueConfig {
535            max_dependency_depth: Some(10),
536            ..Default::default()
537        };
538        assert!(validate_queue_thresholds(&queue).is_ok());
539    }
540
541    #[test]
542    fn test_validate_queue_thresholds_max_dependency_depth_min_boundary() {
543        let queue = QueueConfig {
544            max_dependency_depth: Some(1),
545            ..Default::default()
546        };
547        assert!(validate_queue_thresholds(&queue).is_ok());
548    }
549
550    #[test]
551    fn test_validate_queue_thresholds_max_dependency_depth_max_boundary() {
552        let queue = QueueConfig {
553            max_dependency_depth: Some(100),
554            ..Default::default()
555        };
556        assert!(validate_queue_thresholds(&queue).is_ok());
557    }
558
559    #[test]
560    fn test_validate_queue_thresholds_max_dependency_depth_too_low() {
561        let queue = QueueConfig {
562            max_dependency_depth: Some(0),
563            ..Default::default()
564        };
565        let result = validate_queue_thresholds(&queue);
566        assert!(result.is_err());
567        let err = result.unwrap_err().to_string();
568        assert!(err.contains("max_dependency_depth"));
569        assert!(err.contains("1"));
570        assert!(err.contains("100"));
571    }
572
573    #[test]
574    fn test_validate_queue_thresholds_max_dependency_depth_too_high() {
575        let queue = QueueConfig {
576            max_dependency_depth: Some(200),
577            ..Default::default()
578        };
579        let result = validate_queue_thresholds(&queue);
580        assert!(result.is_err());
581        let err = result.unwrap_err().to_string();
582        assert!(err.contains("max_dependency_depth"));
583    }
584
585    #[test]
586    fn test_validate_queue_thresholds_auto_archive_ok() {
587        let queue = QueueConfig {
588            auto_archive_terminal_after_days: Some(30),
589            ..Default::default()
590        };
591        assert!(validate_queue_thresholds(&queue).is_ok());
592    }
593
594    #[test]
595    fn test_validate_queue_thresholds_auto_archive_min_boundary() {
596        let queue = QueueConfig {
597            auto_archive_terminal_after_days: Some(0),
598            ..Default::default()
599        };
600        assert!(validate_queue_thresholds(&queue).is_ok());
601    }
602
603    #[test]
604    fn test_validate_queue_thresholds_auto_archive_max_boundary() {
605        let queue = QueueConfig {
606            auto_archive_terminal_after_days: Some(3650),
607            ..Default::default()
608        };
609        assert!(validate_queue_thresholds(&queue).is_ok());
610    }
611
612    #[test]
613    fn test_validate_queue_thresholds_auto_archive_too_high() {
614        let queue = QueueConfig {
615            auto_archive_terminal_after_days: Some(4000),
616            ..Default::default()
617        };
618        let result = validate_queue_thresholds(&queue);
619        assert!(result.is_err());
620        let err = result.unwrap_err().to_string();
621        assert!(err.contains("auto_archive_terminal_after_days"));
622        assert!(err.contains("3650"));
623    }
624
625    #[test]
626    fn test_validate_queue_thresholds_none_values_ok() {
627        let queue = QueueConfig {
628            size_warning_threshold_kb: None,
629            task_count_warning_threshold: None,
630            max_dependency_depth: None,
631            auto_archive_terminal_after_days: None,
632            ..Default::default()
633        };
634        assert!(validate_queue_thresholds(&queue).is_ok());
635    }
636
637    #[test]
638    fn test_validate_queue_thresholds_all_boundary_values() {
639        // Test exact boundary values
640        let queue = QueueConfig {
641            size_warning_threshold_kb: Some(100),         // min
642            task_count_warning_threshold: Some(5000),     // max
643            max_dependency_depth: Some(1),                // min
644            auto_archive_terminal_after_days: Some(3650), // max
645            ..Default::default()
646        };
647        assert!(validate_queue_thresholds(&queue).is_ok());
648    }
649
650    #[test]
651    fn test_validate_queue_thresholds_all_max_boundary_values() {
652        let queue = QueueConfig {
653            size_warning_threshold_kb: Some(10000),    // max
654            task_count_warning_threshold: Some(50),    // min
655            max_dependency_depth: Some(100),           // max
656            auto_archive_terminal_after_days: Some(0), // min
657            ..Default::default()
658        };
659        assert!(validate_queue_thresholds(&queue).is_ok());
660    }
661
662    // =========================================================================
663    // validate_queue_overrides tests
664    // =========================================================================
665
666    #[test]
667    fn test_validate_queue_overrides_calls_threshold_validation() {
668        // A queue config with invalid thresholds should fail in validate_queue_overrides
669        let queue = QueueConfig {
670            size_warning_threshold_kb: Some(50), // invalid - below min
671            ..Default::default()
672        };
673        let result = validate_queue_overrides(&queue);
674        assert!(result.is_err());
675        let err = result.unwrap_err().to_string();
676        assert!(err.contains("size_warning_threshold_kb"));
677    }
678
679    // =========================================================================
680    // validate_config integration tests
681    // =========================================================================
682
683    #[test]
684    fn test_validate_config_includes_threshold_validation() {
685        let cfg = Config {
686            version: 1,
687            queue: QueueConfig {
688                size_warning_threshold_kb: Some(50000), // invalid - above max
689                ..Default::default()
690            },
691            ..Default::default()
692        };
693        let result = validate_config(&cfg);
694        assert!(result.is_err());
695        let err = result.unwrap_err().to_string();
696        assert!(err.contains("size_warning_threshold_kb"));
697    }
698}