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;