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//! # Legacy Configs
13//!
14//! Legacy config discovery is intentionally not supported. Only the unified
15//! config path is consulted, and missing config files fall back to defaults.
16use super::parser::parse_env_bool;
17use super::path_resolver::ConfigEnvironment;
18use super::types::{Config, ReviewDepth, Verbosity};
19use super::unified::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, Option<UnifiedConfig>, Vec<String>)` where the last 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    load_config_from_path_with_env(config_path, &super::path_resolver::RealConfigEnvironment)
53}
54
55/// Load configuration from a specific path or the default location using a [`ConfigEnvironment`].
56///
57/// This is the testable version of [`load_config_from_path`]. It uses the provided
58/// environment for all filesystem operations.
59///
60/// # Arguments
61///
62/// * `config_path` - Optional path to a config file. If None, uses the environment's default.
63/// * `env` - The configuration environment to use for filesystem operations.
64///
65/// # Returns
66///
67/// Returns a tuple of `(Config, Option<UnifiedConfig>, Vec<String>)` where the last element
68/// contains any deprecation warnings to be displayed to the user.
69pub fn load_config_from_path_with_env(
70    config_path: Option<&std::path::Path>,
71    env: &dyn ConfigEnvironment,
72) -> (Config, Option<UnifiedConfig>, Vec<String>) {
73    let mut warnings = Vec::new();
74
75    // Try to load unified config from specified path or default
76    let unified = config_path.map_or_else(
77        || UnifiedConfig::load_with_env(env),
78        |path| {
79            if env.file_exists(path) {
80                match UnifiedConfig::load_from_path_with_env(path, env) {
81                    Ok(cfg) => Some(cfg),
82                    Err(e) => {
83                        warnings.push(format!(
84                            "Failed to load config from {}: {}",
85                            path.display(),
86                            e
87                        ));
88                        None
89                    }
90                }
91            } else {
92                warnings.push(format!("Config file not found: {}", path.display()));
93                None
94            }
95        },
96    );
97
98    // Start with defaults, then apply unified config if found
99    let config = if let Some(ref unified_cfg) = unified {
100        config_from_unified(unified_cfg, &mut warnings)
101    } else {
102        // No unified config - use defaults (legacy config discovery removed)
103        default_config()
104    };
105
106    // Apply environment variable overrides
107    let config = apply_env_overrides(config, &mut warnings);
108
109    (config, unified, warnings)
110}
111
112/// Create a Config from `UnifiedConfig`.
113fn config_from_unified(unified: &UnifiedConfig, warnings: &mut Vec<String>) -> Config {
114    use super::types::{BehavioralFlags, FeatureFlags};
115
116    let general = &unified.general;
117    let max_dev_continuations = if general.max_dev_continuations >= 1 {
118        general.max_dev_continuations
119    } else {
120        warnings.push(
121            "Invalid max_dev_continuations in config; must be a positive integer (>= 1). Falling back to default."
122                .to_string(),
123        );
124        2
125    };
126    // max_xsd_retries of 0 is valid and means "disable XSD retries" (immediate agent fallback).
127    // Any non-negative value is accepted; max_xsd_retries comes from a u32 so can't be negative.
128    let max_xsd_retries = general.max_xsd_retries;
129    // max_same_agent_retries of 0 is valid and means "disable same-agent retries"
130    // (immediate fallback to next agent on timeout/internal error).
131    let max_same_agent_retries = general.max_same_agent_retries;
132
133    let review_depth = ReviewDepth::from_str(&general.review_depth).unwrap_or_else(|| {
134        warnings.push(format!(
135            "Invalid review_depth '{}' in config; falling back to 'standard'.",
136            general.review_depth
137        ));
138        ReviewDepth::default()
139    });
140
141    Config {
142        developer_agent: None, // Set from agent_chain or CLI
143        reviewer_agent: None,  // Set from agent_chain or CLI
144        developer_cmd: None,
145        reviewer_cmd: None,
146        commit_cmd: None,
147        developer_model: None,
148        reviewer_model: None,
149        developer_provider: None,
150        reviewer_provider: None,
151        reviewer_json_parser: None, // Set from env var or CLI
152        features: FeatureFlags {
153            checkpoint_enabled: general.workflow.checkpoint_enabled,
154            force_universal_prompt: general.execution.force_universal_prompt,
155        },
156        developer_iters: general.developer_iters,
157        reviewer_reviews: general.reviewer_reviews,
158        fast_check_cmd: None,
159        full_check_cmd: None,
160        behavior: BehavioralFlags {
161            interactive: general.behavior.interactive,
162            auto_detect_stack: general.behavior.auto_detect_stack,
163            strict_validation: general.behavior.strict_validation,
164        },
165        prompt_path: general
166            .prompt_path
167            .as_ref()
168            .map_or_else(|| PathBuf::from(".agent/last_prompt.txt"), PathBuf::from),
169        user_templates_dir: general.templates_dir.as_ref().map(PathBuf::from),
170        developer_context: general.developer_context,
171        reviewer_context: general.reviewer_context,
172        verbosity: Verbosity::from(general.verbosity),
173        review_depth,
174        isolation_mode: general.execution.isolation_mode,
175        git_user_name: general.git_user_name.clone(),
176        git_user_email: general.git_user_email.clone(),
177        show_streaming_metrics: false, // Default to false; can be enabled via CLI flag or config file
178        review_format_retries: 5,      // Default to 5 retries for format correction
179        max_dev_continuations: Some(max_dev_continuations),
180        max_xsd_retries: Some(max_xsd_retries),
181        max_same_agent_retries: Some(max_same_agent_retries),
182    }
183}
184
185/// Default configuration when no config file is found.
186fn default_config() -> Config {
187    use super::types::{BehavioralFlags, FeatureFlags};
188
189    Config {
190        developer_agent: None,
191        reviewer_agent: None,
192        developer_cmd: None,
193        reviewer_cmd: None,
194        commit_cmd: None,
195        developer_model: None,
196        reviewer_model: None,
197        developer_provider: None,
198        reviewer_provider: None,
199        reviewer_json_parser: None,
200        features: FeatureFlags {
201            checkpoint_enabled: true,
202            force_universal_prompt: false,
203        },
204        developer_iters: 5,
205        reviewer_reviews: 2,
206        fast_check_cmd: None,
207        full_check_cmd: None,
208        behavior: BehavioralFlags {
209            interactive: true,
210            auto_detect_stack: true,
211            strict_validation: false,
212        },
213        prompt_path: PathBuf::from(".agent/last_prompt.txt"),
214        user_templates_dir: None,
215        developer_context: 1,
216        reviewer_context: 0,
217        verbosity: Verbosity::Verbose,
218        review_depth: ReviewDepth::default(),
219        isolation_mode: true,
220        git_user_name: None,
221        git_user_email: None,
222        show_streaming_metrics: false,
223        review_format_retries: 5,
224        max_dev_continuations: Some(2), // Default to 2 (initial + 1 continuation)
225        max_xsd_retries: Some(10),      // Default to 10 retries before agent fallback
226        max_same_agent_retries: Some(2), // Default to 2 failures (initial + 1 retry) before agent fallback
227    }
228}
229
230/// Apply environment variable overrides to config.
231fn apply_env_overrides(mut config: Config, warnings: &mut Vec<String>) -> Config {
232    const MAX_ITERS: u32 = 50;
233    const MAX_REVIEWS: u32 = 10;
234    const MAX_CONTEXT: u8 = 2;
235    const MAX_FORMAT_RETRIES: u32 = 20;
236
237    // Apply all environment variable overrides by category
238    apply_agent_selection_env(&mut config, warnings);
239    apply_command_env(&mut config, warnings);
240    apply_model_provider_env(&mut config);
241    apply_iteration_counts_env(&mut config, warnings, MAX_ITERS, MAX_REVIEWS);
242    apply_review_config_env(&mut config, warnings, MAX_FORMAT_RETRIES);
243    apply_boolean_flags_env(&mut config);
244    apply_verbosity_env(&mut config, warnings);
245    apply_review_depth_env(&mut config, warnings);
246    apply_paths_env(&mut config);
247    apply_context_levels_env(&mut config, warnings, MAX_CONTEXT);
248    apply_git_identity_env(&mut config);
249
250    config
251}
252
253/// Apply agent selection environment variables.
254fn apply_agent_selection_env(config: &mut Config, warnings: &mut Vec<String>) {
255    if let Ok(val) = env::var("RALPH_DEVELOPER_AGENT") {
256        let trimmed = val.trim();
257        if trimmed.is_empty() {
258            warnings.push("Env var RALPH_DEVELOPER_AGENT is empty; ignoring.".to_string());
259        } else {
260            config.developer_agent = Some(trimmed.to_string());
261        }
262    }
263
264    if let Ok(val) = env::var("RALPH_REVIEWER_AGENT") {
265        let trimmed = val.trim();
266        if trimmed.is_empty() {
267            warnings.push("Env var RALPH_REVIEWER_AGENT is empty; ignoring.".to_string());
268        } else {
269            config.reviewer_agent = Some(trimmed.to_string());
270        }
271    }
272}
273
274/// Apply command override environment variables.
275fn apply_command_env(config: &mut Config, warnings: &mut Vec<String>) {
276    for (env_var, field) in [
277        ("RALPH_DEVELOPER_CMD", &mut config.developer_cmd),
278        ("RALPH_REVIEWER_CMD", &mut config.reviewer_cmd),
279        ("RALPH_COMMIT_CMD", &mut config.commit_cmd),
280    ] {
281        if let Ok(val) = env::var(env_var) {
282            let trimmed = val.trim();
283            if trimmed.is_empty() {
284                warnings.push(format!("Env var {env_var} is empty; ignoring."));
285            } else {
286                *field = Some(trimmed.to_string());
287            }
288        }
289    }
290
291    for (env_var, field) in [
292        ("FAST_CHECK_CMD", &mut config.fast_check_cmd),
293        ("FULL_CHECK_CMD", &mut config.full_check_cmd),
294    ] {
295        if let Ok(val) = env::var(env_var) {
296            if !val.is_empty() {
297                *field = Some(val);
298            }
299        }
300    }
301}
302
303/// Apply model and provider environment variables.
304fn apply_model_provider_env(config: &mut Config) {
305    for (env_var, field) in [
306        ("RALPH_DEVELOPER_MODEL", &mut config.developer_model),
307        ("RALPH_REVIEWER_MODEL", &mut config.reviewer_model),
308        ("RALPH_DEVELOPER_PROVIDER", &mut config.developer_provider),
309        ("RALPH_REVIEWER_PROVIDER", &mut config.reviewer_provider),
310    ] {
311        if let Ok(val) = env::var(env_var) {
312            *field = Some(val);
313        }
314    }
315
316    // JSON parser override for reviewer (useful for testing different parsers)
317    if let Ok(val) = env::var("RALPH_REVIEWER_JSON_PARSER") {
318        let trimmed = val.trim();
319        if !trimmed.is_empty() {
320            config.reviewer_json_parser = Some(trimmed.to_string());
321        }
322    }
323
324    // Force universal review prompt (useful for problematic agents)
325    if let Ok(val) = env::var("RALPH_REVIEWER_UNIVERSAL_PROMPT") {
326        if let Some(b) = parse_env_bool(&val) {
327            config.features.force_universal_prompt = b;
328        }
329    }
330}
331
332/// Apply iteration count environment variables.
333fn apply_iteration_counts_env(
334    config: &mut Config,
335    warnings: &mut Vec<String>,
336    max_iters: u32,
337    max_reviews: u32,
338) {
339    if let Some(n) = parse_env_u32("RALPH_DEVELOPER_ITERS", warnings, max_iters) {
340        config.developer_iters = n;
341    }
342    if let Some(n) = parse_env_u32("RALPH_REVIEWER_REVIEWS", warnings, max_reviews) {
343        config.reviewer_reviews = n;
344    }
345}
346
347/// Apply review-specific configuration environment variables.
348fn apply_review_config_env(config: &mut Config, warnings: &mut Vec<String>, max_retries: u32) {
349    if let Some(n) = parse_env_u32("RALPH_REVIEW_FORMAT_RETRIES", warnings, max_retries) {
350        config.review_format_retries = n;
351    }
352}
353
354/// Apply boolean flag environment variables.
355fn apply_boolean_flags_env(config: &mut Config) {
356    // Read all boolean env vars first
357    let vars: std::collections::HashMap<&str, bool> = [
358        "RALPH_INTERACTIVE",
359        "RALPH_AUTO_DETECT_STACK",
360        "RALPH_CHECKPOINT_ENABLED",
361        "RALPH_STRICT_VALIDATION",
362        "RALPH_ISOLATION_MODE",
363    ]
364    .iter()
365    .filter_map(|&name| env::var(name).ok().map(|v| (name, v)))
366    .filter_map(|(name, val)| parse_env_bool(&val).map(|b| (name, b)))
367    .collect();
368
369    // Apply each boolean flag
370    for (name, value) in vars {
371        match name {
372            "RALPH_INTERACTIVE" => config.behavior.interactive = value,
373            "RALPH_AUTO_DETECT_STACK" => config.behavior.auto_detect_stack = value,
374            "RALPH_CHECKPOINT_ENABLED" => config.features.checkpoint_enabled = value,
375            "RALPH_STRICT_VALIDATION" => config.behavior.strict_validation = value,
376            "RALPH_ISOLATION_MODE" => config.isolation_mode = value,
377            _ => {}
378        }
379    }
380}
381
382/// Apply verbosity environment variable.
383fn apply_verbosity_env(config: &mut Config, warnings: &mut Vec<String>) {
384    if let Ok(val) = env::var("RALPH_VERBOSITY") {
385        let trimmed = val.trim();
386        if trimmed.is_empty() {
387            return;
388        }
389        match trimmed.parse::<u8>() {
390            Ok(n) => {
391                if n > 4 {
392                    warnings.push(format!(
393                        "Env var RALPH_VERBOSITY={n} is out of range; clamping to 4 (debug)."
394                    ));
395                }
396                config.verbosity = Verbosity::from(n.min(4));
397            }
398            Err(_) => {
399                warnings.push(format!(
400                    "Env var RALPH_VERBOSITY='{trimmed}' is not a valid number; ignoring."
401                ));
402            }
403        }
404    }
405}
406
407/// Apply review depth environment variable.
408fn apply_review_depth_env(config: &mut Config, warnings: &mut Vec<String>) {
409    if let Ok(val) = env::var("RALPH_REVIEW_DEPTH") {
410        if let Some(depth) = ReviewDepth::from_str(&val) {
411            config.review_depth = depth;
412        } else if !val.trim().is_empty() {
413            warnings.push(format!(
414                "Env var RALPH_REVIEW_DEPTH='{}' is invalid; ignoring.",
415                val.trim()
416            ));
417        }
418    }
419}
420
421/// Apply path environment variables.
422fn apply_paths_env(config: &mut Config) {
423    if let Ok(val) = env::var("RALPH_PROMPT_PATH") {
424        config.prompt_path = PathBuf::from(val);
425    }
426    if let Ok(val) = env::var("RALPH_TEMPLATES_DIR") {
427        let trimmed = val.trim();
428        if !trimmed.is_empty() {
429            config.user_templates_dir = Some(PathBuf::from(trimmed));
430        }
431    }
432}
433
434/// Apply context level environment variables.
435fn apply_context_levels_env(config: &mut Config, warnings: &mut Vec<String>, max_context: u8) {
436    if let Some(n) = parse_env_u8("RALPH_DEVELOPER_CONTEXT", warnings, max_context) {
437        config.developer_context = n;
438    }
439    if let Some(n) = parse_env_u8("RALPH_REVIEWER_CONTEXT", warnings, max_context) {
440        config.reviewer_context = n;
441    }
442}
443
444/// Apply git user identity environment variables.
445fn apply_git_identity_env(config: &mut Config) {
446    if let Ok(val) = env::var("RALPH_GIT_USER_NAME") {
447        let trimmed = val.trim();
448        if !trimmed.is_empty() {
449            config.git_user_name = Some(trimmed.to_string());
450        }
451    }
452    if let Ok(val) = env::var("RALPH_GIT_USER_EMAIL") {
453        let trimmed = val.trim();
454        if !trimmed.is_empty() {
455            config.git_user_email = Some(trimmed.to_string());
456        }
457    }
458}
459
460mod env_parsing;
461use env_parsing::{parse_env_u32, parse_env_u8};
462
463mod unified_config_exists;
464
465pub use unified_config_exists::{unified_config_exists, unified_config_exists_with_env};
466
467#[cfg(test)]
468mod tests;