xbp 10.13.1

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use regex::Regex;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use walkdir::{DirEntry, WalkDir};

/// Generate `.env.default` with all discovered keys.
pub fn generate_env_default(project_root: &Path, output: Option<&str>) -> Result<(), String> {
    let keys = get_required_env_vars(project_root)?;
    if keys.is_empty() {
        println!("No environment variables detected during scanning.");
        return Ok(());
    }

    let file_path = output
        .map(PathBuf::from)
        .map(|path| {
            if path.is_relative() {
                project_root.join(path)
            } else {
                path
            }
        })
        .unwrap_or_else(|| project_root.join(".env.default"));

    let mut sorted_keys: Vec<String> = keys.into_iter().collect();
    sorted_keys.sort();

    let content = sorted_keys
        .iter()
        .map(|key| format!("{}=\n", key))
        .collect::<String>();

    fs::write(&file_path, content)
        .map_err(|e| format!("Failed to write {}: {}", file_path.display(), e))?;

    println!(
        "Generated {} key(s) into {}",
        sorted_keys.len(),
        file_path.display()
    );
    Ok(())
}

/// Collect environment variable names referenced across source files.
pub fn get_required_env_vars(project_root: &Path) -> Result<HashSet<String>, String> {
    let walker = WalkDir::new(project_root).into_iter();
    let mut vars = HashSet::new();

    for entry in walker.filter_entry(|e| !should_ignore(e)) {
        let entry = entry.map_err(|e| {
            let path_display = e
                .path()
                .map(|p| p.display().to_string())
                .unwrap_or_else(|| "unknown".to_string());
            format!("Failed to traverse {}: {}", path_display, e)
        })?;
        if !should_analyze(&entry) {
            continue;
        }

        if let Ok(text) = fs::read_to_string(entry.path()) {
            for candidate in capture_candidates(&text) {
                vars.insert(candidate);
            }
        }
    }

    Ok(vars)
}

fn should_ignore(entry: &DirEntry) -> bool {
    if !entry.file_type().is_dir() {
        return false;
    }

    let name = entry.file_name().to_string_lossy();
    matches!(
        name.as_ref(),
        ".git" | ".github" | "node_modules" | "target" | "dist" | "build" | ".xbp" | "vendor"
    )
}

fn should_analyze(entry: &DirEntry) -> bool {
    if entry.file_type().is_dir() {
        return false;
    }

    let name = entry.file_name().to_string_lossy().to_lowercase();
    if name.eq("dockerfile") || name.starts_with("docker-compose") {
        return true;
    }

    if let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) {
        matches!(
            ext.to_lowercase().as_str(),
            "rs" | "py"
                | "ts"
                | "tsx"
                | "js"
                | "jsx"
                | "json"
                | "yaml"
                | "yml"
                | "toml"
                | "env"
                | "sh"
                | "bash"
                | "zsh"
        )
    } else {
        false
    }
}

fn capture_candidates(text: &str) -> HashSet<String> {
    let mut found = HashSet::new();
    for regex in regexes().iter() {
        for capture in regex.captures_iter(text) {
            if let Some(name) = capture.get(1) {
                found.insert(name.as_str().to_string());
            }
        }
    }
    found
}

fn regexes() -> &'static [Regex] {
    static REGEXES: OnceLock<Vec<Regex>> = OnceLock::new();
    REGEXES.get_or_init(|| {
        vec![
            Regex::new(r#"env::var(?:_os)?\(\s*["']([A-Z0-9_]+)["']\s*\)"#).unwrap(),
            Regex::new(r#"process\.env\.([A-Z0-9_]+)"#).unwrap(),
            Regex::new(r#"process\.env\[\s*["']([A-Z0-9_]+)["']\s*\]"#).unwrap(),
            Regex::new(r#"os\.environ\[\s*["']([A-Z0-9_]+)["']\s*\]"#).unwrap(),
            Regex::new(r#"os\.environ\.get\(\s*["']([A-Z0-9_]+)["']"#).unwrap(),
            Regex::new(r#"os\.getenv\(\s*["']([A-Z0-9_]+)["']"#).unwrap(),
            Regex::new(r#"\b(?:ARG|ENV)\s+([A-Z0-9_]+)\b"#).unwrap(),
            Regex::new(r#"\$\{([A-Z0-9_]+)\}"#).unwrap(),
        ]
    })
}