ralph_workflow/app/
validation.rs1use crate::agents::AgentRegistry;
10use crate::app::config_init::AgentResolutionSources;
11use crate::config::Config;
12use crate::logger::Logger;
13use std::path::Path;
14
15#[derive(Debug)]
17pub struct ValidatedAgents {
18 pub developer_agent: String,
20 pub reviewer_agent: String,
22}
23
24pub 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
70pub 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 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 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
138pub 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 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
213pub 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 let err = validate_can_commit(
249 &config,
250 ®istry,
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}