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::Logger;
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/// * `logger` - Logger for output
222pub fn validate_agent_chains(
223    registry: &AgentRegistry,
224    sources: &AgentResolutionSources,
225    logger: &Logger,
226) {
227    if let Err(msg) = registry.validate_agent_chains(&sources.describe_searched_sources()) {
228        logger.error(&msg.to_string());
229        logger.warn("Hint: Run 'ralph --init-global' to create ~/.config/ralph-workflow.toml.");
230        crate::app::env_access::exit_with_code(1);
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn validate_can_commit_uses_fuzzy_resolution() {
240        let registry = AgentRegistry::new().unwrap();
241        let config = Config {
242            developer_cmd: None,
243            reviewer_cmd: None,
244            ..Config::default()
245        };
246
247        // "AiChat" resolves to "aichat" (can_commit=false). This must be rejected.
248        let err = validate_can_commit(
249            &config,
250            &registry,
251            "AiChat",
252            "claude",
253            Path::new("ralph-workflow.toml"),
254        )
255        .unwrap_err();
256        let msg = err.to_string();
257        assert!(msg.contains("can_commit=false"));
258        assert!(msg.contains("AiChat"));
259        assert!(msg.contains("resolved to 'aichat'"));
260    }
261
262    #[test]
263    fn resolve_required_agents_error_mentions_searched_sources() {
264        let config = Config {
265            developer_agent: None,
266            reviewer_agent: Some("claude".to_string()),
267            ..Config::default()
268        };
269
270        let err = resolve_required_agents(
271            &config,
272            &AgentResolutionSources {
273                local_config_path: Some(Path::new(".agent/ralph-workflow.toml").to_path_buf()),
274                global_config_path: Some(Path::new("~/.config/ralph-workflow.toml").to_path_buf()),
275                built_in_defaults: true,
276            },
277        )
278        .unwrap_err();
279        let msg = err.to_string();
280        assert!(
281            msg.contains("local config"),
282            "error should mention local config: {msg}"
283        );
284        assert!(
285            msg.contains("global config"),
286            "error should mention global config: {msg}"
287        );
288        assert!(
289            msg.contains("built-in defaults"),
290            "error should mention built-in defaults: {msg}"
291        );
292        assert!(
293            msg.contains("[agent_chains]/[agent_drains]"),
294            "error should guide users to the canonical named chain/drain schema: {msg}"
295        );
296    }
297
298    #[test]
299    fn resolve_required_agents_error_for_reviewer_mentions_sources() {
300        let config = Config {
301            developer_agent: Some("claude".to_string()),
302            reviewer_agent: None,
303            ..Config::default()
304        };
305
306        let err = resolve_required_agents(
307            &config,
308            &AgentResolutionSources {
309                local_config_path: Some(Path::new(".agent/ralph-workflow.toml").to_path_buf()),
310                global_config_path: Some(Path::new("~/.config/ralph-workflow.toml").to_path_buf()),
311                built_in_defaults: true,
312            },
313        )
314        .unwrap_err();
315        let msg = err.to_string();
316        assert!(
317            msg.contains("reviewer"),
318            "error should name the missing role: {msg}"
319        );
320        assert!(
321            msg.contains("local config"),
322            "error should mention local config: {msg}"
323        );
324        assert!(
325            msg.contains("[agent_chains]/[agent_drains]"),
326            "error should guide users to the canonical named chain/drain schema: {msg}"
327        );
328    }
329
330    #[test]
331    fn resolve_required_agents_error_with_explicit_config_omits_local_source() {
332        let config = Config {
333            developer_agent: None,
334            reviewer_agent: Some("claude".to_string()),
335            ..Config::default()
336        };
337
338        let err = resolve_required_agents(
339            &config,
340            &AgentResolutionSources {
341                local_config_path: None,
342                global_config_path: Some(Path::new("/custom/path.toml").to_path_buf()),
343                built_in_defaults: true,
344            },
345        )
346        .unwrap_err();
347        let msg = err.to_string();
348
349        assert!(
350            msg.contains("global config (/custom/path.toml), built-in defaults"),
351            "error should include actual consulted sources: {msg}"
352        );
353        assert!(
354            !msg.contains("local config"),
355            "error should not mention local config when not consulted: {msg}"
356        );
357    }
358}