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