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 stray_config_files(repo_root: &Path, depth: usize) -> Vec<String> {
194 WalkDir::new(repo_root)
195 .follow_links(false)
196 .max_depth(clamp_depth(depth).saturating_add(1))
200 .sort_by_file_name()
201 .into_iter()
202 .filter_entry(|entry| entry.depth() == 0 || is_scannable(entry))
203 .flatten()
204 .filter(|entry| entry.depth() > 1 && entry.file_type().is_file())
207 .filter(|entry| {
208 let name = entry.file_name().to_string_lossy();
209 name == crate::constants::PER_REPO_CONFIG_FILE
210 || name == crate::constants::PROJECT_REPO_CONFIG_FILE
211 || name == crate::constants::DEVPRUNE_IGNORE_FILE
212 })
213 .map(|entry| relative_label(repo_root, entry.path()))
214 .collect()
215}
216
217pub fn relative_label(root: &Path, path: &Path) -> String {
223 match path.strip_prefix(root) {
224 Ok(rel) if rel.as_os_str().is_empty() => ".".to_string(),
225 Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
226 Err(_) => path.display().to_string(),
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use std::fs;
234 use tempfile::TempDir;
235
236 fn project(root: &Path, rel: &str, files: &[&str]) -> PathBuf {
238 let dir = if rel == "." {
239 root.to_path_buf()
240 } else {
241 root.join(rel)
242 };
243 fs::create_dir_all(&dir).unwrap();
244 for file in files {
245 fs::write(dir.join(file), "{}").unwrap();
246 }
247 dir
248 }
249
250 fn names(projects: &[Project]) -> Vec<(String, Vec<&'static str>)> {
251 let mut out: Vec<(String, Vec<&'static str>)> = projects
252 .iter()
253 .map(|p| {
254 let mut adapters: Vec<&'static str> = p.adapters.iter().map(|a| a.name()).collect();
255 adapters.sort_unstable();
256 (p.relative.clone(), adapters)
257 })
258 .collect();
259 out.sort();
260 out
261 }
262
263 #[test]
264 fn discovers_nothing_in_an_empty_tree() {
265 let tmp = TempDir::new().unwrap();
266 assert!(discover(tmp.path()).is_empty());
267 }
268
269 #[test]
270 fn discovers_three_ecosystems_in_one_root() {
271 let tmp = TempDir::new().unwrap();
272 project(
273 tmp.path(),
274 ".",
275 &["package.json", "package-lock.json", "uv.lock", "go.mod"],
276 );
277
278 assert_eq!(
279 names(&discover(tmp.path())),
280 vec![(".".to_string(), vec!["go", "npm", "uv"])]
281 );
282 }
283
284 #[test]
285 fn discovers_ecosystems_at_different_depths() {
286 let tmp = TempDir::new().unwrap();
287 project(tmp.path(), "frontend", &["pnpm-lock.yaml"]);
288 project(tmp.path(), "services/api", &["uv.lock"]);
289 project(tmp.path(), "tools/cli", &["go.mod"]);
290
291 assert_eq!(
292 names(&discover(tmp.path())),
293 vec![
294 ("frontend".to_string(), vec!["pnpm"]),
295 ("services/api".to_string(), vec!["uv"]),
296 ("tools/cli".to_string(), vec!["go"]),
297 ]
298 );
299 }
300
301 #[test]
302 fn combines_a_root_project_with_nested_ones() {
303 let tmp = TempDir::new().unwrap();
304 project(tmp.path(), ".", &["go.mod"]);
305 project(tmp.path(), "web", &["package.json", "package-lock.json"]);
306
307 assert_eq!(
308 names(&discover(tmp.path())),
309 vec![
310 (".".to_string(), vec!["go"]),
311 ("web".to_string(), vec!["npm"]),
312 ]
313 );
314 }
315
316 #[test]
317 fn never_descends_into_node_modules() {
318 let tmp = TempDir::new().unwrap();
319 project(tmp.path(), ".", &["package.json", "package-lock.json"]);
320 project(
322 tmp.path(),
323 "node_modules/some-dep",
324 &["package.json", "package-lock.json"],
325 );
326
327 assert_eq!(names(&discover(tmp.path())).len(), 1);
328 }
329
330 #[test]
331 fn never_descends_into_target_or_vendor() {
332 let tmp = TempDir::new().unwrap();
333 project(tmp.path(), ".", &["go.mod"]);
334 project(tmp.path(), "target/debug/build/x", &["go.mod"]);
335 project(tmp.path(), "vendor/dep", &["go.mod"]);
336
337 assert_eq!(
338 names(&discover(tmp.path())),
339 vec![(".".to_string(), vec!["go"])]
340 );
341 }
342
343 #[test]
344 fn never_descends_into_a_virtual_environment() {
345 let tmp = TempDir::new().unwrap();
346 project(tmp.path(), ".", &["uv.lock"]);
347 let venv = project(tmp.path(), "my_env", &["pyvenv.cfg"]);
348 project(&venv, "lib/site-packages/dep", &["go.mod"]);
349
350 assert_eq!(
351 names(&discover(tmp.path())),
352 vec![(".".to_string(), vec!["uv"])]
353 );
354 }
355
356 #[test]
357 fn never_descends_into_a_nested_repository() {
358 let tmp = TempDir::new().unwrap();
359 project(tmp.path(), ".", &["go.mod"]);
360 let sub = project(tmp.path(), "submodule", &["package-lock.json"]);
361 fs::create_dir(sub.join(".git")).unwrap();
362
363 assert_eq!(
364 names(&discover(tmp.path())),
365 vec![(".".to_string(), vec!["go"])]
366 );
367 }
368
369 #[test]
370 fn never_descends_into_hidden_directories() {
371 let tmp = TempDir::new().unwrap();
372 project(tmp.path(), ".github/actions/thing", &["package-lock.json"]);
373 assert!(discover(tmp.path()).is_empty());
374 }
375
376 #[test]
377 fn stops_at_the_depth_cap() {
378 let tmp = TempDir::new().unwrap();
379 let deep = "a/b/c/d/e/f/g/h";
380 project(tmp.path(), deep, &["Cargo.toml"]);
381 assert!(discover(tmp.path()).is_empty());
382 }
383
384 #[test]
385 fn only_a_config_below_the_root_counts_as_stray() {
386 let tmp = TempDir::new().unwrap();
387 let root = tmp.path();
388
389 for name in [
392 crate::constants::PER_REPO_CONFIG_FILE,
393 crate::constants::PROJECT_REPO_CONFIG_FILE,
394 crate::constants::DEVPRUNE_IGNORE_FILE,
395 ] {
396 fs::write(root.join(name), "{}").unwrap();
397 }
398 assert!(stray_config_files(root, 4).is_empty());
399
400 project(
401 root,
402 "services/api",
403 &[crate::constants::PER_REPO_CONFIG_FILE],
404 );
405 project(
406 root,
407 "frontend",
408 &[crate::constants::PROJECT_REPO_CONFIG_FILE],
409 );
410 assert_eq!(
411 stray_config_files(root, 4),
412 vec![
413 "frontend/project.devprune.json".to_string(),
414 "services/api/.devprune.json".to_string(),
415 ]
416 );
417 }
418
419 #[test]
420 fn a_stray_config_inside_a_nested_repository_belongs_to_that_repository() {
421 let tmp = TempDir::new().unwrap();
425 let root = tmp.path();
426
427 let nested = root.join("vendor/lib");
428 fs::create_dir_all(nested.join(".git")).unwrap();
429 fs::write(nested.join(crate::constants::PER_REPO_CONFIG_FILE), "{}").unwrap();
430
431 let hidden = root.join(".backup");
434 fs::create_dir_all(&hidden).unwrap();
435 fs::write(hidden.join(crate::constants::PER_REPO_CONFIG_FILE), "{}").unwrap();
436
437 assert!(stray_config_files(root, 4).is_empty());
438 }
439
440 #[test]
441 fn relative_label_is_slash_separated() {
442 let root = Path::new("/repo");
443 assert_eq!(relative_label(root, Path::new("/repo")), ".");
444 assert_eq!(
445 relative_label(root, Path::new("/repo/a/b/node_modules")),
446 "a/b/node_modules"
447 );
448 }
449}