Skip to main content

ralph/config/validation/
agent.rs

1//! Agent validation rules.
2//!
3//! Responsibilities:
4//! - Validate agent-specific numeric limits and binary path overrides.
5//! - Expose helpers used by trust validation to identify execution-sensitive settings.
6//!
7//! Not handled here:
8//! - Queue thresholds or git ref validation.
9//! - Full config version or parallel workspace rules.
10//!
11//! Invariants/assumptions:
12//! - Empty binary-path strings are invalid when provided.
13//! - Agent phases stay within the configured global limits.
14
15use super::ci_gate::validate_ci_gate_config;
16use crate::constants::runner::{MAX_PHASES, MIN_ITERATIONS, MIN_PHASES};
17use crate::contracts::{AgentConfig, PhaseOverrides, Runner, validate_webhook_settings};
18use anyhow::{Result, bail};
19use std::path::PathBuf;
20
21pub fn validate_agent_binary_paths(agent: &AgentConfig, label: &str) -> Result<()> {
22    macro_rules! check_bin {
23        ($field:ident) => {
24            if let Some(bin) = &agent.$field
25                && bin.trim().is_empty()
26            {
27                bail!(
28                    "Empty {label}.{}: binary path is required if specified.",
29                    stringify!($field)
30                );
31            }
32        };
33    }
34
35    check_bin!(codex_bin);
36    check_bin!(opencode_bin);
37    check_bin!(gemini_bin);
38    check_bin!(claude_bin);
39    check_bin!(cursor_bin);
40    check_bin!(kimi_bin);
41    check_bin!(pi_bin);
42
43    Ok(())
44}
45
46/// Reject empty or whitespace-only paths in `instruction_files` before filesystem checks.
47///
48/// Without this, an empty entry can resolve to the repo root and surface a confusing
49/// "is a directory" read error.
50pub(crate) fn validate_instruction_files_entries(
51    paths: Option<&Vec<PathBuf>>,
52    label: &str,
53) -> Result<()> {
54    let Some(paths) = paths else {
55        return Ok(());
56    };
57    for raw in paths {
58        if raw.as_os_str().is_empty() || raw.to_string_lossy().trim().is_empty() {
59            bail!(
60                "Invalid {label}.instruction_files: each entry must be a non-empty path. Remove blank list items or fix the path string in .ralph/config.jsonc."
61            );
62        }
63    }
64    Ok(())
65}
66
67pub fn validate_agent_patch(agent: &AgentConfig, label: &str) -> Result<()> {
68    validate_instruction_files_entries(agent.instruction_files.as_ref(), label)?;
69    if let Some(phases) = agent.phases
70        && !(MIN_PHASES..=MAX_PHASES).contains(&phases)
71    {
72        bail!(
73            "Invalid {label}.phases: {phases}. Supported values are {MIN_PHASES}, {}, or {MAX_PHASES}.",
74            MIN_PHASES + 1
75        );
76    }
77
78    if let Some(iterations) = agent.iterations
79        && iterations < MIN_ITERATIONS
80    {
81        bail!(
82            "Invalid {label}.iterations: {iterations}. Iterations must be at least {MIN_ITERATIONS}."
83        );
84    }
85
86    if let Some(timeout) = agent.session_timeout_hours
87        && timeout == 0
88    {
89        bail!(
90            "Invalid {label}.session_timeout_hours: {timeout}. Session timeout must be greater than 0."
91        );
92    }
93
94    validate_agent_binary_paths(agent, label)?;
95    validate_ci_gate_config(agent.ci_gate.as_ref(), label)?;
96    if let Err(err) = validate_webhook_settings(&agent.webhook) {
97        bail!("Invalid {label}.webhook: {err:#}");
98    }
99    Ok(())
100}
101
102pub(crate) fn agent_has_execution_settings(agent: &AgentConfig) -> bool {
103    agent.ci_gate.is_some()
104        || agent.codex_bin.is_some()
105        || agent.opencode_bin.is_some()
106        || agent.gemini_bin.is_some()
107        || agent.claude_bin.is_some()
108        || agent.cursor_bin.is_some()
109        || agent.kimi_bin.is_some()
110        || agent.pi_bin.is_some()
111        || agent.runner.as_ref().is_some_and(Runner::is_plugin)
112        || agent
113            .phase_overrides
114            .as_ref()
115            .is_some_and(phase_overrides_have_plugin_runner)
116}
117
118fn phase_overrides_have_plugin_runner(overrides: &PhaseOverrides) -> bool {
119    [&overrides.phase1, &overrides.phase2, &overrides.phase3]
120        .into_iter()
121        .flatten()
122        .filter_map(|phase| phase.runner.as_ref())
123        .any(Runner::is_plugin)
124}