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