use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Default)]
pub struct CandidateFiles {
pub dotenv: Vec<PathBuf>,
pub envrc: Vec<PathBuf>,
pub dockerfile: Vec<PathBuf>,
pub docker_compose: Vec<PathBuf>,
pub k8s_yaml: Vec<PathBuf>,
pub helm_values: Vec<PathBuf>,
pub github_workflows: Vec<PathBuf>,
pub sops_files: Vec<PathBuf>,
pub npm_packages: Vec<PathBuf>,
pub tauri_conf: Vec<PathBuf>,
}
const SKIP_DIRS: &[&str] = &[
"node_modules",
"target",
".venv",
"venv",
".git",
"dist",
"build",
".next",
".turbo",
"__pycache__",
".pytest_cache",
".mypy_cache",
".cargo",
".idea",
".vscode",
".loctree",
];
const K8S_DIR_HINTS: &[&str] = &[
"k8s",
"kubernetes",
"deploy",
"manifests",
"chart",
"helm",
"deployment",
"deployments",
];
pub fn discover_candidates(roots: &[PathBuf], restricted_paths: &[PathBuf]) -> CandidateFiles {
let mut out = CandidateFiles::default();
for root in roots {
let root = root.as_path();
if !root.exists() {
continue;
}
for entry in WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
if e.depth() == 0 {
return true;
}
if e.file_type().is_dir() && SKIP_DIRS.iter().any(|d| name == *d) {
return false;
}
true
})
{
let Ok(entry) = entry else { continue };
if !entry.file_type().is_file() {
continue;
}
let path = entry.path().to_path_buf();
if !restricted_paths.is_empty()
&& !path_matches_restriction(&path, root, restricted_paths)
{
continue;
}
classify_into(&mut out, &path, root);
}
}
out
}
fn path_matches_restriction(path: &Path, root: &Path, restricted: &[PathBuf]) -> bool {
let rel = match path.strip_prefix(root) {
Ok(r) => r,
Err(_) => path,
};
let rel_str = rel.to_string_lossy();
restricted.iter().any(|r| {
let r_str = r.to_string_lossy();
rel_str.starts_with(r_str.as_ref())
})
}
fn classify_into(out: &mut CandidateFiles, path: &Path, root: &Path) {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_lowercase();
let rel = path
.strip_prefix(root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|| path.to_string_lossy().to_string());
let in_k8s_dir = rel.split('/').any(|seg| K8S_DIR_HINTS.contains(&seg));
let in_workflows_dir = rel.contains(".github/workflows/");
if (name == ".env" || name.starts_with(".env."))
&& !name.ends_with(".swp")
&& !name.ends_with(".bak")
&& !name.ends_with("~")
{
out.dotenv.push(path.to_path_buf());
return;
}
if name == ".envrc" {
out.envrc.push(path.to_path_buf());
return;
}
if name == "dockerfile" || name.starts_with("dockerfile.") || name.ends_with(".dockerfile") {
out.dockerfile.push(path.to_path_buf());
return;
}
if name.starts_with("docker-compose") && (name.ends_with(".yml") || name.ends_with(".yaml")) {
out.docker_compose.push(path.to_path_buf());
return;
}
if name == "compose.yml" || name == "compose.yaml" {
out.docker_compose.push(path.to_path_buf());
return;
}
if in_workflows_dir && (name.ends_with(".yml") || name.ends_with(".yaml")) {
out.github_workflows.push(path.to_path_buf());
return;
}
if name == "tauri.conf.json" {
out.tauri_conf.push(path.to_path_buf());
return;
}
if name == "package.json" {
out.npm_packages.push(path.to_path_buf());
return;
}
if (name.ends_with(".yml") || name.ends_with(".yaml"))
&& (in_k8s_dir
|| name.starts_with("values")
|| name.starts_with("kustomization")
|| name.starts_with("deployment")
|| name.starts_with("statefulset")
|| name.starts_with("daemonset")
|| name.starts_with("configmap")
|| name.starts_with("secret"))
{
if name.starts_with("values") {
out.helm_values.push(path.to_path_buf());
}
out.k8s_yaml.push(path.to_path_buf());
return;
}
if name.ends_with(".sops.yaml")
|| name.ends_with(".sops.yml")
|| name.ends_with(".sops.json")
|| name.ends_with(".enc.yaml")
|| name.ends_with(".enc.yml")
|| name.ends_with(".sops.env")
{
out.sops_files.push(path.to_path_buf());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn finds_dotenv_and_compose() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join(".env"), "X=1\n").unwrap();
fs::write(root.join(".env.production"), "X=2\n").unwrap();
fs::write(root.join("docker-compose.yml"), "services: {}\n").unwrap();
fs::create_dir_all(root.join(".github/workflows")).unwrap();
fs::write(root.join(".github/workflows/ci.yml"), "name: ci\n").unwrap();
let candidates = discover_candidates(&[root.to_path_buf()], &[]);
assert_eq!(candidates.dotenv.len(), 2, "two .env files");
assert_eq!(candidates.docker_compose.len(), 1);
assert_eq!(candidates.github_workflows.len(), 1);
}
#[test]
fn classifies_k8s_yaml_under_hint_dirs() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("k8s")).unwrap();
fs::write(root.join("k8s/deployment.yaml"), "kind: Deployment\n").unwrap();
fs::write(root.join("k8s/sealed.yaml"), "kind: SealedSecret\n").unwrap();
let candidates = discover_candidates(&[root.to_path_buf()], &[]);
assert_eq!(candidates.k8s_yaml.len(), 2);
}
#[test]
fn skips_node_modules_and_target() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("node_modules/foo")).unwrap();
fs::write(root.join("node_modules/foo/.env"), "JUNK=1\n").unwrap();
fs::write(root.join(".env"), "X=1\n").unwrap();
let candidates = discover_candidates(&[root.to_path_buf()], &[]);
assert_eq!(candidates.dotenv.len(), 1, "node_modules ignored");
}
#[test]
fn restricted_paths_filter_out_other_dirs() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("k8s")).unwrap();
fs::write(root.join(".env"), "X=1\n").unwrap();
fs::write(root.join("k8s/deployment.yaml"), "kind: Deployment\n").unwrap();
let candidates = discover_candidates(&[root.to_path_buf()], &[PathBuf::from("k8s")]);
assert_eq!(candidates.dotenv.len(), 0);
assert_eq!(candidates.k8s_yaml.len(), 1);
}
}