Skip to main content

ralph_workflow/config/
loader.rs

1//! Unified Configuration Loader
2//!
3//! This module handles loading configuration from the unified config file
4//! at `~/.config/ralph-workflow.toml`, with environment variable overrides.
5//!
6//! # Configuration Priority
7//!
8//! 1. **Primary source**: `~/.config/ralph-workflow.toml`
9//! 2. **Override layer**: Environment variables (RALPH_*)
10//! 3. **CLI arguments**: Final override (handled at CLI layer)
11//!
12//! # Migration Support
13//!
14//! For backwards compatibility, the loader also checks legacy config locations
15//! (`~/.config/ralph/agents.toml` and `.agent/agents.toml`) and emits
16//! deprecation warnings when they are used.
17use super::parser::parse_env_bool;
18use super::path_resolver::ConfigEnvironment;
19use super::types::{Config, ReviewDepth, Verbosity};
20use super::unified::{unified_config_path, UnifiedConfig};
21use std::env;
22use std::path::PathBuf;
23
24/// Load configuration with the unified approach.
25///
26/// This function loads configuration from the unified config file
27/// (`~/.config/ralph-workflow.toml`) and applies environment variable overrides.
28///
29/// # Returns
30///
31/// Returns a tuple of `(Config, Vec<String>)` where the second element
32/// contains any deprecation warnings to be displayed to the user.
33pub fn load_config() -> (Config, Option<UnifiedConfig>, Vec<String>) {
34    load_config_from_path(None)
35}
36
37/// Load configuration from a specific path or the default location.
38///
39/// If `config_path` is provided, loads from that file.
40/// Otherwise, loads from the default unified config location.
41///
42/// # Arguments
43///
44/// * `config_path` - Optional path to a config file. If None, uses the default location.
45///
46/// # Returns
47///
48/// Returns a tuple of `(Config, Option<UnifiedConfig>, Vec<String>)` where the last element
49/// contains any deprecation warnings to be displayed to the user.
50///
51/// **Note:** This function uses `std::fs` directly. For testable code,
52/// use [`load_config_from_path_with_env`] with a [`ConfigEnvironment`] instead.
53pub fn load_config_from_path(
54    config_path: Option<&std::path::Path>,
55) -> (Config, Option<UnifiedConfig>, Vec<String>) {
56    let mut warnings = Vec::new();
57
58    // Try to load unified config from specified path or default
59    let unified = config_path.map_or_else(UnifiedConfig::load_default, |path| {
60        if path.exists() {
61            match UnifiedConfig::load_from_path(path) {
62                Ok(cfg) => Some(cfg),
63                Err(e) => {
64                    warnings.push(format!(
65                        "Failed to load config from {}: {}",
66                        path.display(),
67                        e
68                    ));
69                    None
70                }
71            }
72        } else {
73            warnings.push(format!("Config file not found: {}", path.display()));
74            None
75        }
76    });
77
78    // Start with defaults, then apply unified config if found
79    let config = if let Some(ref unified_cfg) = unified {
80        config_from_unified(unified_cfg, &mut warnings)
81    } else {
82        // No unified config - check for legacy configs
83        check_legacy_configs(&mut warnings);
84        default_config()
85    };
86
87    // Apply environment variable overrides
88    let config = apply_env_overrides(config, &mut warnings);
89
90    (config, unified, warnings)
91}
92
93/// Load configuration from a specific path or the default location using a [`ConfigEnvironment`].
94///
95/// This is the testable version of [`load_config_from_path`]. It uses the provided
96/// environment for all filesystem operations.
97///
98/// # Arguments
99///
100/// * `config_path` - Optional path to a config file. If None, uses the environment's default.
101/// * `env` - The configuration environment to use for filesystem operations.
102///
103/// # Returns
104///
105/// Returns a tuple of `(Config, Option<UnifiedConfig>, Vec<String>)` where the last element
106/// contains any deprecation warnings to be displayed to the user.
107pub fn load_config_from_path_with_env(
108    config_path: Option<&std::path::Path>,
109    env: &dyn ConfigEnvironment,
110) -> (Config, Option<UnifiedConfig>, Vec<String>) {
111    let mut warnings = Vec::new();
112
113    // Try to load unified config from specified path or default
114    let unified = config_path.map_or_else(
115        || UnifiedConfig::load_with_env(env),
116        |path| {
117            if env.file_exists(path) {
118                match UnifiedConfig::load_from_path_with_env(path, env) {
119                    Ok(cfg) => Some(cfg),
120                    Err(e) => {
121                        warnings.push(format!(
122                            "Failed to load config from {}: {}",
123                            path.display(),
124                            e
125                        ));
126                        None
127                    }
128                }
129            } else {
130                warnings.push(format!("Config file not found: {}", path.display()));
131                None
132            }
133        },
134    );
135
136    // Start with defaults, then apply unified config if found
137    let config = if let Some(ref unified_cfg) = unified {
138        config_from_unified(unified_cfg, &mut warnings)
139    } else {
140        // No unified config - check for legacy configs (env-aware version)
141        check_legacy_configs_with_env(&mut warnings, env);
142        default_config()
143    };
144
145    // Apply environment variable overrides
146    let config = apply_env_overrides(config, &mut warnings);
147
148    (config, unified, warnings)
149}
150
151/// Create a Config from `UnifiedConfig`.
152fn config_from_unified(unified: &UnifiedConfig, warnings: &mut Vec<String>) -> Config {
153    use super::types::{BehavioralFlags, FeatureFlags};
154
155    let general = &unified.general;
156    let max_dev_continuations = if general.max_dev_continuations >= 1 {
157        general.max_dev_continuations
158    } else {
159        warnings.push(
160            "Invalid max_dev_continuations in config; must be a positive integer (>= 1). Falling back to default."
161                .to_string(),
162        );
163        2
164    };
165
166    let review_depth = ReviewDepth::from_str(&general.review_depth).unwrap_or_else(|| {
167        warnings.push(format!(
168            "Invalid review_depth '{}' in config; falling back to 'standard'.",
169            general.review_depth
170        ));
171        ReviewDepth::default()
172    });
173
174    Config {
175        developer_agent: None, // Set from agent_chain or CLI
176        reviewer_agent: None,  // Set from agent_chain or CLI
177        developer_cmd: None,
178        reviewer_cmd: None,
179        commit_cmd: None,
180        developer_model: None,
181        reviewer_model: None,
182        developer_provider: None,
183        reviewer_provider: None,
184        reviewer_json_parser: None, // Set from env var or CLI
185        features: FeatureFlags {
186            checkpoint_enabled: general.workflow.checkpoint_enabled,
187            force_universal_prompt: general.execution.force_universal_prompt,
188        },
189        developer_iters: general.developer_iters,
190        reviewer_reviews: general.reviewer_reviews,
191        fast_check_cmd: None,
192        full_check_cmd: None,
193        behavior: BehavioralFlags {
194            interactive: general.behavior.interactive,
195            auto_detect_stack: general.behavior.auto_detect_stack,
196            strict_validation: general.behavior.strict_validation,
197        },
198        prompt_path: general
199            .prompt_path
200            .as_ref()
201            .map_or_else(|| PathBuf::from(".agent/last_prompt.txt"), PathBuf::from),
202        user_templates_dir: general.templates_dir.as_ref().map(PathBuf::from),
203        developer_context: general.developer_context,
204        reviewer_context: general.reviewer_context,
205        verbosity: Verbosity::from(general.verbosity),
206        review_depth,
207        isolation_mode: general.execution.isolation_mode,
208        git_user_name: general.git_user_name.clone(),
209        git_user_email: general.git_user_email.clone(),
210        show_streaming_metrics: false, // Default to false; can be enabled via CLI flag or config file
211        review_format_retries: 5,      // Default to 5 retries for format correction
212        max_dev_continuations: Some(max_dev_continuations),
213    }
214}
215
216/// Default configuration when no config file is found.
217fn default_config() -> Config {
218    use super::types::{BehavioralFlags, FeatureFlags};
219
220    Config {
221        developer_agent: None,
222        reviewer_agent: None,
223        developer_cmd: None,
224        reviewer_cmd: None,
225        commit_cmd: None,
226        developer_model: None,
227        reviewer_model: None,
228        developer_provider: None,
229        reviewer_provider: None,
230        reviewer_json_parser: None,
231        features: FeatureFlags {
232            checkpoint_enabled: true,
233            force_universal_prompt: false,
234        },
235        developer_iters: 5,
236        reviewer_reviews: 2,
237        fast_check_cmd: None,
238        full_check_cmd: None,
239        behavior: BehavioralFlags {
240            interactive: true,
241            auto_detect_stack: true,
242            strict_validation: false,
243        },
244        prompt_path: PathBuf::from(".agent/last_prompt.txt"),
245        user_templates_dir: None,
246        developer_context: 1,
247        reviewer_context: 0,
248        verbosity: Verbosity::Verbose,
249        review_depth: ReviewDepth::default(),
250        isolation_mode: true,
251        git_user_name: None,
252        git_user_email: None,
253        show_streaming_metrics: false,
254        review_format_retries: 5,
255        max_dev_continuations: Some(2), // Default to 2 (initial + 1 continuation)
256    }
257}
258
259/// Apply environment variable overrides to config.
260fn apply_env_overrides(mut config: Config, warnings: &mut Vec<String>) -> Config {
261    const MAX_ITERS: u32 = 50;
262    const MAX_REVIEWS: u32 = 10;
263    const MAX_CONTEXT: u8 = 2;
264    const MAX_FORMAT_RETRIES: u32 = 20;
265
266    // Apply all environment variable overrides by category
267    apply_agent_selection_env(&mut config, warnings);
268    apply_command_env(&mut config, warnings);
269    apply_model_provider_env(&mut config);
270    apply_iteration_counts_env(&mut config, warnings, MAX_ITERS, MAX_REVIEWS);
271    apply_review_config_env(&mut config, warnings, MAX_FORMAT_RETRIES);
272    apply_boolean_flags_env(&mut config);
273    apply_verbosity_env(&mut config, warnings);
274    apply_review_depth_env(&mut config, warnings);
275    apply_paths_env(&mut config);
276    apply_context_levels_env(&mut config, warnings, MAX_CONTEXT);
277    apply_git_identity_env(&mut config);
278
279    config
280}
281
282/// Apply agent selection environment variables.
283fn apply_agent_selection_env(config: &mut Config, warnings: &mut Vec<String>) {
284    let developer_agent = env::var("RALPH_DEVELOPER_AGENT")
285        .or_else(|_| env::var("RALPH_DRIVER_AGENT"))
286        .ok();
287    if let Some(val) = developer_agent {
288        let trimmed = val.trim();
289        if trimmed.is_empty() {
290            warnings.push("Env var RALPH_DEVELOPER_AGENT is empty; ignoring.".to_string());
291        } else {
292            config.developer_agent = Some(trimmed.to_string());
293        }
294    }
295
296    if let Ok(val) = env::var("RALPH_REVIEWER_AGENT") {
297        let trimmed = val.trim();
298        if trimmed.is_empty() {
299            warnings.push("Env var RALPH_REVIEWER_AGENT is empty; ignoring.".to_string());
300        } else {
301            config.reviewer_agent = Some(trimmed.to_string());
302        }
303    }
304}
305
306/// Apply command override environment variables.
307fn apply_command_env(config: &mut Config, warnings: &mut Vec<String>) {
308    for (env_var, field) in [
309        ("RALPH_DEVELOPER_CMD", &mut config.developer_cmd),
310        ("RALPH_REVIEWER_CMD", &mut config.reviewer_cmd),
311        ("RALPH_COMMIT_CMD", &mut config.commit_cmd),
312    ] {
313        if let Ok(val) = env::var(env_var) {
314            let trimmed = val.trim();
315            if trimmed.is_empty() {
316                warnings.push(format!("Env var {env_var} is empty; ignoring."));
317            } else {
318                *field = Some(trimmed.to_string());
319            }
320        }
321    }
322
323    for (env_var, field) in [
324        ("FAST_CHECK_CMD", &mut config.fast_check_cmd),
325        ("FULL_CHECK_CMD", &mut config.full_check_cmd),
326    ] {
327        if let Ok(val) = env::var(env_var) {
328            if !val.is_empty() {
329                *field = Some(val);
330            }
331        }
332    }
333}
334
335/// Apply model and provider environment variables.
336fn apply_model_provider_env(config: &mut Config) {
337    for (env_var, field) in [
338        ("RALPH_DEVELOPER_MODEL", &mut config.developer_model),
339        ("RALPH_REVIEWER_MODEL", &mut config.reviewer_model),
340        ("RALPH_DEVELOPER_PROVIDER", &mut config.developer_provider),
341        ("RALPH_REVIEWER_PROVIDER", &mut config.reviewer_provider),
342    ] {
343        if let Ok(val) = env::var(env_var) {
344            *field = Some(val);
345        }
346    }
347
348    // JSON parser override for reviewer (useful for testing different parsers)
349    if let Ok(val) = env::var("RALPH_REVIEWER_JSON_PARSER") {
350        let trimmed = val.trim();
351        if !trimmed.is_empty() {
352            config.reviewer_json_parser = Some(trimmed.to_string());
353        }
354    }
355
356    // Force universal review prompt (useful for problematic agents)
357    if let Ok(val) = env::var("RALPH_REVIEWER_UNIVERSAL_PROMPT") {
358        if let Some(b) = parse_env_bool(&val) {
359            config.features.force_universal_prompt = b;
360        }
361    }
362}
363
364/// Apply iteration count environment variables.
365fn apply_iteration_counts_env(
366    config: &mut Config,
367    warnings: &mut Vec<String>,
368    max_iters: u32,
369    max_reviews: u32,
370) {
371    if let Some(n) = parse_env_u32("RALPH_DEVELOPER_ITERS", warnings, max_iters) {
372        config.developer_iters = n;
373    }
374    if let Some(n) = parse_env_u32("RALPH_REVIEWER_REVIEWS", warnings, max_reviews) {
375        config.reviewer_reviews = n;
376    }
377}
378
379/// Apply review-specific configuration environment variables.
380fn apply_review_config_env(config: &mut Config, warnings: &mut Vec<String>, max_retries: u32) {
381    if let Some(n) = parse_env_u32("RALPH_REVIEW_FORMAT_RETRIES", warnings, max_retries) {
382        config.review_format_retries = n;
383    }
384}
385
386/// Apply boolean flag environment variables.
387fn apply_boolean_flags_env(config: &mut Config) {
388    // Read all boolean env vars first
389    let vars: std::collections::HashMap<&str, bool> = [
390        "RALPH_INTERACTIVE",
391        "RALPH_AUTO_DETECT_STACK",
392        "RALPH_CHECKPOINT_ENABLED",
393        "RALPH_STRICT_VALIDATION",
394        "RALPH_ISOLATION_MODE",
395    ]
396    .iter()
397    .filter_map(|&name| env::var(name).ok().map(|v| (name, v)))
398    .filter_map(|(name, val)| parse_env_bool(&val).map(|b| (name, b)))
399    .collect();
400
401    // Apply each boolean flag
402    for (name, value) in vars {
403        match name {
404            "RALPH_INTERACTIVE" => config.behavior.interactive = value,
405            "RALPH_AUTO_DETECT_STACK" => config.behavior.auto_detect_stack = value,
406            "RALPH_CHECKPOINT_ENABLED" => config.features.checkpoint_enabled = value,
407            "RALPH_STRICT_VALIDATION" => config.behavior.strict_validation = value,
408            "RALPH_ISOLATION_MODE" => config.isolation_mode = value,
409            _ => {}
410        }
411    }
412}
413
414/// Apply verbosity environment variable.
415fn apply_verbosity_env(config: &mut Config, warnings: &mut Vec<String>) {
416    if let Ok(val) = env::var("RALPH_VERBOSITY") {
417        let trimmed = val.trim();
418        if trimmed.is_empty() {
419            return;
420        }
421        match trimmed.parse::<u8>() {
422            Ok(n) => {
423                if n > 4 {
424                    warnings.push(format!(
425                        "Env var RALPH_VERBOSITY={n} is out of range; clamping to 4 (debug)."
426                    ));
427                }
428                config.verbosity = Verbosity::from(n.min(4));
429            }
430            Err(_) => {
431                warnings.push(format!(
432                    "Env var RALPH_VERBOSITY='{trimmed}' is not a valid number; ignoring."
433                ));
434            }
435        }
436    }
437}
438
439/// Apply review depth environment variable.
440fn apply_review_depth_env(config: &mut Config, warnings: &mut Vec<String>) {
441    if let Ok(val) = env::var("RALPH_REVIEW_DEPTH") {
442        if let Some(depth) = ReviewDepth::from_str(&val) {
443            config.review_depth = depth;
444        } else if !val.trim().is_empty() {
445            warnings.push(format!(
446                "Env var RALPH_REVIEW_DEPTH='{}' is invalid; ignoring.",
447                val.trim()
448            ));
449        }
450    }
451}
452
453/// Apply path environment variables.
454fn apply_paths_env(config: &mut Config) {
455    if let Ok(val) = env::var("RALPH_PROMPT_PATH") {
456        config.prompt_path = PathBuf::from(val);
457    }
458    if let Ok(val) = env::var("RALPH_TEMPLATES_DIR") {
459        let trimmed = val.trim();
460        if !trimmed.is_empty() {
461            config.user_templates_dir = Some(PathBuf::from(trimmed));
462        }
463    }
464}
465
466/// Apply context level environment variables.
467fn apply_context_levels_env(config: &mut Config, warnings: &mut Vec<String>, max_context: u8) {
468    if let Some(n) = parse_env_u8("RALPH_DEVELOPER_CONTEXT", warnings, max_context) {
469        config.developer_context = n;
470    }
471    if let Some(n) = parse_env_u8("RALPH_REVIEWER_CONTEXT", warnings, max_context) {
472        config.reviewer_context = n;
473    }
474}
475
476/// Apply git user identity environment variables.
477fn apply_git_identity_env(config: &mut Config) {
478    if let Ok(val) = env::var("RALPH_GIT_USER_NAME") {
479        let trimmed = val.trim();
480        if !trimmed.is_empty() {
481            config.git_user_name = Some(trimmed.to_string());
482        }
483    }
484    if let Ok(val) = env::var("RALPH_GIT_USER_EMAIL") {
485        let trimmed = val.trim();
486        if !trimmed.is_empty() {
487            config.git_user_email = Some(trimmed.to_string());
488        }
489    }
490}
491
492/// Parse a u32 environment variable with validation.
493fn parse_env_u32(name: &str, warnings: &mut Vec<String>, max: u32) -> Option<u32> {
494    let raw = std::env::var(name).ok()?;
495    let trimmed = raw.trim();
496    if trimmed.is_empty() {
497        return None;
498    }
499    match trimmed.parse::<u32>() {
500        Ok(n) if n <= max => Some(n),
501        Ok(n) => {
502            warnings.push(format!(
503                "Env var {name}={n} is too large; clamping to {max}."
504            ));
505            Some(max)
506        }
507        Err(_) => {
508            warnings.push(format!(
509                "Env var {name}='{trimmed}' is not a valid number; ignoring."
510            ));
511            None
512        }
513    }
514}
515
516/// Parse a u8 environment variable with validation.
517fn parse_env_u8(name: &str, warnings: &mut Vec<String>, max: u8) -> Option<u8> {
518    let raw = std::env::var(name).ok()?;
519    let trimmed = raw.trim();
520    if trimmed.is_empty() {
521        return None;
522    }
523    match trimmed.parse::<u8>() {
524        Ok(n) if n <= max => Some(n),
525        Ok(n) => {
526            warnings.push(format!(
527                "Env var {name}={n} is out of range; clamping to {max}."
528            ));
529            Some(max)
530        }
531        Err(_) => {
532            warnings.push(format!(
533                "Env var {name}='{trimmed}' is not a valid number; ignoring."
534            ));
535            None
536        }
537    }
538}
539
540/// Check for legacy config files and add deprecation warnings.
541///
542/// **Note:** This function uses `std::fs` directly via `path.exists()`.
543/// For testable code, use [`check_legacy_configs_with_env`] instead.
544fn check_legacy_configs(warnings: &mut Vec<String>) {
545    // Check for old global config
546    if let Some(config_dir) = dirs::config_dir() {
547        let old_global = config_dir.join("ralph").join("agents.toml");
548        if old_global.exists() {
549            warnings.push(format!(
550                "DEPRECATION: Found legacy config at {}. \
551                 Please migrate to ~/.config/ralph-workflow.toml",
552                old_global.display()
553            ));
554        }
555    }
556
557    // Check for project-level config
558    let project_config = PathBuf::from(".agent/agents.toml");
559    if project_config.exists() && unified_config_path().is_some() && !unified_config_exists() {
560        warnings.push(
561            "DEPRECATION: Found legacy per-repo config at .agent/agents.toml. \
562             Please migrate to ~/.config/ralph-workflow.toml."
563                .to_string(),
564        );
565    }
566}
567
568/// Check for legacy config files using a [`ConfigEnvironment`].
569///
570/// This is the testable version of [`check_legacy_configs`].
571fn check_legacy_configs_with_env(warnings: &mut Vec<String>, env: &dyn ConfigEnvironment) {
572    // Check for old global config
573    // Note: We can't check dirs::config_dir() with ConfigEnvironment, so we skip that check
574    // in the testable version. The legacy global config check is best tested via integration tests.
575
576    // Check for project-level config
577    let project_config = PathBuf::from(".agent/agents.toml");
578    if env.file_exists(&project_config)
579        && env.unified_config_path().is_some()
580        && !env
581            .unified_config_path()
582            .is_some_and(|p| env.file_exists(&p))
583    {
584        warnings.push(
585            "DEPRECATION: Found legacy per-repo config at .agent/agents.toml. \
586             Please migrate to ~/.config/ralph-workflow.toml."
587                .to_string(),
588        );
589    }
590}
591
592/// Check if the unified config file exists.
593pub fn unified_config_exists() -> bool {
594    unified_config_path().is_some_and(|p| p.exists())
595}
596
597/// Check if the unified config file exists using a [`ConfigEnvironment`].
598///
599/// This is the testable version of [`unified_config_exists`]. It uses the provided
600/// environment for path resolution and filesystem operations.
601///
602/// # Arguments
603///
604/// * `env` - The configuration environment to use for path resolution and file existence checks.
605///
606/// # Returns
607///
608/// Returns `true` if the unified config path can be determined and the file exists at that path.
609pub fn unified_config_exists_with_env(env: &dyn ConfigEnvironment) -> bool {
610    env.unified_config_path()
611        .is_some_and(|p| env.file_exists(&p))
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::config::path_resolver::MemoryConfigEnvironment;
618    use serial_test::serial;
619    use std::path::Path;
620
621    #[test]
622    #[serial]
623    fn test_load_config_with_env_from_custom_path() {
624        let toml_str = r#"
625[general]
626verbosity = 3
627interactive = false
628developer_iters = 10
629review_depth = "standard"
630"#;
631        let env = MemoryConfigEnvironment::new()
632            .with_unified_config_path("/test/config/ralph-workflow.toml")
633            .with_file("/custom/config.toml", toml_str);
634
635        let (config, unified, warnings) =
636            load_config_from_path_with_env(Some(Path::new("/custom/config.toml")), &env);
637
638        assert!(warnings.is_empty(), "Unexpected warnings: {:?}", warnings);
639        assert!(unified.is_some());
640        assert_eq!(config.developer_iters, 10);
641        assert!(!config.behavior.interactive);
642    }
643
644    #[test]
645    #[serial]
646    fn test_load_config_with_env_missing_file() {
647        let env = MemoryConfigEnvironment::new()
648            .with_unified_config_path("/test/config/ralph-workflow.toml");
649
650        let (config, unified, warnings) =
651            load_config_from_path_with_env(Some(Path::new("/missing/config.toml")), &env);
652
653        assert!(unified.is_none());
654        assert_eq!(warnings.len(), 1);
655        assert!(warnings[0].contains("not found"));
656        // Should fall back to defaults
657        assert_eq!(config.developer_iters, 5);
658    }
659
660    #[test]
661    #[serial]
662    fn test_load_config_with_env_from_default_path() {
663        let toml_str = r#"
664[general]
665verbosity = 4
666developer_iters = 8
667review_depth = "standard"
668"#;
669        let env = MemoryConfigEnvironment::new()
670            .with_unified_config_path("/test/config/ralph-workflow.toml")
671            .with_file("/test/config/ralph-workflow.toml", toml_str);
672
673        let (config, unified, warnings) = load_config_from_path_with_env(None, &env);
674
675        assert!(warnings.is_empty(), "Unexpected warnings: {:?}", warnings);
676        assert!(unified.is_some());
677        assert_eq!(config.developer_iters, 8);
678        assert_eq!(config.verbosity, Verbosity::Debug);
679    }
680
681    #[test]
682    fn test_default_config() {
683        let config = default_config();
684        assert!(config.developer_agent.is_none());
685        assert!(config.reviewer_agent.is_none());
686        assert_eq!(config.developer_iters, 5);
687        assert_eq!(config.reviewer_reviews, 2);
688        assert!(config.behavior.interactive);
689        assert!(config.isolation_mode);
690        assert_eq!(config.verbosity, Verbosity::Verbose);
691    }
692
693    #[test]
694    #[serial]
695    fn test_apply_env_overrides() {
696        // Set some env vars
697        env::set_var("RALPH_DEVELOPER_ITERS", "10");
698        env::set_var("RALPH_ISOLATION_MODE", "false");
699
700        let mut warnings = Vec::new();
701        let config = apply_env_overrides(default_config(), &mut warnings);
702        assert_eq!(config.developer_iters, 10);
703        assert!(!config.isolation_mode);
704        assert!(warnings.is_empty());
705
706        // Clean up
707        env::remove_var("RALPH_DEVELOPER_ITERS");
708        env::remove_var("RALPH_ISOLATION_MODE");
709    }
710
711    #[test]
712    fn test_unified_config_exists_with_env_returns_false_when_no_path() {
713        // Test when there's no unified config path configured
714        let env = MemoryConfigEnvironment::new();
715        assert!(!unified_config_exists_with_env(&env));
716    }
717
718    #[test]
719    fn test_unified_config_exists_with_env_returns_false_when_file_missing() {
720        // Test when path is configured but file doesn't exist
721        let env = MemoryConfigEnvironment::new()
722            .with_unified_config_path("/test/config/ralph-workflow.toml");
723        assert!(!unified_config_exists_with_env(&env));
724    }
725
726    #[test]
727    fn test_unified_config_exists_with_env_returns_true_when_file_exists() {
728        // Test when path is configured and file exists
729        let env = MemoryConfigEnvironment::new()
730            .with_unified_config_path("/test/config/ralph-workflow.toml")
731            .with_file("/test/config/ralph-workflow.toml", "[general]");
732        assert!(unified_config_exists_with_env(&env));
733    }
734
735    #[test]
736    #[serial]
737    fn test_max_dev_continuations_zero_falls_back_to_default() {
738        let toml_str = r#"
739[general]
740verbosity = 4
741developer_iters = 8
742review_depth = "standard"
743max_dev_continuations = 0
744"#;
745
746        let env = MemoryConfigEnvironment::new()
747            .with_unified_config_path("/test/config/ralph-workflow.toml")
748            .with_file("/test/config/ralph-workflow.toml", toml_str);
749
750        let (config, _unified, warnings) = load_config_from_path_with_env(None, &env);
751
752        assert_eq!(config.max_dev_continuations, Some(2));
753        assert!(
754            warnings
755                .iter()
756                .any(|w| w.contains("max_dev_continuations") && w.contains(">= 1")),
757            "Expected warning about invalid max_dev_continuations, got: {:?}",
758            warnings
759        );
760    }
761
762    #[test]
763    #[serial]
764    fn test_load_config_returns_defaults_without_file() {
765        // Clear env vars that might affect the test
766        env::remove_var("RALPH_DEVELOPER_AGENT");
767        env::remove_var("RALPH_REVIEWER_AGENT");
768        env::remove_var("RALPH_DEVELOPER_ITERS");
769        env::remove_var("RALPH_VERBOSITY");
770
771        let (config, _unified, _warnings) = load_config();
772        assert_eq!(config.developer_iters, 5);
773        assert_eq!(config.verbosity, Verbosity::Verbose);
774    }
775}