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
111pub fn discover_dormant_opt_in_to_depth(repo_root: &Path, depth: usize) -> Vec<Project> {
117 discover_with(repo_root, depth, adapters::detect_dormant_opt_in)
118}
119
120fn discover_with(
123 repo_root: &Path,
124 depth: usize,
125 detect: fn(&Path) -> Vec<Box<dyn PackageManager>>,
126) -> Vec<Project> {
127 WalkDir::new(repo_root)
128 .follow_links(false)
129 .max_depth(clamp_depth(depth))
130 .sort_by_file_name()
134 .into_iter()
135 .filter_entry(|entry| entry.depth() == 0 || is_scannable(entry))
136 .flatten()
137 .filter(|entry| entry.file_type().is_dir())
138 .filter_map(|entry| {
139 let adapters = detect(entry.path());
140 if adapters.is_empty() {
141 return None;
142 }
143 Some(Project {
144 relative: relative_label(repo_root, entry.path()),
145 path: entry.path().to_path_buf(),
146 adapters,
147 })
148 })
149 .collect()
150}
151
152fn is_scannable(entry: &DirEntry) -> bool {
154 if !entry.file_type().is_dir() {
157 return true;
158 }
159
160 let name = entry.file_name().to_string_lossy();
161
162 if name.starts_with('.') {
165 return false;
166 }
167
168 if SKIP_DIRS.contains(&name.as_ref()) {
169 return false;
170 }
171
172 let path = entry.path();
173
174 if path.join("pyvenv.cfg").exists() {
177 return false;
178 }
179
180 if path.join(".git").exists() {
184 return false;
185 }
186
187 true
188}
189
190pub fn stray_config_files(repo_root: &Path, depth: usize) -> Vec<String> {
203 WalkDir::new(repo_root)
204 .follow_links(false)
205 .max_depth(clamp_depth(depth).saturating_add(1))
209 .sort_by_file_name()
210 .into_iter()
211 .filter_entry(|entry| entry.depth() == 0 || is_scannable(entry))
212 .flatten()
213 .filter(|entry| entry.depth() > 1 && entry.file_type().is_file())
216 .filter(|entry| {
217 let name = entry.file_name().to_string_lossy();
218 name == crate::constants::PER_REPO_CONFIG_FILE
219 || name == crate::constants::PROJECT_REPO_CONFIG_FILE
220 || name == crate::constants::DEVPRUNE_IGNORE_FILE
221 })
222 .map(|entry| relative_label(repo_root, entry.path()))
223 .collect()
224}
225
226pub fn relative_label(root: &Path, path: &Path) -> String {
232 match path.strip_prefix(root) {
233 Ok(rel) if rel.as_os_str().is_empty() => ".".to_string(),
234 Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
235 Err(_) => path.display().to_string(),
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use std::fs;
243 use tempfile::TempDir;
244
245 fn project(root: &Path, rel: &str, files: &[&str]) -> PathBuf {
247 let dir = if rel == "." {
248 root.to_path_buf()
249 } else {
250 root.join(rel)
251 };
252 fs::create_dir_all(&dir).unwrap();
253 for file in files {
254 fs::write(dir.join(file), "{}").unwrap();
255 }
256 dir
257 }
258
259 fn names(projects: &[Project]) -> Vec<(String, Vec<&'static str>)> {
260 let mut out: Vec<(String, Vec<&'static str>)> = projects
261 .iter()
262 .map(|p| {
263 let mut adapters: Vec<&'static str> = p.adapters.iter().map(|a| a.name()).collect();
264 adapters.sort_unstable();
265 (p.relative.clone(), adapters)
266 })
267 .collect();
268 out.sort();
269 out
270 }
271
272 #[test]
273 fn discovers_nothing_in_an_empty_tree() {
274 let tmp = TempDir::new().unwrap();
275 assert!(discover(tmp.path()).is_empty());
276 }
277
278 #[test]
279 fn discovers_three_ecosystems_in_one_root() {
280 let tmp = TempDir::new().unwrap();
281 project(
282 tmp.path(),
283 ".",
284 &["package.json", "package-lock.json", "uv.lock", "go.mod"],
285 );
286
287 assert_eq!(
288 names(&discover(tmp.path())),
289 vec![(".".to_string(), vec!["go", "npm", "uv"])]
290 );
291 }
292
293 #[test]
294 fn discovers_ecosystems_at_different_depths() {
295 let tmp = TempDir::new().unwrap();
296 project(tmp.path(), "frontend", &["pnpm-lock.yaml"]);
297 project(tmp.path(), "services/api", &["uv.lock"]);
298 project(tmp.path(), "tools/cli", &["go.mod"]);
299
300 assert_eq!(
301 names(&discover(tmp.path())),
302 vec![
303 ("frontend".to_string(), vec!["pnpm"]),
304 ("services/api".to_string(), vec!["uv"]),
305 ("tools/cli".to_string(), vec!["go"]),
306 ]
307 );
308 }
309
310 #[test]
311 fn combines_a_root_project_with_nested_ones() {
312 let tmp = TempDir::new().unwrap();
313 project(tmp.path(), ".", &["go.mod"]);
314 project(tmp.path(), "web", &["package.json", "package-lock.json"]);
315
316 assert_eq!(
317 names(&discover(tmp.path())),
318 vec![
319 (".".to_string(), vec!["go"]),
320 ("web".to_string(), vec!["npm"]),
321 ]
322 );
323 }
324
325 #[test]
326 fn never_descends_into_node_modules() {
327 let tmp = TempDir::new().unwrap();
328 project(tmp.path(), ".", &["package.json", "package-lock.json"]);
329 project(
331 tmp.path(),
332 "node_modules/some-dep",
333 &["package.json", "package-lock.json"],
334 );
335
336 assert_eq!(names(&discover(tmp.path())).len(), 1);
337 }
338
339 #[test]
340 fn never_descends_into_target_or_vendor() {
341 let tmp = TempDir::new().unwrap();
342 project(tmp.path(), ".", &["go.mod"]);
343 project(tmp.path(), "target/debug/build/x", &["go.mod"]);
344 project(tmp.path(), "vendor/dep", &["go.mod"]);
345
346 assert_eq!(
347 names(&discover(tmp.path())),
348 vec![(".".to_string(), vec!["go"])]
349 );
350 }
351
352 #[test]
353 fn never_descends_into_a_virtual_environment() {
354 let tmp = TempDir::new().unwrap();
355 project(tmp.path(), ".", &["uv.lock"]);
356 let venv = project(tmp.path(), "my_env", &["pyvenv.cfg"]);
357 project(&venv, "lib/site-packages/dep", &["go.mod"]);
358
359 assert_eq!(
360 names(&discover(tmp.path())),
361 vec![(".".to_string(), vec!["uv"])]
362 );
363 }
364
365 #[test]
366 fn never_descends_into_a_nested_repository() {
367 let tmp = TempDir::new().unwrap();
368 project(tmp.path(), ".", &["go.mod"]);
369 let sub = project(tmp.path(), "submodule", &["package-lock.json"]);
370 fs::create_dir(sub.join(".git")).unwrap();
371
372 assert_eq!(
373 names(&discover(tmp.path())),
374 vec![(".".to_string(), vec!["go"])]
375 );
376 }
377
378 #[test]
379 fn never_descends_into_hidden_directories() {
380 let tmp = TempDir::new().unwrap();
381 project(tmp.path(), ".github/actions/thing", &["package-lock.json"]);
382 assert!(discover(tmp.path()).is_empty());
383 }
384
385 #[test]
386 fn stops_at_the_depth_cap() {
387 let tmp = TempDir::new().unwrap();
388 let deep = "a/b/c/d/e/f/g/h";
389 project(tmp.path(), deep, &["Cargo.toml"]);
390 assert!(discover(tmp.path()).is_empty());
391 }
392
393 #[test]
394 fn only_a_config_below_the_root_counts_as_stray() {
395 let tmp = TempDir::new().unwrap();
396 let root = tmp.path();
397
398 for name in [
401 crate::constants::PER_REPO_CONFIG_FILE,
402 crate::constants::PROJECT_REPO_CONFIG_FILE,
403 crate::constants::DEVPRUNE_IGNORE_FILE,
404 ] {
405 fs::write(root.join(name), "{}").unwrap();
406 }
407 assert!(stray_config_files(root, 4).is_empty());
408
409 project(
410 root,
411 "services/api",
412 &[crate::constants::PER_REPO_CONFIG_FILE],
413 );
414 project(
415 root,
416 "frontend",
417 &[crate::constants::PROJECT_REPO_CONFIG_FILE],
418 );
419 assert_eq!(
420 stray_config_files(root, 4),
421 vec![
422 "frontend/project.devprune.json".to_string(),
423 "services/api/.devprune.json".to_string(),
424 ]
425 );
426 }
427
428 #[test]
429 fn a_stray_config_inside_a_nested_repository_belongs_to_that_repository() {
430 let tmp = TempDir::new().unwrap();
434 let root = tmp.path();
435
436 let nested = root.join("vendor/lib");
437 fs::create_dir_all(nested.join(".git")).unwrap();
438 fs::write(nested.join(crate::constants::PER_REPO_CONFIG_FILE), "{}").unwrap();
439
440 let hidden = root.join(".backup");
443 fs::create_dir_all(&hidden).unwrap();
444 fs::write(hidden.join(crate::constants::PER_REPO_CONFIG_FILE), "{}").unwrap();
445
446 assert!(stray_config_files(root, 4).is_empty());
447 }
448
449 #[test]
450 fn relative_label_is_slash_separated() {
451 let root = Path::new("/repo");
452 assert_eq!(relative_label(root, Path::new("/repo")), ".");
453 assert_eq!(
454 relative_label(root, Path::new("/repo/a/b/node_modules")),
455 "a/b/node_modules"
456 );
457 }
458}