use regex::Regex;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use walkdir::{DirEntry, WalkDir};
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(())
}
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["']\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(),
]
})
}