1use crate::agents::AgentRegistry;
10use crate::app::config_init::AgentResolutionSources;
11use crate::config::Config;
12use crate::logger::Colors;
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(
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
66pub 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 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 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
134pub 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 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
209pub 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 let err = validate_can_commit(
260 &config,
261 ®istry,
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 ®istry,
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}