Skip to main content

dev_prune/
declared.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4//! Directories a project declares prunable, and the checks that let dev-prune act on
5//! one.
6//!
7//! Every adapter in this tool earns the right to delete a directory the same way: it
8//! finds a lockfile, verifies the lockfile can rebuild what is about to go, and only
9//! then deletes. A declaration is that same bargain written by hand, for the tree no
10//! adapter can recognise — a generated fixture set, a vendored toolchain, a scratch
11//! cache with a `make` target behind it.
12//!
13//! What makes that safe is *not* trust. `project.devprune.json` is committed, so a
14//! cloned repository can declare anything it likes, and `devp run` may be running from
15//! a scheduler with nobody watching. So a declaration is treated as a claim to be
16//! checked rather than an instruction to be followed: it names a directory, and this
17//! module proves the directory is inside the repository, holds nothing Git is tracking,
18//! and has a rebuild command whose tool this machine actually has. A claim that fails
19//! any of those is reported, in full, and nothing is deleted.
20
21use std::path::{Path, PathBuf};
22
23use crate::config::{DeclaredDir, Prunable};
24use crate::scanner::git;
25
26/// A declared directory that passed every check, ready to be treated as bloat.
27#[derive(Debug, Clone)]
28pub struct Target {
29    /// Repository-relative, `/`-separated — the same label shape adapters report.
30    pub label: String,
31    /// Where it actually is on this machine.
32    pub path: PathBuf,
33    /// The command the project says rebuilds it. Shown to the user, never run.
34    pub rebuild: String,
35    /// The project's own reason, if it gave one.
36    pub why: Option<String>,
37    /// Bytes deleting it would give back.
38    pub size_bytes: u64,
39}
40
41/// What became of one entry in `prunable.directories`.
42#[derive(Debug, Clone)]
43pub enum Declaration {
44    /// Checked out, and safe to delete on the usual terms.
45    Prunable(Box<Target>),
46    /// Something about the claim did not hold. The reason is the user-facing sentence.
47    Refused { label: String, reason: String },
48}
49
50/// Commands that are not programs on disk anywhere.
51///
52/// `"rebuild": "echo not needed"` is the deliberate escape hatch for a directory that
53/// genuinely needs nothing to come back — a scratch area some tool refills on demand.
54/// It has to keep working, and on Windows there is no `echo.exe`: `echo` is a shell
55/// builtin in both `cmd` and PowerShell, so a plain `PATH` search finds nothing and the
56/// documented answer would be refused on the one platform most of this project's users
57/// are on.
58const SHELL_BUILTINS: &[&str] = &["echo", "true", ":"];
59
60/// Builtins that mark the command as shell-shaped rather than a tool invocation.
61///
62/// `cd docs && npm run build` runs fine pasted into a shell, but its first word proves
63/// nothing about what this machine can rebuild — and macOS ships a `/usr/bin/cd` shim,
64/// so a plain `PATH` search would accept there what Windows refuses. Refused
65/// everywhere, with the rewrite in the message, so a committed declaration means one
66/// thing on every clone.
67const SHELL_ONLY: &[&str] = &["cd", "pushd", "source", ".", "export", "set"];
68
69/// Check every declaration in a repository, in the order the file lists them.
70///
71/// Directories that simply are not there are dropped rather than reported: a declared
72/// directory that does not exist is a declaration that has already been honoured, and
73/// a repository that declares four caches and currently has one should not print three
74/// lines about the other three on every single pass.
75///
76/// So is anything `prunable.exclude` names, and for the same reason. Whoever wrote the
77/// exclusion has already answered every question this module would ask about that
78/// directory — including whether to keep saying that it cannot be honoured.
79pub fn resolve(repo_path: &Path, declared: &Prunable) -> Vec<Declaration> {
80    let excluded: Vec<String> = declared.exclude.iter().map(|raw| key(raw)).collect();
81    let mut out = Vec::new();
82    for entry in &declared.directories {
83        if excluded.contains(&key(&entry.path)) {
84            continue;
85        }
86        match check(repo_path, entry) {
87            Ok(Some(target)) => out.push(Declaration::Prunable(Box::new(target))),
88            Ok(None) => {}
89            Err(reason) => out.push(Declaration::Refused {
90                label: entry.path.clone(),
91                reason,
92            }),
93        }
94    }
95    out
96}
97
98/// The comparable spelling of a declared or excluded path.
99///
100/// Both sides go through the same splitter, so `dist`, `dist/`, `./dist` and `dist\`
101/// are one path: an exclusion that missed on a trailing slash would delete the exact
102/// directory it was written to keep. A path the splitter rejects has no normal form, so
103/// its own text is all it can match on — which costs nothing, because a declaration of
104/// that shape is refused rather than deleted anyway.
105pub(crate) fn key(raw: &str) -> String {
106    split_relative(raw).map_or_else(|_| raw.trim().to_string(), |parts| parts.join("/"))
107}
108
109/// One declaration: `Ok(Some)` to delete, `Ok(None)` for absent, `Err` for refused.
110fn check(repo_path: &Path, entry: &DeclaredDir) -> Result<Option<Target>, String> {
111    let parts = split_relative(&entry.path)?;
112    let label = parts.join("/");
113    let path = parts.iter().fold(repo_path.to_path_buf(), |p, s| p.join(s));
114
115    if !path.exists() {
116        return Ok(None);
117    }
118    if !path.is_dir() {
119        return Err(format!(
120            "`{label}` is declared prunable but is a file, not a directory — \
121             dev-prune only deletes whole directories. Left alone."
122        ));
123    }
124
125    // Guards against a symlinked *ancestor*, which is the one way a path with no `..`
126    // in it can still land outside the repository. The leaf being a symlink is caught
127    // later, by the same check every adapter's directories go through.
128    let (Ok(real), Ok(root)) = (path.canonicalize(), repo_path.canonicalize()) else {
129        return Err(format!(
130            "`{label}` is declared prunable but could not be resolved on this machine — \
131             refusing to delete a path dev-prune cannot pin down."
132        ));
133    };
134    if !real.starts_with(&root) {
135        return Err(format!(
136            "`{label}` is declared prunable but resolves to `{}`, outside the \
137             repository. Left alone.",
138            crate::output::clean_path(&real)
139        ));
140    }
141
142    if let Some(tracked) = first_tracked_file(repo_path, &label)? {
143        return Err(format!(
144            "`{label}` is declared prunable but Git is tracking `{tracked}` inside it — \
145             refusing. A lockfile cannot rebuild a file that is in the repository \
146             itself. Remove the declaration, or stop tracking those files."
147        ));
148    }
149
150    let rebuild = entry.rebuild.trim();
151    if rebuild.is_empty() {
152        return Err(format!(
153            "`{label}` is declared prunable with an empty `rebuild` command — refusing. \
154             Say what puts it back, or use `\"rebuild\": \"echo not needed\"` if nothing \
155             does."
156        ));
157    }
158    let tool = first_word(rebuild);
159    if SHELL_ONLY.contains(&tool) {
160        return Err(format!(
161            "`{label}` is declared prunable, rebuilt by `{rebuild}`, but `{tool}` is a \
162             shell builtin, not a program this machine can be checked for — put the \
163             tool first, e.g. `npm --prefix docs run build` rather than \
164             `cd docs && npm run build`."
165        ));
166    }
167    if !SHELL_BUILTINS.contains(&tool) && !on_path(tool) {
168        return Err(format!(
169            "`{label}` is declared prunable, rebuilt by `{rebuild}`, but `{tool}` is not \
170             on this machine — refusing to delete something this machine cannot put \
171             back. Install `{tool}` first."
172        ));
173    }
174
175    Ok(Some(Target {
176        size_bytes: crate::adapters::dir_size(&path),
177        label,
178        path,
179        rebuild: rebuild.to_string(),
180        why: entry.why.clone(),
181    }))
182}
183
184/// Split a declared path into components, refusing anything that could point outward.
185///
186/// Deliberately not `Path::components`: this string is read on every platform from a
187/// file written on one of them, and `Path` disagrees with itself across platforms about
188/// what `C:\x` and `a\b` even are. Splitting on both separators by hand means a
189/// declaration that is refused on Windows is refused on Linux too, which is the whole
190/// value of the file being committed.
191fn split_relative(raw: &str) -> Result<Vec<String>, String> {
192    let trimmed = raw.trim();
193    if trimmed.is_empty() {
194        return Err("An entry in `prunable.directories` has an empty `path`.".to_string());
195    }
196    if trimmed.starts_with('/') || trimmed.starts_with('\\') {
197        return Err(format!(
198            "`{trimmed}` is declared prunable but is an absolute path — declarations are \
199             relative to the repository root. Left alone."
200        ));
201    }
202    let mut parts = Vec::new();
203    for part in trimmed.split(['/', '\\']) {
204        if part.is_empty() || part == "." {
205            continue;
206        }
207        if part == ".." {
208            return Err(format!(
209                "`{trimmed}` is declared prunable but climbs out of the repository with \
210                 `..` — refusing. Left alone."
211            ));
212        }
213        if part.contains(':') {
214            return Err(format!(
215                "`{trimmed}` is declared prunable but names a drive or stream — \
216                 declarations are relative to the repository root. Left alone."
217            ));
218        }
219        if part.eq_ignore_ascii_case(".git") {
220            return Err(format!(
221                "`{trimmed}` is declared prunable but is inside `.git` — the one \
222                 directory dev-prune never crosses. Left alone."
223            ));
224        }
225        parts.push(part.to_string());
226    }
227    if parts.is_empty() {
228        return Err(format!(
229            "`{trimmed}` is declared prunable but resolves to the repository root \
230             itself — refusing. Left alone."
231        ));
232    }
233    Ok(parts)
234}
235
236/// The first Git-tracked file inside `label`, if there is one.
237///
238/// The check that makes a *committed* declaration safe to honour. dev-prune's promise
239/// is that everything it deletes can be rebuilt from something that stays behind, and
240/// the one thing no lockfile can rebuild is the repository's own content. A hostile —
241/// or merely careless — `project.devprune.json` declaring `src` therefore gets refused
242/// on the same grounds as everything else, without dev-prune having to guess intent.
243///
244/// A `git` that cannot answer is an error rather than a shrug: "I could not check" is
245/// not "there is nothing there".
246fn first_tracked_file(repo_path: &Path, label: &str) -> Result<Option<String>, String> {
247    let output = git::git_in(repo_path)
248        .args(["ls-files", "--", label])
249        .output()
250        .map_err(|e| {
251            format!(
252                "`{label}` is declared prunable, but `git ls-files` could not run ({e}) — \
253                 refusing to delete without knowing whether it holds tracked files."
254            )
255        })?;
256    if !output.status.success() {
257        return Err(format!(
258            "`{label}` is declared prunable, but `git ls-files` failed — refusing to \
259             delete without knowing whether it holds tracked files."
260        ));
261    }
262    Ok(String::from_utf8_lossy(&output.stdout)
263        .lines()
264        .next()
265        .map(str::to_string))
266}
267
268/// The program a rebuild command starts with, unquoted.
269fn first_word(command: &str) -> &str {
270    command
271        .split_whitespace()
272        .next()
273        .unwrap_or("")
274        .trim_matches(['"', '\''])
275}
276
277/// Is `program` something this machine could actually run?
278///
279/// Presence on `PATH`, not a `--version` probe. A rebuild command can start with
280/// anything — `make`, `./scripts/gen.sh`, a project's own tool — and most of those have
281/// no version flag, so probing would refuse commands that work perfectly well.
282fn on_path(program: &str) -> bool {
283    let named = Path::new(program);
284    if named.components().count() > 1 {
285        return named.is_file();
286    }
287    let Some(path_var) = std::env::var_os("PATH") else {
288        return false;
289    };
290    // `CreateProcess` only ever appends `.exe`, but a shell resolves the rest, and a
291    // rebuild command is run by a person in a shell.
292    let exts: &[&str] = if cfg!(windows) {
293        &["", "exe", "cmd", "bat", "com", "ps1"]
294    } else {
295        &[""]
296    };
297    std::env::split_paths(&path_var).any(|dir| {
298        exts.iter().any(|ext| {
299            if ext.is_empty() {
300                dir.join(program).is_file()
301            } else {
302                dir.join(format!("{program}.{ext}")).is_file()
303            }
304        })
305    })
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use std::fs;
312    use std::process::Command;
313    use tempfile::TempDir;
314
315    fn declared(path: &str, rebuild: &str) -> DeclaredDir {
316        DeclaredDir {
317            path: path.to_string(),
318            rebuild: rebuild.to_string(),
319            why: None,
320        }
321    }
322
323    /// One declaration and nothing excluded — the shape most of these tests want.
324    fn one(entry: DeclaredDir) -> Prunable {
325        Prunable {
326            directories: vec![entry],
327            exclude: Vec::new(),
328        }
329    }
330
331    /// A repository with one commit, so `git ls-files` has an index to answer from.
332    fn repo() -> TempDir {
333        let tmp = TempDir::new().unwrap();
334        let path = tmp.path();
335        for args in [
336            vec!["init", "-q"],
337            vec!["config", "user.email", "t@example.com"],
338            vec!["config", "user.name", "t"],
339        ] {
340            Command::new("git")
341                .args(&args)
342                .current_dir(path)
343                .output()
344                .unwrap();
345        }
346        tmp
347    }
348
349    fn refusal(repo_path: &Path, entry: DeclaredDir) -> String {
350        match resolve(repo_path, &one(entry)).pop() {
351            Some(Declaration::Refused { reason, .. }) => reason,
352            other => panic!("expected a refusal, got {other:?}"),
353        }
354    }
355
356    #[test]
357    fn a_declaration_that_holds_up_is_prunable_with_its_reason_carried_along() {
358        let tmp = repo();
359        let path = tmp.path();
360        fs::create_dir_all(path.join("build/fixtures")).unwrap();
361        fs::write(path.join("build/fixtures/a.bin"), vec![0u8; 4096]).unwrap();
362
363        let mut entry = declared("build/fixtures", "echo not needed");
364        entry.why = Some("regenerated by the test suite".into());
365        let Some(Declaration::Prunable(target)) = resolve(path, &one(entry)).pop() else {
366            panic!("a declaration nothing is wrong with must be prunable");
367        };
368        assert_eq!(target.label, "build/fixtures");
369        assert_eq!(target.why.as_deref(), Some("regenerated by the test suite"));
370        assert!(target.size_bytes >= 4096);
371    }
372
373    #[test]
374    fn the_documented_escape_hatch_works_on_every_platform() {
375        // `echo` is a shell builtin, not a program, and on Windows there is no
376        // `echo.exe` at all. The one rebuild command the docs hand people has to pass.
377        let tmp = repo();
378        fs::create_dir_all(tmp.path().join("scratch")).unwrap();
379        assert!(matches!(
380            resolve(tmp.path(), &one(declared("scratch", "echo not needed"))).pop(),
381            Some(Declaration::Prunable(_))
382        ));
383    }
384
385    #[test]
386    fn a_declaration_covering_tracked_files_is_refused() {
387        // The check that makes a committed file safe to honour: a repository that
388        // declares its own source is refused without dev-prune having to guess why.
389        let tmp = repo();
390        let path = tmp.path();
391        fs::create_dir_all(path.join("src")).unwrap();
392        fs::write(path.join("src/main.rs"), "fn main() {}").unwrap();
393        Command::new("git")
394            .args(["add", "src/main.rs"])
395            .current_dir(path)
396            .output()
397            .unwrap();
398
399        let reason = refusal(path, declared("src", "echo not needed"));
400        assert!(reason.contains("Git is tracking"), "{reason}");
401        assert!(path.join("src/main.rs").exists());
402    }
403
404    #[test]
405    fn a_declaration_whose_rebuild_tool_is_absent_is_refused() {
406        let tmp = repo();
407        fs::create_dir_all(tmp.path().join("vendor")).unwrap();
408        let reason = refusal(
409            tmp.path(),
410            declared("vendor", "definitely-not-a-real-tool-xyz build"),
411        );
412        assert!(reason.contains("is not on this machine"), "{reason}");
413    }
414
415    #[test]
416    fn a_rebuild_starting_with_cd_gets_the_rewrite_not_install_advice() {
417        let tmp = repo();
418        fs::create_dir_all(tmp.path().join("docs/out")).unwrap();
419        let reason = refusal(tmp.path(), declared("docs/out", "cd docs && npm run build"));
420        assert!(reason.contains("shell builtin"), "{reason}");
421        assert!(reason.contains("--prefix"), "{reason}");
422        assert!(!reason.contains("Install"), "{reason}");
423    }
424
425    #[test]
426    fn an_empty_rebuild_is_refused_and_says_what_to_write_instead() {
427        let tmp = repo();
428        fs::create_dir_all(tmp.path().join("vendor")).unwrap();
429        let reason = refusal(tmp.path(), declared("vendor", "   "));
430        assert!(reason.contains("echo not needed"), "{reason}");
431    }
432
433    #[test]
434    fn paths_that_could_point_outside_the_repository_never_get_that_far() {
435        // Refused on their shape alone, before anything touches the disk — so the
436        // answer is the same on Windows and Linux, which matters for a file that is
437        // committed once and cloned everywhere.
438        for (raw, expected) in [
439            ("../secrets", "climbs out of the repository"),
440            ("/etc", "absolute path"),
441            ("C:/Windows", "names a drive"),
442            (".git/objects", "inside `.git`"),
443            (".", "the repository root itself"),
444        ] {
445            let err = split_relative(raw).unwrap_err();
446            assert!(err.contains(expected), "{raw}: {err}");
447        }
448    }
449
450    #[test]
451    fn a_declared_directory_that_is_not_there_says_nothing_at_all() {
452        // Otherwise a repository declaring four caches prints three "missing" lines on
453        // every pass, for three directories that are already in the state asked for.
454        let tmp = repo();
455        assert!(
456            resolve(
457                tmp.path(),
458                &one(declared("never/existed", "echo not needed"))
459            )
460            .is_empty()
461        );
462    }
463
464    #[test]
465    fn an_exclusion_takes_a_declaration_out_of_play_however_it_is_spelled() {
466        // The committed file is the team's; the exclusion is one machine's answer to it.
467        // It has to survive the spellings a person actually types, because the failure
468        // mode is deleting the directory it was written to keep.
469        let tmp = repo();
470        let path = tmp.path();
471        fs::create_dir_all(path.join("scratch")).unwrap();
472
473        for spelling in ["scratch", "scratch/", "./scratch", r"scratch\"] {
474            let prunable = Prunable {
475                directories: vec![declared("scratch", "echo not needed")],
476                exclude: vec![spelling.to_string()],
477            };
478            assert!(
479                resolve(path, &prunable).is_empty(),
480                "`{spelling}` did not exclude `scratch`"
481            );
482        }
483
484        // And it takes only what it names.
485        fs::create_dir_all(path.join("vendor")).unwrap();
486        let prunable = Prunable {
487            directories: vec![
488                declared("scratch", "echo not needed"),
489                declared("vendor", "echo not needed"),
490            ],
491            exclude: vec!["scratch".to_string()],
492        };
493        let left: Vec<String> = resolve(path, &prunable)
494            .into_iter()
495            .map(|d| match d {
496                Declaration::Prunable(t) => t.label,
497                Declaration::Refused { label, .. } => label,
498            })
499            .collect();
500        assert_eq!(left, ["vendor"]);
501    }
502
503    #[test]
504    fn an_exclusion_silences_the_refusal_too_not_only_the_delete() {
505        // A refusal is a standing complaint printed on every pass. Somebody who has said
506        // this directory is not dev-prune's business has answered that as well.
507        let tmp = repo();
508        let path = tmp.path();
509        fs::create_dir_all(path.join("src")).unwrap();
510        fs::write(path.join("src/main.rs"), "fn main() {}").unwrap();
511        Command::new("git")
512            .args(["add", "src/main.rs"])
513            .current_dir(path)
514            .output()
515            .unwrap();
516
517        assert!(
518            !resolve(path, &one(declared("src", "echo not needed"))).is_empty(),
519            "this repository is supposed to produce a refusal"
520        );
521        let prunable = Prunable {
522            directories: vec![declared("src", "echo not needed")],
523            exclude: vec!["src".to_string()],
524        };
525        assert!(resolve(path, &prunable).is_empty());
526    }
527
528    #[test]
529    fn a_backslash_declaration_reads_the_same_as_a_forward_slash_one() {
530        assert_eq!(
531            split_relative(r"build\fixtures").unwrap(),
532            split_relative("build/fixtures").unwrap()
533        );
534    }
535}