use std::path::{Path, PathBuf};
use walkdir::{DirEntry, WalkDir};
use crate::adapters::{self, PackageManager};
pub const MAX_DEPTH: usize = crate::constants::DEFAULT_SCAN_DEPTH;
pub fn resolve_depth(repo_root: &Path, global: usize) -> usize {
let configured = crate::config::PerRepoConfig::load_with_diagnostics(repo_root)
.ok()
.flatten()
.and_then(|c| c.scan_depth)
.unwrap_or(global);
clamp_depth(configured)
}
pub fn clamp_depth(requested: usize) -> usize {
requested.clamp(1, crate::constants::MAX_SCAN_DEPTH_LIMIT)
}
const SKIP_DIRS: &[&str] = &[
"node_modules",
"target",
"vendor",
"bower_components",
"__pypackages__",
"Pods",
"deps",
"_build",
".build",
];
pub struct Project {
pub path: PathBuf,
pub relative: String,
pub adapters: Vec<Box<dyn PackageManager>>,
}
pub fn discover(repo_root: &Path) -> Vec<Project> {
discover_to_depth(repo_root, MAX_DEPTH)
}
pub fn discover_to_depth(repo_root: &Path, depth: usize) -> Vec<Project> {
WalkDir::new(repo_root)
.follow_links(false)
.max_depth(clamp_depth(depth))
.sort_by_file_name()
.into_iter()
.filter_entry(|entry| entry.depth() == 0 || is_scannable(entry))
.flatten()
.filter(|entry| entry.file_type().is_dir())
.filter_map(|entry| {
let adapters = adapters::detect_adapters(entry.path());
if adapters.is_empty() {
return None;
}
Some(Project {
relative: relative_label(repo_root, entry.path()),
path: entry.path().to_path_buf(),
adapters,
})
})
.collect()
}
fn is_scannable(entry: &DirEntry) -> bool {
if !entry.file_type().is_dir() {
return true;
}
let name = entry.file_name().to_string_lossy();
if name.starts_with('.') {
return false;
}
if SKIP_DIRS.contains(&name.as_ref()) {
return false;
}
let path = entry.path();
if path.join("pyvenv.cfg").exists() {
return false;
}
if path.join(".git").exists() {
return false;
}
true
}
pub fn relative_label(root: &Path, path: &Path) -> String {
match path.strip_prefix(root) {
Ok(rel) if rel.as_os_str().is_empty() => ".".to_string(),
Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
Err(_) => path.display().to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn project(root: &Path, rel: &str, files: &[&str]) -> PathBuf {
let dir = if rel == "." {
root.to_path_buf()
} else {
root.join(rel)
};
fs::create_dir_all(&dir).unwrap();
for file in files {
fs::write(dir.join(file), "{}").unwrap();
}
dir
}
fn names(projects: &[Project]) -> Vec<(String, Vec<&'static str>)> {
let mut out: Vec<(String, Vec<&'static str>)> = projects
.iter()
.map(|p| {
let mut adapters: Vec<&'static str> = p.adapters.iter().map(|a| a.name()).collect();
adapters.sort_unstable();
(p.relative.clone(), adapters)
})
.collect();
out.sort();
out
}
#[test]
fn discovers_nothing_in_an_empty_tree() {
let tmp = TempDir::new().unwrap();
assert!(discover(tmp.path()).is_empty());
}
#[test]
fn discovers_three_ecosystems_in_one_root() {
let tmp = TempDir::new().unwrap();
project(
tmp.path(),
".",
&["package.json", "package-lock.json", "uv.lock", "go.mod"],
);
assert_eq!(
names(&discover(tmp.path())),
vec![(".".to_string(), vec!["go", "npm", "uv"])]
);
}
#[test]
fn discovers_ecosystems_at_different_depths() {
let tmp = TempDir::new().unwrap();
project(tmp.path(), "frontend", &["pnpm-lock.yaml"]);
project(tmp.path(), "services/api", &["uv.lock"]);
project(tmp.path(), "tools/cli", &["go.mod"]);
assert_eq!(
names(&discover(tmp.path())),
vec![
("frontend".to_string(), vec!["pnpm"]),
("services/api".to_string(), vec!["uv"]),
("tools/cli".to_string(), vec!["go"]),
]
);
}
#[test]
fn combines_a_root_project_with_nested_ones() {
let tmp = TempDir::new().unwrap();
project(tmp.path(), ".", &["go.mod"]);
project(tmp.path(), "web", &["package.json", "package-lock.json"]);
assert_eq!(
names(&discover(tmp.path())),
vec![
(".".to_string(), vec!["go"]),
("web".to_string(), vec!["npm"]),
]
);
}
#[test]
fn never_descends_into_node_modules() {
let tmp = TempDir::new().unwrap();
project(tmp.path(), ".", &["package.json", "package-lock.json"]);
project(
tmp.path(),
"node_modules/some-dep",
&["package.json", "package-lock.json"],
);
assert_eq!(names(&discover(tmp.path())).len(), 1);
}
#[test]
fn never_descends_into_target_or_vendor() {
let tmp = TempDir::new().unwrap();
project(tmp.path(), ".", &["go.mod"]);
project(tmp.path(), "target/debug/build/x", &["go.mod"]);
project(tmp.path(), "vendor/dep", &["go.mod"]);
assert_eq!(
names(&discover(tmp.path())),
vec![(".".to_string(), vec!["go"])]
);
}
#[test]
fn never_descends_into_a_virtual_environment() {
let tmp = TempDir::new().unwrap();
project(tmp.path(), ".", &["uv.lock"]);
let venv = project(tmp.path(), "my_env", &["pyvenv.cfg"]);
project(&venv, "lib/site-packages/dep", &["go.mod"]);
assert_eq!(
names(&discover(tmp.path())),
vec![(".".to_string(), vec!["uv"])]
);
}
#[test]
fn never_descends_into_a_nested_repository() {
let tmp = TempDir::new().unwrap();
project(tmp.path(), ".", &["go.mod"]);
let sub = project(tmp.path(), "submodule", &["package-lock.json"]);
fs::create_dir(sub.join(".git")).unwrap();
assert_eq!(
names(&discover(tmp.path())),
vec![(".".to_string(), vec!["go"])]
);
}
#[test]
fn never_descends_into_hidden_directories() {
let tmp = TempDir::new().unwrap();
project(tmp.path(), ".github/actions/thing", &["package-lock.json"]);
assert!(discover(tmp.path()).is_empty());
}
#[test]
fn stops_at_the_depth_cap() {
let tmp = TempDir::new().unwrap();
let deep = "a/b/c/d/e/f/g/h";
project(tmp.path(), deep, &["Cargo.toml"]);
assert!(discover(tmp.path()).is_empty());
}
#[test]
fn relative_label_is_slash_separated() {
let root = Path::new("/repo");
assert_eq!(relative_label(root, Path::new("/repo")), ".");
assert_eq!(
relative_label(root, Path::new("/repo/a/b/node_modules")),
"a/b/node_modules"
);
}
}