Skip to main content

ralph_workflow/app/
validation.rs

1//! Agent validation and chain validation.
2//!
3//! This module handles validation of agents and agent chains:
4//! - Resolving required agent names from config
5//! - Validating that agent commands exist in the registry
6//! - Enforcing workflow-capable agents (`can_commit=true`)
7//! - Validating agent chain configuration
8
9use crate::agents::AgentRegistry;
10use crate::app::config_init::AgentResolutionSources;
11use crate::config::Config;
12use crate::logger::Colors;
13use std::path::Path;
14
15/// Result of agent validation containing the resolved agent names.
16#[derive(Debug)]
17pub struct ValidatedAgents {
18    /// The validated developer agent name.
19    pub developer_agent: String,
20    /// The validated reviewer agent name.
21    pub reviewer_agent: String,
22}
23
24/// Resolves and validates the required agent names from configuration.
25///
26/// Both developer and reviewer agents must be configured at this point,
27/// either via CLI args, environment variables, or `agent_chain` defaults.
28///
29/// # Arguments
30///
31/// * `config` - The pipeline configuration
32/// * `sources` - Description of config sources consulted for resolution
33///
34/// # Returns
35///
36/// Returns the validated agent names or an error if agents are not configured.
37///
38/// # Errors
39///
40/// Returns error if the operation fails.
41pub fn resolve_required_agents(
42    config: &Config,
43    sources: &AgentResolutionSources,
44) -> anyhow::Result<ValidatedAgents> {
45    let searched = sources.describe_searched_sources();
46
47    let developer_agent = config.developer_agent.clone().ok_or_else(|| {
48        anyhow::anyhow!(
49            "No developer agent configured. Searched: {searched}.\n\
50            Set via --developer-agent, RALPH_DEVELOPER_AGENT env, or [agent_chain] in config."
51        )
52    })?;
53    let reviewer_agent = config.reviewer_agent.clone().ok_or_else(|| {
54        anyhow::anyhow!(
55            "No reviewer agent configured. Searched: {searched}.\n\
56            Set via --reviewer-agent, RALPH_REVIEWER_AGENT env, or [agent_chain] in config."
57        )
58    })?;
59
60    Ok(ValidatedAgents {
61        developer_agent,
62        reviewer_agent,
63    })
64}
65
66/// Validates that agent commands exist in the registry.
67///
68/// Checks that both developer and reviewer agents have valid commands
69/// defined either in the config or the registry.
70///
71/// # Arguments
72///
73/// * `config` - The pipeline configuration
74/// * `registry` - The agent registry
75/// * `developer_agent` - Name of the developer agent
76/// * `reviewer_agent` - Name of the reviewer agent
77/// * `config_path` - Path to the unified config file for error messages
78///
79/// # Returns
80///
81/// Returns `Ok(())` if validation passes, or an error with details.
82///
83/// # Errors
84///
85/// Returns error if the operation fails.
86pub fn validate_agent_commands(
87    config: &Config,
88    registry: &AgentRegistry,
89    developer_agent: &str,
90    reviewer_agent: &str,
91    config_path: &Path,
92) -> anyhow::Result<()> {
93    // Validate developer command exists
94    if config.developer_cmd.is_none() {
95        let resolved_developer = registry.resolve_fuzzy(developer_agent);
96        let dev_agent_ref = resolved_developer.as_deref().unwrap_or(developer_agent);
97        registry.developer_cmd(dev_agent_ref).ok_or_else(|| {
98            let suggestion = resolved_developer
99                .as_ref()
100                .filter(|n| n != &developer_agent)
101                .map(|correct| format!(" Did you mean '{correct}'?"))
102                .unwrap_or_default();
103            anyhow::anyhow!(
104                "Unknown developer agent '{}'.{}. Use --list-agents or define it in {} under [agents].",
105                developer_agent,
106                suggestion,
107                config_path.display()
108            )
109        })?;
110    }
111
112    // Validate reviewer command exists
113    if config.reviewer_cmd.is_none() {
114        let resolved_reviewer = registry.resolve_fuzzy(reviewer_agent);
115        let rev_agent_ref = resolved_reviewer.as_deref().unwrap_or(reviewer_agent);
116        registry.reviewer_cmd(rev_agent_ref).ok_or_else(|| {
117            let suggestion = resolved_reviewer
118                .as_ref()
119                .filter(|n| n != &reviewer_agent)
120                .map(|correct| format!(" Did you mean '{correct}'?"))
121                .unwrap_or_default();
122            anyhow::anyhow!(
123                "Unknown reviewer agent '{}'.{}. Use --list-agents or define it in {} under [agents].",
124                reviewer_agent,
125                suggestion,
126                config_path.display()
127            )
128        })?;
129    }
130
131    Ok(())
132}
133
134/// Validates that agents are workflow-capable (`can_commit=true`).
135///
136/// Agents with `can_commit=false` are chat-only / non-tool agents and will
137/// stall Ralph's workflow. This validation is skipped if a custom command
138/// override is provided.
139///
140/// # Arguments
141///
142/// * `config` - The pipeline configuration
143/// * `registry` - The agent registry
144/// * `developer_agent` - Name of the developer agent
145/// * `reviewer_agent` - Name of the reviewer agent
146/// * `config_path` - Path to the unified config file for error messages
147///
148/// # Returns
149///
150/// Returns `Ok(())` if validation passes, or an error with details.
151///
152/// # Errors
153///
154/// Returns error if the operation fails.
155pub fn validate_can_commit(
156    config: &Config,
157    registry: &AgentRegistry,
158    developer_agent: &str,
159    reviewer_agent: &str,
160    config_path: &Path,
161) -> anyhow::Result<()> {
162    // Enforce workflow-capable agents unless custom command override provided
163    if config.developer_cmd.is_none() {
164        let resolved = registry
165            .resolve_fuzzy(developer_agent)
166            .unwrap_or_else(|| developer_agent.to_string());
167        if let Some(cfg) = registry.resolve_config(&resolved) {
168            if !cfg.can_commit {
169                let resolved_note = if resolved == developer_agent {
170                    String::new()
171                } else {
172                    format!(" (resolved to '{resolved}')")
173                };
174                anyhow::bail!(
175                    "Developer agent '{}'{} has can_commit=false and cannot run Ralph's workflow.\n\
176                    Fix: choose a different agent (see --list-agents) or set can_commit=true in {} under [agents].",
177                    developer_agent,
178                    resolved_note,
179                    config_path.display()
180                );
181            }
182        }
183    }
184    if config.reviewer_cmd.is_none() {
185        let resolved = registry
186            .resolve_fuzzy(reviewer_agent)
187            .unwrap_or_else(|| reviewer_agent.to_string());
188        if let Some(cfg) = registry.resolve_config(&resolved) {
189            if !cfg.can_commit {
190                let resolved_note = if resolved == reviewer_agent {
191                    String::new()
192                } else {
193                    format!(" (resolved to '{resolved}')")
194                };
195                anyhow::bail!(
196                    "Reviewer agent '{}'{} has can_commit=false and cannot run Ralph's workflow.\n\
197                    Fix: choose a different agent (see --list-agents) or set can_commit=true in {} under [agents].",
198                    reviewer_agent,
199                    resolved_note,
200                    config_path.display()
201                );
202            }
203        }
204    }
205
206    Ok(())
207}
208
209/// Validates that agent chains are properly configured.
210///
211/// Displays an error and exits if the agent chains are not configured.
212///
213/// # Arguments
214///
215/// * `registry` - The agent registry
216/// * `sources` - Description of config sources consulted for resolution
217/// * `colors` - Color configuration for output
218pub fn validate_agent_chains(
219    registry: &AgentRegistry,
220    sources: &AgentResolutionSources,
221    colors: Colors,
222) {
223    if let Err(msg) = registry.validate_agent_chains(&sources.describe_searched_sources()) {
224        eprintln!();
225        eprintln!(
226            "{}{}Error:{} {}",
227            colors.bold(),
228            colors.red(),
229            colors.reset(),
230            msg
231        );
232        eprintln!();
233        eprintln!(
234            "{}Hint:{} Run 'ralph --init-global' to create ~/.config/ralph-workflow.toml.",
235            colors.yellow(),
236            colors.reset()
237        );
238        eprintln!();
239        std::process::exit(1);
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::config::CcsConfig;
247    use std::collections::HashMap;
248
249    #[test]
250    fn validate_can_commit_uses_fuzzy_resolution() {
251        let registry = AgentRegistry::new().unwrap();
252        let config = Config {
253            developer_cmd: None,
254            reviewer_cmd: None,
255            ..Config::default()
256        };
257
258        // "AiChat" resolves to "aichat" (can_commit=false). This must be rejected.
259        let err = validate_can_commit(
260            &config,
261            &registry,
262            "AiChat",
263            "claude",
264            Path::new("ralph-workflow.toml"),
265        )
266        .unwrap_err();
267        let msg = err.to_string();
268        assert!(msg.contains("can_commit=false"));
269        assert!(msg.contains("AiChat"));
270        assert!(msg.contains("resolved to 'aichat'"));
271    }
272
273    #[test]
274    fn validate_can_commit_uses_resolve_config_for_ccs_refs() {
275        let mut registry = AgentRegistry::new().unwrap();
276        let defaults = CcsConfig {
277            can_commit: false,
278            ..CcsConfig::default()
279        };
280        registry.set_ccs_aliases(&HashMap::new(), defaults);
281
282        let config = Config {
283            developer_cmd: None,
284            reviewer_cmd: None,
285            ..Config::default()
286        };
287
288        let err = validate_can_commit(
289            &config,
290            &registry,
291            "ccs/random",
292            "claude",
293            Path::new("ralph-workflow.toml"),
294        )
295        .unwrap_err();
296        assert!(err.to_string().contains("can_commit=false"));
297    }
298
299    #[test]
300    fn resolve_required_agents_error_mentions_searched_sources() {
301        let config = Config {
302            developer_agent: None,
303            reviewer_agent: Some("claude".to_string()),
304            ..Config::default()
305        };
306
307        let err = resolve_required_agents(
308            &config,
309            &AgentResolutionSources {
310                local_config_path: Some(Path::new(".agent/ralph-workflow.toml").to_path_buf()),
311                global_config_path: Some(Path::new("~/.config/ralph-workflow.toml").to_path_buf()),
312                built_in_defaults: true,
313            },
314        )
315        .unwrap_err();
316        let msg = err.to_string();
317        assert!(
318            msg.contains("local config"),
319            "error should mention local config: {msg}"
320        );
321        assert!(
322            msg.contains("global config"),
323            "error should mention global config: {msg}"
324        );
325        assert!(
326            msg.contains("built-in defaults"),
327            "error should mention built-in defaults: {msg}"
328        );
329    }
330
331    #[test]
332    fn resolve_required_agents_error_for_reviewer_mentions_sources() {
333        let config = Config {
334            developer_agent: Some("claude".to_string()),
335            reviewer_agent: None,
336            ..Config::default()
337        };
338
339        let err = resolve_required_agents(
340            &config,
341            &AgentResolutionSources {
342                local_config_path: Some(Path::new(".agent/ralph-workflow.toml").to_path_buf()),
343                global_config_path: Some(Path::new("~/.config/ralph-workflow.toml").to_path_buf()),
344                built_in_defaults: true,
345            },
346        )
347        .unwrap_err();
348        let msg = err.to_string();
349        assert!(
350            msg.contains("reviewer"),
351            "error should name the missing role: {msg}"
352        );
353        assert!(
354            msg.contains("local config"),
355            "error should mention local config: {msg}"
356        );
357    }
358
359    #[test]
360    fn resolve_required_agents_error_with_explicit_config_omits_local_source() {
361        let config = Config {
362            developer_agent: None,
363            reviewer_agent: Some("claude".to_string()),
364            ..Config::default()
365        };
366
367        let err = resolve_required_agents(
368            &config,
369            &AgentResolutionSources {
370                local_config_path: None,
371                global_config_path: Some(Path::new("/custom/path.toml").to_path_buf()),
372                built_in_defaults: true,
373            },
374        )
375        .unwrap_err();
376        let msg = err.to_string();
377
378        assert!(
379            msg.contains("global config (/custom/path.toml), built-in defaults"),
380            "error should include actual consulted sources: {msg}"
381        );
382        assert!(
383            !msg.contains("local config"),
384            "error should not mention local config when not consulted: {msg}"
385        );
386    }
387}