1use super::parser::parse_env_bool;
18use super::path_resolver::ConfigEnvironment;
19use super::types::{Config, ReviewDepth, Verbosity};
20use super::unified::{unified_config_path, UnifiedConfig};
21use std::env;
22use std::path::PathBuf;
23
24pub fn load_config() -> (Config, Option<UnifiedConfig>, Vec<String>) {
34 load_config_from_path(None)
35}
36
37pub fn load_config_from_path(
54 config_path: Option<&std::path::Path>,
55) -> (Config, Option<UnifiedConfig>, Vec<String>) {
56 let mut warnings = Vec::new();
57
58 let unified = config_path.map_or_else(UnifiedConfig::load_default, |path| {
60 if path.exists() {
61 match UnifiedConfig::load_from_path(path) {
62 Ok(cfg) => Some(cfg),
63 Err(e) => {
64 warnings.push(format!(
65 "Failed to load config from {}: {}",
66 path.display(),
67 e
68 ));
69 None
70 }
71 }
72 } else {
73 warnings.push(format!("Config file not found: {}", path.display()));
74 None
75 }
76 });
77
78 let config = if let Some(ref unified_cfg) = unified {
80 config_from_unified(unified_cfg, &mut warnings)
81 } else {
82 check_legacy_configs(&mut warnings);
84 default_config()
85 };
86
87 let config = apply_env_overrides(config, &mut warnings);
89
90 (config, unified, warnings)
91}
92
93pub fn load_config_from_path_with_env(
108 config_path: Option<&std::path::Path>,
109 env: &dyn ConfigEnvironment,
110) -> (Config, Option<UnifiedConfig>, Vec<String>) {
111 let mut warnings = Vec::new();
112
113 let unified = config_path.map_or_else(
115 || UnifiedConfig::load_with_env(env),
116 |path| {
117 if env.file_exists(path) {
118 match UnifiedConfig::load_from_path_with_env(path, env) {
119 Ok(cfg) => Some(cfg),
120 Err(e) => {
121 warnings.push(format!(
122 "Failed to load config from {}: {}",
123 path.display(),
124 e
125 ));
126 None
127 }
128 }
129 } else {
130 warnings.push(format!("Config file not found: {}", path.display()));
131 None
132 }
133 },
134 );
135
136 let config = if let Some(ref unified_cfg) = unified {
138 config_from_unified(unified_cfg, &mut warnings)
139 } else {
140 check_legacy_configs_with_env(&mut warnings, env);
142 default_config()
143 };
144
145 let config = apply_env_overrides(config, &mut warnings);
147
148 (config, unified, warnings)
149}
150
151fn config_from_unified(unified: &UnifiedConfig, warnings: &mut Vec<String>) -> Config {
153 use super::types::{BehavioralFlags, FeatureFlags};
154
155 let general = &unified.general;
156
157 let review_depth = ReviewDepth::from_str(&general.review_depth).unwrap_or_else(|| {
158 warnings.push(format!(
159 "Invalid review_depth '{}' in config; falling back to 'standard'.",
160 general.review_depth
161 ));
162 ReviewDepth::default()
163 });
164
165 Config {
166 developer_agent: None, reviewer_agent: None, developer_cmd: None,
169 reviewer_cmd: None,
170 commit_cmd: None,
171 developer_model: None,
172 reviewer_model: None,
173 developer_provider: None,
174 reviewer_provider: None,
175 reviewer_json_parser: None, features: FeatureFlags {
177 checkpoint_enabled: general.workflow.checkpoint_enabled,
178 force_universal_prompt: general.execution.force_universal_prompt,
179 },
180 developer_iters: general.developer_iters,
181 reviewer_reviews: general.reviewer_reviews,
182 fast_check_cmd: None,
183 full_check_cmd: None,
184 behavior: BehavioralFlags {
185 interactive: general.behavior.interactive,
186 auto_detect_stack: general.behavior.auto_detect_stack,
187 strict_validation: general.behavior.strict_validation,
188 },
189 prompt_path: general
190 .prompt_path
191 .as_ref()
192 .map_or_else(|| PathBuf::from(".agent/last_prompt.txt"), PathBuf::from),
193 user_templates_dir: general.templates_dir.as_ref().map(PathBuf::from),
194 developer_context: general.developer_context,
195 reviewer_context: general.reviewer_context,
196 verbosity: Verbosity::from(general.verbosity),
197 commit_msg: "chore: apply PROMPT loop + review/fix/review".to_string(),
198 review_depth,
199 isolation_mode: general.execution.isolation_mode,
200 git_user_name: general.git_user_name.clone(),
201 git_user_email: general.git_user_email.clone(),
202 show_streaming_metrics: false, review_format_retries: 5, }
205}
206
207fn default_config() -> Config {
209 use super::types::{BehavioralFlags, FeatureFlags};
210
211 Config {
212 developer_agent: None,
213 reviewer_agent: None,
214 developer_cmd: None,
215 reviewer_cmd: None,
216 commit_cmd: None,
217 developer_model: None,
218 reviewer_model: None,
219 developer_provider: None,
220 reviewer_provider: None,
221 reviewer_json_parser: None,
222 features: FeatureFlags {
223 checkpoint_enabled: true,
224 force_universal_prompt: false,
225 },
226 developer_iters: 5,
227 reviewer_reviews: 2,
228 fast_check_cmd: None,
229 full_check_cmd: None,
230 behavior: BehavioralFlags {
231 interactive: true,
232 auto_detect_stack: true,
233 strict_validation: false,
234 },
235 prompt_path: PathBuf::from(".agent/last_prompt.txt"),
236 user_templates_dir: None,
237 developer_context: 1,
238 reviewer_context: 0,
239 verbosity: Verbosity::Verbose,
240 commit_msg: "chore: apply PROMPT loop + review/fix/review".to_string(),
241 review_depth: ReviewDepth::default(),
242 isolation_mode: true,
243 git_user_name: None,
244 git_user_email: None,
245 show_streaming_metrics: false,
246 review_format_retries: 5,
247 }
248}
249
250fn apply_env_overrides(mut config: Config, warnings: &mut Vec<String>) -> Config {
252 const MAX_ITERS: u32 = 50;
253 const MAX_REVIEWS: u32 = 10;
254 const MAX_CONTEXT: u8 = 2;
255 const MAX_FORMAT_RETRIES: u32 = 20;
256
257 apply_agent_selection_env(&mut config, warnings);
259 apply_command_env(&mut config, warnings);
260 apply_model_provider_env(&mut config);
261 apply_iteration_counts_env(&mut config, warnings, MAX_ITERS, MAX_REVIEWS);
262 apply_review_config_env(&mut config, warnings, MAX_FORMAT_RETRIES);
263 apply_boolean_flags_env(&mut config);
264 apply_verbosity_env(&mut config, warnings);
265 apply_review_depth_env(&mut config, warnings);
266 apply_paths_env(&mut config);
267 apply_context_levels_env(&mut config, warnings, MAX_CONTEXT);
268 apply_git_identity_env(&mut config);
269
270 config
271}
272
273fn apply_agent_selection_env(config: &mut Config, warnings: &mut Vec<String>) {
275 let developer_agent = env::var("RALPH_DEVELOPER_AGENT")
276 .or_else(|_| env::var("RALPH_DRIVER_AGENT"))
277 .ok();
278 if let Some(val) = developer_agent {
279 let trimmed = val.trim();
280 if trimmed.is_empty() {
281 warnings.push("Env var RALPH_DEVELOPER_AGENT is empty; ignoring.".to_string());
282 } else {
283 config.developer_agent = Some(trimmed.to_string());
284 }
285 }
286
287 if let Ok(val) = env::var("RALPH_REVIEWER_AGENT") {
288 let trimmed = val.trim();
289 if trimmed.is_empty() {
290 warnings.push("Env var RALPH_REVIEWER_AGENT is empty; ignoring.".to_string());
291 } else {
292 config.reviewer_agent = Some(trimmed.to_string());
293 }
294 }
295}
296
297fn apply_command_env(config: &mut Config, warnings: &mut Vec<String>) {
299 for (env_var, field) in [
300 ("RALPH_DEVELOPER_CMD", &mut config.developer_cmd),
301 ("RALPH_REVIEWER_CMD", &mut config.reviewer_cmd),
302 ("RALPH_COMMIT_CMD", &mut config.commit_cmd),
303 ] {
304 if let Ok(val) = env::var(env_var) {
305 let trimmed = val.trim();
306 if trimmed.is_empty() {
307 warnings.push(format!("Env var {env_var} is empty; ignoring."));
308 } else {
309 *field = Some(trimmed.to_string());
310 }
311 }
312 }
313
314 for (env_var, field) in [
315 ("FAST_CHECK_CMD", &mut config.fast_check_cmd),
316 ("FULL_CHECK_CMD", &mut config.full_check_cmd),
317 ] {
318 if let Ok(val) = env::var(env_var) {
319 if !val.is_empty() {
320 *field = Some(val);
321 }
322 }
323 }
324}
325
326fn apply_model_provider_env(config: &mut Config) {
328 for (env_var, field) in [
329 ("RALPH_DEVELOPER_MODEL", &mut config.developer_model),
330 ("RALPH_REVIEWER_MODEL", &mut config.reviewer_model),
331 ("RALPH_DEVELOPER_PROVIDER", &mut config.developer_provider),
332 ("RALPH_REVIEWER_PROVIDER", &mut config.reviewer_provider),
333 ] {
334 if let Ok(val) = env::var(env_var) {
335 *field = Some(val);
336 }
337 }
338
339 if let Ok(val) = env::var("RALPH_REVIEWER_JSON_PARSER") {
341 let trimmed = val.trim();
342 if !trimmed.is_empty() {
343 config.reviewer_json_parser = Some(trimmed.to_string());
344 }
345 }
346
347 if let Ok(val) = env::var("RALPH_REVIEWER_UNIVERSAL_PROMPT") {
349 if let Some(b) = parse_env_bool(&val) {
350 config.features.force_universal_prompt = b;
351 }
352 }
353}
354
355fn apply_iteration_counts_env(
357 config: &mut Config,
358 warnings: &mut Vec<String>,
359 max_iters: u32,
360 max_reviews: u32,
361) {
362 if let Some(n) = parse_env_u32("RALPH_DEVELOPER_ITERS", warnings, max_iters) {
363 config.developer_iters = n;
364 }
365 if let Some(n) = parse_env_u32("RALPH_REVIEWER_REVIEWS", warnings, max_reviews) {
366 config.reviewer_reviews = n;
367 }
368}
369
370fn apply_review_config_env(config: &mut Config, warnings: &mut Vec<String>, max_retries: u32) {
372 if let Some(n) = parse_env_u32("RALPH_REVIEW_FORMAT_RETRIES", warnings, max_retries) {
373 config.review_format_retries = n;
374 }
375}
376
377fn apply_boolean_flags_env(config: &mut Config) {
379 let vars: std::collections::HashMap<&str, bool> = [
381 "RALPH_INTERACTIVE",
382 "RALPH_AUTO_DETECT_STACK",
383 "RALPH_CHECKPOINT_ENABLED",
384 "RALPH_STRICT_VALIDATION",
385 "RALPH_ISOLATION_MODE",
386 ]
387 .iter()
388 .filter_map(|&name| env::var(name).ok().map(|v| (name, v)))
389 .filter_map(|(name, val)| parse_env_bool(&val).map(|b| (name, b)))
390 .collect();
391
392 for (name, value) in vars {
394 match name {
395 "RALPH_INTERACTIVE" => config.behavior.interactive = value,
396 "RALPH_AUTO_DETECT_STACK" => config.behavior.auto_detect_stack = value,
397 "RALPH_CHECKPOINT_ENABLED" => config.features.checkpoint_enabled = value,
398 "RALPH_STRICT_VALIDATION" => config.behavior.strict_validation = value,
399 "RALPH_ISOLATION_MODE" => config.isolation_mode = value,
400 _ => {}
401 }
402 }
403}
404
405fn apply_verbosity_env(config: &mut Config, warnings: &mut Vec<String>) {
407 if let Ok(val) = env::var("RALPH_VERBOSITY") {
408 let trimmed = val.trim();
409 if trimmed.is_empty() {
410 return;
411 }
412 match trimmed.parse::<u8>() {
413 Ok(n) => {
414 if n > 4 {
415 warnings.push(format!(
416 "Env var RALPH_VERBOSITY={n} is out of range; clamping to 4 (debug)."
417 ));
418 }
419 config.verbosity = Verbosity::from(n.min(4));
420 }
421 Err(_) => {
422 warnings.push(format!(
423 "Env var RALPH_VERBOSITY='{trimmed}' is not a valid number; ignoring."
424 ));
425 }
426 }
427 }
428}
429
430fn apply_review_depth_env(config: &mut Config, warnings: &mut Vec<String>) {
432 if let Ok(val) = env::var("RALPH_REVIEW_DEPTH") {
433 if let Some(depth) = ReviewDepth::from_str(&val) {
434 config.review_depth = depth;
435 } else if !val.trim().is_empty() {
436 warnings.push(format!(
437 "Env var RALPH_REVIEW_DEPTH='{}' is invalid; ignoring.",
438 val.trim()
439 ));
440 }
441 }
442}
443
444fn apply_paths_env(config: &mut Config) {
446 if let Ok(val) = env::var("RALPH_PROMPT_PATH") {
447 config.prompt_path = PathBuf::from(val);
448 }
449 if let Ok(val) = env::var("RALPH_TEMPLATES_DIR") {
450 let trimmed = val.trim();
451 if !trimmed.is_empty() {
452 config.user_templates_dir = Some(PathBuf::from(trimmed));
453 }
454 }
455}
456
457fn apply_context_levels_env(config: &mut Config, warnings: &mut Vec<String>, max_context: u8) {
459 if let Some(n) = parse_env_u8("RALPH_DEVELOPER_CONTEXT", warnings, max_context) {
460 config.developer_context = n;
461 }
462 if let Some(n) = parse_env_u8("RALPH_REVIEWER_CONTEXT", warnings, max_context) {
463 config.reviewer_context = n;
464 }
465}
466
467fn apply_git_identity_env(config: &mut Config) {
469 if let Ok(val) = env::var("RALPH_GIT_USER_NAME") {
470 let trimmed = val.trim();
471 if !trimmed.is_empty() {
472 config.git_user_name = Some(trimmed.to_string());
473 }
474 }
475 if let Ok(val) = env::var("RALPH_GIT_USER_EMAIL") {
476 let trimmed = val.trim();
477 if !trimmed.is_empty() {
478 config.git_user_email = Some(trimmed.to_string());
479 }
480 }
481}
482
483fn parse_env_u32(name: &str, warnings: &mut Vec<String>, max: u32) -> Option<u32> {
485 let raw = std::env::var(name).ok()?;
486 let trimmed = raw.trim();
487 if trimmed.is_empty() {
488 return None;
489 }
490 match trimmed.parse::<u32>() {
491 Ok(n) if n <= max => Some(n),
492 Ok(n) => {
493 warnings.push(format!(
494 "Env var {name}={n} is too large; clamping to {max}."
495 ));
496 Some(max)
497 }
498 Err(_) => {
499 warnings.push(format!(
500 "Env var {name}='{trimmed}' is not a valid number; ignoring."
501 ));
502 None
503 }
504 }
505}
506
507fn parse_env_u8(name: &str, warnings: &mut Vec<String>, max: u8) -> Option<u8> {
509 let raw = std::env::var(name).ok()?;
510 let trimmed = raw.trim();
511 if trimmed.is_empty() {
512 return None;
513 }
514 match trimmed.parse::<u8>() {
515 Ok(n) if n <= max => Some(n),
516 Ok(n) => {
517 warnings.push(format!(
518 "Env var {name}={n} is out of range; clamping to {max}."
519 ));
520 Some(max)
521 }
522 Err(_) => {
523 warnings.push(format!(
524 "Env var {name}='{trimmed}' is not a valid number; ignoring."
525 ));
526 None
527 }
528 }
529}
530
531fn check_legacy_configs(warnings: &mut Vec<String>) {
536 if let Some(config_dir) = dirs::config_dir() {
538 let old_global = config_dir.join("ralph").join("agents.toml");
539 if old_global.exists() {
540 warnings.push(format!(
541 "DEPRECATION: Found legacy config at {}. \
542 Please migrate to ~/.config/ralph-workflow.toml",
543 old_global.display()
544 ));
545 }
546 }
547
548 let project_config = PathBuf::from(".agent/agents.toml");
550 if project_config.exists() && unified_config_path().is_some() && !unified_config_exists() {
551 warnings.push(
552 "DEPRECATION: Found legacy per-repo config at .agent/agents.toml. \
553 Please migrate to ~/.config/ralph-workflow.toml."
554 .to_string(),
555 );
556 }
557}
558
559fn check_legacy_configs_with_env(warnings: &mut Vec<String>, env: &dyn ConfigEnvironment) {
563 let project_config = PathBuf::from(".agent/agents.toml");
569 if env.file_exists(&project_config)
570 && env.unified_config_path().is_some()
571 && !env
572 .unified_config_path()
573 .is_some_and(|p| env.file_exists(&p))
574 {
575 warnings.push(
576 "DEPRECATION: Found legacy per-repo config at .agent/agents.toml. \
577 Please migrate to ~/.config/ralph-workflow.toml."
578 .to_string(),
579 );
580 }
581}
582
583pub fn unified_config_exists() -> bool {
585 unified_config_path().is_some_and(|p| p.exists())
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::config::path_resolver::MemoryConfigEnvironment;
592 use serial_test::serial;
593 use std::path::Path;
594
595 #[test]
596 #[serial]
597 fn test_load_config_with_env_from_custom_path() {
598 let toml_str = r#"
599[general]
600verbosity = 3
601interactive = false
602developer_iters = 10
603review_depth = "standard"
604"#;
605 let env = MemoryConfigEnvironment::new()
606 .with_unified_config_path("/test/config/ralph-workflow.toml")
607 .with_file("/custom/config.toml", toml_str);
608
609 let (config, unified, warnings) =
610 load_config_from_path_with_env(Some(Path::new("/custom/config.toml")), &env);
611
612 assert!(warnings.is_empty(), "Unexpected warnings: {:?}", warnings);
613 assert!(unified.is_some());
614 assert_eq!(config.developer_iters, 10);
615 assert!(!config.behavior.interactive);
616 }
617
618 #[test]
619 #[serial]
620 fn test_load_config_with_env_missing_file() {
621 let env = MemoryConfigEnvironment::new()
622 .with_unified_config_path("/test/config/ralph-workflow.toml");
623
624 let (config, unified, warnings) =
625 load_config_from_path_with_env(Some(Path::new("/missing/config.toml")), &env);
626
627 assert!(unified.is_none());
628 assert_eq!(warnings.len(), 1);
629 assert!(warnings[0].contains("not found"));
630 assert_eq!(config.developer_iters, 5);
632 }
633
634 #[test]
635 #[serial]
636 fn test_load_config_with_env_from_default_path() {
637 let toml_str = r#"
638[general]
639verbosity = 4
640developer_iters = 8
641review_depth = "standard"
642"#;
643 let env = MemoryConfigEnvironment::new()
644 .with_unified_config_path("/test/config/ralph-workflow.toml")
645 .with_file("/test/config/ralph-workflow.toml", toml_str);
646
647 let (config, unified, warnings) = load_config_from_path_with_env(None, &env);
648
649 assert!(warnings.is_empty(), "Unexpected warnings: {:?}", warnings);
650 assert!(unified.is_some());
651 assert_eq!(config.developer_iters, 8);
652 assert_eq!(config.verbosity, Verbosity::Debug);
653 }
654
655 #[test]
656 fn test_default_config() {
657 let config = default_config();
658 assert!(config.developer_agent.is_none());
659 assert!(config.reviewer_agent.is_none());
660 assert_eq!(config.developer_iters, 5);
661 assert_eq!(config.reviewer_reviews, 2);
662 assert!(config.behavior.interactive);
663 assert!(config.isolation_mode);
664 assert_eq!(config.verbosity, Verbosity::Verbose);
665 }
666
667 #[test]
668 #[serial]
669 fn test_apply_env_overrides() {
670 env::set_var("RALPH_DEVELOPER_ITERS", "10");
672 env::set_var("RALPH_ISOLATION_MODE", "false");
673
674 let mut warnings = Vec::new();
675 let config = apply_env_overrides(default_config(), &mut warnings);
676 assert_eq!(config.developer_iters, 10);
677 assert!(!config.isolation_mode);
678 assert!(warnings.is_empty());
679
680 env::remove_var("RALPH_DEVELOPER_ITERS");
682 env::remove_var("RALPH_ISOLATION_MODE");
683 }
684
685 #[test]
686 #[serial]
687 fn test_unified_config_exists_respects_xdg_config_home() {
688 let dir = tempfile::tempdir().unwrap();
689 env::set_var("XDG_CONFIG_HOME", dir.path());
690
691 let path = unified_config_path().unwrap();
692 if path.exists() {
693 std::fs::remove_file(&path).unwrap();
694 }
695 assert!(!unified_config_exists());
696
697 std::fs::write(&path, "").unwrap();
698 assert!(unified_config_exists());
699
700 env::remove_var("XDG_CONFIG_HOME");
701 }
702
703 #[test]
704 #[serial]
705 fn test_load_config_returns_defaults_without_file() {
706 env::remove_var("RALPH_DEVELOPER_AGENT");
708 env::remove_var("RALPH_REVIEWER_AGENT");
709 env::remove_var("RALPH_DEVELOPER_ITERS");
710 env::remove_var("RALPH_VERBOSITY");
711
712 let (config, _unified, _warnings) = load_config();
713 assert_eq!(config.developer_iters, 5);
714 assert_eq!(config.verbosity, Verbosity::Verbose);
715 }
716}