1use std::path::{Path, PathBuf};
27
28use crate::config::{DeclaredDir, Prunable};
29use crate::scanner::git;
30
31mod make;
32
33#[derive(Debug, Clone)]
35pub struct Target {
36 pub label: String,
38 pub path: PathBuf,
40 pub rebuild: String,
42 pub why: Option<String>,
44 pub size_bytes: u64,
46}
47
48#[derive(Debug, Clone)]
50pub enum Declaration {
51 Prunable(Box<Target>),
53 Refused { label: String, reason: String },
55}
56
57const SHELL_BUILTINS: &[&str] = &["echo", "true", ":"];
66
67const SHELL_ONLY: &[&str] = &["cd", "pushd", "source", ".", "export", "set"];
75
76pub 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
105pub(crate) fn key(raw: &str) -> String {
113 split_relative(raw).map_or_else(|_| raw.trim().to_string(), |parts| parts.join("/"))
114}
115
116fn 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 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
198pub(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
250fn 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
282fn first_word(command: &str) -> &str {
284 command
285 .split_whitespace()
286 .next()
287 .unwrap_or("")
288 .trim_matches(['"', '\''])
289}
290
291pub(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 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
322pub(crate) struct Gap {
324 pub(crate) what: String,
327 pub(crate) fix: String,
329}
330
331const SHELL_METACHARACTERS: &[char] = &[
335 '&', '|', ';', '$', '`', '>', '<', '(', ')', '*', '?', '%', '{', '}', '#',
336];
337
338pub(crate) trait RebuildCheck: Sync {
357 fn tools(&self) -> &'static [&'static str];
359
360 fn gap(&self, repo_path: &Path, tool: &str, args: &[&str]) -> Option<Gap>;
363}
364
365static 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
376fn 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
405fn 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
420fn 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
433pub(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
444pub(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
452pub(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 fn one(entry: DeclaredDir) -> Prunable {
478 Prunable {
479 directories: vec![entry],
480 exclude: Vec::new(),
481 }
482 }
483
484 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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", "npm run build && npm run docs", "npm run $TARGET", "npm run build | tee log", "npm ci", "npm --workspace api run build", "npm run -- build", "make", "make -j4 vendor", "uv sync", "echo not needed", "./scripts/gen.sh", ] {
862 assert!(
863 rebuild_gap(path, rebuild).is_none(),
864 "`{rebuild}` should have been allowed through, not refused"
865 );
866 }
867
868 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 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 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 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}