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