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 tempfile::TempDir;
466
467 fn declared(path: &str, rebuild: &str) -> DeclaredDir {
468 DeclaredDir {
469 path: path.to_string(),
470 rebuild: rebuild.to_string(),
471 why: None,
472 }
473 }
474
475 fn one(entry: DeclaredDir) -> Prunable {
477 Prunable {
478 directories: vec![entry],
479 exclude: Vec::new(),
480 }
481 }
482
483 fn repo() -> TempDir {
490 let tmp = TempDir::new().unwrap();
491 let path = tmp.path();
492 for args in [
493 vec!["init", "-q"],
494 vec!["config", "user.email", "t@example.com"],
495 vec!["config", "user.name", "t"],
496 ] {
497 crate::scanner::git::git_in(path)
498 .args(&args)
499 .output()
500 .unwrap();
501 }
502 tmp
503 }
504
505 fn refusal(repo_path: &Path, entry: DeclaredDir) -> String {
506 match resolve(repo_path, &one(entry)).pop() {
507 Some(Declaration::Refused { reason, .. }) => reason,
508 other => panic!("expected a refusal, got {other:?}"),
509 }
510 }
511
512 #[test]
513 fn a_declaration_that_holds_up_is_prunable_with_its_reason_carried_along() {
514 let tmp = repo();
515 let path = tmp.path();
516 fs::create_dir_all(path.join("build/fixtures")).unwrap();
517 fs::write(path.join("build/fixtures/a.bin"), vec![0u8; 4096]).unwrap();
518
519 let mut entry = declared("build/fixtures", "echo not needed");
520 entry.why = Some("regenerated by the test suite".into());
521 let Some(Declaration::Prunable(target)) = resolve(path, &one(entry)).pop() else {
522 panic!("a declaration nothing is wrong with must be prunable");
523 };
524 assert_eq!(target.label, "build/fixtures");
525 assert_eq!(target.why.as_deref(), Some("regenerated by the test suite"));
526 assert!(target.size_bytes >= 4096);
527 }
528
529 #[test]
530 fn the_documented_escape_hatch_works_on_every_platform() {
531 let tmp = repo();
534 fs::create_dir_all(tmp.path().join("scratch")).unwrap();
535 assert!(matches!(
536 resolve(tmp.path(), &one(declared("scratch", "echo not needed"))).pop(),
537 Some(Declaration::Prunable(_))
538 ));
539 }
540
541 #[test]
542 fn a_declaration_covering_tracked_files_is_refused() {
543 let tmp = repo();
546 let path = tmp.path();
547 fs::create_dir_all(path.join("src")).unwrap();
548 fs::write(path.join("src/main.rs"), "fn main() {}").unwrap();
549 crate::scanner::git::git_in(path)
550 .args(["add", "src/main.rs"])
551 .output()
552 .unwrap();
553
554 let reason = refusal(path, declared("src", "echo not needed"));
555 assert!(reason.contains("Git is tracking"), "{reason}");
556 assert!(path.join("src/main.rs").exists());
557 }
558
559 #[test]
560 fn a_declaration_whose_rebuild_tool_is_absent_is_refused() {
561 let tmp = repo();
562 fs::create_dir_all(tmp.path().join("vendor")).unwrap();
563 let reason = refusal(
564 tmp.path(),
565 declared("vendor", "definitely-not-a-real-tool-xyz build"),
566 );
567 assert!(reason.contains("is not on this machine"), "{reason}");
568 }
569
570 #[test]
571 fn a_rebuild_starting_with_cd_gets_the_rewrite_not_install_advice() {
572 let tmp = repo();
573 fs::create_dir_all(tmp.path().join("docs/out")).unwrap();
574 let reason = refusal(tmp.path(), declared("docs/out", "cd docs && npm run build"));
575 assert!(reason.contains("shell builtin"), "{reason}");
576 assert!(reason.contains("--prefix"), "{reason}");
577 assert!(!reason.contains("Install"), "{reason}");
578 }
579
580 #[test]
581 fn an_empty_rebuild_is_refused_and_says_what_to_write_instead() {
582 let tmp = repo();
583 fs::create_dir_all(tmp.path().join("vendor")).unwrap();
584 let reason = refusal(tmp.path(), declared("vendor", " "));
585 assert!(reason.contains("echo not needed"), "{reason}");
586 }
587
588 #[test]
589 fn paths_that_could_point_outside_the_repository_never_get_that_far() {
590 for (raw, expected) in [
594 ("../secrets", "climbs out of the repository"),
595 ("/etc", "absolute path"),
596 ("C:/Windows", "names a drive"),
597 (".git/objects", "inside `.git`"),
598 (".", "the repository root itself"),
599 ] {
600 let err = split_relative(raw).unwrap_err();
601 assert!(err.contains(expected), "{raw}: {err}");
602 }
603 }
604
605 #[test]
606 fn a_declared_directory_that_is_not_there_says_nothing_at_all() {
607 let tmp = repo();
610 assert!(
611 resolve(
612 tmp.path(),
613 &one(declared("never/existed", "echo not needed"))
614 )
615 .is_empty()
616 );
617 }
618
619 #[test]
620 fn an_exclusion_takes_a_declaration_out_of_play_however_it_is_spelled() {
621 let tmp = repo();
625 let path = tmp.path();
626 fs::create_dir_all(path.join("scratch")).unwrap();
627
628 for spelling in ["scratch", "scratch/", "./scratch", r"scratch\"] {
629 let prunable = Prunable {
630 directories: vec![declared("scratch", "echo not needed")],
631 exclude: vec![spelling.to_string()],
632 };
633 assert!(
634 resolve(path, &prunable).is_empty(),
635 "`{spelling}` did not exclude `scratch`"
636 );
637 }
638
639 fs::create_dir_all(path.join("vendor")).unwrap();
641 let prunable = Prunable {
642 directories: vec![
643 declared("scratch", "echo not needed"),
644 declared("vendor", "echo not needed"),
645 ],
646 exclude: vec!["scratch".to_string()],
647 };
648 let left: Vec<String> = resolve(path, &prunable)
649 .into_iter()
650 .map(|d| match d {
651 Declaration::Prunable(t) => t.label,
652 Declaration::Refused { label, .. } => label,
653 })
654 .collect();
655 assert_eq!(left, ["vendor"]);
656 }
657
658 #[test]
659 fn an_exclusion_silences_the_refusal_too_not_only_the_delete() {
660 let tmp = repo();
663 let path = tmp.path();
664 fs::create_dir_all(path.join("src")).unwrap();
665 fs::write(path.join("src/main.rs"), "fn main() {}").unwrap();
666 crate::scanner::git::git_in(path)
667 .args(["add", "src/main.rs"])
668 .output()
669 .unwrap();
670
671 assert!(
672 !resolve(path, &one(declared("src", "echo not needed"))).is_empty(),
673 "this repository is supposed to produce a refusal"
674 );
675 let prunable = Prunable {
676 directories: vec![declared("src", "echo not needed")],
677 exclude: vec!["src".to_string()],
678 };
679 assert!(resolve(path, &prunable).is_empty());
680 }
681
682 #[test]
683 fn a_backslash_declaration_reads_the_same_as_a_forward_slash_one() {
684 assert_eq!(
685 split_relative(r"build\fixtures").unwrap(),
686 split_relative("build/fixtures").unwrap()
687 );
688 }
689
690 fn gap(repo_path: &Path, rebuild: &str) -> Gap {
696 match rebuild_gap(repo_path, rebuild) {
697 Some(gap) => gap,
698 None => panic!("`{rebuild}` should have been refused"),
699 }
700 }
701
702 #[test]
703 fn a_rebuild_naming_a_script_the_package_json_does_not_have_is_refused() {
704 let tmp = TempDir::new().unwrap();
707 fs::write(
708 tmp.path().join("package.json"),
709 r#"{"scripts":{"test":"vitest"}}"#,
710 )
711 .unwrap();
712
713 let gap = gap(tmp.path(), "npm run build");
714 assert!(gap.what.contains("package.json"), "{}", gap.what);
715 assert!(gap.what.contains("`build`"), "{}", gap.what);
716 assert!(gap.fix.contains("Add a `build` script"), "{}", gap.fix);
717 }
718
719 #[test]
720 fn a_rebuild_naming_a_script_that_is_there_is_left_alone() {
721 let tmp = TempDir::new().unwrap();
722 fs::write(
723 tmp.path().join("package.json"),
724 r#"{"scripts":{"build":"tsc -p .","test":"vitest"}}"#,
725 )
726 .unwrap();
727
728 for rebuild in [
729 "npm run build",
730 "pnpm run build",
731 "yarn run build",
732 "pnpm build",
733 "yarn build",
734 "npm.cmd run build",
735 ] {
736 assert!(
737 rebuild_gap(tmp.path(), rebuild).is_none(),
738 "`{rebuild}` names a script that is right there"
739 );
740 }
741 }
742
743 #[test]
744 fn the_prefix_flag_decides_which_package_json_is_read() {
745 let tmp = TempDir::new().unwrap();
748 let path = tmp.path();
749 fs::write(
750 path.join("package.json"),
751 r#"{"scripts":{"lint":"eslint"}}"#,
752 )
753 .unwrap();
754 fs::create_dir_all(path.join("docs")).unwrap();
755 fs::write(
756 path.join("docs/package.json"),
757 r#"{"scripts":{"build":"astro build"}}"#,
758 )
759 .unwrap();
760
761 for rebuild in [
762 "npm --prefix docs run build",
763 "npm --prefix=docs run build",
764 "pnpm -C docs run build",
765 "pnpm --dir docs build",
766 "yarn --cwd docs build",
767 ] {
768 assert!(
769 rebuild_gap(path, rebuild).is_none(),
770 "`{rebuild}` should have read docs/package.json"
771 );
772 }
773
774 assert!(gap(path, "npm run build").what.contains("`package.json`"));
777 let elsewhere = gap(path, "npm --prefix docs run missing");
778 assert!(
779 elsewhere.what.contains("`docs/package.json`"),
780 "{}",
781 elsewhere.what
782 );
783 }
784
785 #[test]
786 fn a_make_target_the_makefile_does_not_define_is_refused() {
787 let tmp = TempDir::new().unwrap();
788 fs::write(
789 tmp.path().join("Makefile"),
790 "CACHE := .cache\n\n.PHONY: clean\n\nvendor: tools/manifest.toml\n\tgo mod vendor\n",
791 )
792 .unwrap();
793
794 assert!(rebuild_gap(tmp.path(), "make vendor").is_none());
795 assert!(rebuild_gap(tmp.path(), "make clean").is_none());
796 assert!(rebuild_gap(tmp.path(), "make CACHE=x vendor").is_none());
797
798 let gap = gap(tmp.path(), "make fixtures");
799 assert!(gap.what.contains("`Makefile`"), "{}", gap.what);
800 assert!(gap.what.contains("`fixtures`"), "{}", gap.what);
801 }
802
803 #[test]
804 fn a_uv_script_declared_in_tool_uv_scripts_is_refused_because_uv_never_reads_it() {
805 let tmp = TempDir::new().unwrap();
808 let path = tmp.path();
809 fs::write(
810 path.join("pyproject.toml"),
811 "[project]\nname = \"proj\"\n\n[tool.uv.scripts]\nregen-fixtures = \"tools.gen:main\"\n",
812 )
813 .unwrap();
814
815 let gap = gap(path, "uv run regen-fixtures");
816 assert!(gap.what.contains("pyproject.toml"), "{}", gap.what);
817 assert!(gap.fix.contains("[project.scripts]"), "{}", gap.fix);
818
819 fs::write(
821 path.join("pyproject.toml"),
822 "[project]\nname = \"proj\"\n\n[project.scripts]\nregen-fixtures = \"tools.gen:main\"\n",
823 )
824 .unwrap();
825 assert!(rebuild_gap(path, "uv run regen-fixtures").is_none());
826
827 fs::write(
829 path.join("pyproject.toml"),
830 "[project]\nname = \"proj\"\ndependencies = [\n \"pytest>=8\",\n]\n",
831 )
832 .unwrap();
833 assert!(rebuild_gap(path, "uv run pytest").is_none());
834 }
835
836 #[test]
837 fn a_command_shape_this_cannot_read_is_allowed_rather_than_guessed_at() {
838 let tmp = TempDir::new().unwrap();
841 let path = tmp.path();
842 fs::write(
843 path.join("package.json"),
844 r#"{"scripts":{"lint":"eslint"}}"#,
845 )
846 .unwrap();
847 fs::write(path.join("Makefile"), "vendor:\n\tgo mod vendor\n").unwrap();
848
849 for rebuild in [
850 "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", ] {
863 assert!(
864 rebuild_gap(path, rebuild).is_none(),
865 "`{rebuild}` should have been allowed through, not refused"
866 );
867 }
868
869 let bare = TempDir::new().unwrap();
871 assert!(rebuild_gap(bare.path(), "npm run build").is_none());
872 assert!(rebuild_gap(bare.path(), "make vendor").is_none());
873 assert!(rebuild_gap(bare.path(), "uv run regen").is_none());
874 fs::write(bare.path().join("package.json"), "{ not json").unwrap();
875 assert!(rebuild_gap(bare.path(), "npm run build").is_none());
876 }
877
878 #[test]
879 fn a_makefile_this_cannot_see_all_of_is_not_answered_from_the_part_it_can() {
880 let tmp = TempDir::new().unwrap();
883 for makefile in [
884 "include common.mk\n\nvendor:\n\tgo mod vendor\n",
885 "%.pb.go: %.proto\n\tprotoc $<\n",
886 "$(GENERATED): schema.json\n\tgen\n",
887 ] {
888 fs::write(tmp.path().join("Makefile"), makefile).unwrap();
889 assert!(
890 rebuild_gap(tmp.path(), "make fixtures").is_none(),
891 "a makefile with hidden targets must not produce a refusal"
892 );
893 }
894 }
895
896 #[test]
897 fn the_refusal_reads_like_every_other_one_in_this_module() {
898 if !on_path("npm") {
901 return;
902 }
903 let tmp = repo();
904 let path = tmp.path();
905 fs::create_dir_all(path.join("site/dist")).unwrap();
906 fs::write(
907 path.join("package.json"),
908 r#"{"scripts":{"test":"vitest"}}"#,
909 )
910 .unwrap();
911
912 let reason = refusal(path, declared("site/dist", "npm run build"));
913 assert!(
914 reason.contains("`site/dist` is declared prunable, rebuilt by `npm run build`"),
915 "{reason}"
916 );
917 assert!(reason.contains("defines no `build` script"), "{reason}");
918 assert!(reason.contains("cannot put back"), "{reason}");
919 assert!(path.join("site/dist").exists());
920 }
921
922 #[test]
923 fn no_tool_name_is_claimed_by_two_rebuild_checks() {
924 let mut seen = std::collections::HashSet::new();
925 for check in REBUILD_CHECKS {
926 for tool in check.tools() {
927 assert!(
928 seen.insert(*tool),
929 "`{tool}` is claimed by more than one rebuild check"
930 );
931 }
932 }
933 }
934
935 #[test]
936 fn every_rebuild_check_allows_what_it_cannot_parse() {
937 let tmp = TempDir::new().unwrap();
941 for check in REBUILD_CHECKS {
942 for tool in check.tools() {
943 assert!(
944 check.gap(tmp.path(), tool, &[]).is_none(),
945 "`{tool}` with no arguments must not refuse"
946 );
947 assert!(
948 check
949 .gap(tmp.path(), tool, &["--a-flag-this-does-not-model"])
950 .is_none(),
951 "`{tool}` with an unmodelled flag must not refuse"
952 );
953 }
954 }
955 }
956}