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 "__pycache__",
73];
74
75pub struct Project {
77 pub path: PathBuf,
79 pub relative: String,
81 pub adapters: Vec<Box<dyn PackageManager>>,
84}
85
86pub fn discover(repo_root: &Path) -> Vec<Project> {
93 discover_to_depth(repo_root, MAX_DEPTH)
94}
95
96pub fn discover_to_depth(repo_root: &Path, depth: usize) -> Vec<Project> {
101 discover_with(repo_root, depth, adapters::detect_adapters)
102}
103
104pub fn discover_all_to_depth(repo_root: &Path, depth: usize) -> Vec<Project> {
109 discover_with(repo_root, depth, adapters::detect_all_adapters)
110}
111
112pub fn discover_dormant_opt_in_to_depth(repo_root: &Path, depth: usize) -> Vec<Project> {
118 discover_with(repo_root, depth, adapters::detect_dormant_opt_in)
119}
120
121fn discover_with(
124 repo_root: &Path,
125 depth: usize,
126 detect: fn(&Path) -> Vec<Box<dyn PackageManager>>,
127) -> Vec<Project> {
128 WalkDir::new(repo_root)
129 .follow_links(false)
130 .max_depth(clamp_depth(depth))
131 .sort_by_file_name()
135 .into_iter()
136 .filter_entry(|entry| entry.depth() == 0 || is_scannable(entry))
137 .flatten()
138 .filter(|entry| entry.file_type().is_dir())
139 .filter_map(|entry| {
140 let adapters = detect(entry.path());
141 if adapters.is_empty() {
142 return None;
143 }
144 Some(Project {
145 relative: relative_label(repo_root, entry.path()),
146 path: entry.path().to_path_buf(),
147 adapters,
148 })
149 })
150 .collect()
151}
152
153pub(crate) fn is_scannable(entry: &DirEntry) -> bool {
159 if !entry.file_type().is_dir() {
162 return true;
163 }
164
165 let name = entry.file_name().to_string_lossy();
166
167 if name.starts_with('.') {
170 return false;
171 }
172
173 if SKIP_DIRS.contains(&name.as_ref()) {
174 return false;
175 }
176
177 let path = entry.path();
178
179 if path.join("pyvenv.cfg").exists() {
182 return false;
183 }
184
185 if path.join(".git").exists() {
189 return false;
190 }
191
192 true
193}
194
195pub fn stray_config_files(repo_root: &Path, depth: usize) -> Vec<String> {
208 WalkDir::new(repo_root)
209 .follow_links(false)
210 .max_depth(clamp_depth(depth).saturating_add(1))
214 .sort_by_file_name()
215 .into_iter()
216 .filter_entry(|entry| entry.depth() == 0 || is_scannable(entry))
217 .flatten()
218 .filter(|entry| entry.depth() > 1 && entry.file_type().is_file())
221 .filter(|entry| {
222 let name = entry.file_name().to_string_lossy();
223 name == crate::constants::PER_REPO_CONFIG_FILE
224 || name == crate::constants::PROJECT_REPO_CONFIG_FILE
225 || name == crate::constants::DEVPRUNE_IGNORE_FILE
226 })
227 .map(|entry| relative_label(repo_root, entry.path()))
228 .collect()
229}
230
231pub fn relative_label(root: &Path, path: &Path) -> String {
237 match path.strip_prefix(root) {
238 Ok(rel) if rel.as_os_str().is_empty() => ".".to_string(),
239 Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
240 Err(_) => path.display().to_string(),
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use std::fs;
248 use tempfile::TempDir;
249
250 fn project(root: &Path, rel: &str, files: &[&str]) -> PathBuf {
252 let dir = if rel == "." {
253 root.to_path_buf()
254 } else {
255 root.join(rel)
256 };
257 fs::create_dir_all(&dir).unwrap();
258 for file in files {
259 fs::write(dir.join(file), "{}").unwrap();
260 }
261 dir
262 }
263
264 fn names(projects: &[Project]) -> Vec<(String, Vec<&'static str>)> {
265 let mut out: Vec<(String, Vec<&'static str>)> = projects
266 .iter()
267 .map(|p| {
268 let mut adapters: Vec<&'static str> = p.adapters.iter().map(|a| a.name()).collect();
269 adapters.sort_unstable();
270 (p.relative.clone(), adapters)
271 })
272 .collect();
273 out.sort();
274 out
275 }
276
277 #[test]
278 fn discovers_nothing_in_an_empty_tree() {
279 let tmp = TempDir::new().unwrap();
280 assert!(discover(tmp.path()).is_empty());
281 }
282
283 #[test]
284 fn discovers_three_ecosystems_in_one_root() {
285 let tmp = TempDir::new().unwrap();
286 project(
287 tmp.path(),
288 ".",
289 &["package.json", "package-lock.json", "uv.lock", "go.mod"],
290 );
291
292 assert_eq!(
293 names(&discover(tmp.path())),
294 vec![(".".to_string(), vec!["go", "npm", "uv"])]
295 );
296 }
297
298 #[test]
299 fn discovers_ecosystems_at_different_depths() {
300 let tmp = TempDir::new().unwrap();
301 project(tmp.path(), "frontend", &["pnpm-lock.yaml"]);
302 project(tmp.path(), "services/api", &["uv.lock"]);
303 project(tmp.path(), "tools/cli", &["go.mod"]);
304
305 assert_eq!(
306 names(&discover(tmp.path())),
307 vec![
308 ("frontend".to_string(), vec!["pnpm"]),
309 ("services/api".to_string(), vec!["uv"]),
310 ("tools/cli".to_string(), vec!["go"]),
311 ]
312 );
313 }
314
315 #[test]
316 fn combines_a_root_project_with_nested_ones() {
317 let tmp = TempDir::new().unwrap();
318 project(tmp.path(), ".", &["go.mod"]);
319 project(tmp.path(), "web", &["package.json", "package-lock.json"]);
320
321 assert_eq!(
322 names(&discover(tmp.path())),
323 vec![
324 (".".to_string(), vec!["go"]),
325 ("web".to_string(), vec!["npm"]),
326 ]
327 );
328 }
329
330 #[test]
331 fn never_descends_into_node_modules() {
332 let tmp = TempDir::new().unwrap();
333 project(tmp.path(), ".", &["package.json", "package-lock.json"]);
334 project(
336 tmp.path(),
337 "node_modules/some-dep",
338 &["package.json", "package-lock.json"],
339 );
340
341 assert_eq!(names(&discover(tmp.path())).len(), 1);
342 }
343
344 #[test]
345 fn never_descends_into_target_or_vendor() {
346 let tmp = TempDir::new().unwrap();
347 project(tmp.path(), ".", &["go.mod"]);
348 project(tmp.path(), "target/debug/build/x", &["go.mod"]);
349 project(tmp.path(), "vendor/dep", &["go.mod"]);
350
351 assert_eq!(
352 names(&discover(tmp.path())),
353 vec![(".".to_string(), vec!["go"])]
354 );
355 }
356
357 #[test]
358 fn never_descends_into_a_virtual_environment() {
359 let tmp = TempDir::new().unwrap();
360 project(tmp.path(), ".", &["uv.lock"]);
361 let venv = project(tmp.path(), "my_env", &["pyvenv.cfg"]);
362 project(&venv, "lib/site-packages/dep", &["go.mod"]);
363
364 assert_eq!(
365 names(&discover(tmp.path())),
366 vec![(".".to_string(), vec!["uv"])]
367 );
368 }
369
370 #[test]
371 fn never_descends_into_a_nested_repository() {
372 let tmp = TempDir::new().unwrap();
373 project(tmp.path(), ".", &["go.mod"]);
374 let sub = project(tmp.path(), "submodule", &["package-lock.json"]);
375 fs::create_dir(sub.join(".git")).unwrap();
376
377 assert_eq!(
378 names(&discover(tmp.path())),
379 vec![(".".to_string(), vec!["go"])]
380 );
381 }
382
383 #[test]
384 fn never_descends_into_hidden_directories() {
385 let tmp = TempDir::new().unwrap();
386 project(tmp.path(), ".github/actions/thing", &["package-lock.json"]);
387 assert!(discover(tmp.path()).is_empty());
388 }
389
390 #[test]
391 fn never_descends_into_python_bytecode_caches() {
392 let tmp = TempDir::new().unwrap();
393 project(tmp.path(), ".", &["requirements.txt"]);
394 project(tmp.path(), ".venv", &["pyvenv.cfg"]);
395 project(
397 tmp.path(),
398 "pkg/__pycache__/some_dep",
399 &["requirements.txt"],
400 );
401
402 assert_eq!(
403 names(&discover(tmp.path())),
404 vec![(".".to_string(), vec!["venv"])]
405 );
406 }
407
408 #[test]
409 fn stops_at_the_depth_cap() {
410 let tmp = TempDir::new().unwrap();
411 let deep = "a/b/c/d/e/f/g/h";
412 project(tmp.path(), deep, &["Cargo.toml"]);
413 assert!(discover(tmp.path()).is_empty());
414 }
415
416 #[test]
417 fn only_a_config_below_the_root_counts_as_stray() {
418 let tmp = TempDir::new().unwrap();
419 let root = tmp.path();
420
421 for name in [
424 crate::constants::PER_REPO_CONFIG_FILE,
425 crate::constants::PROJECT_REPO_CONFIG_FILE,
426 crate::constants::DEVPRUNE_IGNORE_FILE,
427 ] {
428 fs::write(root.join(name), "{}").unwrap();
429 }
430 assert!(stray_config_files(root, 4).is_empty());
431
432 project(
433 root,
434 "services/api",
435 &[crate::constants::PER_REPO_CONFIG_FILE],
436 );
437 project(
438 root,
439 "frontend",
440 &[crate::constants::PROJECT_REPO_CONFIG_FILE],
441 );
442 assert_eq!(
443 stray_config_files(root, 4),
444 vec![
445 "frontend/project.devprune.json".to_string(),
446 "services/api/.devprune.json".to_string(),
447 ]
448 );
449 }
450
451 #[test]
452 fn a_stray_config_inside_a_nested_repository_belongs_to_that_repository() {
453 let tmp = TempDir::new().unwrap();
457 let root = tmp.path();
458
459 let nested = root.join("vendor/lib");
460 fs::create_dir_all(nested.join(".git")).unwrap();
461 fs::write(nested.join(crate::constants::PER_REPO_CONFIG_FILE), "{}").unwrap();
462
463 let hidden = root.join(".backup");
466 fs::create_dir_all(&hidden).unwrap();
467 fs::write(hidden.join(crate::constants::PER_REPO_CONFIG_FILE), "{}").unwrap();
468
469 assert!(stray_config_files(root, 4).is_empty());
470 }
471
472 #[test]
473 fn relative_label_is_slash_separated() {
474 let root = Path::new("/repo");
475 assert_eq!(relative_label(root, Path::new("/repo")), ".");
476 assert_eq!(
477 relative_label(root, Path::new("/repo/a/b/node_modules")),
478 "a/b/node_modules"
479 );
480 }
481}