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