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)]
88#[allow(clippy::unwrap_used)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn test_basic_substitution() {
94        env::set_var("TEST_INTERP_VAR", "hello");
95        let result = interpolate_env("value: ${TEST_INTERP_VAR}").unwrap();
96        assert_eq!(result, "value: hello");
97        env::remove_var("TEST_INTERP_VAR");
98    }
99
100    #[test]
101    fn test_default_value() {
102        env::remove_var("TEST_INTERP_MISSING");
103        let result = interpolate_env("value: ${TEST_INTERP_MISSING:-fallback}").unwrap();
104        assert_eq!(result, "value: fallback");
105    }
106
107    #[test]
108    fn test_default_with_existing_var() {
109        env::set_var("TEST_INTERP_EXISTS", "real_value");
110        let result = interpolate_env("value: ${TEST_INTERP_EXISTS:-fallback}").unwrap();
111        assert_eq!(result, "value: real_value");
112        env::remove_var("TEST_INTERP_EXISTS");
113    }
114
115    #[test]
116    fn test_missing_required_variable() {
117        env::remove_var("TEST_INTERP_REQUIRED");
118        let result = interpolate_env("value: ${TEST_INTERP_REQUIRED}");
119        assert!(result.is_err());
120    }
121
122    #[test]
123    fn test_no_interpolation_needed() {
124        let result = interpolate_env("plain text without variables").unwrap();
125        assert_eq!(result, "plain text without variables");
126    }
127
128    #[test]
129    fn test_multiple_variables() {
130        env::set_var("TEST_INTERP_A", "alpha");
131        env::set_var("TEST_INTERP_B", "beta");
132        let result = interpolate_env("${TEST_INTERP_A} and ${TEST_INTERP_B}").unwrap();
133        assert_eq!(result, "alpha and beta");
134        env::remove_var("TEST_INTERP_A");
135        env::remove_var("TEST_INTERP_B");
136    }
137
138    #[test]
139    fn test_empty_default() {
140        env::remove_var("TEST_INTERP_EMPTY_DEFAULT");
141        let result = interpolate_env("value: ${TEST_INTERP_EMPTY_DEFAULT:-}").unwrap();
142        assert_eq!(result, "value: ");
143    }
144}