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