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