1use std::path::{Path, PathBuf};
17
18use walkdir::{DirEntry, WalkDir};
19
20use crate::adapters::{self, PackageManager};
21
22pub const MAX_DEPTH: usize = crate::constants::DEFAULT_SCAN_DEPTH;
27
28pub fn resolve_depth(repo_root: &Path, global: usize) -> usize {
36 let configured = crate::config::PerRepoConfig::load_with_diagnostics(repo_root)
37 .ok()
38 .flatten()
39 .and_then(|c| c.scan_depth)
40 .unwrap_or(global);
41 clamp_depth(configured)
42}
43
44pub fn clamp_depth(requested: usize) -> usize {
51 requested.clamp(1, crate::constants::MAX_SCAN_DEPTH_LIMIT)
52}
53
54const SKIP_DIRS: &[&str] = &[
63 "node_modules",
64 "target",
65 "vendor",
66 "bower_components",
67 "__pypackages__",
68 "Pods",
69 "deps",
70 "_build",
71 ".build",
72];
73
74pub struct Project {
76 pub path: PathBuf,
78 pub relative: String,
80 pub adapters: Vec<Box<dyn PackageManager>>,
83}
84
85pub fn discover(repo_root: &Path) -> Vec<Project> {
92 discover_to_depth(repo_root, MAX_DEPTH)
93}
94
95pub fn discover_to_depth(repo_root: &Path, depth: usize) -> Vec<Project> {
100 discover_with(repo_root, depth, adapters::detect_adapters)
101}
102
103pub fn discover_all_to_depth(repo_root: &Path, depth: usize) -> Vec<Project> {
108 discover_with(repo_root, depth, adapters::detect_all_adapters)
109}
110
111fn discover_with(
114 repo_root: &Path,
115 depth: usize,
116 detect: fn(&Path) -> Vec<Box<dyn PackageManager>>,
117) -> Vec<Project> {
118 WalkDir::new(repo_root)
119 .follow_links(false)
120 .max_depth(clamp_depth(depth))
121 .sort_by_file_name()
125 .into_iter()
126 .filter_entry(|entry| entry.depth() == 0 || is_scannable(entry))
127 .flatten()
128 .filter(|entry| entry.file_type().is_dir())
129 .filter_map(|entry| {
130 let adapters = detect(entry.path());
131 if adapters.is_empty() {
132 return None;
133 }
134 Some(Project {
135 relative: relative_label(repo_root, entry.path()),
136 path: entry.path().to_path_buf(),
137 adapters,
138 })
139 })
140 .collect()
141}
142
143fn is_scannable(entry: &DirEntry) -> bool {
145 if !entry.file_type().is_dir() {
148 return true;
149 }
150
151 let name = entry.file_name().to_string_lossy();
152
153 if name.starts_with('.') {
156 return false;
157 }
158
159 if SKIP_DIRS.contains(&name.as_ref()) {
160 return false;
161 }
162
163 let path = entry.path();
164
165 if path.join("pyvenv.cfg").exists() {
168 return false;
169 }
170
171 if path.join(".git").exists() {
175 return false;
176 }
177
178 true
179}
180
181pub fn relative_label(root: &Path, path: &Path) -> String {
187 match path.strip_prefix(root) {
188 Ok(rel) if rel.as_os_str().is_empty() => ".".to_string(),
189 Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
190 Err(_) => path.display().to_string(),
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use std::fs;
198 use tempfile::TempDir;
199
200 fn project(root: &Path, rel: &str, files: &[&str]) -> PathBuf {
202 let dir = if rel == "." {
203 root.to_path_buf()
204 } else {
205 root.join(rel)
206 };
207 fs::create_dir_all(&dir).unwrap();
208 for file in files {
209 fs::write(dir.join(file), "{}").unwrap();
210 }
211 dir
212 }
213
214 fn names(projects: &[Project]) -> Vec<(String, Vec<&'static str>)> {
215 let mut out: Vec<(String, Vec<&'static str>)> = projects
216 .iter()
217 .map(|p| {
218 let mut adapters: Vec<&'static str> = p.adapters.iter().map(|a| a.name()).collect();
219 adapters.sort_unstable();
220 (p.relative.clone(), adapters)
221 })
222 .collect();
223 out.sort();
224 out
225 }
226
227 #[test]
228 fn discovers_nothing_in_an_empty_tree() {
229 let tmp = TempDir::new().unwrap();
230 assert!(discover(tmp.path()).is_empty());
231 }
232
233 #[test]
234 fn discovers_three_ecosystems_in_one_root() {
235 let tmp = TempDir::new().unwrap();
236 project(
237 tmp.path(),
238 ".",
239 &["package.json", "package-lock.json", "uv.lock", "go.mod"],
240 );
241
242 assert_eq!(
243 names(&discover(tmp.path())),
244 vec![(".".to_string(), vec!["go", "npm", "uv"])]
245 );
246 }
247
248 #[test]
249 fn discovers_ecosystems_at_different_depths() {
250 let tmp = TempDir::new().unwrap();
251 project(tmp.path(), "frontend", &["pnpm-lock.yaml"]);
252 project(tmp.path(), "services/api", &["uv.lock"]);
253 project(tmp.path(), "tools/cli", &["go.mod"]);
254
255 assert_eq!(
256 names(&discover(tmp.path())),
257 vec![
258 ("frontend".to_string(), vec!["pnpm"]),
259 ("services/api".to_string(), vec!["uv"]),
260 ("tools/cli".to_string(), vec!["go"]),
261 ]
262 );
263 }
264
265 #[test]
266 fn combines_a_root_project_with_nested_ones() {
267 let tmp = TempDir::new().unwrap();
268 project(tmp.path(), ".", &["go.mod"]);
269 project(tmp.path(), "web", &["package.json", "package-lock.json"]);
270
271 assert_eq!(
272 names(&discover(tmp.path())),
273 vec![
274 (".".to_string(), vec!["go"]),
275 ("web".to_string(), vec!["npm"]),
276 ]
277 );
278 }
279
280 #[test]
281 fn never_descends_into_node_modules() {
282 let tmp = TempDir::new().unwrap();
283 project(tmp.path(), ".", &["package.json", "package-lock.json"]);
284 project(
286 tmp.path(),
287 "node_modules/some-dep",
288 &["package.json", "package-lock.json"],
289 );
290
291 assert_eq!(names(&discover(tmp.path())).len(), 1);
292 }
293
294 #[test]
295 fn never_descends_into_target_or_vendor() {
296 let tmp = TempDir::new().unwrap();
297 project(tmp.path(), ".", &["go.mod"]);
298 project(tmp.path(), "target/debug/build/x", &["go.mod"]);
299 project(tmp.path(), "vendor/dep", &["go.mod"]);
300
301 assert_eq!(
302 names(&discover(tmp.path())),
303 vec![(".".to_string(), vec!["go"])]
304 );
305 }
306
307 #[test]
308 fn never_descends_into_a_virtual_environment() {
309 let tmp = TempDir::new().unwrap();
310 project(tmp.path(), ".", &["uv.lock"]);
311 let venv = project(tmp.path(), "my_env", &["pyvenv.cfg"]);
312 project(&venv, "lib/site-packages/dep", &["go.mod"]);
313
314 assert_eq!(
315 names(&discover(tmp.path())),
316 vec![(".".to_string(), vec!["uv"])]
317 );
318 }
319
320 #[test]
321 fn never_descends_into_a_nested_repository() {
322 let tmp = TempDir::new().unwrap();
323 project(tmp.path(), ".", &["go.mod"]);
324 let sub = project(tmp.path(), "submodule", &["package-lock.json"]);
325 fs::create_dir(sub.join(".git")).unwrap();
326
327 assert_eq!(
328 names(&discover(tmp.path())),
329 vec![(".".to_string(), vec!["go"])]
330 );
331 }
332
333 #[test]
334 fn never_descends_into_hidden_directories() {
335 let tmp = TempDir::new().unwrap();
336 project(tmp.path(), ".github/actions/thing", &["package-lock.json"]);
337 assert!(discover(tmp.path()).is_empty());
338 }
339
340 #[test]
341 fn stops_at_the_depth_cap() {
342 let tmp = TempDir::new().unwrap();
343 let deep = "a/b/c/d/e/f/g/h";
344 project(tmp.path(), deep, &["Cargo.toml"]);
345 assert!(discover(tmp.path()).is_empty());
346 }
347
348 #[test]
349 fn relative_label_is_slash_separated() {
350 let root = Path::new("/repo");
351 assert_eq!(relative_label(root, Path::new("/repo")), ".");
352 assert_eq!(
353 relative_label(root, Path::new("/repo/a/b/node_modules")),
354 "a/b/node_modules"
355 );
356 }
357}