Skip to main content

dev_prune/declared/
mod.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//! has a rebuild command whose tool this machine actually has, and — where a manifest
19//! already in the tree can answer — that the command's target is one that manifest
20//! defines. A claim that fails any of those is reported, in full, and nothing is
21//! deleted.
22//!
23//! None of those checks runs the rebuild command, or any part of it. Every one is a
24//! read.
25
26use std::path::{Path, PathBuf};
27
28use crate::config::{DeclaredDir, Prunable};
29use crate::scanner::git;
30
31mod make;
32
33/// A declared directory that passed every check, ready to be treated as bloat.
34#[derive(Debug, Clone)]
35pub struct Target {
36    /// Repository-relative, `/`-separated — the same label shape adapters report.
37    pub label: String,
38    /// Where it actually is on this machine.
39    pub path: PathBuf,
40    /// The command the project says rebuilds it. Shown to the user, never run.
41    pub rebuild: String,
42    /// The project's own reason, if it gave one.
43    pub why: Option<String>,
44    /// Bytes deleting it would give back.
45    pub size_bytes: u64,
46}
47
48/// What became of one entry in `prunable.directories`.
49#[derive(Debug, Clone)]
50pub enum Declaration {
51    /// Checked out, and safe to delete on the usual terms.
52    Prunable(Box<Target>),
53    /// Something about the claim did not hold. The reason is the user-facing sentence.
54    Refused { label: String, reason: String },
55}
56
57/// Commands that are not programs on disk anywhere.
58///
59/// `"rebuild": "echo not needed"` is the deliberate escape hatch for a directory that
60/// genuinely needs nothing to come back — a scratch area some tool refills on demand.
61/// It has to keep working, and on Windows there is no `echo.exe`: `echo` is a shell
62/// builtin in both `cmd` and PowerShell, so a plain `PATH` search finds nothing and the
63/// documented answer would be refused on the one platform most of this project's users
64/// are on.
65const SHELL_BUILTINS: &[&str] = &["echo", "true", ":"];
66
67/// Builtins that mark the command as shell-shaped rather than a tool invocation.
68///
69/// `cd docs && npm run build` runs fine pasted into a shell, but its first word proves
70/// nothing about what this machine can rebuild — and macOS ships a `/usr/bin/cd` shim,
71/// so a plain `PATH` search would accept there what Windows refuses. Refused
72/// everywhere, with the rewrite in the message, so a committed declaration means one
73/// thing on every clone.
74const SHELL_ONLY: &[&str] = &["cd", "pushd", "source", ".", "export", "set"];
75
76/// Check every declaration in a repository, in the order the file lists them.
77///
78/// Directories that simply are not there are dropped rather than reported: a declared
79/// directory that does not exist is a declaration that has already been honoured, and
80/// a repository that declares four caches and currently has one should not print three
81/// lines about the other three on every single pass.
82///
83/// So is anything `prunable.exclude` names, and for the same reason. Whoever wrote the
84/// exclusion has already answered every question this module would ask about that
85/// directory — including whether to keep saying that it cannot be honoured.
86pub fn resolve(repo_path: &Path, declared: &Prunable) -> Vec<Declaration> {
87    let excluded: Vec<String> = declared.exclude.iter().map(|raw| key(raw)).collect();
88    let mut out = Vec::new();
89    for entry in &declared.directories {
90        if excluded.contains(&key(&entry.path)) {
91            continue;
92        }
93        match check(repo_path, entry) {
94            Ok(Some(target)) => out.push(Declaration::Prunable(Box::new(target))),
95            Ok(None) => {}
96            Err(reason) => out.push(Declaration::Refused {
97                label: entry.path.clone(),
98                reason,
99            }),
100        }
101    }
102    out
103}
104
105/// The comparable spelling of a declared or excluded path.
106///
107/// Both sides go through the same splitter, so `dist`, `dist/`, `./dist` and `dist\`
108/// are one path: an exclusion that missed on a trailing slash would delete the exact
109/// directory it was written to keep. A path the splitter rejects has no normal form, so
110/// its own text is all it can match on — which costs nothing, because a declaration of
111/// that shape is refused rather than deleted anyway.
112pub(crate) fn key(raw: &str) -> String {
113    split_relative(raw).map_or_else(|_| raw.trim().to_string(), |parts| parts.join("/"))
114}
115
116/// One declaration: `Ok(Some)` to delete, `Ok(None)` for absent, `Err` for refused.
117fn check(repo_path: &Path, entry: &DeclaredDir) -> Result<Option<Target>, String> {
118    let parts = split_relative(&entry.path)?;
119    let label = parts.join("/");
120    let path = parts.iter().fold(repo_path.to_path_buf(), |p, s| p.join(s));
121
122    if !path.exists() {
123        return Ok(None);
124    }
125    if !path.is_dir() {
126        return Err(format!(
127            "`{label}` is declared prunable but is a file, not a directory — \
128             dev-prune only deletes whole directories. Left alone."
129        ));
130    }
131
132    // Guards against a symlinked *ancestor*, which is the one way a path with no `..`
133    // in it can still land outside the repository. The leaf being a symlink is caught
134    // later, by the same check every adapter's directories go through.
135    let (Ok(real), Ok(root)) = (path.canonicalize(), repo_path.canonicalize()) else {
136        return Err(format!(
137            "`{label}` is declared prunable but could not be resolved on this machine — \
138             refusing to delete a path dev-prune cannot pin down."
139        ));
140    };
141    if !real.starts_with(&root) {
142        return Err(format!(
143            "`{label}` is declared prunable but resolves to `{}`, outside the \
144             repository. Left alone.",
145            crate::output::clean_path(&real)
146        ));
147    }
148
149    if let Some(tracked) = first_tracked_file(repo_path, &label)? {
150        return Err(format!(
151            "`{label}` is declared prunable but Git is tracking `{tracked}` inside it — \
152             refusing. A lockfile cannot rebuild a file that is in the repository \
153             itself. Remove the declaration, or stop tracking those files."
154        ));
155    }
156
157    let rebuild = entry.rebuild.trim();
158    if rebuild.is_empty() {
159        return Err(format!(
160            "`{label}` is declared prunable with an empty `rebuild` command — refusing. \
161             Say what puts it back, or use `\"rebuild\": \"echo not needed\"` if nothing \
162             does."
163        ));
164    }
165    let tool = first_word(rebuild);
166    if SHELL_ONLY.contains(&tool) {
167        return Err(format!(
168            "`{label}` is declared prunable, rebuilt by `{rebuild}`, but `{tool}` is a \
169             shell builtin, not a program this machine can be checked for — put the \
170             tool first, e.g. `npm --prefix docs run build` rather than \
171             `cd docs && npm run build`."
172        ));
173    }
174    if !SHELL_BUILTINS.contains(&tool) && !on_path(tool) {
175        return Err(format!(
176            "`{label}` is declared prunable, rebuilt by `{rebuild}`, but `{tool}` is not \
177             on this machine — refusing to delete something this machine cannot put \
178             back. Install `{tool}` first."
179        ));
180    }
181    if let Some(gap) = rebuild_gap(repo_path, rebuild) {
182        return Err(format!(
183            "`{label}` is declared prunable, rebuilt by `{rebuild}`, but {} — refusing to \
184             delete something that command cannot put back. {}",
185            gap.what, gap.fix
186        ));
187    }
188
189    Ok(Some(Target {
190        size_bytes: crate::adapters::dir_size(&path),
191        label,
192        path,
193        rebuild: rebuild.to_string(),
194        why: entry.why.clone(),
195    }))
196}
197
198/// Split a declared path into components, refusing anything that could point outward.
199///
200/// Deliberately not `Path::components`: this string is read on every platform from a
201/// file written on one of them, and `Path` disagrees with itself across platforms about
202/// what `C:\x` and `a\b` even are. Splitting on both separators by hand means a
203/// declaration that is refused on Windows is refused on Linux too, which is the whole
204/// value of the file being committed.
205pub(crate) fn split_relative(raw: &str) -> Result<Vec<String>, String> {
206    let trimmed = raw.trim();
207    if trimmed.is_empty() {
208        return Err("An entry in `prunable.directories` has an empty `path`.".to_string());
209    }
210    if trimmed.starts_with('/') || trimmed.starts_with('\\') {
211        return Err(format!(
212            "`{trimmed}` is declared prunable but is an absolute path — declarations are \
213             relative to the repository root. Left alone."
214        ));
215    }
216    let mut parts = Vec::new();
217    for part in trimmed.split(['/', '\\']) {
218        if part.is_empty() || part == "." {
219            continue;
220        }
221        if part == ".." {
222            return Err(format!(
223                "`{trimmed}` is declared prunable but climbs out of the repository with \
224                 `..` — refusing. Left alone."
225            ));
226        }
227        if part.contains(':') {
228            return Err(format!(
229                "`{trimmed}` is declared prunable but names a drive or stream — \
230                 declarations are relative to the repository root. Left alone."
231            ));
232        }
233        if part.eq_ignore_ascii_case(".git") {
234            return Err(format!(
235                "`{trimmed}` is declared prunable but is inside `.git` — the one \
236                 directory dev-prune never crosses. Left alone."
237            ));
238        }
239        parts.push(part.to_string());
240    }
241    if parts.is_empty() {
242        return Err(format!(
243            "`{trimmed}` is declared prunable but resolves to the repository root \
244             itself — refusing. Left alone."
245        ));
246    }
247    Ok(parts)
248}
249
250/// The first Git-tracked file inside `label`, if there is one.
251///
252/// The check that makes a *committed* declaration safe to honour. dev-prune's promise
253/// is that everything it deletes can be rebuilt from something that stays behind, and
254/// the one thing no lockfile can rebuild is the repository's own content. A hostile —
255/// or merely careless — `project.devprune.json` declaring `src` therefore gets refused
256/// on the same grounds as everything else, without dev-prune having to guess intent.
257///
258/// A `git` that cannot answer is an error rather than a shrug: "I could not check" is
259/// not "there is nothing there".
260fn first_tracked_file(repo_path: &Path, label: &str) -> Result<Option<String>, String> {
261    let output = git::git_in(repo_path)
262        .args(["ls-files", "--", label])
263        .output()
264        .map_err(|e| {
265            format!(
266                "`{label}` is declared prunable, but `git ls-files` could not run ({e}) — \
267                 refusing to delete without knowing whether it holds tracked files."
268            )
269        })?;
270    if !output.status.success() {
271        return Err(format!(
272            "`{label}` is declared prunable, but `git ls-files` failed — refusing to \
273             delete without knowing whether it holds tracked files."
274        ));
275    }
276    Ok(String::from_utf8_lossy(&output.stdout)
277        .lines()
278        .next()
279        .map(str::to_string))
280}
281
282/// The program a rebuild command starts with, unquoted.
283fn first_word(command: &str) -> &str {
284    command
285        .split_whitespace()
286        .next()
287        .unwrap_or("")
288        .trim_matches(['"', '\''])
289}
290
291/// Is `program` something this machine could actually run?
292///
293/// Presence on `PATH`, not a `--version` probe. A rebuild command can start with
294/// anything — `make`, `./scripts/gen.sh`, a project's own tool — and most of those have
295/// no version flag, so probing would refuse commands that work perfectly well.
296pub(crate) fn on_path(program: &str) -> bool {
297    let named = Path::new(program);
298    if named.components().count() > 1 {
299        return named.is_file();
300    }
301    let Some(path_var) = std::env::var_os("PATH") else {
302        return false;
303    };
304    // `CreateProcess` only ever appends `.exe`, but a shell resolves the rest, and a
305    // rebuild command is run by a person in a shell.
306    let exts: &[&str] = if cfg!(windows) {
307        &["", "exe", "cmd", "bat", "com", "ps1"]
308    } else {
309        &[""]
310    };
311    std::env::split_paths(&path_var).any(|dir| {
312        exts.iter().any(|ext| {
313            if ext.is_empty() {
314                dir.join(program).is_file()
315            } else {
316                dir.join(format!("{program}.{ext}")).is_file()
317            }
318        })
319    })
320}
321
322/// Something a rebuild command names that the manifest it would read does not define.
323pub(crate) struct Gap {
324    /// What was looked for and where, as a clause: "`package.json` defines no `build`
325    /// script".
326    pub(crate) what: String,
327    /// What to do about it.
328    pub(crate) fix: String,
329}
330
331/// Characters that make a rebuild command a shell script rather than one invocation.
332///
333/// `:` is deliberately absent — `npm run build:prod` is an ordinary script name.
334const SHELL_METACHARACTERS: &[char] = &[
335    '&', '|', ';', '$', '`', '>', '<', '(', ')', '*', '?', '%', '{', '}', '#',
336];
337
338/// One tool family's check of a declared rebuild command against the manifest that
339/// command would read.
340///
341/// The contract every implementation inherits, the same one spelled out on
342/// [`rebuild_gap`]:
343///
344/// - Return `Some` only on positive proof: a manifest that is present, readable, and
345///   definitively does not provide what the command names.
346/// - Anything unanswerable returns `None`, which allows the prune. An argument shape
347///   the check does not model, and a manifest that is absent or unparseable, are both
348///   "cannot tell", never "refuse".
349/// - Never execute the rebuild command, or any part of it. Every check is a read.
350///
351/// A package manager's check lives beside its adapter in `crate::adapters`, so one
352/// tool's knowledge stays in one file. `make` is a build tool with no adapter, so its
353/// check lives in this module instead, in [`make`]. Adding a tool means one
354/// implementation of this trait, one line in [`REBUILD_CHECKS`], and tests pinning its
355/// refusals and its pass-throughs.
356pub(crate) trait RebuildCheck: Sync {
357    /// The tool names this check answers for, as [`tool_name`] spells them.
358    fn tools(&self) -> &'static [&'static str];
359
360    /// The gap between what `tool args` was asked to do and what the manifest in the
361    /// tree defines, when there provably is one.
362    fn gap(&self, repo_path: &Path, tool: &str, args: &[&str]) -> Option<Gap>;
363}
364
365/// Every rebuild check, one entry per tool family.
366///
367/// [`rebuild_gap`] resolves a command's first word against this table. Order does not
368/// matter: a test refuses to let two entries claim the same tool name.
369static REBUILD_CHECKS: &[&dyn RebuildCheck] = &[
370    &crate::adapters::npm::NodeScripts,
371    &crate::adapters::uv::UvScripts,
372    &crate::adapters::cargo_adapter::CargoSubcommands,
373    &make::MakeTargets,
374];
375
376/// What the rebuild command's target is missing, when a manifest in the tree can say.
377///
378/// The `PATH` check above proves the *tool* is installed. It proves nothing about what
379/// the tool was asked to do: `"rebuild": "npm run build"` passes it on any machine with
380/// node on it, including one whose `package.json` has no `build` script at all. The user
381/// is then told the directory is recoverable, dev-prune deletes it, and the command that
382/// was supposed to put it back fails — silent data loss, from a check that stopped one
383/// word too early. This closes that, by reading the manifest the tool itself would read.
384///
385/// **Anything this cannot answer returns `None`, which allows the prune.** An
386/// unrecognised tool, a shell pipeline, a variable in place of the target, a flag whose
387/// meaning would have to be guessed, a manifest that is absent or unparseable — every
388/// one of those falls through to the `PATH` check alone. That asymmetry is deliberate: a
389/// false refusal blocks a prune that was safe, in a committed file the user cannot
390/// easily debug, and is a worse bug than the gap being closed here. Only a manifest that
391/// positively does not define the named target produces a refusal.
392///
393/// Nothing in here runs the rebuild command, or any part of it. Every check is a read.
394fn rebuild_gap(repo_path: &Path, rebuild: &str) -> Option<Gap> {
395    let words = command_words(rebuild)?;
396    let (tool, rest) = words.split_first()?;
397    let args: Vec<&str> = rest.iter().map(String::as_str).collect();
398    let tool = tool_name(tool);
399    REBUILD_CHECKS
400        .iter()
401        .find(|check| check.tools().contains(&tool))
402        .and_then(|check| check.gap(repo_path, tool, &args))
403}
404
405/// The words of a rebuild command, or `None` when it is not a single invocation.
406///
407/// A shell metacharacter anywhere means the string is a script — a pipeline, a
408/// substitution, a glob — and the words around it do not mean what they look like.
409fn command_words(rebuild: &str) -> Option<Vec<String>> {
410    let mut words = Vec::new();
411    for raw in rebuild.split_whitespace() {
412        if raw.contains(SHELL_METACHARACTERS) {
413            return None;
414        }
415        words.push(raw.trim_matches(['"', '\'']).to_string());
416    }
417    Some(words)
418}
419
420/// A tool's name with the executable extension a Windows declaration might carry.
421fn tool_name(word: &str) -> &str {
422    if word.contains(['/', '\\']) {
423        return word;
424    }
425    for ext in [".cmd", ".exe", ".bat", ".ps1"] {
426        if let Some(stem) = word.strip_suffix(ext) {
427            return stem;
428        }
429    }
430    word
431}
432
433/// A `--prefix`-style directory as repository-relative components.
434///
435/// Run through the same splitter declarations are. This only ever reads a manifest, but
436/// it should only ever read one out of the tree it was asked about.
437pub(crate) fn relative_parts(dir: Option<&str>) -> Option<Vec<String>> {
438    match dir.map(str::trim) {
439        None | Some("") | Some(".") => Some(Vec::new()),
440        Some(raw) => split_relative(raw).ok(),
441    }
442}
443
444/// Where a manifest sits on disk, given repository-relative components.
445pub(crate) fn path_of(repo_path: &Path, parts: &[String], file: &str) -> PathBuf {
446    parts
447        .iter()
448        .fold(repo_path.to_path_buf(), |acc, part| acc.join(part))
449        .join(file)
450}
451
452/// How that manifest is named back to the user: repository-relative, `/`-separated.
453pub(crate) fn label_of(parts: &[String], file: &str) -> String {
454    if parts.is_empty() {
455        file.to_string()
456    } else {
457        format!("{}/{file}", parts.join("/"))
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use std::fs;
465    use std::process::Command;
466    use tempfile::TempDir;
467
468    fn declared(path: &str, rebuild: &str) -> DeclaredDir {
469        DeclaredDir {
470            path: path.to_string(),
471            rebuild: rebuild.to_string(),
472            why: None,
473        }
474    }
475
476    /// One declaration and nothing excluded — the shape most of these tests want.
477    fn one(entry: DeclaredDir) -> Prunable {
478        Prunable {
479            directories: vec![entry],
480            exclude: Vec::new(),
481        }
482    }
483
484    /// A repository with one commit, so `git ls-files` has an index to answer from.
485    fn repo() -> TempDir {
486        let tmp = TempDir::new().unwrap();
487        let path = tmp.path();
488        for args in [
489            vec!["init", "-q"],
490            vec!["config", "user.email", "t@example.com"],
491            vec!["config", "user.name", "t"],
492        ] {
493            Command::new("git")
494                .args(&args)
495                .current_dir(path)
496                .output()
497                .unwrap();
498        }
499        tmp
500    }
501
502    fn refusal(repo_path: &Path, entry: DeclaredDir) -> String {
503        match resolve(repo_path, &one(entry)).pop() {
504            Some(Declaration::Refused { reason, .. }) => reason,
505            other => panic!("expected a refusal, got {other:?}"),
506        }
507    }
508
509    #[test]
510    fn a_declaration_that_holds_up_is_prunable_with_its_reason_carried_along() {
511        let tmp = repo();
512        let path = tmp.path();
513        fs::create_dir_all(path.join("build/fixtures")).unwrap();
514        fs::write(path.join("build/fixtures/a.bin"), vec![0u8; 4096]).unwrap();
515
516        let mut entry = declared("build/fixtures", "echo not needed");
517        entry.why = Some("regenerated by the test suite".into());
518        let Some(Declaration::Prunable(target)) = resolve(path, &one(entry)).pop() else {
519            panic!("a declaration nothing is wrong with must be prunable");
520        };
521        assert_eq!(target.label, "build/fixtures");
522        assert_eq!(target.why.as_deref(), Some("regenerated by the test suite"));
523        assert!(target.size_bytes >= 4096);
524    }
525
526    #[test]
527    fn the_documented_escape_hatch_works_on_every_platform() {
528        // `echo` is a shell builtin, not a program, and on Windows there is no
529        // `echo.exe` at all. The one rebuild command the docs hand people has to pass.
530        let tmp = repo();
531        fs::create_dir_all(tmp.path().join("scratch")).unwrap();
532        assert!(matches!(
533            resolve(tmp.path(), &one(declared("scratch", "echo not needed"))).pop(),
534            Some(Declaration::Prunable(_))
535        ));
536    }
537
538    #[test]
539    fn a_declaration_covering_tracked_files_is_refused() {
540        // The check that makes a committed file safe to honour: a repository that
541        // declares its own source is refused without dev-prune having to guess why.
542        let tmp = repo();
543        let path = tmp.path();
544        fs::create_dir_all(path.join("src")).unwrap();
545        fs::write(path.join("src/main.rs"), "fn main() {}").unwrap();
546        Command::new("git")
547            .args(["add", "src/main.rs"])
548            .current_dir(path)
549            .output()
550            .unwrap();
551
552        let reason = refusal(path, declared("src", "echo not needed"));
553        assert!(reason.contains("Git is tracking"), "{reason}");
554        assert!(path.join("src/main.rs").exists());
555    }
556
557    #[test]
558    fn a_declaration_whose_rebuild_tool_is_absent_is_refused() {
559        let tmp = repo();
560        fs::create_dir_all(tmp.path().join("vendor")).unwrap();
561        let reason = refusal(
562            tmp.path(),
563            declared("vendor", "definitely-not-a-real-tool-xyz build"),
564        );
565        assert!(reason.contains("is not on this machine"), "{reason}");
566    }
567
568    #[test]
569    fn a_rebuild_starting_with_cd_gets_the_rewrite_not_install_advice() {
570        let tmp = repo();
571        fs::create_dir_all(tmp.path().join("docs/out")).unwrap();
572        let reason = refusal(tmp.path(), declared("docs/out", "cd docs && npm run build"));
573        assert!(reason.contains("shell builtin"), "{reason}");
574        assert!(reason.contains("--prefix"), "{reason}");
575        assert!(!reason.contains("Install"), "{reason}");
576    }
577
578    #[test]
579    fn an_empty_rebuild_is_refused_and_says_what_to_write_instead() {
580        let tmp = repo();
581        fs::create_dir_all(tmp.path().join("vendor")).unwrap();
582        let reason = refusal(tmp.path(), declared("vendor", "   "));
583        assert!(reason.contains("echo not needed"), "{reason}");
584    }
585
586    #[test]
587    fn paths_that_could_point_outside_the_repository_never_get_that_far() {
588        // Refused on their shape alone, before anything touches the disk — so the
589        // answer is the same on Windows and Linux, which matters for a file that is
590        // committed once and cloned everywhere.
591        for (raw, expected) in [
592            ("../secrets", "climbs out of the repository"),
593            ("/etc", "absolute path"),
594            ("C:/Windows", "names a drive"),
595            (".git/objects", "inside `.git`"),
596            (".", "the repository root itself"),
597        ] {
598            let err = split_relative(raw).unwrap_err();
599            assert!(err.contains(expected), "{raw}: {err}");
600        }
601    }
602
603    #[test]
604    fn a_declared_directory_that_is_not_there_says_nothing_at_all() {
605        // Otherwise a repository declaring four caches prints three "missing" lines on
606        // every pass, for three directories that are already in the state asked for.
607        let tmp = repo();
608        assert!(
609            resolve(
610                tmp.path(),
611                &one(declared("never/existed", "echo not needed"))
612            )
613            .is_empty()
614        );
615    }
616
617    #[test]
618    fn an_exclusion_takes_a_declaration_out_of_play_however_it_is_spelled() {
619        // The committed file is the team's; the exclusion is one machine's answer to it.
620        // It has to survive the spellings a person actually types, because the failure
621        // mode is deleting the directory it was written to keep.
622        let tmp = repo();
623        let path = tmp.path();
624        fs::create_dir_all(path.join("scratch")).unwrap();
625
626        for spelling in ["scratch", "scratch/", "./scratch", r"scratch\"] {
627            let prunable = Prunable {
628                directories: vec![declared("scratch", "echo not needed")],
629                exclude: vec![spelling.to_string()],
630            };
631            assert!(
632                resolve(path, &prunable).is_empty(),
633                "`{spelling}` did not exclude `scratch`"
634            );
635        }
636
637        // And it takes only what it names.
638        fs::create_dir_all(path.join("vendor")).unwrap();
639        let prunable = Prunable {
640            directories: vec![
641                declared("scratch", "echo not needed"),
642                declared("vendor", "echo not needed"),
643            ],
644            exclude: vec!["scratch".to_string()],
645        };
646        let left: Vec<String> = resolve(path, &prunable)
647            .into_iter()
648            .map(|d| match d {
649                Declaration::Prunable(t) => t.label,
650                Declaration::Refused { label, .. } => label,
651            })
652            .collect();
653        assert_eq!(left, ["vendor"]);
654    }
655
656    #[test]
657    fn an_exclusion_silences_the_refusal_too_not_only_the_delete() {
658        // A refusal is a standing complaint printed on every pass. Somebody who has said
659        // this directory is not dev-prune's business has answered that as well.
660        let tmp = repo();
661        let path = tmp.path();
662        fs::create_dir_all(path.join("src")).unwrap();
663        fs::write(path.join("src/main.rs"), "fn main() {}").unwrap();
664        Command::new("git")
665            .args(["add", "src/main.rs"])
666            .current_dir(path)
667            .output()
668            .unwrap();
669
670        assert!(
671            !resolve(path, &one(declared("src", "echo not needed"))).is_empty(),
672            "this repository is supposed to produce a refusal"
673        );
674        let prunable = Prunable {
675            directories: vec![declared("src", "echo not needed")],
676            exclude: vec!["src".to_string()],
677        };
678        assert!(resolve(path, &prunable).is_empty());
679    }
680
681    #[test]
682    fn a_backslash_declaration_reads_the_same_as_a_forward_slash_one() {
683        assert_eq!(
684            split_relative(r"build\fixtures").unwrap(),
685            split_relative("build/fixtures").unwrap()
686        );
687    }
688
689    /// The rebuild-target checks are exercised through `rebuild_gap` rather than
690    /// `resolve`, because the `PATH` check runs first: on a machine without `make` or
691    /// `uv` the refusal would be the "not on this machine" one instead, and these have
692    /// to mean the same thing on every platform CI runs. The end-to-end composition is
693    /// covered separately, below.
694    fn gap(repo_path: &Path, rebuild: &str) -> Gap {
695        match rebuild_gap(repo_path, rebuild) {
696            Some(gap) => gap,
697            None => panic!("`{rebuild}` should have been refused"),
698        }
699    }
700
701    #[test]
702    fn a_rebuild_naming_a_script_the_package_json_does_not_have_is_refused() {
703        // The gap this whole section exists to close: `npm` being installed says nothing
704        // about whether `npm run build` would do anything.
705        let tmp = TempDir::new().unwrap();
706        fs::write(
707            tmp.path().join("package.json"),
708            r#"{"scripts":{"test":"vitest"}}"#,
709        )
710        .unwrap();
711
712        let gap = gap(tmp.path(), "npm run build");
713        assert!(gap.what.contains("package.json"), "{}", gap.what);
714        assert!(gap.what.contains("`build`"), "{}", gap.what);
715        assert!(gap.fix.contains("Add a `build` script"), "{}", gap.fix);
716    }
717
718    #[test]
719    fn a_rebuild_naming_a_script_that_is_there_is_left_alone() {
720        let tmp = TempDir::new().unwrap();
721        fs::write(
722            tmp.path().join("package.json"),
723            r#"{"scripts":{"build":"tsc -p .","test":"vitest"}}"#,
724        )
725        .unwrap();
726
727        for rebuild in [
728            "npm run build",
729            "pnpm run build",
730            "yarn run build",
731            "pnpm build",
732            "yarn build",
733            "npm.cmd run build",
734        ] {
735            assert!(
736                rebuild_gap(tmp.path(), rebuild).is_none(),
737                "`{rebuild}` names a script that is right there"
738            );
739        }
740    }
741
742    #[test]
743    fn the_prefix_flag_decides_which_package_json_is_read() {
744        // The refusal one check earlier hands people `npm --prefix docs run build`.
745        // Following that advice has to land on `docs/package.json`, not the root one.
746        let tmp = TempDir::new().unwrap();
747        let path = tmp.path();
748        fs::write(
749            path.join("package.json"),
750            r#"{"scripts":{"lint":"eslint"}}"#,
751        )
752        .unwrap();
753        fs::create_dir_all(path.join("docs")).unwrap();
754        fs::write(
755            path.join("docs/package.json"),
756            r#"{"scripts":{"build":"astro build"}}"#,
757        )
758        .unwrap();
759
760        for rebuild in [
761            "npm --prefix docs run build",
762            "npm --prefix=docs run build",
763            "pnpm -C docs run build",
764            "pnpm --dir docs build",
765            "yarn --cwd docs build",
766        ] {
767            assert!(
768                rebuild_gap(path, rebuild).is_none(),
769                "`{rebuild}` should have read docs/package.json"
770            );
771        }
772
773        // The root manifest is the one without a `build` script, and saying which file
774        // was read is the difference between a useful refusal and a confusing one.
775        assert!(gap(path, "npm run build").what.contains("`package.json`"));
776        let elsewhere = gap(path, "npm --prefix docs run missing");
777        assert!(
778            elsewhere.what.contains("`docs/package.json`"),
779            "{}",
780            elsewhere.what
781        );
782    }
783
784    #[test]
785    fn a_make_target_the_makefile_does_not_define_is_refused() {
786        let tmp = TempDir::new().unwrap();
787        fs::write(
788            tmp.path().join("Makefile"),
789            "CACHE := .cache\n\n.PHONY: clean\n\nvendor: tools/manifest.toml\n\tgo mod vendor\n",
790        )
791        .unwrap();
792
793        assert!(rebuild_gap(tmp.path(), "make vendor").is_none());
794        assert!(rebuild_gap(tmp.path(), "make clean").is_none());
795        assert!(rebuild_gap(tmp.path(), "make CACHE=x vendor").is_none());
796
797        let gap = gap(tmp.path(), "make fixtures");
798        assert!(gap.what.contains("`Makefile`"), "{}", gap.what);
799        assert!(gap.what.contains("`fixtures`"), "{}", gap.what);
800    }
801
802    #[test]
803    fn a_uv_script_declared_in_tool_uv_scripts_is_refused_because_uv_never_reads_it() {
804        // `[tool.uv.scripts]` is not a uv field. It parses, it looks right, and it does
805        // nothing — so a directory declared behind one is a directory nothing rebuilds.
806        let tmp = TempDir::new().unwrap();
807        let path = tmp.path();
808        fs::write(
809            path.join("pyproject.toml"),
810            "[project]\nname = \"proj\"\n\n[tool.uv.scripts]\nregen-fixtures = \"tools.gen:main\"\n",
811        )
812        .unwrap();
813
814        let gap = gap(path, "uv run regen-fixtures");
815        assert!(gap.what.contains("pyproject.toml"), "{}", gap.what);
816        assert!(gap.fix.contains("[project.scripts]"), "{}", gap.fix);
817
818        // Moved to the table uv actually reads, the same declaration passes.
819        fs::write(
820            path.join("pyproject.toml"),
821            "[project]\nname = \"proj\"\n\n[project.scripts]\nregen-fixtures = \"tools.gen:main\"\n",
822        )
823        .unwrap();
824        assert!(rebuild_gap(path, "uv run regen-fixtures").is_none());
825
826        // And a console script a dependency brings in is not in either table.
827        fs::write(
828            path.join("pyproject.toml"),
829            "[project]\nname = \"proj\"\ndependencies = [\n  \"pytest>=8\",\n]\n",
830        )
831        .unwrap();
832        assert!(rebuild_gap(path, "uv run pytest").is_none());
833    }
834
835    #[test]
836    fn a_command_shape_this_cannot_read_is_allowed_rather_than_guessed_at() {
837        // The asymmetry the whole check is built on. A false refusal blocks a prune that
838        // was safe, in a committed file that is awkward to debug — worse than the gap.
839        let tmp = TempDir::new().unwrap();
840        let path = tmp.path();
841        fs::write(
842            path.join("package.json"),
843            r#"{"scripts":{"lint":"eslint"}}"#,
844        )
845        .unwrap();
846        fs::write(path.join("Makefile"), "vendor:\n\tgo mod vendor\n").unwrap();
847
848        for rebuild in [
849            "definitely-not-a-real-tool-xyz build", // a tool with no manifest to read
850            "npm run build && npm run docs",        // a chain, not one invocation
851            "npm run $TARGET",                      // the target is a variable
852            "npm run build | tee log",              // a pipeline
853            "npm ci",                               // not a script invocation at all
854            "npm --workspace api run build",        // a flag this does not model
855            "npm run -- build",                     // everything after `--` is opaque
856            "make",                                 // the default goal has no name
857            "make -j4 vendor",                      // a flag this does not model
858            "uv sync",                              // not a script invocation
859            "echo not needed",                      // the documented escape hatch
860            "./scripts/gen.sh",                     // a program, not a subcommand
861        ] {
862            assert!(
863                rebuild_gap(path, rebuild).is_none(),
864                "`{rebuild}` should have been allowed through, not refused"
865            );
866        }
867
868        // A manifest that is not there, or that does not parse, is also "cannot tell".
869        let bare = TempDir::new().unwrap();
870        assert!(rebuild_gap(bare.path(), "npm run build").is_none());
871        assert!(rebuild_gap(bare.path(), "make vendor").is_none());
872        assert!(rebuild_gap(bare.path(), "uv run regen").is_none());
873        fs::write(bare.path().join("package.json"), "{ not json").unwrap();
874        assert!(rebuild_gap(bare.path(), "npm run build").is_none());
875    }
876
877    #[test]
878    fn a_makefile_this_cannot_see_all_of_is_not_answered_from_the_part_it_can() {
879        // An `include`, a pattern rule or a variable in the target position each mean
880        // the file names targets that are not in its text.
881        let tmp = TempDir::new().unwrap();
882        for makefile in [
883            "include common.mk\n\nvendor:\n\tgo mod vendor\n",
884            "%.pb.go: %.proto\n\tprotoc $<\n",
885            "$(GENERATED): schema.json\n\tgen\n",
886        ] {
887            fs::write(tmp.path().join("Makefile"), makefile).unwrap();
888            assert!(
889                rebuild_gap(tmp.path(), "make fixtures").is_none(),
890                "a makefile with hidden targets must not produce a refusal"
891            );
892        }
893    }
894
895    #[test]
896    fn the_refusal_reads_like_every_other_one_in_this_module() {
897        // Composition, end to end. Guarded because the `PATH` check runs first: without
898        // node the refusal is the "not on this machine" one, which is correct there.
899        if !on_path("npm") {
900            return;
901        }
902        let tmp = repo();
903        let path = tmp.path();
904        fs::create_dir_all(path.join("site/dist")).unwrap();
905        fs::write(
906            path.join("package.json"),
907            r#"{"scripts":{"test":"vitest"}}"#,
908        )
909        .unwrap();
910
911        let reason = refusal(path, declared("site/dist", "npm run build"));
912        assert!(
913            reason.contains("`site/dist` is declared prunable, rebuilt by `npm run build`"),
914            "{reason}"
915        );
916        assert!(reason.contains("defines no `build` script"), "{reason}");
917        assert!(reason.contains("cannot put back"), "{reason}");
918        assert!(path.join("site/dist").exists());
919    }
920
921    #[test]
922    fn no_tool_name_is_claimed_by_two_rebuild_checks() {
923        let mut seen = std::collections::HashSet::new();
924        for check in REBUILD_CHECKS {
925            for tool in check.tools() {
926                assert!(
927                    seen.insert(*tool),
928                    "`{tool}` is claimed by more than one rebuild check"
929                );
930            }
931        }
932    }
933
934    #[test]
935    fn every_rebuild_check_allows_what_it_cannot_parse() {
936        // The contract every future check inherits, exercised against the registry
937        // itself so a check added in another file cannot opt out: no arguments and an
938        // unmodelled flag are both "cannot tell", and "cannot tell" allows the prune.
939        let tmp = TempDir::new().unwrap();
940        for check in REBUILD_CHECKS {
941            for tool in check.tools() {
942                assert!(
943                    check.gap(tmp.path(), tool, &[]).is_none(),
944                    "`{tool}` with no arguments must not refuse"
945                );
946                assert!(
947                    check
948                        .gap(tmp.path(), tool, &["--a-flag-this-does-not-model"])
949                        .is_none(),
950                    "`{tool}` with an unmodelled flag must not refuse"
951                );
952            }
953        }
954    }
955}