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
157    let review_depth = ReviewDepth::from_str(&general.review_depth).unwrap_or_else(|| {
158        warnings.push(format!(
159            "Invalid review_depth '{}' in config; falling back to 'standard'.",
160            general.review_depth
161        ));
162        ReviewDepth::default()
163    });
164
165    Config {
166        developer_agent: None, // Set from agent_chain or CLI
167        reviewer_agent: None,  // Set from agent_chain or CLI
168        developer_cmd: None,
169        reviewer_cmd: None,
170        commit_cmd: None,
171        developer_model: None,
172        reviewer_model: None,
173        developer_provider: None,
174        reviewer_provider: None,
175        reviewer_json_parser: None, // Set from env var or CLI
176        features: FeatureFlags {
177            checkpoint_enabled: general.workflow.checkpoint_enabled,
178            force_universal_prompt: general.execution.force_universal_prompt,
179        },
180        developer_iters: general.developer_iters,
181        reviewer_reviews: general.reviewer_reviews,
182        fast_check_cmd: None,
183        full_check_cmd: None,
184        behavior: BehavioralFlags {
185            interactive: general.behavior.interactive,
186            auto_detect_stack: general.behavior.auto_detect_stack,
187            strict_validation: general.behavior.strict_validation,
188        },
189        prompt_path: general
190            .prompt_path
191            .as_ref()
192            .map_or_else(|| PathBuf::from(".agent/last_prompt.txt"), PathBuf::from),
193        user_templates_dir: general.templates_dir.as_ref().map(PathBuf::from),
194        developer_context: general.developer_context,
195        reviewer_context: general.reviewer_context,
196        verbosity: Verbosity::from(general.verbosity),
197        commit_msg: "chore: apply PROMPT loop + review/fix/review".to_string(),
198        review_depth,
199        isolation_mode: general.execution.isolation_mode,
200        git_user_name: general.git_user_name.clone(),
201        git_user_email: general.git_user_email.clone(),
202        show_streaming_metrics: false, // Default to false; can be enabled via CLI flag or config file
203        review_format_retries: 5,      // Default to 5 retries for format correction
204    }
205}
206
207/// Default configuration when no config file is found.
208fn default_config() -> Config {
209    use super::types::{BehavioralFlags, FeatureFlags};
210
211    Config {
212        developer_agent: None,
213        reviewer_agent: None,
214        developer_cmd: None,
215        reviewer_cmd: None,
216        commit_cmd: None,
217        developer_model: None,
218        reviewer_model: None,
219        developer_provider: None,
220        reviewer_provider: None,
221        reviewer_json_parser: None,
222        features: FeatureFlags {
223            checkpoint_enabled: true,
224            force_universal_prompt: false,
225        },
226        developer_iters: 5,
227        reviewer_reviews: 2,
228        fast_check_cmd: None,
229        full_check_cmd: None,
230        behavior: BehavioralFlags {
231            interactive: true,
232            auto_detect_stack: true,
233            strict_validation: false,
234        },
235        prompt_path: PathBuf::from(".agent/last_prompt.txt"),
236        user_templates_dir: None,
237        developer_context: 1,
238        reviewer_context: 0,
239        verbosity: Verbosity::Verbose,
240        commit_msg: "chore: apply PROMPT loop + review/fix/review".to_string(),
241        review_depth: ReviewDepth::default(),
242        isolation_mode: true,
243        git_user_name: None,
244        git_user_email: None,
245        show_streaming_metrics: false,
246        review_format_retries: 5,
247    }
248}
249
250/// Apply environment variable overrides to config.
251fn apply_env_overrides(mut config: Config, warnings: &mut Vec<String>) -> Config {
252    const MAX_ITERS: u32 = 50;
253    const MAX_REVIEWS: u32 = 10;
254    const MAX_CONTEXT: u8 = 2;
255    const MAX_FORMAT_RETRIES: u32 = 20;
256
257    // Apply all environment variable overrides by category
258    apply_agent_selection_env(&mut config, warnings);
259    apply_command_env(&mut config, warnings);
260    apply_model_provider_env(&mut config);
261    apply_iteration_counts_env(&mut config, warnings, MAX_ITERS, MAX_REVIEWS);
262    apply_review_config_env(&mut config, warnings, MAX_FORMAT_RETRIES);
263    apply_boolean_flags_env(&mut config);
264    apply_verbosity_env(&mut config, warnings);
265    apply_review_depth_env(&mut config, warnings);
266    apply_paths_env(&mut config);
267    apply_context_levels_env(&mut config, warnings, MAX_CONTEXT);
268    apply_git_identity_env(&mut config);
269
270    config
271}
272
273/// Apply agent selection environment variables.
274fn apply_agent_selection_env(config: &mut Config, warnings: &mut Vec<String>) {
275    let developer_agent = env::var("RALPH_DEVELOPER_AGENT")
276        .or_else(|_| env::var("RALPH_DRIVER_AGENT"))
277        .ok();
278    if let Some(val) = developer_agent {
279        let trimmed = val.trim();
280        if trimmed.is_empty() {
281            warnings.push("Env var RALPH_DEVELOPER_AGENT is empty; ignoring.".to_string());
282        } else {
283            config.developer_agent = Some(trimmed.to_string());
284        }
285    }
286
287    if let Ok(val) = env::var("RALPH_REVIEWER_AGENT") {
288        let trimmed = val.trim();
289        if trimmed.is_empty() {
290            warnings.push("Env var RALPH_REVIEWER_AGENT is empty; ignoring.".to_string());
291        } else {
292            config.reviewer_agent = Some(trimmed.to_string());
293        }
294    }
295}
296
297/// Apply command override environment variables.
298fn apply_command_env(config: &mut Config, warnings: &mut Vec<String>) {
299    for (env_var, field) in [
300        ("RALPH_DEVELOPER_CMD", &mut config.developer_cmd),
301        ("RALPH_REVIEWER_CMD", &mut config.reviewer_cmd),
302        ("RALPH_COMMIT_CMD", &mut config.commit_cmd),
303    ] {
304        if let Ok(val) = env::var(env_var) {
305            let trimmed = val.trim();
306            if trimmed.is_empty() {
307                warnings.push(format!("Env var {env_var} is empty; ignoring."));
308            } else {
309                *field = Some(trimmed.to_string());
310            }
311        }
312    }
313
314    for (env_var, field) in [
315        ("FAST_CHECK_CMD", &mut config.fast_check_cmd),
316        ("FULL_CHECK_CMD", &mut config.full_check_cmd),
317    ] {
318        if let Ok(val) = env::var(env_var) {
319            if !val.is_empty() {
320                *field = Some(val);
321            }
322        }
323    }
324}
325
326/// Apply model and provider environment variables.
327fn apply_model_provider_env(config: &mut Config) {
328    for (env_var, field) in [
329        ("RALPH_DEVELOPER_MODEL", &mut config.developer_model),
330        ("RALPH_REVIEWER_MODEL", &mut config.reviewer_model),
331        ("RALPH_DEVELOPER_PROVIDER", &mut config.developer_provider),
332        ("RALPH_REVIEWER_PROVIDER", &mut config.reviewer_provider),
333    ] {
334        if let Ok(val) = env::var(env_var) {
335            *field = Some(val);
336        }
337    }
338
339    // JSON parser override for reviewer (useful for testing different parsers)
340    if let Ok(val) = env::var("RALPH_REVIEWER_JSON_PARSER") {
341        let trimmed = val.trim();
342        if !trimmed.is_empty() {
343            config.reviewer_json_parser = Some(trimmed.to_string());
344        }
345    }
346
347    // Force universal review prompt (useful for problematic agents)
348    if let Ok(val) = env::var("RALPH_REVIEWER_UNIVERSAL_PROMPT") {
349        if let Some(b) = parse_env_bool(&val) {
350            config.features.force_universal_prompt = b;
351        }
352    }
353}
354
355/// Apply iteration count environment variables.
356fn apply_iteration_counts_env(
357    config: &mut Config,
358    warnings: &mut Vec<String>,
359    max_iters: u32,
360    max_reviews: u32,
361) {
362    if let Some(n) = parse_env_u32("RALPH_DEVELOPER_ITERS", warnings, max_iters) {
363        config.developer_iters = n;
364    }
365    if let Some(n) = parse_env_u32("RALPH_REVIEWER_REVIEWS", warnings, max_reviews) {
366        config.reviewer_reviews = n;
367    }
368}
369
370/// Apply review-specific configuration environment variables.
371fn apply_review_config_env(config: &mut Config, warnings: &mut Vec<String>, max_retries: u32) {
372    if let Some(n) = parse_env_u32("RALPH_REVIEW_FORMAT_RETRIES", warnings, max_retries) {
373        config.review_format_retries = n;
374    }
375}
376
377/// Apply boolean flag environment variables.
378fn apply_boolean_flags_env(config: &mut Config) {
379    // Read all boolean env vars first
380    let vars: std::collections::HashMap<&str, bool> = [
381        "RALPH_INTERACTIVE",
382        "RALPH_AUTO_DETECT_STACK",
383        "RALPH_CHECKPOINT_ENABLED",
384        "RALPH_STRICT_VALIDATION",
385        "RALPH_ISOLATION_MODE",
386    ]
387    .iter()
388    .filter_map(|&name| env::var(name).ok().map(|v| (name, v)))
389    .filter_map(|(name, val)| parse_env_bool(&val).map(|b| (name, b)))
390    .collect();
391
392    // Apply each boolean flag
393    for (name, value) in vars {
394        match name {
395            "RALPH_INTERACTIVE" => config.behavior.interactive = value,
396            "RALPH_AUTO_DETECT_STACK" => config.behavior.auto_detect_stack = value,
397            "RALPH_CHECKPOINT_ENABLED" => config.features.checkpoint_enabled = value,
398            "RALPH_STRICT_VALIDATION" => config.behavior.strict_validation = value,
399            "RALPH_ISOLATION_MODE" => config.isolation_mode = value,
400            _ => {}
401        }
402    }
403}
404
405/// Apply verbosity environment variable.
406fn apply_verbosity_env(config: &mut Config, warnings: &mut Vec<String>) {
407    if let Ok(val) = env::var("RALPH_VERBOSITY") {
408        let trimmed = val.trim();
409        if trimmed.is_empty() {
410            return;
411        }
412        match trimmed.parse::<u8>() {
413            Ok(n) => {
414                if n > 4 {
415                    warnings.push(format!(
416                        "Env var RALPH_VERBOSITY={n} is out of range; clamping to 4 (debug)."
417                    ));
418                }
419                config.verbosity = Verbosity::from(n.min(4));
420            }
421            Err(_) => {
422                warnings.push(format!(
423                    "Env var RALPH_VERBOSITY='{trimmed}' is not a valid number; ignoring."
424                ));
425            }
426        }
427    }
428}
429
430/// Apply review depth environment variable.
431fn apply_review_depth_env(config: &mut Config, warnings: &mut Vec<String>) {
432    if let Ok(val) = env::var("RALPH_REVIEW_DEPTH") {
433        if let Some(depth) = ReviewDepth::from_str(&val) {
434            config.review_depth = depth;
435        } else if !val.trim().is_empty() {
436            warnings.push(format!(
437                "Env var RALPH_REVIEW_DEPTH='{}' is invalid; ignoring.",
438                val.trim()
439            ));
440        }
441    }
442}
443
444/// Apply path environment variables.
445fn apply_paths_env(config: &mut Config) {
446    if let Ok(val) = env::var("RALPH_PROMPT_PATH") {
447        config.prompt_path = PathBuf::from(val);
448    }
449    if let Ok(val) = env::var("RALPH_TEMPLATES_DIR") {
450        let trimmed = val.trim();
451        if !trimmed.is_empty() {
452            config.user_templates_dir = Some(PathBuf::from(trimmed));
453        }
454    }
455}
456
457/// Apply context level environment variables.
458fn apply_context_levels_env(config: &mut Config, warnings: &mut Vec<String>, max_context: u8) {
459    if let Some(n) = parse_env_u8("RALPH_DEVELOPER_CONTEXT", warnings, max_context) {
460        config.developer_context = n;
461    }
462    if let Some(n) = parse_env_u8("RALPH_REVIEWER_CONTEXT", warnings, max_context) {
463        config.reviewer_context = n;
464    }
465}
466
467/// Apply git user identity environment variables.
468fn apply_git_identity_env(config: &mut Config) {
469    if let Ok(val) = env::var("RALPH_GIT_USER_NAME") {
470        let trimmed = val.trim();
471        if !trimmed.is_empty() {
472            config.git_user_name = Some(trimmed.to_string());
473        }
474    }
475    if let Ok(val) = env::var("RALPH_GIT_USER_EMAIL") {
476        let trimmed = val.trim();
477        if !trimmed.is_empty() {
478            config.git_user_email = Some(trimmed.to_string());
479        }
480    }
481}
482
483/// Parse a u32 environment variable with validation.
484fn parse_env_u32(name: &str, warnings: &mut Vec<String>, max: u32) -> Option<u32> {
485    let raw = std::env::var(name).ok()?;
486    let trimmed = raw.trim();
487    if trimmed.is_empty() {
488        return None;
489    }
490    match trimmed.parse::<u32>() {
491        Ok(n) if n <= max => Some(n),
492        Ok(n) => {
493            warnings.push(format!(
494                "Env var {name}={n} is too large; clamping to {max}."
495            ));
496            Some(max)
497        }
498        Err(_) => {
499            warnings.push(format!(
500                "Env var {name}='{trimmed}' is not a valid number; ignoring."
501            ));
502            None
503        }
504    }
505}
506
507/// Parse a u8 environment variable with validation.
508fn parse_env_u8(name: &str, warnings: &mut Vec<String>, max: u8) -> Option<u8> {
509    let raw = std::env::var(name).ok()?;
510    let trimmed = raw.trim();
511    if trimmed.is_empty() {
512        return None;
513    }
514    match trimmed.parse::<u8>() {
515        Ok(n) if n <= max => Some(n),
516        Ok(n) => {
517            warnings.push(format!(
518                "Env var {name}={n} is out of range; clamping to {max}."
519            ));
520            Some(max)
521        }
522        Err(_) => {
523            warnings.push(format!(
524                "Env var {name}='{trimmed}' is not a valid number; ignoring."
525            ));
526            None
527        }
528    }
529}
530
531/// Check for legacy config files and add deprecation warnings.
532///
533/// **Note:** This function uses `std::fs` directly via `path.exists()`.
534/// For testable code, use [`check_legacy_configs_with_env`] instead.
535fn check_legacy_configs(warnings: &mut Vec<String>) {
536    // Check for old global config
537    if let Some(config_dir) = dirs::config_dir() {
538        let old_global = config_dir.join("ralph").join("agents.toml");
539        if old_global.exists() {
540            warnings.push(format!(
541                "DEPRECATION: Found legacy config at {}. \
542                 Please migrate to ~/.config/ralph-workflow.toml",
543                old_global.display()
544            ));
545        }
546    }
547
548    // Check for project-level config
549    let project_config = PathBuf::from(".agent/agents.toml");
550    if project_config.exists() && unified_config_path().is_some() && !unified_config_exists() {
551        warnings.push(
552            "DEPRECATION: Found legacy per-repo config at .agent/agents.toml. \
553             Please migrate to ~/.config/ralph-workflow.toml."
554                .to_string(),
555        );
556    }
557}
558
559/// Check for legacy config files using a [`ConfigEnvironment`].
560///
561/// This is the testable version of [`check_legacy_configs`].
562fn check_legacy_configs_with_env(warnings: &mut Vec<String>, env: &dyn ConfigEnvironment) {
563    // Check for old global config
564    // Note: We can't check dirs::config_dir() with ConfigEnvironment, so we skip that check
565    // in the testable version. The legacy global config check is best tested via integration tests.
566
567    // Check for project-level config
568    let project_config = PathBuf::from(".agent/agents.toml");
569    if env.file_exists(&project_config)
570        && env.unified_config_path().is_some()
571        && !env
572            .unified_config_path()
573            .is_some_and(|p| env.file_exists(&p))
574    {
575        warnings.push(
576            "DEPRECATION: Found legacy per-repo config at .agent/agents.toml. \
577             Please migrate to ~/.config/ralph-workflow.toml."
578                .to_string(),
579        );
580    }
581}
582
583/// Check if the unified config file exists.
584pub fn unified_config_exists() -> bool {
585    unified_config_path().is_some_and(|p| p.exists())
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use crate::config::path_resolver::MemoryConfigEnvironment;
592    use serial_test::serial;
593    use std::path::Path;
594
595    #[test]
596    #[serial]
597    fn test_load_config_with_env_from_custom_path() {
598        let toml_str = r#"
599[general]
600verbosity = 3
601interactive = false
602developer_iters = 10
603review_depth = "standard"
604"#;
605        let env = MemoryConfigEnvironment::new()
606            .with_unified_config_path("/test/config/ralph-workflow.toml")
607            .with_file("/custom/config.toml", toml_str);
608
609        let (config, unified, warnings) =
610            load_config_from_path_with_env(Some(Path::new("/custom/config.toml")), &env);
611
612        assert!(warnings.is_empty(), "Unexpected warnings: {:?}", warnings);
613        assert!(unified.is_some());
614        assert_eq!(config.developer_iters, 10);
615        assert!(!config.behavior.interactive);
616    }
617
618    #[test]
619    #[serial]
620    fn test_load_config_with_env_missing_file() {
621        let env = MemoryConfigEnvironment::new()
622            .with_unified_config_path("/test/config/ralph-workflow.toml");
623
624        let (config, unified, warnings) =
625            load_config_from_path_with_env(Some(Path::new("/missing/config.toml")), &env);
626
627        assert!(unified.is_none());
628        assert_eq!(warnings.len(), 1);
629        assert!(warnings[0].contains("not found"));
630        // Should fall back to defaults
631        assert_eq!(config.developer_iters, 5);
632    }
633
634    #[test]
635    #[serial]
636    fn test_load_config_with_env_from_default_path() {
637        let toml_str = r#"
638[general]
639verbosity = 4
640developer_iters = 8
641review_depth = "standard"
642"#;
643        let env = MemoryConfigEnvironment::new()
644            .with_unified_config_path("/test/config/ralph-workflow.toml")
645            .with_file("/test/config/ralph-workflow.toml", toml_str);
646
647        let (config, unified, warnings) = load_config_from_path_with_env(None, &env);
648
649        assert!(warnings.is_empty(), "Unexpected warnings: {:?}", warnings);
650        assert!(unified.is_some());
651        assert_eq!(config.developer_iters, 8);
652        assert_eq!(config.verbosity, Verbosity::Debug);
653    }
654
655    #[test]
656    fn test_default_config() {
657        let config = default_config();
658        assert!(config.developer_agent.is_none());
659        assert!(config.reviewer_agent.is_none());
660        assert_eq!(config.developer_iters, 5);
661        assert_eq!(config.reviewer_reviews, 2);
662        assert!(config.behavior.interactive);
663        assert!(config.isolation_mode);
664        assert_eq!(config.verbosity, Verbosity::Verbose);
665    }
666
667    #[test]
668    #[serial]
669    fn test_apply_env_overrides() {
670        // Set some env vars
671        env::set_var("RALPH_DEVELOPER_ITERS", "10");
672        env::set_var("RALPH_ISOLATION_MODE", "false");
673
674        let mut warnings = Vec::new();
675        let config = apply_env_overrides(default_config(), &mut warnings);
676        assert_eq!(config.developer_iters, 10);
677        assert!(!config.isolation_mode);
678        assert!(warnings.is_empty());
679
680        // Clean up
681        env::remove_var("RALPH_DEVELOPER_ITERS");
682        env::remove_var("RALPH_ISOLATION_MODE");
683    }
684
685    #[test]
686    #[serial]
687    fn test_unified_config_exists_respects_xdg_config_home() {
688        let dir = tempfile::tempdir().unwrap();
689        env::set_var("XDG_CONFIG_HOME", dir.path());
690
691        let path = unified_config_path().unwrap();
692        if path.exists() {
693            std::fs::remove_file(&path).unwrap();
694        }
695        assert!(!unified_config_exists());
696
697        std::fs::write(&path, "").unwrap();
698        assert!(unified_config_exists());
699
700        env::remove_var("XDG_CONFIG_HOME");
701    }
702
703    #[test]
704    #[serial]
705    fn test_load_config_returns_defaults_without_file() {
706        // Clear env vars that might affect the test
707        env::remove_var("RALPH_DEVELOPER_AGENT");
708        env::remove_var("RALPH_REVIEWER_AGENT");
709        env::remove_var("RALPH_DEVELOPER_ITERS");
710        env::remove_var("RALPH_VERBOSITY");
711
712        let (config, _unified, _warnings) = load_config();
713        assert_eq!(config.developer_iters, 5);
714        assert_eq!(config.verbosity, Verbosity::Verbose);
715    }
716}