1use crate::agents::AgentRegistry;
11use crate::config::unified::UnifiedConfig;
12use crate::config::{ConfigEnvironment, RealConfigEnvironment};
13use crate::logger::Colors;
14use std::collections::BTreeMap;
15
16trait StdIoWriteCompat {
17 fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::io::Result<()>;
18}
19
20impl<T: std::io::Write> StdIoWriteCompat for T {
21 fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
22 std::io::Write::write_fmt(self, args)
23 }
24}
25
26fn generate_local_config_template<R: ConfigEnvironment>(env: &R) -> anyhow::Result<String> {
32 generate_local_config_template_with(env, built_in_default_drains)
33}
34
35fn generate_local_config_template_with<R, F>(
36 env: &R,
37 default_drain_loader: F,
38) -> anyhow::Result<String>
39where
40 R: ConfigEnvironment,
41 F: FnOnce() -> anyhow::Result<crate::agents::fallback::ResolvedDrainConfig>,
42{
43 let effective = resolve_effective_init_template_config(env)?;
44 let default_drains = default_drain_loader()?;
45
46 let general = &effective.general;
47 let resolved_drains = resolve_template_drains(&effective, &default_drains)?;
48 let chain_definitions = collect_named_chain_definitions(&effective, &resolved_drains);
49 let rendered_chain_definitions = chain_definitions
50 .iter()
51 .map(|(name, agents)| format!("# {name} = {}", format_toml_string_array(agents)))
52 .collect::<Vec<_>>()
53 .join("\n");
54 let rendered_drain_bindings = crate::agents::AgentDrain::all()
55 .into_iter()
56 .map(|drain| {
57 let binding = resolved_drains
58 .binding(drain)
59 .expect("built-in drain bindings should be fully resolved");
60 format!("# {} = \"{}\"", drain.as_str(), binding.chain_name)
61 })
62 .collect::<Vec<_>>()
63 .join("\n");
64
65 let lines: Vec<String> = std::iter::empty()
66 .chain(std::iter::once(
67 "# Local Ralph configuration (.agent/ralph-workflow.toml)".to_string(),
68 ))
69 .chain(std::iter::once(
70 "# Overrides ~/.config/ralph-workflow.toml for this project.".to_string(),
71 ))
72 .chain(std::iter::once(
73 "# Only uncomment settings you want to override.".to_string(),
74 ))
75 .chain(std::iter::once(
76 "# Run `ralph --check-config` to validate and see effective settings.".to_string(),
77 ))
78 .chain(std::iter::once(String::new()))
79 .chain(std::iter::once("[general]".to_string()))
80 .chain(std::iter::once(
81 "# Project-specific iteration limits".to_string(),
82 ))
83 .chain(std::iter::once(format!(
84 "# developer_iters = {}",
85 general.developer_iters
86 )))
87 .chain(std::iter::once(format!(
88 "# reviewer_reviews = {}",
89 general.reviewer_reviews
90 )))
91 .chain(std::iter::once(String::new()))
92 .chain(std::iter::once(
93 "# Project-specific context levels".to_string(),
94 ))
95 .chain(std::iter::once(format!(
96 "# developer_context = {}",
97 general.developer_context
98 )))
99 .chain(std::iter::once(format!(
100 "# reviewer_context = {}",
101 general.reviewer_context
102 )))
103 .chain(std::iter::once(String::new()))
104 .chain(
105 render_retry_settings_comments(general)
106 .lines()
107 .map(String::from),
108 )
109 .chain(
110 render_provider_fallback_comments(general)
111 .lines()
112 .map(String::from),
113 )
114 .chain(std::iter::once(String::new()))
115 .chain(std::iter::once("# [agent_chains]".to_string()))
116 .chain(std::iter::once(
117 "# Reusable named chain definitions".to_string(),
118 ))
119 .chain(rendered_chain_definitions.lines().map(String::from))
120 .chain(std::iter::once(String::new()))
121 .chain(std::iter::once("# [agent_drains]".to_string()))
122 .chain(std::iter::once(
123 "# Built-in drains attached to those chains".to_string(),
124 ))
125 .chain(rendered_drain_bindings.lines().map(String::from))
126 .collect();
127
128 Ok(lines.join("\n"))
129}
130
131fn render_retry_settings_comments(general: &crate::config::unified::GeneralConfig) -> String {
132 let lines = [
133 "# Agent retry/backoff settings for all configured drains",
134 &format!("# max_retries = {}", general.max_retries),
135 &format!("# retry_delay_ms = {}", general.retry_delay_ms),
136 &format!("# backoff_multiplier = {}", general.backoff_multiplier),
137 &format!("# max_backoff_ms = {}", general.max_backoff_ms),
138 &format!("# max_cycles = {}", general.max_cycles),
139 ];
140
141 lines.join("\n")
142}
143
144fn render_provider_fallback_comments(general: &crate::config::unified::GeneralConfig) -> String {
145 if general.provider_fallback.is_empty() {
146 return String::new();
147 }
148
149 let lines: Vec<String> = std::iter::empty()
150 .chain(std::iter::once(String::new()))
151 .chain(std::iter::once("# [general.provider_fallback]".to_string()))
152 .chain(std::iter::once(
153 "# Provider/model fallback settings by agent".to_string(),
154 ))
155 .chain(general.provider_fallback.iter().map(|(provider, models)| {
156 format!("# {provider} = {}", format_toml_string_array(models))
157 }))
158 .collect();
159
160 lines.join("\n")
161}
162
163fn resolve_template_drains(
164 effective: &UnifiedConfig,
165 default_drains: &crate::agents::fallback::ResolvedDrainConfig,
166) -> anyhow::Result<crate::agents::fallback::ResolvedDrainConfig> {
167 match effective.resolve_agent_drains_checked() {
168 Ok(Some(resolved)) => Ok(resolved),
169 Ok(None) => Ok(default_drains.clone()),
170 Err(message) => {
171 let message_string = message.to_string();
172 if named_chain_template_can_fall_back_to_defaults(effective, &message_string) {
173 Ok(default_drains.clone())
174 } else {
175 Err(anyhow::Error::msg(message))
176 }
177 }
178 }
179}
180
181fn named_chain_template_can_fall_back_to_defaults(
182 effective: &UnifiedConfig,
183 message: &str,
184) -> bool {
185 !effective.agent_chains.is_empty()
186 && effective.agent_drains.is_empty()
187 && message.contains("agent_drains does not resolve all built-in drains")
188}
189
190fn resolve_effective_init_template_config<R: ConfigEnvironment>(
191 env: &R,
192) -> anyhow::Result<UnifiedConfig> {
193 let Some(global_path) = env.unified_config_path() else {
194 return Ok(UnifiedConfig::default());
195 };
196
197 if !env.file_exists(&global_path) {
198 return Ok(UnifiedConfig::default());
199 }
200
201 let global_content = env.read_file(&global_path).map_err(|e| {
202 anyhow::anyhow!(
203 "Failed to read global config {} while generating local config template: {e}",
204 global_path.display()
205 )
206 })?;
207
208 let global = UnifiedConfig::load_from_content(&global_content).map_err(|e| {
209 anyhow::anyhow!(
210 "Failed to parse global config {} while generating local config template: {e}",
211 global_path.display()
212 )
213 })?;
214
215 Ok(UnifiedConfig::default().merge_with_content(&global_content, &global))
216}
217
218fn built_in_default_drains() -> anyhow::Result<crate::agents::fallback::ResolvedDrainConfig> {
219 AgentRegistry::new()
220 .map(|registry| registry.resolved_drains().clone())
221 .map_err(map_registry_init_error)
222}
223
224fn map_registry_init_error(error: impl std::fmt::Display) -> anyhow::Error {
225 anyhow::anyhow!("Failed to load built-in default agent chains: {error}")
226}
227
228fn format_toml_string_array(items: &[String]) -> String {
230 let inner: Vec<String> = items.iter().map(|s| format!(r#""{s}""#)).collect();
231 format!("[{}]", inner.join(", "))
232}
233
234fn collect_named_chain_definitions(
235 effective: &UnifiedConfig,
236 resolved: &crate::agents::fallback::ResolvedDrainConfig,
237) -> BTreeMap<String, Vec<String>> {
238 let drain_chains: BTreeMap<String, Vec<String>> = crate::agents::AgentDrain::all()
239 .into_iter()
240 .map(|drain| {
241 let binding = resolved
242 .binding(drain)
243 .expect("built-in drain bindings should be fully resolved");
244 (binding.chain_name.clone(), binding.agents.clone())
245 })
246 .collect();
247
248 effective
249 .agent_chains
250 .iter()
251 .chain(drain_chains.iter())
252 .map(|(name, agents)| (name.clone(), agents.clone()))
253 .collect()
254}
255
256pub fn handle_init_local_config_with<R: ConfigEnvironment>(
277 colors: Colors,
278 env: &R,
279 force: bool,
280) -> anyhow::Result<bool> {
281 let local_path = env
282 .local_config_path()
283 .ok_or_else(|| anyhow::anyhow!("Cannot determine local config path"))?;
284
285 if env.file_exists(&local_path) && !force {
287 let _ = writeln!(
288 std::io::stdout(),
289 "{}Local config already exists:{} {}",
290 colors.yellow(),
291 colors.reset(),
292 local_path.display()
293 );
294 let _ = writeln!(
295 std::io::stdout(),
296 "Use --force-overwrite to replace it, or edit the existing file."
297 );
298 let _ = writeln!(std::io::stdout());
299 let _ = writeln!(
300 std::io::stdout(),
301 "Run `ralph --check-config` to see effective configuration."
302 );
303 return Ok(true);
304 }
305
306 let template = generate_local_config_template(env)?;
308
309 env.write_file(&local_path, &template).map_err(|e| {
311 anyhow::anyhow!(
312 "Failed to create local config file {}: {}",
313 local_path.display(),
314 e
315 )
316 })?;
317
318 let display_path = local_path
320 .canonicalize()
321 .unwrap_or_else(|_| local_path.clone());
322
323 let _ = writeln!(
324 std::io::stdout(),
325 "{}Created{} {}",
326 colors.green(),
327 colors.reset(),
328 display_path.display()
329 );
330 let _ = writeln!(std::io::stdout());
331 let _ = writeln!(
332 std::io::stdout(),
333 "This local config will override your global settings (~/.config/ralph-workflow.toml)."
334 );
335 let _ = writeln!(
336 std::io::stdout(),
337 "Edit the file to customize Ralph for this project."
338 );
339 let _ = writeln!(std::io::stdout());
340 let _ = writeln!(
341 std::io::stdout(),
342 "Tip: Run `ralph --check-config` to validate your configuration."
343 );
344
345 Ok(true)
346}
347
348pub fn handle_init_local_config(colors: Colors, force: bool) -> anyhow::Result<bool> {
356 handle_init_local_config_with(colors, &RealConfigEnvironment, force)
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::config::path_resolver::MemoryConfigEnvironment;
363 use std::path::Path;
364
365 #[test]
366 fn test_init_local_config_shows_global_values() {
367 let env = MemoryConfigEnvironment::new()
368 .with_unified_config_path("/test/config/ralph-workflow.toml")
369 .with_local_config_path("/test/repo/.agent/ralph-workflow.toml")
370 .with_file(
371 "/test/config/ralph-workflow.toml",
372 "\n[general]\ndeveloper_iters = 8\nreviewer_reviews = 3\n\
373 developer_context = 2\nreviewer_context = 1\n\
374 max_retries = 9\nretry_delay_ms = 2500\n\
375 backoff_multiplier = 3.0\nmax_backoff_ms = 90000\n\
376 max_cycles = 7\n",
377 );
378
379 handle_init_local_config_with(Colors::new(), &env, false).unwrap();
380
381 let content = env
382 .get_file(Path::new("/test/repo/.agent/ralph-workflow.toml"))
383 .expect("local config should be written");
384
385 assert!(
387 content.contains("developer_iters = 8"),
388 "should show global developer_iters=8, got:\n{content}"
389 );
390 assert!(
391 content.contains("reviewer_reviews = 3"),
392 "should show global reviewer_reviews=3, got:\n{content}"
393 );
394 assert!(
395 content.contains("developer_context = 2"),
396 "should show global developer_context=2, got:\n{content}"
397 );
398 assert!(
399 content.contains("reviewer_context = 1"),
400 "should show global reviewer_context=1, got:\n{content}"
401 );
402 assert!(
403 !content.contains("# [agent_chain]"),
404 "should not include the removed legacy agent_chain section, got:\n{content}"
405 );
406 assert!(
407 content.contains("# max_retries = 9"),
408 "should show global max_retries=9, got:\n{content}"
409 );
410 assert!(
411 content.contains("# retry_delay_ms = 2500"),
412 "should show global retry_delay_ms=2500, got:\n{content}"
413 );
414 assert!(
415 content.contains("# backoff_multiplier = 3"),
416 "should show global backoff_multiplier=3.0, got:\n{content}"
417 );
418 assert!(
419 content.contains("# max_backoff_ms = 90000"),
420 "should show global max_backoff_ms=90000, got:\n{content}"
421 );
422 assert!(
423 content.contains("# max_cycles = 7"),
424 "should show global max_cycles=7, got:\n{content}"
425 );
426 }
427
428 #[test]
429 fn test_init_local_config_shows_general_provider_fallback_values() {
430 let env = MemoryConfigEnvironment::new()
431 .with_unified_config_path("/test/config/ralph-workflow.toml")
432 .with_local_config_path("/test/repo/.agent/ralph-workflow.toml")
433 .with_file(
434 "/test/config/ralph-workflow.toml",
435 "\n[general]\nmax_retries = 9\n\n[general.provider_fallback]\nopencode = [\"-m opencode/glm-4.7-free\"]\n",
436 );
437
438 handle_init_local_config_with(Colors::new(), &env, false).unwrap();
439
440 let content = env
441 .get_file(Path::new("/test/repo/.agent/ralph-workflow.toml"))
442 .expect("local config should be written");
443
444 assert!(
445 content.contains("# [general.provider_fallback]"),
446 "should render general.provider_fallback section, got:\n{content}"
447 );
448 assert!(
449 content.contains(r#"# opencode = ["-m opencode/glm-4.7-free"]"#),
450 "should render provider fallback values, got:\n{content}"
451 );
452 }
453
454 #[test]
455 fn test_init_local_config_mentions_named_chain_migration_not_agent_chain() {
456 let env = MemoryConfigEnvironment::new()
457 .with_unified_config_path("/test/config/ralph-workflow.toml")
458 .with_local_config_path("/test/repo/.agent/ralph-workflow.toml");
459
460 handle_init_local_config_with(Colors::new(), &env, false).unwrap();
461
462 let content = env
463 .get_file(Path::new("/test/repo/.agent/ralph-workflow.toml"))
464 .expect("local config should be written");
465
466 assert!(
467 content.contains("[agent_chains]"),
468 "should include named chain section, got:\n{content}"
469 );
470 assert!(
471 content.contains("[agent_drains]"),
472 "should include drain binding section, got:\n{content}"
473 );
474 assert!(
475 !content.contains("[agent_chain]"),
476 "should not mention the legacy [agent_chain] section, got:\n{content}"
477 );
478 }
479
480 #[test]
481 fn test_init_local_config_uses_defaults_without_global() {
482 let env = MemoryConfigEnvironment::new()
483 .with_unified_config_path("/test/config/ralph-workflow.toml")
484 .with_local_config_path("/test/repo/.agent/ralph-workflow.toml");
485 handle_init_local_config_with(Colors::new(), &env, false).unwrap();
488
489 let content = env
490 .get_file(Path::new("/test/repo/.agent/ralph-workflow.toml"))
491 .expect("local config should be written");
492
493 assert!(
495 content.contains("developer_iters = 5"),
496 "should show default developer_iters=5, got:\n{content}"
497 );
498 assert!(
499 content.contains("reviewer_reviews = 2"),
500 "should show default reviewer_reviews=2, got:\n{content}"
501 );
502 }
503
504 #[test]
505 fn test_init_local_config_fails_when_built_in_default_chain_cannot_be_loaded() {
506 let env = MemoryConfigEnvironment::new()
507 .with_unified_config_path("/test/config/ralph-workflow.toml")
508 .with_local_config_path("/test/repo/.agent/ralph-workflow.toml");
509
510 let err = generate_local_config_template_with(&env, || {
511 Err(anyhow::anyhow!("simulated built-in agents load failure"))
512 })
513 .expect_err("template generation should fail when built-in defaults cannot be loaded");
514 let msg = err.to_string();
515
516 assert!(
517 msg.contains("simulated built-in agents load failure"),
518 "error should include built-in chain load failure reason, got:\n{msg}"
519 );
520 assert!(
521 !env.was_written(Path::new("/test/repo/.agent/ralph-workflow.toml")),
522 "local config should not be created when template generation fails"
523 );
524 }
525
526 #[test]
527 fn test_init_local_config_uses_built_in_agent_chain_defaults_without_global() {
528 let env = MemoryConfigEnvironment::new()
529 .with_unified_config_path("/test/config/ralph-workflow.toml")
530 .with_local_config_path("/test/repo/.agent/ralph-workflow.toml");
531
532 handle_init_local_config_with(Colors::new(), &env, false).unwrap();
533
534 let content = env
535 .get_file(Path::new("/test/repo/.agent/ralph-workflow.toml"))
536 .expect("local config should be written");
537
538 let registry = crate::agents::AgentRegistry::new().expect("built-in registry should load");
539 let builtins = registry.resolved_drains();
540 let expected_developer = format_toml_string_array(
541 &builtins
542 .binding(crate::agents::AgentDrain::Development)
543 .expect("development drain")
544 .agents,
545 );
546 let expected_reviewer = format_toml_string_array(
547 &builtins
548 .binding(crate::agents::AgentDrain::Review)
549 .expect("review drain")
550 .agents,
551 );
552
553 assert!(
554 content.contains(&format!("developer = {expected_developer}")),
555 "should use built-in developer chain defaults, got:\n{content}"
556 );
557 assert!(
558 content.contains(&format!("reviewer = {expected_reviewer}")),
559 "should use built-in reviewer chain defaults, got:\n{content}"
560 );
561 assert!(
562 content.contains(r#"# planning = "developer""#),
563 "should include the planning drain binding, got:\n{content}"
564 );
565 assert!(
566 content.contains(r#"# review = "reviewer""#),
567 "should include the review drain binding, got:\n{content}"
568 );
569 }
570
571 #[test]
572 fn test_init_local_config_shows_global_agent_chains() {
573 let env = MemoryConfigEnvironment::new()
574 .with_unified_config_path("/test/config/ralph-workflow.toml")
575 .with_local_config_path("/test/repo/.agent/ralph-workflow.toml")
576 .with_file(
577 "/test/config/ralph-workflow.toml",
578 r#"
579[agent_chain]
580developer = ["codex", "claude"]
581reviewer = ["claude"]
582"#,
583 );
584
585 handle_init_local_config_with(Colors::new(), &env, false).unwrap();
586
587 let content = env
588 .get_file(Path::new("/test/repo/.agent/ralph-workflow.toml"))
589 .expect("local config should be written");
590
591 assert!(
592 content.contains(r#"developer = ["codex", "claude"]"#),
593 "should show global developer chain, got:\n{content}"
594 );
595 assert!(
596 content.contains(r#"reviewer = ["claude"]"#),
597 "should show global reviewer chain, got:\n{content}"
598 );
599 assert!(
600 content.contains(r#"# development = "developer""#),
601 "should show the development drain binding, got:\n{content}"
602 );
603 assert!(
604 content.contains(r#"# review = "reviewer""#),
605 "should show the review drain binding, got:\n{content}"
606 );
607
608 let named_env = MemoryConfigEnvironment::new()
609 .with_unified_config_path("/test/config/ralph-workflow.toml")
610 .with_local_config_path("/test/repo/.agent/ralph-workflow.toml")
611 .with_file(
612 "/test/config/ralph-workflow.toml",
613 r#"
614[agent_chains]
615shared_dev = ["codex", "claude"]
616shared_review = ["claude"]
617
618[agent_drains]
619planning = "shared_dev"
620development = "shared_dev"
621review = "shared_review"
622fix = "shared_review"
623commit = "shared_review"
624analysis = "shared_dev"
625"#,
626 );
627
628 handle_init_local_config_with(Colors::new(), &named_env, true).unwrap();
629
630 let content = named_env
631 .get_file(Path::new("/test/repo/.agent/ralph-workflow.toml"))
632 .expect("local config should be written");
633
634 assert!(
635 content.contains(r#"shared_dev = ["codex", "claude"]"#),
636 "should show the named shared_dev chain, got:\n{content}"
637 );
638 assert!(
639 content.contains(r#"shared_review = ["claude"]"#),
640 "should show the named shared_review chain, got:\n{content}"
641 );
642 assert!(
643 content.contains(r#"# planning = "shared_dev""#),
644 "should preserve planning drain binding, got:\n{content}"
645 );
646 assert!(
647 content.contains(r#"# commit = "shared_review""#),
648 "should preserve commit drain binding, got:\n{content}"
649 );
650 }
651
652 #[test]
653 fn test_init_local_config_partial_global_agent_chain_uses_builtin_missing_roles() {
654 let env = MemoryConfigEnvironment::new()
655 .with_unified_config_path("/test/config/ralph-workflow.toml")
656 .with_local_config_path("/test/repo/.agent/ralph-workflow.toml")
657 .with_file(
658 "/test/config/ralph-workflow.toml",
659 r#"
660[agent_chain]
661developer = ["codex"]
662"#,
663 );
664
665 handle_init_local_config_with(Colors::new(), &env, false).unwrap();
666
667 let content = env
668 .get_file(Path::new("/test/repo/.agent/ralph-workflow.toml"))
669 .expect("local config should be written");
670
671 let registry = crate::agents::AgentRegistry::new().expect("built-in registry should load");
672 let builtins = registry.resolved_drains();
673 let expected_reviewer = format_toml_string_array(
674 &builtins
675 .binding(crate::agents::AgentDrain::Review)
676 .expect("review drain")
677 .agents,
678 );
679
680 assert!(
681 content.contains(r#"developer = ["codex"]"#),
682 "should show global developer chain, got:\n{content}"
683 );
684 assert!(
685 content.contains(&format!("reviewer = {expected_reviewer}")),
686 "missing global reviewer should fall back to built-in defaults, got:\n{content}"
687 );
688 assert!(
689 content.contains(r#"# development = "developer""#),
690 "should bind development to the developer chain, got:\n{content}"
691 );
692 assert!(
693 content.contains(r#"# review = "reviewer""#),
694 "should bind review to the reviewer chain, got:\n{content}"
695 );
696 }
697
698 #[test]
699 fn test_init_local_config_generates_valid_toml_structure() {
700 let env = MemoryConfigEnvironment::new()
701 .with_unified_config_path("/test/config/ralph-workflow.toml")
702 .with_local_config_path("/test/repo/.agent/ralph-workflow.toml")
703 .with_file(
704 "/test/config/ralph-workflow.toml",
705 "[general]\ndeveloper_iters = 8",
706 );
707
708 handle_init_local_config_with(Colors::new(), &env, false).unwrap();
709
710 let content = env
711 .get_file(Path::new("/test/repo/.agent/ralph-workflow.toml"))
712 .expect("local config should be written");
713
714 assert!(
717 content.contains("[general]"),
718 "should have [general] section, got:\n{content}"
719 );
720 assert!(
721 content.contains("# developer_iters"),
722 "values should be commented out, got:\n{content}"
723 );
724 }
725
726 #[test]
727 fn test_format_toml_string_array() {
728 assert_eq!(
729 format_toml_string_array(&["claude".to_string()]),
730 r#"["claude"]"#
731 );
732 assert_eq!(
733 format_toml_string_array(&["codex".to_string(), "claude".to_string()]),
734 r#"["codex", "claude"]"#
735 );
736 assert_eq!(format_toml_string_array(&[]), r"[]");
737 }
738
739 #[test]
740 fn test_init_local_config_at_worktree_root() {
741 let env = MemoryConfigEnvironment::new()
742 .with_unified_config_path("/test/config/ralph-workflow.toml")
743 .with_worktree_root("/test/main-repo")
744 .with_file(
745 "/test/config/ralph-workflow.toml",
746 "[general]\nverbosity = 2",
747 );
748
749 handle_init_local_config_with(Colors::new(), &env, false).unwrap();
750
751 assert!(
752 env.was_written(Path::new("/test/main-repo/.agent/ralph-workflow.toml")),
753 "Config should be written at canonical repo root"
754 );
755 }
756}