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. **Explicit config path**: `--config PATH` (if provided)
9//! 2. **Global config**: `~/.config/ralph-workflow.toml` (when no explicit path)
10//! 3. **Local config**: `.agent/ralph-workflow.toml` (overrides global, only when no explicit path)
11//! 4. **Override layer**: Environment variables (RALPH_*)
12//! 5. **CLI arguments**: Final override (handled at CLI layer)
13//!
14//! # Legacy Configs
15//!
16//! Legacy config discovery is intentionally not supported. Only the unified
17//! config path is consulted, and missing config files fall back to defaults.
18//!
19//! # Fail-Fast Validation
20//!
21//! Ralph validates ALL config files before starting the pipeline. Invalid TOML,
22//! type mismatches, or unknown keys will cause Ralph to refuse to start with
23//! a clear error message. This is not optional - config validation runs on
24//! every startup before any other CLI operation.
25use super::path_resolver::ConfigEnvironment;
26use super::unified::UnifiedConfig;
27use super::validation::{validate_config_file, ConfigValidationError};
28use std::path::PathBuf;
29
30mod error_types;
31pub use error_types::ConfigLoadWithValidationError;
32
33mod config_builder;
34use config_builder::config_from_unified;
35pub(super) use config_builder::default_config;
36
37mod env_overrides;
38pub(super) use env_overrides::apply_env_overrides;
39
40/// Load configuration with the unified approach.
41///
42/// This function loads configuration from the unified config file
43/// (`~/.config/ralph-workflow.toml`) and applies environment variable overrides.
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.
49///
50/// # Errors
51///
52/// Returns error if the operation fails.
53pub fn load_config(
54) -> Result<(super::types::Config, Option<UnifiedConfig>, Vec<String>), ConfigLoadWithValidationError>
55{
56    load_config_from_path(None)
57}
58
59/// Load configuration from a specific path or the default location.
60///
61/// If `config_path` is provided, loads from that file.
62/// Otherwise, loads from the default unified config location.
63///
64/// # Arguments
65///
66/// * `config_path` - Optional path to a config file. If None, uses the default location.
67///
68/// # Returns
69///
70/// Returns a tuple of `(Config, Option<UnifiedConfig>, Vec<String>)` where the last element
71/// contains any deprecation warnings to be displayed to the user.
72///
73/// # Panics
74///
75/// This function does not panic. Validation errors are returned to the caller.
76///
77/// # Errors
78///
79/// Returns error if the operation fails.
80pub fn load_config_from_path(
81    config_path: Option<&std::path::Path>,
82) -> Result<(super::types::Config, Option<UnifiedConfig>, Vec<String>), ConfigLoadWithValidationError>
83{
84    load_config_from_path_with_env(config_path, &super::path_resolver::RealConfigEnvironment)
85}
86
87/// Load configuration from a specific path or the default location using a [`ConfigEnvironment`].
88///
89/// This is the testable version of [`load_config_from_path`]. It uses the provided
90/// environment for all filesystem operations.
91///
92/// # Arguments
93///
94/// * `config_path` - Optional path to a config file. If None, uses the environment's default.
95/// * `env` - The configuration environment to use for filesystem operations.
96///
97/// # Returns
98///
99/// Returns a tuple of `(Config, Option<UnifiedConfig>, Vec<String>)` where the last element
100/// contains any deprecation warnings to be displayed to the user.
101///
102/// # Errors
103///
104/// Returns `Err(ConfigLoadWithValidationError)` if any config file has validation errors
105/// (invalid TOML, type mismatches, unknown keys). Per requirements, Ralph refuses to start
106/// if ANY config file has errors.
107pub fn load_config_from_path_with_env(
108    config_path: Option<&std::path::Path>,
109    env: &dyn ConfigEnvironment,
110) -> Result<(super::types::Config, Option<UnifiedConfig>, Vec<String>), ConfigLoadWithValidationError>
111{
112    let mut warnings = Vec::new();
113    let mut validation_errors = Vec::new();
114
115    // Step 1: Load and validate global config
116    let global_config_path = config_path
117        .map(std::path::Path::to_path_buf)
118        .or_else(|| env.unified_config_path());
119    let mut global_content: Option<String> = None;
120
121    let global_unified = if let Some(path) = global_config_path.as_ref() {
122        if env.file_exists(path) {
123            let content = env.read_file(path)?;
124            // Validate the config file
125            match validate_config_file(path, &content) {
126                Ok(config_warnings) => {
127                    warnings.extend(config_warnings);
128                }
129                Err(errors) => {
130                    validation_errors.extend(errors);
131                }
132            }
133            match UnifiedConfig::load_from_content(&content) {
134                Ok(cfg) => {
135                    global_content = Some(content);
136                    Some(cfg)
137                }
138                Err(e) => {
139                    validation_errors.push(ConfigValidationError::InvalidValue {
140                        file: path.clone(),
141                        key: "config".to_string(),
142                        message: format!("Failed to parse config: {e}"),
143                    });
144                    None
145                }
146            }
147        } else {
148            if config_path.is_some() {
149                warnings.push(format!("Global config file not found: {}", path.display()));
150            }
151            None
152        }
153    } else {
154        None
155    };
156
157    // Step 2: Load and validate local config (only when no explicit --config path).
158    let (local_unified, local_content) = if config_path.is_none() {
159        if let Some(local_path) = env.local_config_path() {
160            if env.file_exists(&local_path) {
161                let content = env.read_file(&local_path)?;
162                // Validate the config file
163                match validate_config_file(&local_path, &content) {
164                    Ok(config_warnings) => {
165                        warnings.extend(config_warnings);
166                    }
167                    Err(errors) => {
168                        validation_errors.extend(errors);
169                    }
170                }
171                match UnifiedConfig::load_from_content(&content) {
172                    Ok(cfg) => (Some(cfg), Some(content)),
173                    Err(e) => {
174                        validation_errors.push(ConfigValidationError::InvalidValue {
175                            file: local_path,
176                            key: "config".to_string(),
177                            message: format!("Failed to parse config: {e}"),
178                        });
179                        (None, None)
180                    }
181                }
182            } else {
183                (None, None)
184            }
185        } else {
186            (None, None)
187        }
188    } else {
189        (None, None)
190    };
191
192    // Fail-fast: if there are any validation errors, return them immediately
193    if !validation_errors.is_empty() {
194        return Err(ConfigLoadWithValidationError::ValidationErrors(
195            validation_errors,
196        ));
197    }
198
199    // Step 3: Merge configs (local overrides global)
200    let merged_unified = match (global_unified, local_unified, local_content) {
201        (Some(global), Some(local), Some(content)) => {
202            // Both exist: first normalize global agent_chain against built-in defaults
203            // using raw global TOML key presence, then merge local with raw local presence.
204            let normalized_global = global_content.as_ref().map_or_else(
205                || global.clone(),
206                |raw_global_content| {
207                    merge_global_with_built_in_agent_chain_defaults(&global, raw_global_content)
208                },
209            );
210
211            // Pass raw local TOML content for local presence tracking
212            Some(normalized_global.merge_with_content(&content, &local))
213        }
214        (Some(_global), Some(_local), None) => {
215            // SAFETY: This case is impossible in production. If local_unified is Some,
216            // then local_content must also be Some (they're set together at line 281).
217            // If we reach here, there's a bug in the config loading logic.
218            unreachable!(
219                "BUG: local_unified is Some but local_content is None. \
220                 This indicates a logic error in config loading - they should always be set together."
221            )
222        }
223        (Some(global), None, _) => {
224            // Only global exists: preserve explicit global values exactly.
225            // For agent_chain, resolve missing roles through built-in defaults using
226            // raw global key presence so omitted roles inherit defaults while explicit
227            // empty lists still override.
228            if let Some(content) = global_content.as_ref() {
229                Some(merge_global_with_built_in_agent_chain_defaults(
230                    &global, content,
231                ))
232            } else {
233                Some(global)
234            }
235        }
236        (None, Some(local), Some(content)) => {
237            // Only local exists: merge against `UnifiedConfig::default()` so missing keys
238            // still resolve through local > global > defaults semantics in the unified layer.
239            Some(UnifiedConfig::default().merge_with_content(&content, &local))
240        }
241        (None, Some(_local), None) => {
242            // SAFETY: This case is impossible in production. If local_unified is Some,
243            // then local_content must also be Some (they're set together at line 281).
244            unreachable!(
245                "BUG: local_unified is Some but local_content is None. \
246                 This indicates a logic error in config loading - they should always be set together."
247            )
248        }
249        (None, None, _) => {
250            // Neither exists: use defaults
251            None
252        }
253    };
254
255    if let Some(unified_cfg) = merged_unified.as_ref() {
256        if let Err(message) = unified_cfg.resolve_agent_drains_checked() {
257            let key = if message.contains("references unknown chain") {
258                message
259                    .split_whitespace()
260                    .next()
261                    .map_or_else(|| "agent_drains".to_string(), ToString::to_string)
262            } else if message.contains("cannot be combined") {
263                "agent_chain".to_string()
264            } else {
265                "agent_drains".to_string()
266            };
267
268            return Err(ConfigLoadWithValidationError::ValidationErrors(vec![
269                ConfigValidationError::InvalidValue {
270                    file: PathBuf::from("<merged-config>"),
271                    key,
272                    message,
273                },
274            ]));
275        }
276    }
277
278    // Step 4: Convert to Config
279    // Build cloud config from the injected env (not the real process env) so that
280    // callers using MemoryConfigEnvironment get a deterministic, isolated cloud config.
281    let cloud = super::types::CloudConfig::from_env_fn(|k| env.get_env_var(k));
282    let config = {
283        let mut cfg = merged_unified
284            .as_ref()
285            .map_or_else(default_config, |unified_cfg| {
286                config_from_unified(unified_cfg, &mut warnings)
287            });
288        cfg.cloud = cloud;
289        cfg
290    };
291
292    // Step 5: Apply environment variable overrides
293    let config = apply_env_overrides(config, &mut warnings, env);
294
295    // Step 6: Validate cloud configuration (fail-fast)
296    if let Err(e) = config.cloud.validate() {
297        return Err(ConfigLoadWithValidationError::ValidationErrors(vec![
298            ConfigValidationError::InvalidValue {
299                file: PathBuf::from("<environment>"),
300                key: "cloud".to_string(),
301                message: e,
302            },
303        ]));
304    }
305
306    Ok((config, merged_unified, warnings))
307}
308
309fn merge_global_with_built_in_agent_chain_defaults(
310    global: &UnifiedConfig,
311    global_content: &str,
312) -> UnifiedConfig {
313    let mut merged = global.clone();
314    let resolved = UnifiedConfig::default().merge_with_content(global_content, global);
315    merged.agent_chain = resolved.agent_chain;
316    merged
317}
318
319mod env_parsing;
320
321mod unified_config_exists;
322
323pub use unified_config_exists::{unified_config_exists, unified_config_exists_with_env};
324
325#[cfg(test)]
326mod tests;