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