use std::path::{Path, PathBuf};
pub const DEFAULT_ENV_SCAN_DEPTH: usize = 4;
#[derive(Debug)]
pub struct EnvFileCandidate {
pub path: PathBuf,
pub service_path: Option<String>,
pub service_name: Option<String>,
pub env_name: String,
pub is_template: bool,
}
pub fn scan_env_files(root_dir: &Path, configured_envs: &[String]) -> Vec<EnvFileCandidate> {
let mut candidates = Vec::new();
let walk = ignore::WalkBuilder::new(root_dir)
.max_depth(Some(DEFAULT_ENV_SCAN_DEPTH + 1))
.hidden(false)
.standard_filters(false)
.build();
for result in walk {
let entry = match result {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
let Some((env_name, is_template)) = classify_env_file(file_name, configured_envs) else {
continue;
};
if is_in_skipped_dir(path, root_dir) {
continue;
}
let service_path = infer_service_path(path, root_dir);
let service_name = service_path.as_deref().map(service_name_from_path);
candidates.push(EnvFileCandidate {
path: path.to_path_buf(),
service_path,
service_name,
env_name,
is_template,
});
}
candidates.sort_by(|a, b| a.path.cmp(&b.path));
candidates
}
fn is_in_skipped_dir(path: &Path, root_dir: &Path) -> bool {
let skipped = [
"node_modules",
".git",
"target",
"dist",
"build",
".next",
"out",
"vendor",
];
for component in path.strip_prefix(root_dir).unwrap_or(path).components() {
if let std::path::Component::Normal(name) = component {
let name = name.to_string_lossy();
if skipped.iter().any(|s| name == *s) {
return true;
}
}
}
false
}
fn classify_env_file(file_name: &str, configured_envs: &[String]) -> Option<(String, bool)> {
if file_name == ".env" {
return Some((kagi_domain::config::DEFAULT_ENV_NAME.to_string(), false));
}
let suffix = file_name.strip_prefix(".env.")?;
if matches!(suffix, "example" | "sample" | "template") {
return Some((kagi_domain::config::DEFAULT_ENV_NAME.to_string(), true));
}
if configured_envs.iter().any(|env| env == suffix) {
return Some((suffix.to_string(), false));
}
None
}
fn infer_service_path(file_path: &Path, root_dir: &Path) -> Option<String> {
let parent = file_path.parent()?;
if parent == root_dir {
return None;
}
let relative = parent.strip_prefix(root_dir).ok()?;
let parts: Vec<String> = relative
.components()
.filter_map(|part| part.as_os_str().to_str().map(str::to_string))
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join("/"))
}
}
pub fn service_name_from_path(path: &str) -> String {
path.replace('\\', "/")
.split('/')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_scan_finds_root_env() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(".env"), "KEY=val\n").unwrap();
let found = scan_env_files(dir.path(), &["development".to_string()]);
assert_eq!(found.len(), 1);
assert_eq!(found[0].service_name, None);
assert_eq!(found[0].env_name, "development");
}
#[test]
fn test_scan_finds_hidden_env_files() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(".env"), "KEY=val\n").unwrap();
fs::create_dir(dir.path().join("api")).unwrap();
fs::write(dir.path().join("api/.env"), "KEY=val\n").unwrap();
let found = scan_env_files(dir.path(), &["development".to_string()]);
assert_eq!(
found.len(),
2,
"should find both .env files, got: {found:?}",
);
}
#[test]
fn test_scan_finds_nested_env() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("api")).unwrap();
fs::write(dir.path().join("api/.env"), "KEY=val\n").unwrap();
let found = scan_env_files(dir.path(), &["development".to_string()]);
assert_eq!(found.len(), 1);
assert_eq!(found[0].service_name, Some("api".to_string()));
assert_eq!(found[0].service_path, Some("api".to_string()));
}
#[test]
fn test_scan_finds_monorepo_env() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("apps/api")).unwrap();
fs::write(dir.path().join("apps/api/.env.dev"), "KEY=val\n").unwrap();
let found = scan_env_files(dir.path(), &["development".to_string(), "dev".to_string()]);
assert_eq!(found.len(), 1);
assert_eq!(found[0].service_path, Some("apps/api".to_string()));
assert_eq!(found[0].service_name, Some("apps-api".to_string()));
assert_eq!(found[0].env_name, "dev");
}
#[test]
fn test_scan_respects_max_depth() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("a/b/c")).unwrap();
fs::write(dir.path().join("a/.env"), "KEY=val\n").unwrap();
fs::write(dir.path().join("a/b/.env"), "KEY=val\n").unwrap();
fs::write(dir.path().join("a/b/c/.env"), "KEY=val\n").unwrap();
fs::create_dir_all(dir.path().join("a/b/c/d")).unwrap();
fs::write(dir.path().join("a/b/c/d/.env"), "KEY=val\n").unwrap();
let found = scan_env_files(dir.path(), &["development".to_string()]);
let paths: Vec<_> = found.iter().map(|c| c.path.clone()).collect();
assert!(paths.contains(&dir.path().join("a/.env")));
assert!(paths.contains(&dir.path().join("a/b/.env")));
assert!(paths.contains(&dir.path().join("a/b/c/.env")));
assert!(paths.contains(&dir.path().join("a/b/c/d/.env")));
}
#[test]
fn test_scan_skips_common_non_project_dirs() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("node_modules/some-pkg")).unwrap();
fs::write(dir.path().join("node_modules/some-pkg/.env"), "KEY=val\n").unwrap();
fs::create_dir_all(dir.path().join(".git/hooks")).unwrap();
fs::write(dir.path().join(".git/hooks/.env"), "KEY=val\n").unwrap();
fs::create_dir_all(dir.path().join("target/debug")).unwrap();
fs::write(dir.path().join("target/debug/.env"), "KEY=val\n").unwrap();
fs::write(dir.path().join(".env"), "KEY=val\n").unwrap();
let found = scan_env_files(dir.path(), &["development".to_string()]);
assert_eq!(found.len(), 1);
assert_eq!(found[0].service_name, None);
}
#[test]
fn test_scan_skips_env_example() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(".env"), "KEY=val\n").unwrap();
fs::write(dir.path().join(".env.example"), "KEY=\n").unwrap();
fs::write(dir.path().join(".env.local"), "KEY=val\n").unwrap();
let found = scan_env_files(dir.path(), &["development".to_string()]);
assert_eq!(found.len(), 2);
assert!(found.iter().any(|candidate| candidate.is_template));
}
#[test]
fn test_scan_skips_deep_env() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("a/b/c/d/e")).unwrap();
fs::write(dir.path().join("a/b/c/d/e/.env"), "KEY=val\n").unwrap();
let found = scan_env_files(dir.path(), &["development".to_string()]);
assert!(found.is_empty(), "should not find .env at depth > 4");
}
}