1use std::path::{Path, PathBuf};
22
23use crate::config::{DeclaredDir, Prunable};
24use crate::scanner::git;
25
26#[derive(Debug, Clone)]
28pub struct Target {
29 pub label: String,
31 pub path: PathBuf,
33 pub rebuild: String,
35 pub why: Option<String>,
37 pub size_bytes: u64,
39}
40
41#[derive(Debug, Clone)]
43pub enum Declaration {
44 Prunable(Box<Target>),
46 Refused { label: String, reason: String },
48}
49
50const SHELL_BUILTINS: &[&str] = &["echo", "true", ":"];
59
60const SHELL_ONLY: &[&str] = &["cd", "pushd", "source", ".", "export", "set"];
68
69pub fn resolve(repo_path: &Path, declared: &Prunable) -> Vec<Declaration> {
80 let excluded: Vec<String> = declared.exclude.iter().map(|raw| key(raw)).collect();
81 let mut out = Vec::new();
82 for entry in &declared.directories {
83 if excluded.contains(&key(&entry.path)) {
84 continue;
85 }
86 match check(repo_path, entry) {
87 Ok(Some(target)) => out.push(Declaration::Prunable(Box::new(target))),
88 Ok(None) => {}
89 Err(reason) => out.push(Declaration::Refused {
90 label: entry.path.clone(),
91 reason,
92 }),
93 }
94 }
95 out
96}
97
98pub(crate) fn key(raw: &str) -> String {
106 split_relative(raw).map_or_else(|_| raw.trim().to_string(), |parts| parts.join("/"))
107}
108
109fn check(repo_path: &Path, entry: &DeclaredDir) -> Result<Option<Target>, String> {
111 let parts = split_relative(&entry.path)?;
112 let label = parts.join("/");
113 let path = parts.iter().fold(repo_path.to_path_buf(), |p, s| p.join(s));
114
115 if !path.exists() {
116 return Ok(None);
117 }
118 if !path.is_dir() {
119 return Err(format!(
120 "`{label}` is declared prunable but is a file, not a directory — \
121 dev-prune only deletes whole directories. Left alone."
122 ));
123 }
124
125 let (Ok(real), Ok(root)) = (path.canonicalize(), repo_path.canonicalize()) else {
129 return Err(format!(
130 "`{label}` is declared prunable but could not be resolved on this machine — \
131 refusing to delete a path dev-prune cannot pin down."
132 ));
133 };
134 if !real.starts_with(&root) {
135 return Err(format!(
136 "`{label}` is declared prunable but resolves to `{}`, outside the \
137 repository. Left alone.",
138 crate::output::clean_path(&real)
139 ));
140 }
141
142 if let Some(tracked) = first_tracked_file(repo_path, &label)? {
143 return Err(format!(
144 "`{label}` is declared prunable but Git is tracking `{tracked}` inside it — \
145 refusing. A lockfile cannot rebuild a file that is in the repository \
146 itself. Remove the declaration, or stop tracking those files."
147 ));
148 }
149
150 let rebuild = entry.rebuild.trim();
151 if rebuild.is_empty() {
152 return Err(format!(
153 "`{label}` is declared prunable with an empty `rebuild` command — refusing. \
154 Say what puts it back, or use `\"rebuild\": \"echo not needed\"` if nothing \
155 does."
156 ));
157 }
158 let tool = first_word(rebuild);
159 if SHELL_ONLY.contains(&tool) {
160 return Err(format!(
161 "`{label}` is declared prunable, rebuilt by `{rebuild}`, but `{tool}` is a \
162 shell builtin, not a program this machine can be checked for — put the \
163 tool first, e.g. `npm --prefix docs run build` rather than \
164 `cd docs && npm run build`."
165 ));
166 }
167 if !SHELL_BUILTINS.contains(&tool) && !on_path(tool) {
168 return Err(format!(
169 "`{label}` is declared prunable, rebuilt by `{rebuild}`, but `{tool}` is not \
170 on this machine — refusing to delete something this machine cannot put \
171 back. Install `{tool}` first."
172 ));
173 }
174
175 Ok(Some(Target {
176 size_bytes: crate::adapters::dir_size(&path),
177 label,
178 path,
179 rebuild: rebuild.to_string(),
180 why: entry.why.clone(),
181 }))
182}
183
184fn split_relative(raw: &str) -> Result<Vec<String>, String> {
192 let trimmed = raw.trim();
193 if trimmed.is_empty() {
194 return Err("An entry in `prunable.directories` has an empty `path`.".to_string());
195 }
196 if trimmed.starts_with('/') || trimmed.starts_with('\\') {
197 return Err(format!(
198 "`{trimmed}` is declared prunable but is an absolute path — declarations are \
199 relative to the repository root. Left alone."
200 ));
201 }
202 let mut parts = Vec::new();
203 for part in trimmed.split(['/', '\\']) {
204 if part.is_empty() || part == "." {
205 continue;
206 }
207 if part == ".." {
208 return Err(format!(
209 "`{trimmed}` is declared prunable but climbs out of the repository with \
210 `..` — refusing. Left alone."
211 ));
212 }
213 if part.contains(':') {
214 return Err(format!(
215 "`{trimmed}` is declared prunable but names a drive or stream — \
216 declarations are relative to the repository root. Left alone."
217 ));
218 }
219 if part.eq_ignore_ascii_case(".git") {
220 return Err(format!(
221 "`{trimmed}` is declared prunable but is inside `.git` — the one \
222 directory dev-prune never crosses. Left alone."
223 ));
224 }
225 parts.push(part.to_string());
226 }
227 if parts.is_empty() {
228 return Err(format!(
229 "`{trimmed}` is declared prunable but resolves to the repository root \
230 itself — refusing. Left alone."
231 ));
232 }
233 Ok(parts)
234}
235
236fn first_tracked_file(repo_path: &Path, label: &str) -> Result<Option<String>, String> {
247 let output = git::git_in(repo_path)
248 .args(["ls-files", "--", label])
249 .output()
250 .map_err(|e| {
251 format!(
252 "`{label}` is declared prunable, but `git ls-files` could not run ({e}) — \
253 refusing to delete without knowing whether it holds tracked files."
254 )
255 })?;
256 if !output.status.success() {
257 return Err(format!(
258 "`{label}` is declared prunable, but `git ls-files` failed — refusing to \
259 delete without knowing whether it holds tracked files."
260 ));
261 }
262 Ok(String::from_utf8_lossy(&output.stdout)
263 .lines()
264 .next()
265 .map(str::to_string))
266}
267
268fn first_word(command: &str) -> &str {
270 command
271 .split_whitespace()
272 .next()
273 .unwrap_or("")
274 .trim_matches(['"', '\''])
275}
276
277fn on_path(program: &str) -> bool {
283 let named = Path::new(program);
284 if named.components().count() > 1 {
285 return named.is_file();
286 }
287 let Some(path_var) = std::env::var_os("PATH") else {
288 return false;
289 };
290 let exts: &[&str] = if cfg!(windows) {
293 &["", "exe", "cmd", "bat", "com", "ps1"]
294 } else {
295 &[""]
296 };
297 std::env::split_paths(&path_var).any(|dir| {
298 exts.iter().any(|ext| {
299 if ext.is_empty() {
300 dir.join(program).is_file()
301 } else {
302 dir.join(format!("{program}.{ext}")).is_file()
303 }
304 })
305 })
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use std::fs;
312 use std::process::Command;
313 use tempfile::TempDir;
314
315 fn declared(path: &str, rebuild: &str) -> DeclaredDir {
316 DeclaredDir {
317 path: path.to_string(),
318 rebuild: rebuild.to_string(),
319 why: None,
320 }
321 }
322
323 fn one(entry: DeclaredDir) -> Prunable {
325 Prunable {
326 directories: vec![entry],
327 exclude: Vec::new(),
328 }
329 }
330
331 fn repo() -> TempDir {
333 let tmp = TempDir::new().unwrap();
334 let path = tmp.path();
335 for args in [
336 vec!["init", "-q"],
337 vec!["config", "user.email", "t@example.com"],
338 vec!["config", "user.name", "t"],
339 ] {
340 Command::new("git")
341 .args(&args)
342 .current_dir(path)
343 .output()
344 .unwrap();
345 }
346 tmp
347 }
348
349 fn refusal(repo_path: &Path, entry: DeclaredDir) -> String {
350 match resolve(repo_path, &one(entry)).pop() {
351 Some(Declaration::Refused { reason, .. }) => reason,
352 other => panic!("expected a refusal, got {other:?}"),
353 }
354 }
355
356 #[test]
357 fn a_declaration_that_holds_up_is_prunable_with_its_reason_carried_along() {
358 let tmp = repo();
359 let path = tmp.path();
360 fs::create_dir_all(path.join("build/fixtures")).unwrap();
361 fs::write(path.join("build/fixtures/a.bin"), vec![0u8; 4096]).unwrap();
362
363 let mut entry = declared("build/fixtures", "echo not needed");
364 entry.why = Some("regenerated by the test suite".into());
365 let Some(Declaration::Prunable(target)) = resolve(path, &one(entry)).pop() else {
366 panic!("a declaration nothing is wrong with must be prunable");
367 };
368 assert_eq!(target.label, "build/fixtures");
369 assert_eq!(target.why.as_deref(), Some("regenerated by the test suite"));
370 assert!(target.size_bytes >= 4096);
371 }
372
373 #[test]
374 fn the_documented_escape_hatch_works_on_every_platform() {
375 let tmp = repo();
378 fs::create_dir_all(tmp.path().join("scratch")).unwrap();
379 assert!(matches!(
380 resolve(tmp.path(), &one(declared("scratch", "echo not needed"))).pop(),
381 Some(Declaration::Prunable(_))
382 ));
383 }
384
385 #[test]
386 fn a_declaration_covering_tracked_files_is_refused() {
387 let tmp = repo();
390 let path = tmp.path();
391 fs::create_dir_all(path.join("src")).unwrap();
392 fs::write(path.join("src/main.rs"), "fn main() {}").unwrap();
393 Command::new("git")
394 .args(["add", "src/main.rs"])
395 .current_dir(path)
396 .output()
397 .unwrap();
398
399 let reason = refusal(path, declared("src", "echo not needed"));
400 assert!(reason.contains("Git is tracking"), "{reason}");
401 assert!(path.join("src/main.rs").exists());
402 }
403
404 #[test]
405 fn a_declaration_whose_rebuild_tool_is_absent_is_refused() {
406 let tmp = repo();
407 fs::create_dir_all(tmp.path().join("vendor")).unwrap();
408 let reason = refusal(
409 tmp.path(),
410 declared("vendor", "definitely-not-a-real-tool-xyz build"),
411 );
412 assert!(reason.contains("is not on this machine"), "{reason}");
413 }
414
415 #[test]
416 fn a_rebuild_starting_with_cd_gets_the_rewrite_not_install_advice() {
417 let tmp = repo();
418 fs::create_dir_all(tmp.path().join("docs/out")).unwrap();
419 let reason = refusal(tmp.path(), declared("docs/out", "cd docs && npm run build"));
420 assert!(reason.contains("shell builtin"), "{reason}");
421 assert!(reason.contains("--prefix"), "{reason}");
422 assert!(!reason.contains("Install"), "{reason}");
423 }
424
425 #[test]
426 fn an_empty_rebuild_is_refused_and_says_what_to_write_instead() {
427 let tmp = repo();
428 fs::create_dir_all(tmp.path().join("vendor")).unwrap();
429 let reason = refusal(tmp.path(), declared("vendor", " "));
430 assert!(reason.contains("echo not needed"), "{reason}");
431 }
432
433 #[test]
434 fn paths_that_could_point_outside_the_repository_never_get_that_far() {
435 for (raw, expected) in [
439 ("../secrets", "climbs out of the repository"),
440 ("/etc", "absolute path"),
441 ("C:/Windows", "names a drive"),
442 (".git/objects", "inside `.git`"),
443 (".", "the repository root itself"),
444 ] {
445 let err = split_relative(raw).unwrap_err();
446 assert!(err.contains(expected), "{raw}: {err}");
447 }
448 }
449
450 #[test]
451 fn a_declared_directory_that_is_not_there_says_nothing_at_all() {
452 let tmp = repo();
455 assert!(
456 resolve(
457 tmp.path(),
458 &one(declared("never/existed", "echo not needed"))
459 )
460 .is_empty()
461 );
462 }
463
464 #[test]
465 fn an_exclusion_takes_a_declaration_out_of_play_however_it_is_spelled() {
466 let tmp = repo();
470 let path = tmp.path();
471 fs::create_dir_all(path.join("scratch")).unwrap();
472
473 for spelling in ["scratch", "scratch/", "./scratch", r"scratch\"] {
474 let prunable = Prunable {
475 directories: vec![declared("scratch", "echo not needed")],
476 exclude: vec![spelling.to_string()],
477 };
478 assert!(
479 resolve(path, &prunable).is_empty(),
480 "`{spelling}` did not exclude `scratch`"
481 );
482 }
483
484 fs::create_dir_all(path.join("vendor")).unwrap();
486 let prunable = Prunable {
487 directories: vec![
488 declared("scratch", "echo not needed"),
489 declared("vendor", "echo not needed"),
490 ],
491 exclude: vec!["scratch".to_string()],
492 };
493 let left: Vec<String> = resolve(path, &prunable)
494 .into_iter()
495 .map(|d| match d {
496 Declaration::Prunable(t) => t.label,
497 Declaration::Refused { label, .. } => label,
498 })
499 .collect();
500 assert_eq!(left, ["vendor"]);
501 }
502
503 #[test]
504 fn an_exclusion_silences_the_refusal_too_not_only_the_delete() {
505 let tmp = repo();
508 let path = tmp.path();
509 fs::create_dir_all(path.join("src")).unwrap();
510 fs::write(path.join("src/main.rs"), "fn main() {}").unwrap();
511 Command::new("git")
512 .args(["add", "src/main.rs"])
513 .current_dir(path)
514 .output()
515 .unwrap();
516
517 assert!(
518 !resolve(path, &one(declared("src", "echo not needed"))).is_empty(),
519 "this repository is supposed to produce a refusal"
520 );
521 let prunable = Prunable {
522 directories: vec![declared("src", "echo not needed")],
523 exclude: vec!["src".to_string()],
524 };
525 assert!(resolve(path, &prunable).is_empty());
526 }
527
528 #[test]
529 fn a_backslash_declaration_reads_the_same_as_a_forward_slash_one() {
530 assert_eq!(
531 split_relative(r"build\fixtures").unwrap(),
532 split_relative("build/fixtures").unwrap()
533 );
534 }
535}