Skip to main content

ralph_workflow/config/
loader.rs

1//! Unified Configuration Loader
2//!
3//! This module handles loading configuration from the unified config file
4//! at `~/.config/ralph-workflow.toml`, with environment variable overrides.
5//!
6//! # Configuration Priority
7//!
8//! 1. **Global config**: `~/.config/ralph-workflow.toml`
9//! 2. **Local config**: `.agent/ralph-workflow.toml` (overrides global)
10//! 3. **Override layer**: Environment variables (RALPH_*)
11//! 4. **CLI arguments**: Final override (handled at CLI layer)
12//!
13//! # Legacy Configs
14//!
15//! Legacy config discovery is intentionally not supported. Only the unified
16//! config path is consulted, and missing config files fall back to defaults.
17//!
18//! # Fail-Fast Validation
19//!
20//! Ralph validates ALL config files before starting the pipeline. Invalid TOML,
21//! type mismatches, or unknown keys will cause Ralph to refuse to start with
22//! a clear error message. This is not optional - config validation runs on
23//! every startup before any other CLI operation.
24use 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/// Error type for config loading with validation.
33#[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    /// Format all validation errors for user display.
43    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                // Group errors by file for clearer presentation
50                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
102/// Format a single validation error for display.
103fn 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    /// Get the file path from the error.
125    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
134/// Load configuration with the unified approach.
135///
136/// This function loads configuration from the unified config file
137/// (`~/.config/ralph-workflow.toml`) and applies environment variable overrides.
138///
139/// # Returns
140///
141/// Returns a tuple of `(Config, Vec<String>)` where the second element
142/// contains any deprecation warnings to be displayed to the user.
143pub fn load_config() -> (Config, Option<UnifiedConfig>, Vec<String>) {
144    load_config_from_path(None)
145}
146
147/// Load configuration from a specific path or the default location.
148///
149/// If `config_path` is provided, loads from that file.
150/// Otherwise, loads from the default unified config location.
151///
152/// # Arguments
153///
154/// * `config_path` - Optional path to a config file. If None, uses the default location.
155///
156/// # Returns
157///
158/// Returns a tuple of `(Config, Option<UnifiedConfig>, Vec<String>)` where the last element
159/// contains any deprecation warnings to be displayed to the user.
160///
161/// # Panics
162///
163/// Panics if config validation fails. This should be handled at the CLI layer in production.
164pub 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
177/// Load configuration from a specific path or the default location using a [`ConfigEnvironment`].
178///
179/// This is the testable version of [`load_config_from_path`]. It uses the provided
180/// environment for all filesystem operations.
181///
182/// # Arguments
183///
184/// * `config_path` - Optional path to a config file. If None, uses the environment's default.
185/// * `env` - The configuration environment to use for filesystem operations.
186///
187/// # Returns
188///
189/// Returns a tuple of `(Config, Option<UnifiedConfig>, Vec<String>)` where the last element
190/// contains any deprecation warnings to be displayed to the user.
191///
192/// # Errors
193///
194/// Returns `Err(ConfigLoadWithValidationError)` if any config file has validation errors
195/// (invalid TOML, type mismatches, unknown keys). Per requirements, Ralph refuses to start
196/// if ANY config file has errors.
197pub 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    // Step 1: Load and validate global config
205    let global_unified = if let Some(path) = config_path {
206        // Use provided path
207        if env.file_exists(path) {
208            let content = env.read_file(path)?;
209            // Validate the config file
210            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        // Use default path
235        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                // Validate the config file
239                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                // File doesn't exist - not an error, use defaults
260                None
261            }
262        } else {
263            None
264        }
265    };
266
267    // Step 2: Load and validate local config
268    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            // Validate the config file
272            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    // Fail-fast: if there are any validation errors, return them immediately
299    if !validation_errors.is_empty() {
300        return Err(ConfigLoadWithValidationError::ValidationErrors(
301            validation_errors,
302        ));
303    }
304
305    // Step 3: Merge configs (local overrides global)
306    let merged_unified = match (global_unified, local_unified, local_content) {
307        (Some(global), Some(local), Some(content)) => {
308            // Both exist: merge with local overriding global
309            // Pass raw TOML content for presence tracking
310            Some(global.merge_with_content(&content, &local))
311        }
312        (Some(_global), Some(_local), None) => {
313            // SAFETY: This case is impossible in production. If local_unified is Some,
314            // then local_content must also be Some (they're set together at line 281).
315            // If we reach here, there's a bug in the config loading logic.
316            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            // Only global exists
323            Some(global)
324        }
325        (None, Some(local), _) => {
326            // Only local exists (unusual but valid)
327            Some(local)
328        }
329        (None, None, _) => {
330            // Neither exists: use defaults
331            None
332        }
333    };
334
335    // Step 4: Convert to Config
336    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    // Step 5: Apply environment variable overrides
343    let config = apply_env_overrides(config, &mut warnings);
344
345    Ok((config, merged_unified, warnings))
346}
347
348/// Create a Config from `UnifiedConfig`.
349fn 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    // max_xsd_retries of 0 is valid and means "disable XSD retries" (immediate agent fallback).
363    // Any non-negative value is accepted; max_xsd_retries comes from a u32 so can't be negative.
364    let max_xsd_retries = general.max_xsd_retries;
365    // max_same_agent_retries of 0 is valid and means "disable same-agent retries"
366    // (immediate fallback to next agent on timeout/internal error).
367    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, // Set from agent_chain or CLI
379        reviewer_agent: None,  // Set from agent_chain or CLI
380        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, // Set from env var or CLI
388        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, // Default to false; can be enabled via CLI flag or config file
414        review_format_retries: 5,      // Default to 5 retries for format correction
415        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
421/// Default configuration when no config file is found.
422fn 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), // Default to 2 (initial + 1 continuation)
461        max_xsd_retries: Some(10),      // Default to 10 retries before agent fallback
462        max_same_agent_retries: Some(2), // Default to 2 failures (initial + 1 retry) before agent fallback
463    }
464}
465
466/// Apply environment variable overrides to config.
467fn 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 all environment variable overrides by category
474    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
489/// Apply agent selection environment variables.
490fn 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
510/// Apply command override environment variables.
511fn 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
539/// Apply model and provider environment variables.
540fn 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    // JSON parser override for reviewer (useful for testing different parsers)
553    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    // Force universal review prompt (useful for problematic agents)
561    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
568/// Apply iteration count environment variables.
569fn 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
583/// Apply review-specific configuration environment variables.
584fn 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
590/// Apply boolean flag environment variables.
591fn apply_boolean_flags_env(config: &mut Config) {
592    // Read all boolean env vars first
593    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    // Apply each boolean flag
606    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
618/// Apply verbosity environment variable.
619fn 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
643/// Apply review depth environment variable.
644fn 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
657/// Apply path environment variables.
658fn 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
670/// Apply context level environment variables.
671fn 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
680/// Apply git user identity environment variables.
681fn 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;