Skip to main content

ralph_workflow/cli/init/config_generation/
local.rs

1//! Local configuration file creation.
2//!
3//! Handles `--init-local-config` flag to create a local config file at
4//! `.agent/ralph-workflow.toml` in the current directory.
5//!
6//! The generated template shows the user's current effective values
7//! (from global config or built-in defaults) as commented-out entries,
8//! so they know what they can override.
9
10use 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
26/// Generate a local config template populated with effective values.
27///
28/// Reads the global config (if available) and falls back to built-in defaults
29/// for any missing values. All values are shown as commented-out entries so
30/// users can selectively uncomment and override only what they need.
31fn 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
228/// Format a string slice as a TOML array literal (e.g. `["claude", "codex"]`).
229fn 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
256/// Handle the `--init-local-config` flag with a custom path resolver.
257///
258/// Creates a local config file at `.agent/ralph-workflow.toml` in the current directory.
259/// The generated template shows the user's current effective configuration values
260/// (from global config or built-in defaults) as commented-out entries.
261///
262/// # Arguments
263///
264/// * `colors` - Terminal color configuration for output
265/// * `env` - Path resolver for determining config file location
266/// * `force` - Whether to overwrite existing config file
267///
268/// # Returns
269///
270/// Returns `Ok(true)` if the flag was handled (program should exit after),
271/// or an error if config creation failed.
272///
273/// # Errors
274///
275/// Returns error if the operation fails.
276pub 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    // Check if config already exists
286    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    // Generate template populated with current effective values
307    let template = generate_local_config_template(env)?;
308
309    // Create config using the environment's file operations
310    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    // Try to show absolute path, fall back to the path as-is if canonicalization fails
319    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
348/// Handle the `--init-local-config` flag using the default path resolver.
349///
350/// Convenience wrapper that uses [`RealConfigEnvironment`] internally.
351///
352/// # Errors
353///
354/// Returns error if the operation fails.
355pub 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        // Should reflect global values, not built-in defaults
386        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        // No global config file exists
486
487        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        // Should fall back to built-in defaults
494        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        // All value lines should be comments (starts with #)
715        // so the file is valid TOML as-is (all commented out)
716        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}