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