xbp 10.26.3

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use std::collections::HashMap;
use std::fs;
use std::path::Path;

pub fn normalize_env_value(raw: &str) -> String {
    let mut value = raw.trim().to_string();

    loop {
        let trimmed = value.trim();
        let bytes = trimmed.as_bytes();

        if bytes.len() >= 2 {
            let first = bytes[0] as char;
            let last = bytes[bytes.len() - 1] as char;
            if (first == '"' || first == '\'') && first == last {
                value = trimmed[1..trimmed.len() - 1].trim().to_string();
                continue;
            }
        }

        let unescaped = trimmed.replace("\\\"", "\"").replace("\\'", "'");
        if unescaped != trimmed {
            value = unescaped;
            continue;
        }

        return trimmed.to_string();
    }
}

pub fn parse_env_content(content: &str) -> HashMap<String, String> {
    let mut result = HashMap::new();

    for line in content.lines() {
        let mut trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }

        if trimmed.starts_with("export ") {
            trimmed = trimmed.trim_start_matches("export ").trim();
        }

        let Some((key, value)) = trimmed.split_once('=') else {
            continue;
        };

        let key = key.trim();
        if key.is_empty() {
            continue;
        }

        result.insert(key.to_string(), normalize_env_value(value));
    }

    result
}

pub fn parse_env_file(path: &Path) -> Result<HashMap<String, String>, String> {
    let content = fs::read_to_string(path)
        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
    Ok(parse_env_content(&content))
}

pub fn to_env_references(vars: &HashMap<String, String>) -> HashMap<String, String> {
    vars.keys()
        .map(|key| (key.clone(), format!("${{{}}}", key)))
        .collect()
}

pub fn resolve_env_placeholders(
    project_root: &Path,
    envs: &HashMap<String, String>,
) -> HashMap<String, String> {
    let lookup = load_env_lookup(project_root);

    envs.iter()
        .map(|(key, value)| {
            let resolved = env_reference_name(value)
                .and_then(|name| lookup.get(name).cloned())
                .unwrap_or_else(|| value.clone());
            (key.clone(), resolved)
        })
        .collect()
}

fn load_env_lookup(project_root: &Path) -> HashMap<String, String> {
    let mut lookup = HashMap::new();

    for name in [".env", ".env.local", ".env.development", ".env.production"] {
        let path = project_root.join(name);
        if !path.exists() {
            continue;
        }

        if let Ok(parsed) = parse_env_file(&path) {
            lookup.extend(parsed);
        }
    }

    lookup.extend(std::env::vars());
    lookup
}

fn env_reference_name(value: &str) -> Option<&str> {
    let trimmed = value.trim();
    if let Some(name) = trimmed
        .strip_prefix("${")
        .and_then(|rest| rest.strip_suffix('}'))
    {
        return (!name.trim().is_empty()).then_some(name.trim());
    }

    trimmed
        .strip_prefix('$')
        .map(str::trim)
        .filter(|name| !name.is_empty())
}

#[cfg(test)]
mod tests {
    use super::{
        normalize_env_value, parse_env_content, resolve_env_placeholders, to_env_references,
    };
    use std::collections::HashMap;
    use std::fs;
    use std::path::PathBuf;

    fn make_temp_dir(label: &str) -> PathBuf {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("system clock should be after epoch")
            .as_nanos();
        let dir = std::env::temp_dir().join(format!("xbp-env-files-{label}-{nanos}"));
        fs::create_dir_all(&dir).expect("temp dir should be created");
        dir
    }

    #[test]
    fn normalize_env_value_strips_redundant_wrapping_quotes() {
        assert_eq!(normalize_env_value(r#""hello""#), "hello");
        assert_eq!(normalize_env_value("'hello'"), "hello");
        assert_eq!(normalize_env_value(r#"'\"hello\"'"#), "hello");
        assert_eq!(normalize_env_value("''hello''"), "hello");
        assert_eq!(normalize_env_value("hello"), "hello");
    }

    #[test]
    fn parse_env_content_normalizes_quotes_and_exports() {
        let parsed = parse_env_content(
            r#"
                export FIRST='"hello"'
                SECOND='world'
                THIRD=plain
            "#,
        );

        assert_eq!(parsed.get("FIRST"), Some(&"hello".to_string()));
        assert_eq!(parsed.get("SECOND"), Some(&"world".to_string()));
        assert_eq!(parsed.get("THIRD"), Some(&"plain".to_string()));
    }

    #[test]
    fn to_env_references_maps_values_to_placeholders() {
        let mut vars = HashMap::new();
        vars.insert("DATABASE_URL".to_string(), "postgres://demo".to_string());

        let refs = to_env_references(&vars);
        assert_eq!(
            refs.get("DATABASE_URL"),
            Some(&"${DATABASE_URL}".to_string())
        );
    }

    #[test]
    fn resolve_env_placeholders_reads_local_env_files() {
        let project_root = make_temp_dir("resolve-placeholders");
        fs::write(
            project_root.join(".env.local"),
            "DATABASE_URL='postgres://demo'\nAPI_KEY='\"secret\"'\n",
        )
        .expect("env file should be written");

        let mut envs = HashMap::new();
        envs.insert("DATABASE_URL".to_string(), "${DATABASE_URL}".to_string());
        envs.insert("API_KEY".to_string(), "${API_KEY}".to_string());
        envs.insert("NODE_ENV".to_string(), "production".to_string());

        let resolved = resolve_env_placeholders(&project_root, &envs);
        assert_eq!(
            resolved.get("DATABASE_URL"),
            Some(&"postgres://demo".to_string())
        );
        assert_eq!(resolved.get("API_KEY"), Some(&"secret".to_string()));
        assert_eq!(resolved.get("NODE_ENV"), Some(&"production".to_string()));

        let _ = fs::remove_dir_all(project_root);
    }
}