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 let max_dev_continuations = if general.max_dev_continuations >= 1 {
157 general.max_dev_continuations
158 } else {
159 warnings.push(
160 "Invalid max_dev_continuations in config; must be a positive integer (>= 1). Falling back to default."
161 .to_string(),
162 );
163 2
164 };
165
166 let review_depth = ReviewDepth::from_str(&general.review_depth).unwrap_or_else(|| {
167 warnings.push(format!(
168 "Invalid review_depth '{}' in config; falling back to 'standard'.",
169 general.review_depth
170 ));
171 ReviewDepth::default()
172 });
173
174 Config {
175 developer_agent: None, reviewer_agent: None, developer_cmd: None,
178 reviewer_cmd: None,
179 commit_cmd: None,
180 developer_model: None,
181 reviewer_model: None,
182 developer_provider: None,
183 reviewer_provider: None,
184 reviewer_json_parser: None, features: FeatureFlags {
186 checkpoint_enabled: general.workflow.checkpoint_enabled,
187 force_universal_prompt: general.execution.force_universal_prompt,
188 },
189 developer_iters: general.developer_iters,
190 reviewer_reviews: general.reviewer_reviews,
191 fast_check_cmd: None,
192 full_check_cmd: None,
193 behavior: BehavioralFlags {
194 interactive: general.behavior.interactive,
195 auto_detect_stack: general.behavior.auto_detect_stack,
196 strict_validation: general.behavior.strict_validation,
197 },
198 prompt_path: general
199 .prompt_path
200 .as_ref()
201 .map_or_else(|| PathBuf::from(".agent/last_prompt.txt"), PathBuf::from),
202 user_templates_dir: general.templates_dir.as_ref().map(PathBuf::from),
203 developer_context: general.developer_context,
204 reviewer_context: general.reviewer_context,
205 verbosity: Verbosity::from(general.verbosity),
206 review_depth,
207 isolation_mode: general.execution.isolation_mode,
208 git_user_name: general.git_user_name.clone(),
209 git_user_email: general.git_user_email.clone(),
210 show_streaming_metrics: false, review_format_retries: 5, max_dev_continuations: Some(max_dev_continuations),
213 }
214}
215
216fn default_config() -> Config {
218 use super::types::{BehavioralFlags, FeatureFlags};
219
220 Config {
221 developer_agent: None,
222 reviewer_agent: None,
223 developer_cmd: None,
224 reviewer_cmd: None,
225 commit_cmd: None,
226 developer_model: None,
227 reviewer_model: None,
228 developer_provider: None,
229 reviewer_provider: None,
230 reviewer_json_parser: None,
231 features: FeatureFlags {
232 checkpoint_enabled: true,
233 force_universal_prompt: false,
234 },
235 developer_iters: 5,
236 reviewer_reviews: 2,
237 fast_check_cmd: None,
238 full_check_cmd: None,
239 behavior: BehavioralFlags {
240 interactive: true,
241 auto_detect_stack: true,
242 strict_validation: false,
243 },
244 prompt_path: PathBuf::from(".agent/last_prompt.txt"),
245 user_templates_dir: None,
246 developer_context: 1,
247 reviewer_context: 0,
248 verbosity: Verbosity::Verbose,
249 review_depth: ReviewDepth::default(),
250 isolation_mode: true,
251 git_user_name: None,
252 git_user_email: None,
253 show_streaming_metrics: false,
254 review_format_retries: 5,
255 max_dev_continuations: Some(2), }
257}
258
259fn apply_env_overrides(mut config: Config, warnings: &mut Vec<String>) -> Config {
261 const MAX_ITERS: u32 = 50;
262 const MAX_REVIEWS: u32 = 10;
263 const MAX_CONTEXT: u8 = 2;
264 const MAX_FORMAT_RETRIES: u32 = 20;
265
266 apply_agent_selection_env(&mut config, warnings);
268 apply_command_env(&mut config, warnings);
269 apply_model_provider_env(&mut config);
270 apply_iteration_counts_env(&mut config, warnings, MAX_ITERS, MAX_REVIEWS);
271 apply_review_config_env(&mut config, warnings, MAX_FORMAT_RETRIES);
272 apply_boolean_flags_env(&mut config);
273 apply_verbosity_env(&mut config, warnings);
274 apply_review_depth_env(&mut config, warnings);
275 apply_paths_env(&mut config);
276 apply_context_levels_env(&mut config, warnings, MAX_CONTEXT);
277 apply_git_identity_env(&mut config);
278
279 config
280}
281
282fn apply_agent_selection_env(config: &mut Config, warnings: &mut Vec<String>) {
284 let developer_agent = env::var("RALPH_DEVELOPER_AGENT")
285 .or_else(|_| env::var("RALPH_DRIVER_AGENT"))
286 .ok();
287 if let Some(val) = developer_agent {
288 let trimmed = val.trim();
289 if trimmed.is_empty() {
290 warnings.push("Env var RALPH_DEVELOPER_AGENT is empty; ignoring.".to_string());
291 } else {
292 config.developer_agent = Some(trimmed.to_string());
293 }
294 }
295
296 if let Ok(val) = env::var("RALPH_REVIEWER_AGENT") {
297 let trimmed = val.trim();
298 if trimmed.is_empty() {
299 warnings.push("Env var RALPH_REVIEWER_AGENT is empty; ignoring.".to_string());
300 } else {
301 config.reviewer_agent = Some(trimmed.to_string());
302 }
303 }
304}
305
306fn apply_command_env(config: &mut Config, warnings: &mut Vec<String>) {
308 for (env_var, field) in [
309 ("RALPH_DEVELOPER_CMD", &mut config.developer_cmd),
310 ("RALPH_REVIEWER_CMD", &mut config.reviewer_cmd),
311 ("RALPH_COMMIT_CMD", &mut config.commit_cmd),
312 ] {
313 if let Ok(val) = env::var(env_var) {
314 let trimmed = val.trim();
315 if trimmed.is_empty() {
316 warnings.push(format!("Env var {env_var} is empty; ignoring."));
317 } else {
318 *field = Some(trimmed.to_string());
319 }
320 }
321 }
322
323 for (env_var, field) in [
324 ("FAST_CHECK_CMD", &mut config.fast_check_cmd),
325 ("FULL_CHECK_CMD", &mut config.full_check_cmd),
326 ] {
327 if let Ok(val) = env::var(env_var) {
328 if !val.is_empty() {
329 *field = Some(val);
330 }
331 }
332 }
333}
334
335fn apply_model_provider_env(config: &mut Config) {
337 for (env_var, field) in [
338 ("RALPH_DEVELOPER_MODEL", &mut config.developer_model),
339 ("RALPH_REVIEWER_MODEL", &mut config.reviewer_model),
340 ("RALPH_DEVELOPER_PROVIDER", &mut config.developer_provider),
341 ("RALPH_REVIEWER_PROVIDER", &mut config.reviewer_provider),
342 ] {
343 if let Ok(val) = env::var(env_var) {
344 *field = Some(val);
345 }
346 }
347
348 if let Ok(val) = env::var("RALPH_REVIEWER_JSON_PARSER") {
350 let trimmed = val.trim();
351 if !trimmed.is_empty() {
352 config.reviewer_json_parser = Some(trimmed.to_string());
353 }
354 }
355
356 if let Ok(val) = env::var("RALPH_REVIEWER_UNIVERSAL_PROMPT") {
358 if let Some(b) = parse_env_bool(&val) {
359 config.features.force_universal_prompt = b;
360 }
361 }
362}
363
364fn apply_iteration_counts_env(
366 config: &mut Config,
367 warnings: &mut Vec<String>,
368 max_iters: u32,
369 max_reviews: u32,
370) {
371 if let Some(n) = parse_env_u32("RALPH_DEVELOPER_ITERS", warnings, max_iters) {
372 config.developer_iters = n;
373 }
374 if let Some(n) = parse_env_u32("RALPH_REVIEWER_REVIEWS", warnings, max_reviews) {
375 config.reviewer_reviews = n;
376 }
377}
378
379fn apply_review_config_env(config: &mut Config, warnings: &mut Vec<String>, max_retries: u32) {
381 if let Some(n) = parse_env_u32("RALPH_REVIEW_FORMAT_RETRIES", warnings, max_retries) {
382 config.review_format_retries = n;
383 }
384}
385
386fn apply_boolean_flags_env(config: &mut Config) {
388 let vars: std::collections::HashMap<&str, bool> = [
390 "RALPH_INTERACTIVE",
391 "RALPH_AUTO_DETECT_STACK",
392 "RALPH_CHECKPOINT_ENABLED",
393 "RALPH_STRICT_VALIDATION",
394 "RALPH_ISOLATION_MODE",
395 ]
396 .iter()
397 .filter_map(|&name| env::var(name).ok().map(|v| (name, v)))
398 .filter_map(|(name, val)| parse_env_bool(&val).map(|b| (name, b)))
399 .collect();
400
401 for (name, value) in vars {
403 match name {
404 "RALPH_INTERACTIVE" => config.behavior.interactive = value,
405 "RALPH_AUTO_DETECT_STACK" => config.behavior.auto_detect_stack = value,
406 "RALPH_CHECKPOINT_ENABLED" => config.features.checkpoint_enabled = value,
407 "RALPH_STRICT_VALIDATION" => config.behavior.strict_validation = value,
408 "RALPH_ISOLATION_MODE" => config.isolation_mode = value,
409 _ => {}
410 }
411 }
412}
413
414fn apply_verbosity_env(config: &mut Config, warnings: &mut Vec<String>) {
416 if let Ok(val) = env::var("RALPH_VERBOSITY") {
417 let trimmed = val.trim();
418 if trimmed.is_empty() {
419 return;
420 }
421 match trimmed.parse::<u8>() {
422 Ok(n) => {
423 if n > 4 {
424 warnings.push(format!(
425 "Env var RALPH_VERBOSITY={n} is out of range; clamping to 4 (debug)."
426 ));
427 }
428 config.verbosity = Verbosity::from(n.min(4));
429 }
430 Err(_) => {
431 warnings.push(format!(
432 "Env var RALPH_VERBOSITY='{trimmed}' is not a valid number; ignoring."
433 ));
434 }
435 }
436 }
437}
438
439fn apply_review_depth_env(config: &mut Config, warnings: &mut Vec<String>) {
441 if let Ok(val) = env::var("RALPH_REVIEW_DEPTH") {
442 if let Some(depth) = ReviewDepth::from_str(&val) {
443 config.review_depth = depth;
444 } else if !val.trim().is_empty() {
445 warnings.push(format!(
446 "Env var RALPH_REVIEW_DEPTH='{}' is invalid; ignoring.",
447 val.trim()
448 ));
449 }
450 }
451}
452
453fn apply_paths_env(config: &mut Config) {
455 if let Ok(val) = env::var("RALPH_PROMPT_PATH") {
456 config.prompt_path = PathBuf::from(val);
457 }
458 if let Ok(val) = env::var("RALPH_TEMPLATES_DIR") {
459 let trimmed = val.trim();
460 if !trimmed.is_empty() {
461 config.user_templates_dir = Some(PathBuf::from(trimmed));
462 }
463 }
464}
465
466fn apply_context_levels_env(config: &mut Config, warnings: &mut Vec<String>, max_context: u8) {
468 if let Some(n) = parse_env_u8("RALPH_DEVELOPER_CONTEXT", warnings, max_context) {
469 config.developer_context = n;
470 }
471 if let Some(n) = parse_env_u8("RALPH_REVIEWER_CONTEXT", warnings, max_context) {
472 config.reviewer_context = n;
473 }
474}
475
476fn apply_git_identity_env(config: &mut Config) {
478 if let Ok(val) = env::var("RALPH_GIT_USER_NAME") {
479 let trimmed = val.trim();
480 if !trimmed.is_empty() {
481 config.git_user_name = Some(trimmed.to_string());
482 }
483 }
484 if let Ok(val) = env::var("RALPH_GIT_USER_EMAIL") {
485 let trimmed = val.trim();
486 if !trimmed.is_empty() {
487 config.git_user_email = Some(trimmed.to_string());
488 }
489 }
490}
491
492fn parse_env_u32(name: &str, warnings: &mut Vec<String>, max: u32) -> Option<u32> {
494 let raw = std::env::var(name).ok()?;
495 let trimmed = raw.trim();
496 if trimmed.is_empty() {
497 return None;
498 }
499 match trimmed.parse::<u32>() {
500 Ok(n) if n <= max => Some(n),
501 Ok(n) => {
502 warnings.push(format!(
503 "Env var {name}={n} is too large; clamping to {max}."
504 ));
505 Some(max)
506 }
507 Err(_) => {
508 warnings.push(format!(
509 "Env var {name}='{trimmed}' is not a valid number; ignoring."
510 ));
511 None
512 }
513 }
514}
515
516fn parse_env_u8(name: &str, warnings: &mut Vec<String>, max: u8) -> Option<u8> {
518 let raw = std::env::var(name).ok()?;
519 let trimmed = raw.trim();
520 if trimmed.is_empty() {
521 return None;
522 }
523 match trimmed.parse::<u8>() {
524 Ok(n) if n <= max => Some(n),
525 Ok(n) => {
526 warnings.push(format!(
527 "Env var {name}={n} is out of range; clamping to {max}."
528 ));
529 Some(max)
530 }
531 Err(_) => {
532 warnings.push(format!(
533 "Env var {name}='{trimmed}' is not a valid number; ignoring."
534 ));
535 None
536 }
537 }
538}
539
540fn check_legacy_configs(warnings: &mut Vec<String>) {
545 if let Some(config_dir) = dirs::config_dir() {
547 let old_global = config_dir.join("ralph").join("agents.toml");
548 if old_global.exists() {
549 warnings.push(format!(
550 "DEPRECATION: Found legacy config at {}. \
551 Please migrate to ~/.config/ralph-workflow.toml",
552 old_global.display()
553 ));
554 }
555 }
556
557 let project_config = PathBuf::from(".agent/agents.toml");
559 if project_config.exists() && unified_config_path().is_some() && !unified_config_exists() {
560 warnings.push(
561 "DEPRECATION: Found legacy per-repo config at .agent/agents.toml. \
562 Please migrate to ~/.config/ralph-workflow.toml."
563 .to_string(),
564 );
565 }
566}
567
568fn check_legacy_configs_with_env(warnings: &mut Vec<String>, env: &dyn ConfigEnvironment) {
572 let project_config = PathBuf::from(".agent/agents.toml");
578 if env.file_exists(&project_config)
579 && env.unified_config_path().is_some()
580 && !env
581 .unified_config_path()
582 .is_some_and(|p| env.file_exists(&p))
583 {
584 warnings.push(
585 "DEPRECATION: Found legacy per-repo config at .agent/agents.toml. \
586 Please migrate to ~/.config/ralph-workflow.toml."
587 .to_string(),
588 );
589 }
590}
591
592pub fn unified_config_exists() -> bool {
594 unified_config_path().is_some_and(|p| p.exists())
595}
596
597pub fn unified_config_exists_with_env(env: &dyn ConfigEnvironment) -> bool {
610 env.unified_config_path()
611 .is_some_and(|p| env.file_exists(&p))
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::config::path_resolver::MemoryConfigEnvironment;
618 use serial_test::serial;
619 use std::path::Path;
620
621 #[test]
622 #[serial]
623 fn test_load_config_with_env_from_custom_path() {
624 let toml_str = r#"
625[general]
626verbosity = 3
627interactive = false
628developer_iters = 10
629review_depth = "standard"
630"#;
631 let env = MemoryConfigEnvironment::new()
632 .with_unified_config_path("/test/config/ralph-workflow.toml")
633 .with_file("/custom/config.toml", toml_str);
634
635 let (config, unified, warnings) =
636 load_config_from_path_with_env(Some(Path::new("/custom/config.toml")), &env);
637
638 assert!(warnings.is_empty(), "Unexpected warnings: {:?}", warnings);
639 assert!(unified.is_some());
640 assert_eq!(config.developer_iters, 10);
641 assert!(!config.behavior.interactive);
642 }
643
644 #[test]
645 #[serial]
646 fn test_load_config_with_env_missing_file() {
647 let env = MemoryConfigEnvironment::new()
648 .with_unified_config_path("/test/config/ralph-workflow.toml");
649
650 let (config, unified, warnings) =
651 load_config_from_path_with_env(Some(Path::new("/missing/config.toml")), &env);
652
653 assert!(unified.is_none());
654 assert_eq!(warnings.len(), 1);
655 assert!(warnings[0].contains("not found"));
656 assert_eq!(config.developer_iters, 5);
658 }
659
660 #[test]
661 #[serial]
662 fn test_load_config_with_env_from_default_path() {
663 let toml_str = r#"
664[general]
665verbosity = 4
666developer_iters = 8
667review_depth = "standard"
668"#;
669 let env = MemoryConfigEnvironment::new()
670 .with_unified_config_path("/test/config/ralph-workflow.toml")
671 .with_file("/test/config/ralph-workflow.toml", toml_str);
672
673 let (config, unified, warnings) = load_config_from_path_with_env(None, &env);
674
675 assert!(warnings.is_empty(), "Unexpected warnings: {:?}", warnings);
676 assert!(unified.is_some());
677 assert_eq!(config.developer_iters, 8);
678 assert_eq!(config.verbosity, Verbosity::Debug);
679 }
680
681 #[test]
682 fn test_default_config() {
683 let config = default_config();
684 assert!(config.developer_agent.is_none());
685 assert!(config.reviewer_agent.is_none());
686 assert_eq!(config.developer_iters, 5);
687 assert_eq!(config.reviewer_reviews, 2);
688 assert!(config.behavior.interactive);
689 assert!(config.isolation_mode);
690 assert_eq!(config.verbosity, Verbosity::Verbose);
691 }
692
693 #[test]
694 #[serial]
695 fn test_apply_env_overrides() {
696 env::set_var("RALPH_DEVELOPER_ITERS", "10");
698 env::set_var("RALPH_ISOLATION_MODE", "false");
699
700 let mut warnings = Vec::new();
701 let config = apply_env_overrides(default_config(), &mut warnings);
702 assert_eq!(config.developer_iters, 10);
703 assert!(!config.isolation_mode);
704 assert!(warnings.is_empty());
705
706 env::remove_var("RALPH_DEVELOPER_ITERS");
708 env::remove_var("RALPH_ISOLATION_MODE");
709 }
710
711 #[test]
712 fn test_unified_config_exists_with_env_returns_false_when_no_path() {
713 let env = MemoryConfigEnvironment::new();
715 assert!(!unified_config_exists_with_env(&env));
716 }
717
718 #[test]
719 fn test_unified_config_exists_with_env_returns_false_when_file_missing() {
720 let env = MemoryConfigEnvironment::new()
722 .with_unified_config_path("/test/config/ralph-workflow.toml");
723 assert!(!unified_config_exists_with_env(&env));
724 }
725
726 #[test]
727 fn test_unified_config_exists_with_env_returns_true_when_file_exists() {
728 let env = MemoryConfigEnvironment::new()
730 .with_unified_config_path("/test/config/ralph-workflow.toml")
731 .with_file("/test/config/ralph-workflow.toml", "[general]");
732 assert!(unified_config_exists_with_env(&env));
733 }
734
735 #[test]
736 #[serial]
737 fn test_max_dev_continuations_zero_falls_back_to_default() {
738 let toml_str = r#"
739[general]
740verbosity = 4
741developer_iters = 8
742review_depth = "standard"
743max_dev_continuations = 0
744"#;
745
746 let env = MemoryConfigEnvironment::new()
747 .with_unified_config_path("/test/config/ralph-workflow.toml")
748 .with_file("/test/config/ralph-workflow.toml", toml_str);
749
750 let (config, _unified, warnings) = load_config_from_path_with_env(None, &env);
751
752 assert_eq!(config.max_dev_continuations, Some(2));
753 assert!(
754 warnings
755 .iter()
756 .any(|w| w.contains("max_dev_continuations") && w.contains(">= 1")),
757 "Expected warning about invalid max_dev_continuations, got: {:?}",
758 warnings
759 );
760 }
761
762 #[test]
763 #[serial]
764 fn test_load_config_returns_defaults_without_file() {
765 env::remove_var("RALPH_DEVELOPER_AGENT");
767 env::remove_var("RALPH_REVIEWER_AGENT");
768 env::remove_var("RALPH_DEVELOPER_ITERS");
769 env::remove_var("RALPH_VERBOSITY");
770
771 let (config, _unified, _warnings) = load_config();
772 assert_eq!(config.developer_iters, 5);
773 assert_eq!(config.verbosity, Verbosity::Verbose);
774 }
775}