Skip to main content

dev_prune/
workspace.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Workspace discovery — finds every package-manager project inside a repository.
5//
6// A repository is not necessarily one project. A monorepo can carry `frontend/` on
7// pnpm, `services/api/` on uv, and `cli/` on cargo, or hold all three manifests side
8// by side in the root. This module walks the repo once and reports every directory
9// where at least one adapter applies, so the engine can prune, verify and restore each
10// of them independently.
11//
12// The walk deliberately never descends into the directories it is looking for. A
13// `node_modules` tree contains thousands of nested `package.json` files, each of which
14// would otherwise register as its own project.
15
16use std::path::{Path, PathBuf};
17
18use walkdir::{DirEntry, WalkDir};
19
20use crate::adapters::{self, PackageManager};
21
22/// The depth [`discover`] uses when no configuration says otherwise.
23///
24/// Re-exported from [`crate::constants`] so callers that only need the default do not
25/// have to reach past this module for it.
26pub const MAX_DEPTH: usize = crate::constants::DEFAULT_SCAN_DEPTH;
27
28/// The depth to walk `repo_root` with, given the global setting.
29///
30/// Resolution order is the same one every other tunable follows: the repository's own
31/// `.devprune.json` wins over the global setting, which wins over the default. A config
32/// that will not parse is *not* consulted — the caller reports that repository as a
33/// `config_error` and never gets here — so this deliberately looks only at a config it
34/// could read.
35pub 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
44/// Hold a requested depth inside the range the walk can afford.
45///
46/// A zero would find nothing at all — not even the repository root, which is depth 0 in
47/// `WalkDir` terms but only yields projects because the walk includes it — so it is
48/// raised to 1 rather than silently pruning nothing. The ceiling keeps a mistyped
49/// `scan_depth: 900` from turning a background pass into a full-disk crawl.
50pub fn clamp_depth(requested: usize) -> usize {
51    requested.clamp(1, crate::constants::MAX_SCAN_DEPTH_LIMIT)
52}
53
54/// Directory names that are never descended into.
55///
56/// Hidden directories, virtual environments and nested repositories are excluded
57/// separately in [`is_scannable`] because they cannot be matched by name alone.
58const SKIP_DIRS: [&str; 4] = ["node_modules", "target", "vendor", "bower_components"];
59
60/// A directory inside a repository that at least one package manager owns.
61pub struct Project {
62    /// Absolute path to the project directory.
63    pub path: PathBuf,
64    /// Path relative to the repository root, `/`-separated. `"."` for the root itself.
65    pub relative: String,
66    /// Adapters that apply here. More than one is normal — cargo and npm in the same
67    /// directory own `target` and `node_modules` respectively.
68    pub adapters: Vec<Box<dyn PackageManager>>,
69}
70
71/// Find every package-manager project in `repo_root`, including the root itself.
72///
73/// Walks to the default depth. Callers that have the user's settings to hand should use
74/// [`discover_to_depth`] with [`resolve_depth`] instead.
75///
76/// Returns an empty vector when nothing in the tree is recognised.
77pub fn discover(repo_root: &Path) -> Vec<Project> {
78    discover_to_depth(repo_root, MAX_DEPTH)
79}
80
81/// [`discover`], to an explicit depth.
82///
83/// `depth` is clamped, so a caller cannot hand this an unbounded or useless walk even by
84/// reading a hand-edited config straight off disk.
85pub 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        // Directory order otherwise comes from the filesystem, so the same repository
90        // lists its projects in a different order on different machines — and so does
91        // every prune summary and JSON document built from them.
92        .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
111/// Whether the walk should descend into this entry.
112fn is_scannable(entry: &DirEntry) -> bool {
113    // Symlinked directories report as symlinks with `follow_links(false)` and are never
114    // descended, so only real directories need filtering. Files pass through untouched.
115    if !entry.file_type().is_dir() {
116        return true;
117    }
118
119    let name = entry.file_name().to_string_lossy();
120
121    // `.git`, `.venv`, `.tox`, `.next`, `.turbo`, editor state — none of them hold
122    // projects worth pruning, and all of them are expensive to walk.
123    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    // A virtual environment can be called anything; `pyvenv.cfg` is the marker. It is
134    // pruneable output, not a project, and it contains a full package tree.
135    if path.join("pyvenv.cfg").exists() {
136        return false;
137    }
138
139    // Submodules and nested clones are separate repositories with their own activity
140    // history and their own `.devprune.json`. They are registered and pruned in their
141    // own right, never as part of their parent.
142    if path.join(".git").exists() {
143        return false;
144    }
145
146    true
147}
148
149/// Render `path` relative to `root` with forward slashes; `"."` when they are equal.
150///
151/// Used for every user-facing directory label so that `frontend/node_modules` reads the
152/// same on Windows as on Linux, and so the interactive selector can address a specific
153/// nested directory unambiguously.
154pub 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    /// Create `dir` and drop the given files into it.
169    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        // A dependency that ships its own lockfile must not become a project.
253        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}