Skip to main content

datasynth_config/
env_interpolation.rs

1//! Environment variable interpolation for YAML configuration.
2//!
3//! Supports:
4//! - `${VAR_NAME}` - substitute from environment, error if unset
5//! - `${VAR_NAME:-default}` - substitute from environment, use default if unset
6
7use regex::Regex;
8use std::env;
9use thiserror::Error;
10
11#[derive(Debug, Error)]
12pub enum EnvInterpolationError {
13    #[error("Environment variable '{0}' is not set and no default provided")]
14    MissingVariable(String),
15}
16
17/// Interpolate environment variables in a string.
18///
19/// Patterns:
20/// - `${VAR}` - required variable (error if not set)
21/// - `${VAR:-default}` - optional variable with default
22///
23/// # Examples
24///
25/// ```
26/// use datasynth_config::env_interpolation::interpolate_env;
27///
28/// std::env::set_var("TEST_PORT", "8080");
29/// let result = interpolate_env("port: ${TEST_PORT}").unwrap();
30/// assert_eq!(result, "port: 8080");
31///
32/// let result = interpolate_env("host: ${MISSING_VAR:-localhost}").unwrap();
33/// assert_eq!(result, "host: localhost");
34/// std::env::remove_var("TEST_PORT");
35/// ```
36pub fn interpolate_env(input: &str) -> Result<String, EnvInterpolationError> {
37    let re = Regex::new(r"\$\{([^}]+)\}").expect("valid env interpolation regex");
38    let mut result = input.to_string();
39    let mut errors = Vec::new();
40
41    // Collect all matches first to avoid borrow issues
42    let matches: Vec<(String, String)> = re
43        .captures_iter(input)
44        .map(|cap| {
45            let full_match = cap
46                .get(0)
47                .expect("capture group 0 always exists")
48                .as_str()
49                .to_string();
50            let inner = cap
51                .get(1)
52                .expect("capture group 1 defined in regex")
53                .as_str()
54                .to_string();
55            (full_match, inner)
56        })
57        .collect();
58
59    for (full_match, inner) in matches {
60        let replacement = if let Some((var_name, default_value)) = inner.split_once(":-") {
61            // Pattern: ${VAR:-default}
62            match env::var(var_name) {
63                Ok(val) => val,
64                Err(_) => default_value.to_string(),
65            }
66        } else {
67            // Pattern: ${VAR}
68            match env::var(&inner) {
69                Ok(val) => val,
70                Err(_) => {
71                    errors.push(inner.clone());
72                    continue;
73                }
74            }
75        };
76
77        result = result.replace(&full_match, &replacement);
78    }
79
80    if let Some(first_error) = errors.into_iter().next() {
81        return Err(EnvInterpolationError::MissingVariable(first_error));
82    }
83
84    Ok(result)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_basic_substitution() {
93        env::set_var("TEST_INTERP_VAR", "hello");
94        let result = interpolate_env("value: ${TEST_INTERP_VAR}").unwrap();
95        assert_eq!(result, "value: hello");
96        env::remove_var("TEST_INTERP_VAR");
97    }
98
99    #[test]
100    fn test_default_value() {
101        env::remove_var("TEST_INTERP_MISSING");
102        let result = interpolate_env("value: ${TEST_INTERP_MISSING:-fallback}").unwrap();
103        assert_eq!(result, "value: fallback");
104    }
105
106    #[test]
107    fn test_default_with_existing_var() {
108        env::set_var("TEST_INTERP_EXISTS", "real_value");
109        let result = interpolate_env("value: ${TEST_INTERP_EXISTS:-fallback}").unwrap();
110        assert_eq!(result, "value: real_value");
111        env::remove_var("TEST_INTERP_EXISTS");
112    }
113
114    #[test]
115    fn test_missing_required_variable() {
116        env::remove_var("TEST_INTERP_REQUIRED");
117        let result = interpolate_env("value: ${TEST_INTERP_REQUIRED}");
118        assert!(result.is_err());
119    }
120
121    #[test]
122    fn test_no_interpolation_needed() {
123        let result = interpolate_env("plain text without variables").unwrap();
124        assert_eq!(result, "plain text without variables");
125    }
126
127    #[test]
128    fn test_multiple_variables() {
129        env::set_var("TEST_INTERP_A", "alpha");
130        env::set_var("TEST_INTERP_B", "beta");
131        let result = interpolate_env("${TEST_INTERP_A} and ${TEST_INTERP_B}").unwrap();
132        assert_eq!(result, "alpha and beta");
133        env::remove_var("TEST_INTERP_A");
134        env::remove_var("TEST_INTERP_B");
135    }
136
137    #[test]
138    fn test_empty_default() {
139        env::remove_var("TEST_INTERP_EMPTY_DEFAULT");
140        let result = interpolate_env("value: ${TEST_INTERP_EMPTY_DEFAULT:-}").unwrap();
141        assert_eq!(result, "value: ");
142    }
143}