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 WalkDir::new(repo_root)
101 .follow_links(false)
102 .max_depth(clamp_depth(depth))
103 .sort_by_file_name()
107 .into_iter()
108 .filter_entry(|entry| entry.depth() == 0 || is_scannable(entry))
109 .flatten()
110 .filter(|entry| entry.file_type().is_dir())
111 .filter_map(|entry| {
112 let adapters = adapters::detect_adapters(entry.path());
113 if adapters.is_empty() {
114 return None;
115 }
116 Some(Project {
117 relative: relative_label(repo_root, entry.path()),
118 path: entry.path().to_path_buf(),
119 adapters,
120 })
121 })
122 .collect()
123}
124
125fn is_scannable(entry: &DirEntry) -> bool {
127 if !entry.file_type().is_dir() {
130 return true;
131 }
132
133 let name = entry.file_name().to_string_lossy();
134
135 if name.starts_with('.') {
138 return false;
139 }
140
141 if SKIP_DIRS.contains(&name.as_ref()) {
142 return false;
143 }
144
145 let path = entry.path();
146
147 if path.join("pyvenv.cfg").exists() {
150 return false;
151 }
152
153 if path.join(".git").exists() {
157 return false;
158 }
159
160 true
161}
162
163pub fn relative_label(root: &Path, path: &Path) -> String {
169 match path.strip_prefix(root) {
170 Ok(rel) if rel.as_os_str().is_empty() => ".".to_string(),
171 Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
172 Err(_) => path.display().to_string(),
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use std::fs;
180 use tempfile::TempDir;
181
182 fn project(root: &Path, rel: &str, files: &[&str]) -> PathBuf {
184 let dir = if rel == "." {
185 root.to_path_buf()
186 } else {
187 root.join(rel)
188 };
189 fs::create_dir_all(&dir).unwrap();
190 for file in files {
191 fs::write(dir.join(file), "{}").unwrap();
192 }
193 dir
194 }
195
196 fn names(projects: &[Project]) -> Vec<(String, Vec<&'static str>)> {
197 let mut out: Vec<(String, Vec<&'static str>)> = projects
198 .iter()
199 .map(|p| {
200 let mut adapters: Vec<&'static str> = p.adapters.iter().map(|a| a.name()).collect();
201 adapters.sort_unstable();
202 (p.relative.clone(), adapters)
203 })
204 .collect();
205 out.sort();
206 out
207 }
208
209 #[test]
210 fn discovers_nothing_in_an_empty_tree() {
211 let tmp = TempDir::new().unwrap();
212 assert!(discover(tmp.path()).is_empty());
213 }
214
215 #[test]
216 fn discovers_three_ecosystems_in_one_root() {
217 let tmp = TempDir::new().unwrap();
218 project(
219 tmp.path(),
220 ".",
221 &["package.json", "package-lock.json", "uv.lock", "Cargo.toml"],
222 );
223
224 assert_eq!(
225 names(&discover(tmp.path())),
226 vec![(".".to_string(), vec!["cargo", "npm", "uv"])]
227 );
228 }
229
230 #[test]
231 fn discovers_ecosystems_at_different_depths() {
232 let tmp = TempDir::new().unwrap();
233 project(tmp.path(), "frontend", &["pnpm-lock.yaml"]);
234 project(tmp.path(), "services/api", &["uv.lock"]);
235 project(tmp.path(), "tools/cli", &["Cargo.toml"]);
236
237 assert_eq!(
238 names(&discover(tmp.path())),
239 vec![
240 ("frontend".to_string(), vec!["pnpm"]),
241 ("services/api".to_string(), vec!["uv"]),
242 ("tools/cli".to_string(), vec!["cargo"]),
243 ]
244 );
245 }
246
247 #[test]
248 fn combines_a_root_project_with_nested_ones() {
249 let tmp = TempDir::new().unwrap();
250 project(tmp.path(), ".", &["Cargo.toml"]);
251 project(tmp.path(), "web", &["package.json", "package-lock.json"]);
252
253 assert_eq!(
254 names(&discover(tmp.path())),
255 vec![
256 (".".to_string(), vec!["cargo"]),
257 ("web".to_string(), vec!["npm"]),
258 ]
259 );
260 }
261
262 #[test]
263 fn never_descends_into_node_modules() {
264 let tmp = TempDir::new().unwrap();
265 project(tmp.path(), ".", &["package.json", "package-lock.json"]);
266 project(
268 tmp.path(),
269 "node_modules/some-dep",
270 &["package.json", "package-lock.json"],
271 );
272
273 assert_eq!(names(&discover(tmp.path())).len(), 1);
274 }
275
276 #[test]
277 fn never_descends_into_target_or_vendor() {
278 let tmp = TempDir::new().unwrap();
279 project(tmp.path(), ".", &["Cargo.toml"]);
280 project(tmp.path(), "target/debug/build/x", &["Cargo.toml"]);
281 project(tmp.path(), "vendor/dep", &["go.mod"]);
282
283 assert_eq!(
284 names(&discover(tmp.path())),
285 vec![(".".to_string(), vec!["cargo"])]
286 );
287 }
288
289 #[test]
290 fn never_descends_into_a_virtual_environment() {
291 let tmp = TempDir::new().unwrap();
292 project(tmp.path(), ".", &["uv.lock"]);
293 let venv = project(tmp.path(), "my_env", &["pyvenv.cfg"]);
294 project(&venv, "lib/site-packages/dep", &["Cargo.toml"]);
295
296 assert_eq!(
297 names(&discover(tmp.path())),
298 vec![(".".to_string(), vec!["uv"])]
299 );
300 }
301
302 #[test]
303 fn never_descends_into_a_nested_repository() {
304 let tmp = TempDir::new().unwrap();
305 project(tmp.path(), ".", &["Cargo.toml"]);
306 let sub = project(tmp.path(), "submodule", &["package-lock.json"]);
307 fs::create_dir(sub.join(".git")).unwrap();
308
309 assert_eq!(
310 names(&discover(tmp.path())),
311 vec![(".".to_string(), vec!["cargo"])]
312 );
313 }
314
315 #[test]
316 fn never_descends_into_hidden_directories() {
317 let tmp = TempDir::new().unwrap();
318 project(tmp.path(), ".github/actions/thing", &["package-lock.json"]);
319 assert!(discover(tmp.path()).is_empty());
320 }
321
322 #[test]
323 fn stops_at_the_depth_cap() {
324 let tmp = TempDir::new().unwrap();
325 let deep = "a/b/c/d/e/f/g/h";
326 project(tmp.path(), deep, &["Cargo.toml"]);
327 assert!(discover(tmp.path()).is_empty());
328 }
329
330 #[test]
331 fn relative_label_is_slash_separated() {
332 let root = Path::new("/repo");
333 assert_eq!(relative_label(root, Path::new("/repo")), ".");
334 assert_eq!(
335 relative_label(root, Path::new("/repo/a/b/node_modules")),
336 "a/b/node_modules"
337 );
338 }
339}