1use super::parser::parse_env_bool;
25use super::path_resolver::ConfigEnvironment;
26use super::types::{Config, ReviewDepth, Verbosity};
27use super::unified::UnifiedConfig;
28use super::validation::{validate_config_file, ConfigValidationError};
29use std::env;
30use std::path::PathBuf;
31
32#[derive(Debug, thiserror::Error)]
34pub enum ConfigLoadWithValidationError {
35 #[error("Configuration validation failed")]
36 ValidationErrors(Vec<ConfigValidationError>),
37 #[error("Failed to read config file: {0}")]
38 Io(#[from] std::io::Error),
39}
40
41impl ConfigLoadWithValidationError {
42 pub fn format_errors(&self) -> String {
44 match self {
45 ConfigLoadWithValidationError::ValidationErrors(errors) => {
46 let mut output =
47 String::from("Error: Configuration invalid - cannot start Ralph\n\n");
48
49 let mut global_errors: Vec<&ConfigValidationError> = Vec::new();
51 let mut local_errors: Vec<&ConfigValidationError> = Vec::new();
52 let mut other_errors: Vec<&ConfigValidationError> = Vec::new();
53
54 for error in errors {
55 let path_str = error.file().to_string_lossy();
56 if path_str.contains(".config") {
57 global_errors.push(error);
58 } else if path_str.contains(".agent") {
59 local_errors.push(error);
60 } else {
61 other_errors.push(error);
62 }
63 }
64
65 if !global_errors.is_empty() {
66 output.push_str("~/.config/ralph-workflow.toml:\n");
67 for error in global_errors {
68 output.push_str(&format!(" {}\n", format_single_error(error)));
69 }
70 output.push('\n');
71 }
72
73 if !local_errors.is_empty() {
74 output.push_str(".agent/ralph-workflow.toml:\n");
75 for error in local_errors {
76 output.push_str(&format!(" {}\n", format_single_error(error)));
77 }
78 output.push('\n');
79 }
80
81 if !other_errors.is_empty() {
82 for error in other_errors {
83 output.push_str(&format!(
84 "{}:\n {}\n",
85 error.file().display(),
86 format_single_error(error)
87 ));
88 }
89 output.push('\n');
90 }
91
92 output.push_str(
93 "Fix these errors and try again, or run `ralph --check-config` for details.",
94 );
95 output
96 }
97 ConfigLoadWithValidationError::Io(e) => e.to_string(),
98 }
99 }
100}
101
102fn format_single_error(error: &ConfigValidationError) -> String {
104 match error {
105 ConfigValidationError::TomlSyntax { error, .. } => {
106 format!("TOML syntax error: {}", error)
107 }
108 ConfigValidationError::UnknownKey {
109 key, suggestion, ..
110 } => {
111 if let Some(s) = suggestion {
112 format!("Unknown key '{}'. Did you mean '{}'?", key, s)
113 } else {
114 format!("Unknown key '{}'", key)
115 }
116 }
117 ConfigValidationError::InvalidValue { key, message, .. } => {
118 format!("Invalid value for '{}': {}", key, message)
119 }
120 }
121}
122
123impl ConfigValidationError {
124 pub fn file(&self) -> &std::path::Path {
126 match self {
127 ConfigValidationError::TomlSyntax { file, .. } => file,
128 ConfigValidationError::InvalidValue { file, .. } => file,
129 ConfigValidationError::UnknownKey { file, .. } => file,
130 }
131 }
132}
133
134pub fn load_config() -> (Config, Option<UnifiedConfig>, Vec<String>) {
144 load_config_from_path(None)
145}
146
147pub fn load_config_from_path(
165 config_path: Option<&std::path::Path>,
166) -> (Config, Option<UnifiedConfig>, Vec<String>) {
167 match load_config_from_path_with_env(config_path, &super::path_resolver::RealConfigEnvironment)
168 {
169 Ok(result) => result,
170 Err(e) => {
171 eprintln!("{}", e.format_errors());
172 panic!("Configuration validation failed - cannot continue");
173 }
174 }
175}
176
177pub fn load_config_from_path_with_env(
198 config_path: Option<&std::path::Path>,
199 env: &dyn ConfigEnvironment,
200) -> Result<(Config, Option<UnifiedConfig>, Vec<String>), ConfigLoadWithValidationError> {
201 let mut warnings = Vec::new();
202 let mut validation_errors = Vec::new();
203
204 let global_unified = if let Some(path) = config_path {
206 if env.file_exists(path) {
208 let content = env.read_file(path)?;
209 match validate_config_file(path, &content) {
211 Ok(config_warnings) => {
212 warnings.extend(config_warnings);
213 }
214 Err(errors) => {
215 validation_errors.extend(errors);
216 }
217 }
218 match UnifiedConfig::load_from_content(&content) {
219 Ok(cfg) => Some(cfg),
220 Err(e) => {
221 validation_errors.push(ConfigValidationError::InvalidValue {
222 file: path.to_path_buf(),
223 key: "config".to_string(),
224 message: format!("Failed to parse config: {}", e),
225 });
226 None
227 }
228 }
229 } else {
230 warnings.push(format!("Global config file not found: {}", path.display()));
231 None
232 }
233 } else {
234 if let Some(global_path) = env.unified_config_path() {
236 if env.file_exists(&global_path) {
237 let content = env.read_file(&global_path)?;
238 match validate_config_file(&global_path, &content) {
240 Ok(config_warnings) => {
241 warnings.extend(config_warnings);
242 }
243 Err(errors) => {
244 validation_errors.extend(errors);
245 }
246 }
247 match UnifiedConfig::load_from_content(&content) {
248 Ok(cfg) => Some(cfg),
249 Err(e) => {
250 validation_errors.push(ConfigValidationError::InvalidValue {
251 file: global_path.to_path_buf(),
252 key: "config".to_string(),
253 message: format!("Failed to parse config: {}", e),
254 });
255 None
256 }
257 }
258 } else {
259 None
261 }
262 } else {
263 None
264 }
265 };
266
267 let (local_unified, local_content) = if let Some(local_path) = env.local_config_path() {
269 if env.file_exists(&local_path) {
270 let content = env.read_file(&local_path)?;
271 match validate_config_file(&local_path, &content) {
273 Ok(config_warnings) => {
274 warnings.extend(config_warnings);
275 }
276 Err(errors) => {
277 validation_errors.extend(errors);
278 }
279 }
280 match UnifiedConfig::load_from_content(&content) {
281 Ok(cfg) => (Some(cfg), Some(content)),
282 Err(e) => {
283 validation_errors.push(ConfigValidationError::InvalidValue {
284 file: local_path.to_path_buf(),
285 key: "config".to_string(),
286 message: format!("Failed to parse config: {}", e),
287 });
288 (None, None)
289 }
290 }
291 } else {
292 (None, None)
293 }
294 } else {
295 (None, None)
296 };
297
298 if !validation_errors.is_empty() {
300 return Err(ConfigLoadWithValidationError::ValidationErrors(
301 validation_errors,
302 ));
303 }
304
305 let merged_unified = match (global_unified, local_unified, local_content) {
307 (Some(global), Some(local), Some(content)) => {
308 Some(global.merge_with_content(&content, &local))
311 }
312 (Some(_global), Some(_local), None) => {
313 unreachable!(
317 "BUG: local_unified is Some but local_content is None. \
318 This indicates a logic error in config loading - they should always be set together."
319 )
320 }
321 (Some(global), None, _) => {
322 Some(global)
324 }
325 (None, Some(local), _) => {
326 Some(local)
328 }
329 (None, None, _) => {
330 None
332 }
333 };
334
335 let config = if let Some(ref unified_cfg) = merged_unified {
337 config_from_unified(unified_cfg, &mut warnings)
338 } else {
339 default_config()
340 };
341
342 let config = apply_env_overrides(config, &mut warnings);
344
345 Ok((config, merged_unified, warnings))
346}
347
348fn config_from_unified(unified: &UnifiedConfig, warnings: &mut Vec<String>) -> Config {
350 use super::types::{BehavioralFlags, FeatureFlags};
351
352 let general = &unified.general;
353 let max_dev_continuations = if general.max_dev_continuations >= 1 {
354 general.max_dev_continuations
355 } else {
356 warnings.push(
357 "Invalid max_dev_continuations in config; must be a positive integer (>= 1). Falling back to default."
358 .to_string(),
359 );
360 2
361 };
362 let max_xsd_retries = general.max_xsd_retries;
365 let max_same_agent_retries = general.max_same_agent_retries;
368
369 let review_depth = ReviewDepth::from_str(&general.review_depth).unwrap_or_else(|| {
370 warnings.push(format!(
371 "Invalid review_depth '{}' in config; falling back to 'standard'.",
372 general.review_depth
373 ));
374 ReviewDepth::default()
375 });
376
377 Config {
378 developer_agent: None, reviewer_agent: None, developer_cmd: None,
381 reviewer_cmd: None,
382 commit_cmd: None,
383 developer_model: None,
384 reviewer_model: None,
385 developer_provider: None,
386 reviewer_provider: None,
387 reviewer_json_parser: None, features: FeatureFlags {
389 checkpoint_enabled: general.workflow.checkpoint_enabled,
390 force_universal_prompt: general.execution.force_universal_prompt,
391 },
392 developer_iters: general.developer_iters,
393 reviewer_reviews: general.reviewer_reviews,
394 fast_check_cmd: None,
395 full_check_cmd: None,
396 behavior: BehavioralFlags {
397 interactive: general.behavior.interactive,
398 auto_detect_stack: general.behavior.auto_detect_stack,
399 strict_validation: general.behavior.strict_validation,
400 },
401 prompt_path: general
402 .prompt_path
403 .as_ref()
404 .map_or_else(|| PathBuf::from(".agent/last_prompt.txt"), PathBuf::from),
405 user_templates_dir: general.templates_dir.as_ref().map(PathBuf::from),
406 developer_context: general.developer_context,
407 reviewer_context: general.reviewer_context,
408 verbosity: Verbosity::from(general.verbosity),
409 review_depth,
410 isolation_mode: general.execution.isolation_mode,
411 git_user_name: general.git_user_name.clone(),
412 git_user_email: general.git_user_email.clone(),
413 show_streaming_metrics: false, review_format_retries: 5, max_dev_continuations: Some(max_dev_continuations),
416 max_xsd_retries: Some(max_xsd_retries),
417 max_same_agent_retries: Some(max_same_agent_retries),
418 }
419}
420
421fn default_config() -> Config {
423 use super::types::{BehavioralFlags, FeatureFlags};
424
425 Config {
426 developer_agent: None,
427 reviewer_agent: None,
428 developer_cmd: None,
429 reviewer_cmd: None,
430 commit_cmd: None,
431 developer_model: None,
432 reviewer_model: None,
433 developer_provider: None,
434 reviewer_provider: None,
435 reviewer_json_parser: None,
436 features: FeatureFlags {
437 checkpoint_enabled: true,
438 force_universal_prompt: false,
439 },
440 developer_iters: 5,
441 reviewer_reviews: 2,
442 fast_check_cmd: None,
443 full_check_cmd: None,
444 behavior: BehavioralFlags {
445 interactive: true,
446 auto_detect_stack: true,
447 strict_validation: false,
448 },
449 prompt_path: PathBuf::from(".agent/last_prompt.txt"),
450 user_templates_dir: None,
451 developer_context: 1,
452 reviewer_context: 0,
453 verbosity: Verbosity::Verbose,
454 review_depth: ReviewDepth::default(),
455 isolation_mode: true,
456 git_user_name: None,
457 git_user_email: None,
458 show_streaming_metrics: false,
459 review_format_retries: 5,
460 max_dev_continuations: Some(2), max_xsd_retries: Some(10), max_same_agent_retries: Some(2), }
464}
465
466fn apply_env_overrides(mut config: Config, warnings: &mut Vec<String>) -> Config {
468 const MAX_ITERS: u32 = 50;
469 const MAX_REVIEWS: u32 = 10;
470 const MAX_CONTEXT: u8 = 2;
471 const MAX_FORMAT_RETRIES: u32 = 20;
472
473 apply_agent_selection_env(&mut config, warnings);
475 apply_command_env(&mut config, warnings);
476 apply_model_provider_env(&mut config);
477 apply_iteration_counts_env(&mut config, warnings, MAX_ITERS, MAX_REVIEWS);
478 apply_review_config_env(&mut config, warnings, MAX_FORMAT_RETRIES);
479 apply_boolean_flags_env(&mut config);
480 apply_verbosity_env(&mut config, warnings);
481 apply_review_depth_env(&mut config, warnings);
482 apply_paths_env(&mut config);
483 apply_context_levels_env(&mut config, warnings, MAX_CONTEXT);
484 apply_git_identity_env(&mut config);
485
486 config
487}
488
489fn apply_agent_selection_env(config: &mut Config, warnings: &mut Vec<String>) {
491 if let Ok(val) = env::var("RALPH_DEVELOPER_AGENT") {
492 let trimmed = val.trim();
493 if trimmed.is_empty() {
494 warnings.push("Env var RALPH_DEVELOPER_AGENT is empty; ignoring.".to_string());
495 } else {
496 config.developer_agent = Some(trimmed.to_string());
497 }
498 }
499
500 if let Ok(val) = env::var("RALPH_REVIEWER_AGENT") {
501 let trimmed = val.trim();
502 if trimmed.is_empty() {
503 warnings.push("Env var RALPH_REVIEWER_AGENT is empty; ignoring.".to_string());
504 } else {
505 config.reviewer_agent = Some(trimmed.to_string());
506 }
507 }
508}
509
510fn apply_command_env(config: &mut Config, warnings: &mut Vec<String>) {
512 for (env_var, field) in [
513 ("RALPH_DEVELOPER_CMD", &mut config.developer_cmd),
514 ("RALPH_REVIEWER_CMD", &mut config.reviewer_cmd),
515 ("RALPH_COMMIT_CMD", &mut config.commit_cmd),
516 ] {
517 if let Ok(val) = env::var(env_var) {
518 let trimmed = val.trim();
519 if trimmed.is_empty() {
520 warnings.push(format!("Env var {env_var} is empty; ignoring."));
521 } else {
522 *field = Some(trimmed.to_string());
523 }
524 }
525 }
526
527 for (env_var, field) in [
528 ("FAST_CHECK_CMD", &mut config.fast_check_cmd),
529 ("FULL_CHECK_CMD", &mut config.full_check_cmd),
530 ] {
531 if let Ok(val) = env::var(env_var) {
532 if !val.is_empty() {
533 *field = Some(val);
534 }
535 }
536 }
537}
538
539fn apply_model_provider_env(config: &mut Config) {
541 for (env_var, field) in [
542 ("RALPH_DEVELOPER_MODEL", &mut config.developer_model),
543 ("RALPH_REVIEWER_MODEL", &mut config.reviewer_model),
544 ("RALPH_DEVELOPER_PROVIDER", &mut config.developer_provider),
545 ("RALPH_REVIEWER_PROVIDER", &mut config.reviewer_provider),
546 ] {
547 if let Ok(val) = env::var(env_var) {
548 *field = Some(val);
549 }
550 }
551
552 if let Ok(val) = env::var("RALPH_REVIEWER_JSON_PARSER") {
554 let trimmed = val.trim();
555 if !trimmed.is_empty() {
556 config.reviewer_json_parser = Some(trimmed.to_string());
557 }
558 }
559
560 if let Ok(val) = env::var("RALPH_REVIEWER_UNIVERSAL_PROMPT") {
562 if let Some(b) = parse_env_bool(&val) {
563 config.features.force_universal_prompt = b;
564 }
565 }
566}
567
568fn apply_iteration_counts_env(
570 config: &mut Config,
571 warnings: &mut Vec<String>,
572 max_iters: u32,
573 max_reviews: u32,
574) {
575 if let Some(n) = parse_env_u32("RALPH_DEVELOPER_ITERS", warnings, max_iters) {
576 config.developer_iters = n;
577 }
578 if let Some(n) = parse_env_u32("RALPH_REVIEWER_REVIEWS", warnings, max_reviews) {
579 config.reviewer_reviews = n;
580 }
581}
582
583fn apply_review_config_env(config: &mut Config, warnings: &mut Vec<String>, max_retries: u32) {
585 if let Some(n) = parse_env_u32("RALPH_REVIEW_FORMAT_RETRIES", warnings, max_retries) {
586 config.review_format_retries = n;
587 }
588}
589
590fn apply_boolean_flags_env(config: &mut Config) {
592 let vars: std::collections::HashMap<&str, bool> = [
594 "RALPH_INTERACTIVE",
595 "RALPH_AUTO_DETECT_STACK",
596 "RALPH_CHECKPOINT_ENABLED",
597 "RALPH_STRICT_VALIDATION",
598 "RALPH_ISOLATION_MODE",
599 ]
600 .iter()
601 .filter_map(|&name| env::var(name).ok().map(|v| (name, v)))
602 .filter_map(|(name, val)| parse_env_bool(&val).map(|b| (name, b)))
603 .collect();
604
605 for (name, value) in vars {
607 match name {
608 "RALPH_INTERACTIVE" => config.behavior.interactive = value,
609 "RALPH_AUTO_DETECT_STACK" => config.behavior.auto_detect_stack = value,
610 "RALPH_CHECKPOINT_ENABLED" => config.features.checkpoint_enabled = value,
611 "RALPH_STRICT_VALIDATION" => config.behavior.strict_validation = value,
612 "RALPH_ISOLATION_MODE" => config.isolation_mode = value,
613 _ => {}
614 }
615 }
616}
617
618fn apply_verbosity_env(config: &mut Config, warnings: &mut Vec<String>) {
620 if let Ok(val) = env::var("RALPH_VERBOSITY") {
621 let trimmed = val.trim();
622 if trimmed.is_empty() {
623 return;
624 }
625 match trimmed.parse::<u8>() {
626 Ok(n) => {
627 if n > 4 {
628 warnings.push(format!(
629 "Env var RALPH_VERBOSITY={n} is out of range; clamping to 4 (debug)."
630 ));
631 }
632 config.verbosity = Verbosity::from(n.min(4));
633 }
634 Err(_) => {
635 warnings.push(format!(
636 "Env var RALPH_VERBOSITY='{trimmed}' is not a valid number; ignoring."
637 ));
638 }
639 }
640 }
641}
642
643fn apply_review_depth_env(config: &mut Config, warnings: &mut Vec<String>) {
645 if let Ok(val) = env::var("RALPH_REVIEW_DEPTH") {
646 if let Some(depth) = ReviewDepth::from_str(&val) {
647 config.review_depth = depth;
648 } else if !val.trim().is_empty() {
649 warnings.push(format!(
650 "Env var RALPH_REVIEW_DEPTH='{}' is invalid; ignoring.",
651 val.trim()
652 ));
653 }
654 }
655}
656
657fn apply_paths_env(config: &mut Config) {
659 if let Ok(val) = env::var("RALPH_PROMPT_PATH") {
660 config.prompt_path = PathBuf::from(val);
661 }
662 if let Ok(val) = env::var("RALPH_TEMPLATES_DIR") {
663 let trimmed = val.trim();
664 if !trimmed.is_empty() {
665 config.user_templates_dir = Some(PathBuf::from(trimmed));
666 }
667 }
668}
669
670fn apply_context_levels_env(config: &mut Config, warnings: &mut Vec<String>, max_context: u8) {
672 if let Some(n) = parse_env_u8("RALPH_DEVELOPER_CONTEXT", warnings, max_context) {
673 config.developer_context = n;
674 }
675 if let Some(n) = parse_env_u8("RALPH_REVIEWER_CONTEXT", warnings, max_context) {
676 config.reviewer_context = n;
677 }
678}
679
680fn apply_git_identity_env(config: &mut Config) {
682 if let Ok(val) = env::var("RALPH_GIT_USER_NAME") {
683 let trimmed = val.trim();
684 if !trimmed.is_empty() {
685 config.git_user_name = Some(trimmed.to_string());
686 }
687 }
688 if let Ok(val) = env::var("RALPH_GIT_USER_EMAIL") {
689 let trimmed = val.trim();
690 if !trimmed.is_empty() {
691 config.git_user_email = Some(trimmed.to_string());
692 }
693 }
694}
695
696mod env_parsing;
697use env_parsing::{parse_env_u32, parse_env_u8};
698
699mod unified_config_exists;
700
701pub use unified_config_exists::{unified_config_exists, unified_config_exists_with_env};
702
703#[cfg(test)]
704mod tests;