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