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::types::{Config, ReviewDepth, Verbosity};
19use super::unified::{unified_config_path, UnifiedConfig};
20use std::env;
21use std::path::PathBuf;
22
23/// Load configuration with the unified approach.
24///
25/// This function loads configuration from the unified config file
26/// (`~/.config/ralph-workflow.toml`) and applies environment variable overrides.
27///
28/// # Returns
29///
30/// Returns a tuple of `(Config, Vec<String>)` where the second element
31/// contains any deprecation warnings to be displayed to the user.
32pub fn load_config() -> (Config, Option<UnifiedConfig>, Vec<String>) {
33    load_config_from_path(None)
34}
35
36/// Load configuration from a specific path or the default location.
37///
38/// If `config_path` is provided, loads from that file.
39/// Otherwise, loads from the default unified config location.
40///
41/// # Arguments
42///
43/// * `config_path` - Optional path to a config file. If None, uses the default location.
44///
45/// # Returns
46///
47/// Returns a tuple of `(Config, Vec<String>)` where the second element
48/// contains any deprecation warnings to be displayed to the user.
49pub fn load_config_from_path(
50    config_path: Option<&std::path::Path>,
51) -> (Config, Option<UnifiedConfig>, Vec<String>) {
52    let mut warnings = Vec::new();
53
54    // Try to load unified config from specified path or default
55    let unified = config_path.map_or_else(UnifiedConfig::load_default, |path| {
56        if path.exists() {
57            match UnifiedConfig::load_from_path(path) {
58                Ok(cfg) => Some(cfg),
59                Err(e) => {
60                    warnings.push(format!(
61                        "Failed to load config from {}: {}",
62                        path.display(),
63                        e
64                    ));
65                    None
66                }
67            }
68        } else {
69            warnings.push(format!("Config file not found: {}", path.display()));
70            None
71        }
72    });
73
74    // Start with defaults, then apply unified config if found
75    let config = if let Some(ref unified_cfg) = unified {
76        config_from_unified(unified_cfg, &mut warnings)
77    } else {
78        // No unified config - check for legacy configs
79        check_legacy_configs(&mut warnings);
80        default_config()
81    };
82
83    // Apply environment variable overrides
84    let config = apply_env_overrides(config, &mut warnings);
85
86    (config, unified, warnings)
87}
88
89/// Create a Config from `UnifiedConfig`.
90fn config_from_unified(unified: &UnifiedConfig, warnings: &mut Vec<String>) -> Config {
91    use super::types::{BehavioralFlags, FeatureFlags};
92
93    let general = &unified.general;
94
95    let review_depth = ReviewDepth::from_str(&general.review_depth).unwrap_or_else(|| {
96        warnings.push(format!(
97            "Invalid review_depth '{}' in config; falling back to 'standard'.",
98            general.review_depth
99        ));
100        ReviewDepth::default()
101    });
102
103    Config {
104        developer_agent: None, // Set from agent_chain or CLI
105        reviewer_agent: None,  // Set from agent_chain or CLI
106        developer_cmd: None,
107        reviewer_cmd: None,
108        developer_model: None,
109        reviewer_model: None,
110        developer_provider: None,
111        reviewer_provider: None,
112        reviewer_json_parser: None, // Set from env var or CLI
113        features: FeatureFlags {
114            checkpoint_enabled: general.workflow.checkpoint_enabled,
115            force_universal_prompt: general.execution.force_universal_prompt,
116        },
117        developer_iters: general.developer_iters,
118        reviewer_reviews: general.reviewer_reviews,
119        fast_check_cmd: None,
120        full_check_cmd: None,
121        behavior: BehavioralFlags {
122            interactive: general.behavior.interactive,
123            auto_detect_stack: general.behavior.auto_detect_stack,
124            strict_validation: general.behavior.strict_validation,
125        },
126        prompt_path: general
127            .prompt_path
128            .as_ref()
129            .map_or_else(|| PathBuf::from(".agent/last_prompt.txt"), PathBuf::from),
130        user_templates_dir: general.templates_dir.as_ref().map(PathBuf::from),
131        developer_context: general.developer_context,
132        reviewer_context: general.reviewer_context,
133        verbosity: Verbosity::from(general.verbosity),
134        commit_msg: "chore: apply PROMPT loop + review/fix/review".to_string(),
135        review_depth,
136        isolation_mode: general.execution.isolation_mode,
137        git_user_name: general.git_user_name.clone(),
138        git_user_email: general.git_user_email.clone(),
139        show_streaming_metrics: false, // Default to false; can be enabled via CLI flag or config file
140    }
141}
142
143/// Default configuration when no config file is found.
144fn default_config() -> Config {
145    use super::types::{BehavioralFlags, FeatureFlags};
146
147    Config {
148        developer_agent: None,
149        reviewer_agent: None,
150        developer_cmd: None,
151        reviewer_cmd: None,
152        developer_model: None,
153        reviewer_model: None,
154        developer_provider: None,
155        reviewer_provider: None,
156        reviewer_json_parser: None,
157        features: FeatureFlags {
158            checkpoint_enabled: true,
159            force_universal_prompt: false,
160        },
161        developer_iters: 5,
162        reviewer_reviews: 2,
163        fast_check_cmd: None,
164        full_check_cmd: None,
165        behavior: BehavioralFlags {
166            interactive: true,
167            auto_detect_stack: true,
168            strict_validation: false,
169        },
170        prompt_path: PathBuf::from(".agent/last_prompt.txt"),
171        user_templates_dir: None,
172        developer_context: 1,
173        reviewer_context: 0,
174        verbosity: Verbosity::Verbose,
175        commit_msg: "chore: apply PROMPT loop + review/fix/review".to_string(),
176        review_depth: ReviewDepth::default(),
177        isolation_mode: true,
178        git_user_name: None,
179        git_user_email: None,
180        show_streaming_metrics: false,
181    }
182}
183
184/// Apply environment variable overrides to config.
185fn apply_env_overrides(mut config: Config, warnings: &mut Vec<String>) -> Config {
186    const MAX_ITERS: u32 = 50;
187    const MAX_REVIEWS: u32 = 10;
188    const MAX_CONTEXT: u8 = 2;
189
190    // Apply all environment variable overrides by category
191    apply_agent_selection_env(&mut config, warnings);
192    apply_command_env(&mut config, warnings);
193    apply_model_provider_env(&mut config);
194    apply_iteration_counts_env(&mut config, warnings, MAX_ITERS, MAX_REVIEWS);
195    apply_boolean_flags_env(&mut config);
196    apply_verbosity_env(&mut config, warnings);
197    apply_review_depth_env(&mut config, warnings);
198    apply_paths_env(&mut config);
199    apply_context_levels_env(&mut config, warnings, MAX_CONTEXT);
200    apply_git_identity_env(&mut config);
201
202    config
203}
204
205/// Apply agent selection environment variables.
206fn apply_agent_selection_env(config: &mut Config, warnings: &mut Vec<String>) {
207    let developer_agent = env::var("RALPH_DEVELOPER_AGENT")
208        .or_else(|_| env::var("RALPH_DRIVER_AGENT"))
209        .ok();
210    if let Some(val) = developer_agent {
211        let trimmed = val.trim();
212        if trimmed.is_empty() {
213            warnings.push("Env var RALPH_DEVELOPER_AGENT is empty; ignoring.".to_string());
214        } else {
215            config.developer_agent = Some(trimmed.to_string());
216        }
217    }
218
219    if let Ok(val) = env::var("RALPH_REVIEWER_AGENT") {
220        let trimmed = val.trim();
221        if trimmed.is_empty() {
222            warnings.push("Env var RALPH_REVIEWER_AGENT is empty; ignoring.".to_string());
223        } else {
224            config.reviewer_agent = Some(trimmed.to_string());
225        }
226    }
227}
228
229/// Apply command override environment variables.
230fn apply_command_env(config: &mut Config, warnings: &mut Vec<String>) {
231    for (env_var, field) in [
232        ("RALPH_DEVELOPER_CMD", &mut config.developer_cmd),
233        ("RALPH_REVIEWER_CMD", &mut config.reviewer_cmd),
234    ] {
235        if let Ok(val) = env::var(env_var) {
236            let trimmed = val.trim();
237            if trimmed.is_empty() {
238                warnings.push(format!("Env var {env_var} is empty; ignoring."));
239            } else {
240                *field = Some(trimmed.to_string());
241            }
242        }
243    }
244
245    for (env_var, field) in [
246        ("FAST_CHECK_CMD", &mut config.fast_check_cmd),
247        ("FULL_CHECK_CMD", &mut config.full_check_cmd),
248    ] {
249        if let Ok(val) = env::var(env_var) {
250            if !val.is_empty() {
251                *field = Some(val);
252            }
253        }
254    }
255}
256
257/// Apply model and provider environment variables.
258fn apply_model_provider_env(config: &mut Config) {
259    for (env_var, field) in [
260        ("RALPH_DEVELOPER_MODEL", &mut config.developer_model),
261        ("RALPH_REVIEWER_MODEL", &mut config.reviewer_model),
262        ("RALPH_DEVELOPER_PROVIDER", &mut config.developer_provider),
263        ("RALPH_REVIEWER_PROVIDER", &mut config.reviewer_provider),
264    ] {
265        if let Ok(val) = env::var(env_var) {
266            *field = Some(val);
267        }
268    }
269
270    // JSON parser override for reviewer (useful for testing different parsers)
271    if let Ok(val) = env::var("RALPH_REVIEWER_JSON_PARSER") {
272        let trimmed = val.trim();
273        if !trimmed.is_empty() {
274            config.reviewer_json_parser = Some(trimmed.to_string());
275        }
276    }
277
278    // Force universal review prompt (useful for problematic agents)
279    if let Ok(val) = env::var("RALPH_REVIEWER_UNIVERSAL_PROMPT") {
280        if let Some(b) = parse_env_bool(&val) {
281            config.features.force_universal_prompt = b;
282        }
283    }
284}
285
286/// Apply iteration count environment variables.
287fn apply_iteration_counts_env(
288    config: &mut Config,
289    warnings: &mut Vec<String>,
290    max_iters: u32,
291    max_reviews: u32,
292) {
293    if let Some(n) = parse_env_u32("RALPH_DEVELOPER_ITERS", warnings, max_iters) {
294        config.developer_iters = n;
295    }
296    if let Some(n) = parse_env_u32("RALPH_REVIEWER_REVIEWS", warnings, max_reviews) {
297        config.reviewer_reviews = n;
298    }
299}
300
301/// Apply boolean flag environment variables.
302fn apply_boolean_flags_env(config: &mut Config) {
303    // Read all boolean env vars first
304    let vars: std::collections::HashMap<&str, bool> = [
305        "RALPH_INTERACTIVE",
306        "RALPH_AUTO_DETECT_STACK",
307        "RALPH_CHECKPOINT_ENABLED",
308        "RALPH_STRICT_VALIDATION",
309        "RALPH_ISOLATION_MODE",
310    ]
311    .iter()
312    .filter_map(|&name| env::var(name).ok().map(|v| (name, v)))
313    .filter_map(|(name, val)| parse_env_bool(&val).map(|b| (name, b)))
314    .collect();
315
316    // Apply each boolean flag
317    for (name, value) in vars {
318        match name {
319            "RALPH_INTERACTIVE" => config.behavior.interactive = value,
320            "RALPH_AUTO_DETECT_STACK" => config.behavior.auto_detect_stack = value,
321            "RALPH_CHECKPOINT_ENABLED" => config.features.checkpoint_enabled = value,
322            "RALPH_STRICT_VALIDATION" => config.behavior.strict_validation = value,
323            "RALPH_ISOLATION_MODE" => config.isolation_mode = value,
324            _ => {}
325        }
326    }
327}
328
329/// Apply verbosity environment variable.
330fn apply_verbosity_env(config: &mut Config, warnings: &mut Vec<String>) {
331    if let Ok(val) = env::var("RALPH_VERBOSITY") {
332        let trimmed = val.trim();
333        if trimmed.is_empty() {
334            return;
335        }
336        match trimmed.parse::<u8>() {
337            Ok(n) => {
338                if n > 4 {
339                    warnings.push(format!(
340                        "Env var RALPH_VERBOSITY={n} is out of range; clamping to 4 (debug)."
341                    ));
342                }
343                config.verbosity = Verbosity::from(n.min(4));
344            }
345            Err(_) => {
346                warnings.push(format!(
347                    "Env var RALPH_VERBOSITY='{trimmed}' is not a valid number; ignoring."
348                ));
349            }
350        }
351    }
352}
353
354/// Apply review depth environment variable.
355fn apply_review_depth_env(config: &mut Config, warnings: &mut Vec<String>) {
356    if let Ok(val) = env::var("RALPH_REVIEW_DEPTH") {
357        if let Some(depth) = ReviewDepth::from_str(&val) {
358            config.review_depth = depth;
359        } else if !val.trim().is_empty() {
360            warnings.push(format!(
361                "Env var RALPH_REVIEW_DEPTH='{}' is invalid; ignoring.",
362                val.trim()
363            ));
364        }
365    }
366}
367
368/// Apply path environment variables.
369fn apply_paths_env(config: &mut Config) {
370    if let Ok(val) = env::var("RALPH_PROMPT_PATH") {
371        config.prompt_path = PathBuf::from(val);
372    }
373    if let Ok(val) = env::var("RALPH_TEMPLATES_DIR") {
374        let trimmed = val.trim();
375        if !trimmed.is_empty() {
376            config.user_templates_dir = Some(PathBuf::from(trimmed));
377        }
378    }
379}
380
381/// Apply context level environment variables.
382fn apply_context_levels_env(config: &mut Config, warnings: &mut Vec<String>, max_context: u8) {
383    if let Some(n) = parse_env_u8("RALPH_DEVELOPER_CONTEXT", warnings, max_context) {
384        config.developer_context = n;
385    }
386    if let Some(n) = parse_env_u8("RALPH_REVIEWER_CONTEXT", warnings, max_context) {
387        config.reviewer_context = n;
388    }
389}
390
391/// Apply git user identity environment variables.
392fn apply_git_identity_env(config: &mut Config) {
393    if let Ok(val) = env::var("RALPH_GIT_USER_NAME") {
394        let trimmed = val.trim();
395        if !trimmed.is_empty() {
396            config.git_user_name = Some(trimmed.to_string());
397        }
398    }
399    if let Ok(val) = env::var("RALPH_GIT_USER_EMAIL") {
400        let trimmed = val.trim();
401        if !trimmed.is_empty() {
402            config.git_user_email = Some(trimmed.to_string());
403        }
404    }
405}
406
407/// Parse a u32 environment variable with validation.
408fn parse_env_u32(name: &str, warnings: &mut Vec<String>, max: u32) -> Option<u32> {
409    let raw = std::env::var(name).ok()?;
410    let trimmed = raw.trim();
411    if trimmed.is_empty() {
412        return None;
413    }
414    match trimmed.parse::<u32>() {
415        Ok(n) if n <= max => Some(n),
416        Ok(n) => {
417            warnings.push(format!(
418                "Env var {name}={n} is too large; clamping to {max}."
419            ));
420            Some(max)
421        }
422        Err(_) => {
423            warnings.push(format!(
424                "Env var {name}='{trimmed}' is not a valid number; ignoring."
425            ));
426            None
427        }
428    }
429}
430
431/// Parse a u8 environment variable with validation.
432fn parse_env_u8(name: &str, warnings: &mut Vec<String>, max: u8) -> Option<u8> {
433    let raw = std::env::var(name).ok()?;
434    let trimmed = raw.trim();
435    if trimmed.is_empty() {
436        return None;
437    }
438    match trimmed.parse::<u8>() {
439        Ok(n) if n <= max => Some(n),
440        Ok(n) => {
441            warnings.push(format!(
442                "Env var {name}={n} is out of range; clamping to {max}."
443            ));
444            Some(max)
445        }
446        Err(_) => {
447            warnings.push(format!(
448                "Env var {name}='{trimmed}' is not a valid number; ignoring."
449            ));
450            None
451        }
452    }
453}
454
455/// Check for legacy config files and add deprecation warnings.
456fn check_legacy_configs(warnings: &mut Vec<String>) {
457    // Check for old global config
458    if let Some(config_dir) = dirs::config_dir() {
459        let old_global = config_dir.join("ralph").join("agents.toml");
460        if old_global.exists() {
461            warnings.push(format!(
462                "DEPRECATION: Found legacy config at {}. \
463                 Please migrate to ~/.config/ralph-workflow.toml",
464                old_global.display()
465            ));
466        }
467    }
468
469    // Check for project-level config
470    let project_config = PathBuf::from(".agent/agents.toml");
471    if project_config.exists() && unified_config_path().is_some() && !unified_config_exists() {
472        warnings.push(
473            "DEPRECATION: Found legacy per-repo config at .agent/agents.toml. \
474             Please migrate to ~/.config/ralph-workflow.toml."
475                .to_string(),
476        );
477    }
478}
479
480/// Check if the unified config file exists.
481pub fn unified_config_exists() -> bool {
482    unified_config_path().is_some_and(|p| p.exists())
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use std::sync::Mutex;
489
490    static ENV_MUTEX: Mutex<()> = Mutex::new(());
491
492    #[test]
493    fn test_default_config() {
494        let config = default_config();
495        assert!(config.developer_agent.is_none());
496        assert!(config.reviewer_agent.is_none());
497        assert_eq!(config.developer_iters, 5);
498        assert_eq!(config.reviewer_reviews, 2);
499        assert!(config.behavior.interactive);
500        assert!(config.isolation_mode);
501        assert_eq!(config.verbosity, Verbosity::Verbose);
502    }
503
504    #[test]
505    fn test_apply_env_overrides() {
506        let _guard = ENV_MUTEX.lock().unwrap();
507
508        // Set some env vars
509        env::set_var("RALPH_DEVELOPER_ITERS", "10");
510        env::set_var("RALPH_ISOLATION_MODE", "false");
511
512        let mut warnings = Vec::new();
513        let config = apply_env_overrides(default_config(), &mut warnings);
514        assert_eq!(config.developer_iters, 10);
515        assert!(!config.isolation_mode);
516        assert!(warnings.is_empty());
517
518        // Clean up
519        env::remove_var("RALPH_DEVELOPER_ITERS");
520        env::remove_var("RALPH_ISOLATION_MODE");
521    }
522
523    #[test]
524    fn test_unified_config_exists_respects_xdg_config_home() {
525        let _guard = ENV_MUTEX.lock().unwrap();
526
527        let dir = tempfile::tempdir().unwrap();
528        env::set_var("XDG_CONFIG_HOME", dir.path());
529
530        let path = unified_config_path().unwrap();
531        if path.exists() {
532            std::fs::remove_file(&path).unwrap();
533        }
534        assert!(!unified_config_exists());
535
536        std::fs::write(&path, "").unwrap();
537        assert!(unified_config_exists());
538
539        env::remove_var("XDG_CONFIG_HOME");
540    }
541
542    #[test]
543    fn test_load_config_returns_defaults_without_file() {
544        let _guard = ENV_MUTEX.lock().unwrap();
545
546        // Clear env vars that might affect the test
547        env::remove_var("RALPH_DEVELOPER_AGENT");
548        env::remove_var("RALPH_REVIEWER_AGENT");
549        env::remove_var("RALPH_DEVELOPER_ITERS");
550        env::remove_var("RALPH_VERBOSITY");
551
552        let (config, _unified, _warnings) = load_config();
553        assert_eq!(config.developer_iters, 5);
554        assert_eq!(config.verbosity, Verbosity::Verbose);
555    }
556}