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(
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 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 let err = validate_can_commit(
264 &config,
265 ®istry,
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 ®istry,
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}