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