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.
58/// Several of these hold whole projects of their own: `deps/` is full of Elixir
59/// packages with their own `mix.exs`, `.build/` of Swift checkouts with their own
60/// `Package.swift`. Descending would register a dependency as a project and offer to
61/// prune inside something the parent repository rebuilds wholesale.
62const 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
74/// A directory inside a repository that at least one package manager owns.
75pub struct Project {
76    /// Absolute path to the project directory.
77    pub path: PathBuf,
78    /// Path relative to the repository root, `/`-separated. `"."` for the root itself.
79    pub relative: String,
80    /// Adapters that apply here. More than one is normal — cargo and npm in the same
81    /// directory own `target` and `node_modules` respectively.
82    pub adapters: Vec<Box<dyn PackageManager>>,
83}
84
85/// Find every package-manager project in `repo_root`, including the root itself.
86///
87/// Walks to the default depth. Callers that have the user's settings to hand should use
88/// [`discover_to_depth`] with [`resolve_depth`] instead.
89///
90/// Returns an empty vector when nothing in the tree is recognised.
91pub fn discover(repo_root: &Path) -> Vec<Project> {
92    discover_to_depth(repo_root, MAX_DEPTH)
93}
94
95/// [`discover`], to an explicit depth.
96///
97/// `depth` is clamped, so a caller cannot hand this an unbounded or useless walk even by
98/// reading a hand-edited config straight off disk.
99pub fn discover_to_depth(repo_root: &Path, depth: usize) -> Vec<Project> {
100    discover_with(repo_root, depth, adapters::detect_adapters)
101}
102
103/// [`discover_to_depth`], counting managers the user has switched off for pruning too.
104///
105/// For the one caller that is asking which package managers a repository *uses* rather
106/// than which ones a pass would act on: see [`adapters::detect_all_adapters`].
107pub fn discover_all_to_depth(repo_root: &Path, depth: usize) -> Vec<Project> {
108    discover_with(repo_root, depth, adapters::detect_all_adapters)
109}
110
111/// The walk both discovery functions share, parameterised only by which detector runs
112/// at each directory.
113fn discover_with(
114    repo_root: &Path,
115    depth: usize,
116    detect: fn(&Path) -> Vec<Box<dyn PackageManager>>,
117) -> Vec<Project> {
118    WalkDir::new(repo_root)
119        .follow_links(false)
120        .max_depth(clamp_depth(depth))
121        // Directory order otherwise comes from the filesystem, so the same repository
122        // lists its projects in a different order on different machines — and so does
123        // every prune summary and JSON document built from them.
124        .sort_by_file_name()
125        .into_iter()
126        .filter_entry(|entry| entry.depth() == 0 || is_scannable(entry))
127        .flatten()
128        .filter(|entry| entry.file_type().is_dir())
129        .filter_map(|entry| {
130            let adapters = detect(entry.path());
131            if adapters.is_empty() {
132                return None;
133            }
134            Some(Project {
135                relative: relative_label(repo_root, entry.path()),
136                path: entry.path().to_path_buf(),
137                adapters,
138            })
139        })
140        .collect()
141}
142
143/// Whether the walk should descend into this entry.
144fn is_scannable(entry: &DirEntry) -> bool {
145    // Symlinked directories report as symlinks with `follow_links(false)` and are never
146    // descended, so only real directories need filtering. Files pass through untouched.
147    if !entry.file_type().is_dir() {
148        return true;
149    }
150
151    let name = entry.file_name().to_string_lossy();
152
153    // `.git`, `.venv`, `.tox`, `.next`, `.turbo`, editor state — none of them hold
154    // projects worth pruning, and all of them are expensive to walk.
155    if name.starts_with('.') {
156        return false;
157    }
158
159    if SKIP_DIRS.contains(&name.as_ref()) {
160        return false;
161    }
162
163    let path = entry.path();
164
165    // A virtual environment can be called anything; `pyvenv.cfg` is the marker. It is
166    // pruneable output, not a project, and it contains a full package tree.
167    if path.join("pyvenv.cfg").exists() {
168        return false;
169    }
170
171    // Submodules and nested clones are separate repositories with their own activity
172    // history and their own `.devprune.json`. They are registered and pruned in their
173    // own right, never as part of their parent.
174    if path.join(".git").exists() {
175        return false;
176    }
177
178    true
179}
180
181/// Every config file below `repo_root` — which is to say, every one with no effect.
182///
183/// All three are read from the repository root and nowhere else, because the paths
184/// inside them are relative to that root. A copy one directory down is not a narrower
185/// scope, it is a file nothing ever opens, and the failure is silent in the worst way:
186/// the settings look written, `git status` stays clean, and the pass behaves as though
187/// they were never typed.
188///
189/// Bounded by the repository's own scan depth rather than walking the whole tree. That
190/// is the region dev-prune already treats as this repository, and a doctor run that
191/// walks a monorepo end to end looking for three filenames is a diagnostic nobody waits
192/// for twice.
193pub fn stray_config_files(repo_root: &Path, depth: usize) -> Vec<String> {
194    WalkDir::new(repo_root)
195        .follow_links(false)
196        // One deeper than the project scan, because a file sits one level below the
197        // directory holding it: the deepest directory that scan reaches is exactly the
198        // deepest one a config file could be hiding in.
199        .max_depth(clamp_depth(depth).saturating_add(1))
200        .sort_by_file_name()
201        .into_iter()
202        .filter_entry(|entry| entry.depth() == 0 || is_scannable(entry))
203        .flatten()
204        // Depth 0 is the root directory; its own files are depth 1, and that is where
205        // these three belong.
206        .filter(|entry| entry.depth() > 1 && entry.file_type().is_file())
207        .filter(|entry| {
208            let name = entry.file_name().to_string_lossy();
209            name == crate::constants::PER_REPO_CONFIG_FILE
210                || name == crate::constants::PROJECT_REPO_CONFIG_FILE
211                || name == crate::constants::DEVPRUNE_IGNORE_FILE
212        })
213        .map(|entry| relative_label(repo_root, entry.path()))
214        .collect()
215}
216
217/// Render `path` relative to `root` with forward slashes; `"."` when they are equal.
218///
219/// Used for every user-facing directory label so that `frontend/node_modules` reads the
220/// same on Windows as on Linux, and so the interactive selector can address a specific
221/// nested directory unambiguously.
222pub fn relative_label(root: &Path, path: &Path) -> String {
223    match path.strip_prefix(root) {
224        Ok(rel) if rel.as_os_str().is_empty() => ".".to_string(),
225        Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
226        Err(_) => path.display().to_string(),
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use std::fs;
234    use tempfile::TempDir;
235
236    /// Create `dir` and drop the given files into it.
237    fn project(root: &Path, rel: &str, files: &[&str]) -> PathBuf {
238        let dir = if rel == "." {
239            root.to_path_buf()
240        } else {
241            root.join(rel)
242        };
243        fs::create_dir_all(&dir).unwrap();
244        for file in files {
245            fs::write(dir.join(file), "{}").unwrap();
246        }
247        dir
248    }
249
250    fn names(projects: &[Project]) -> Vec<(String, Vec<&'static str>)> {
251        let mut out: Vec<(String, Vec<&'static str>)> = projects
252            .iter()
253            .map(|p| {
254                let mut adapters: Vec<&'static str> = p.adapters.iter().map(|a| a.name()).collect();
255                adapters.sort_unstable();
256                (p.relative.clone(), adapters)
257            })
258            .collect();
259        out.sort();
260        out
261    }
262
263    #[test]
264    fn discovers_nothing_in_an_empty_tree() {
265        let tmp = TempDir::new().unwrap();
266        assert!(discover(tmp.path()).is_empty());
267    }
268
269    #[test]
270    fn discovers_three_ecosystems_in_one_root() {
271        let tmp = TempDir::new().unwrap();
272        project(
273            tmp.path(),
274            ".",
275            &["package.json", "package-lock.json", "uv.lock", "go.mod"],
276        );
277
278        assert_eq!(
279            names(&discover(tmp.path())),
280            vec![(".".to_string(), vec!["go", "npm", "uv"])]
281        );
282    }
283
284    #[test]
285    fn discovers_ecosystems_at_different_depths() {
286        let tmp = TempDir::new().unwrap();
287        project(tmp.path(), "frontend", &["pnpm-lock.yaml"]);
288        project(tmp.path(), "services/api", &["uv.lock"]);
289        project(tmp.path(), "tools/cli", &["go.mod"]);
290
291        assert_eq!(
292            names(&discover(tmp.path())),
293            vec![
294                ("frontend".to_string(), vec!["pnpm"]),
295                ("services/api".to_string(), vec!["uv"]),
296                ("tools/cli".to_string(), vec!["go"]),
297            ]
298        );
299    }
300
301    #[test]
302    fn combines_a_root_project_with_nested_ones() {
303        let tmp = TempDir::new().unwrap();
304        project(tmp.path(), ".", &["go.mod"]);
305        project(tmp.path(), "web", &["package.json", "package-lock.json"]);
306
307        assert_eq!(
308            names(&discover(tmp.path())),
309            vec![
310                (".".to_string(), vec!["go"]),
311                ("web".to_string(), vec!["npm"]),
312            ]
313        );
314    }
315
316    #[test]
317    fn never_descends_into_node_modules() {
318        let tmp = TempDir::new().unwrap();
319        project(tmp.path(), ".", &["package.json", "package-lock.json"]);
320        // A dependency that ships its own lockfile must not become a project.
321        project(
322            tmp.path(),
323            "node_modules/some-dep",
324            &["package.json", "package-lock.json"],
325        );
326
327        assert_eq!(names(&discover(tmp.path())).len(), 1);
328    }
329
330    #[test]
331    fn never_descends_into_target_or_vendor() {
332        let tmp = TempDir::new().unwrap();
333        project(tmp.path(), ".", &["go.mod"]);
334        project(tmp.path(), "target/debug/build/x", &["go.mod"]);
335        project(tmp.path(), "vendor/dep", &["go.mod"]);
336
337        assert_eq!(
338            names(&discover(tmp.path())),
339            vec![(".".to_string(), vec!["go"])]
340        );
341    }
342
343    #[test]
344    fn never_descends_into_a_virtual_environment() {
345        let tmp = TempDir::new().unwrap();
346        project(tmp.path(), ".", &["uv.lock"]);
347        let venv = project(tmp.path(), "my_env", &["pyvenv.cfg"]);
348        project(&venv, "lib/site-packages/dep", &["go.mod"]);
349
350        assert_eq!(
351            names(&discover(tmp.path())),
352            vec![(".".to_string(), vec!["uv"])]
353        );
354    }
355
356    #[test]
357    fn never_descends_into_a_nested_repository() {
358        let tmp = TempDir::new().unwrap();
359        project(tmp.path(), ".", &["go.mod"]);
360        let sub = project(tmp.path(), "submodule", &["package-lock.json"]);
361        fs::create_dir(sub.join(".git")).unwrap();
362
363        assert_eq!(
364            names(&discover(tmp.path())),
365            vec![(".".to_string(), vec!["go"])]
366        );
367    }
368
369    #[test]
370    fn never_descends_into_hidden_directories() {
371        let tmp = TempDir::new().unwrap();
372        project(tmp.path(), ".github/actions/thing", &["package-lock.json"]);
373        assert!(discover(tmp.path()).is_empty());
374    }
375
376    #[test]
377    fn stops_at_the_depth_cap() {
378        let tmp = TempDir::new().unwrap();
379        let deep = "a/b/c/d/e/f/g/h";
380        project(tmp.path(), deep, &["Cargo.toml"]);
381        assert!(discover(tmp.path()).is_empty());
382    }
383
384    #[test]
385    fn only_a_config_below_the_root_counts_as_stray() {
386        let tmp = TempDir::new().unwrap();
387        let root = tmp.path();
388
389        // The root copies are the ones that work. Reporting them would send somebody to
390        // move the only file that was ever being read.
391        for name in [
392            crate::constants::PER_REPO_CONFIG_FILE,
393            crate::constants::PROJECT_REPO_CONFIG_FILE,
394            crate::constants::DEVPRUNE_IGNORE_FILE,
395        ] {
396            fs::write(root.join(name), "{}").unwrap();
397        }
398        assert!(stray_config_files(root, 4).is_empty());
399
400        project(
401            root,
402            "services/api",
403            &[crate::constants::PER_REPO_CONFIG_FILE],
404        );
405        project(
406            root,
407            "frontend",
408            &[crate::constants::PROJECT_REPO_CONFIG_FILE],
409        );
410        assert_eq!(
411            stray_config_files(root, 4),
412            vec![
413                "frontend/project.devprune.json".to_string(),
414                "services/api/.devprune.json".to_string(),
415            ]
416        );
417    }
418
419    #[test]
420    fn a_stray_config_inside_a_nested_repository_belongs_to_that_repository() {
421        // `is_scannable` stops at a nested `.git`, and this walk inherits that: the file
422        // is at the root of a repository dev-prune registers in its own right, so it is
423        // read, and calling it stray would be wrong twice over.
424        let tmp = TempDir::new().unwrap();
425        let root = tmp.path();
426
427        let nested = root.join("vendor/lib");
428        fs::create_dir_all(nested.join(".git")).unwrap();
429        fs::write(nested.join(crate::constants::PER_REPO_CONFIG_FILE), "{}").unwrap();
430
431        // Hidden directories are skipped for the same reason they are skipped by the
432        // project scan: nothing in them is a repository of ours.
433        let hidden = root.join(".backup");
434        fs::create_dir_all(&hidden).unwrap();
435        fs::write(hidden.join(crate::constants::PER_REPO_CONFIG_FILE), "{}").unwrap();
436
437        assert!(stray_config_files(root, 4).is_empty());
438    }
439
440    #[test]
441    fn relative_label_is_slash_separated() {
442        let root = Path::new("/repo");
443        assert_eq!(relative_label(root, Path::new("/repo")), ".");
444        assert_eq!(
445            relative_label(root, Path::new("/repo/a/b/node_modules")),
446            "a/b/node_modules"
447        );
448    }
449}