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