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
60pub fn resolve(repo_path: &Path, declared: &Prunable) -> Vec<Declaration> {
71 let excluded: Vec<String> = declared.exclude.iter().map(|raw| key(raw)).collect();
72 let mut out = Vec::new();
73 for entry in &declared.directories {
74 if excluded.contains(&key(&entry.path)) {
75 continue;
76 }
77 match check(repo_path, entry) {
78 Ok(Some(target)) => out.push(Declaration::Prunable(Box::new(target))),
79 Ok(None) => {}
80 Err(reason) => out.push(Declaration::Refused {
81 label: entry.path.clone(),
82 reason,
83 }),
84 }
85 }
86 out
87}
88
89pub(crate) fn key(raw: &str) -> String {
97 split_relative(raw).map_or_else(|_| raw.trim().to_string(), |parts| parts.join("/"))
98}
99
100fn check(repo_path: &Path, entry: &DeclaredDir) -> Result<Option<Target>, String> {
102 let parts = split_relative(&entry.path)?;
103 let label = parts.join("/");
104 let path = parts.iter().fold(repo_path.to_path_buf(), |p, s| p.join(s));
105
106 if !path.exists() {
107 return Ok(None);
108 }
109 if !path.is_dir() {
110 return Err(format!(
111 "`{label}` is declared prunable but is a file, not a directory — \
112 dev-prune only deletes whole directories. Left alone."
113 ));
114 }
115
116 let (Ok(real), Ok(root)) = (path.canonicalize(), repo_path.canonicalize()) else {
120 return Err(format!(
121 "`{label}` is declared prunable but could not be resolved on this machine — \
122 refusing to delete a path dev-prune cannot pin down."
123 ));
124 };
125 if !real.starts_with(&root) {
126 return Err(format!(
127 "`{label}` is declared prunable but resolves to `{}`, outside the \
128 repository. Left alone.",
129 real.display()
130 ));
131 }
132
133 if let Some(tracked) = first_tracked_file(repo_path, &label)? {
134 return Err(format!(
135 "`{label}` is declared prunable but Git is tracking `{tracked}` inside it — \
136 refusing. A lockfile cannot rebuild a file that is in the repository \
137 itself. Remove the declaration, or stop tracking those files."
138 ));
139 }
140
141 let rebuild = entry.rebuild.trim();
142 if rebuild.is_empty() {
143 return Err(format!(
144 "`{label}` is declared prunable with an empty `rebuild` command — refusing. \
145 Say what puts it back, or use `\"rebuild\": \"echo not needed\"` if nothing \
146 does."
147 ));
148 }
149 let tool = first_word(rebuild);
150 if !SHELL_BUILTINS.contains(&tool) && !on_path(tool) {
151 return Err(format!(
152 "`{label}` is declared prunable, rebuilt by `{rebuild}`, but `{tool}` is not \
153 on this machine — refusing to delete something this machine cannot put \
154 back. Install `{tool}` first."
155 ));
156 }
157
158 Ok(Some(Target {
159 size_bytes: crate::adapters::dir_size(&path),
160 label,
161 path,
162 rebuild: rebuild.to_string(),
163 why: entry.why.clone(),
164 }))
165}
166
167fn split_relative(raw: &str) -> Result<Vec<String>, String> {
175 let trimmed = raw.trim();
176 if trimmed.is_empty() {
177 return Err("An entry in `prunable.directories` has an empty `path`.".to_string());
178 }
179 if trimmed.starts_with('/') || trimmed.starts_with('\\') {
180 return Err(format!(
181 "`{trimmed}` is declared prunable but is an absolute path — declarations are \
182 relative to the repository root. Left alone."
183 ));
184 }
185 let mut parts = Vec::new();
186 for part in trimmed.split(['/', '\\']) {
187 if part.is_empty() || part == "." {
188 continue;
189 }
190 if part == ".." {
191 return Err(format!(
192 "`{trimmed}` is declared prunable but climbs out of the repository with \
193 `..` — refusing. Left alone."
194 ));
195 }
196 if part.contains(':') {
197 return Err(format!(
198 "`{trimmed}` is declared prunable but names a drive or stream — \
199 declarations are relative to the repository root. Left alone."
200 ));
201 }
202 if part.eq_ignore_ascii_case(".git") {
203 return Err(format!(
204 "`{trimmed}` is declared prunable but is inside `.git` — the one \
205 directory dev-prune never crosses. Left alone."
206 ));
207 }
208 parts.push(part.to_string());
209 }
210 if parts.is_empty() {
211 return Err(format!(
212 "`{trimmed}` is declared prunable but resolves to the repository root \
213 itself — refusing. Left alone."
214 ));
215 }
216 Ok(parts)
217}
218
219fn first_tracked_file(repo_path: &Path, label: &str) -> Result<Option<String>, String> {
230 let output = git::git_in(repo_path)
231 .args(["ls-files", "--", label])
232 .output()
233 .map_err(|e| {
234 format!(
235 "`{label}` is declared prunable, but `git ls-files` could not run ({e}) — \
236 refusing to delete without knowing whether it holds tracked files."
237 )
238 })?;
239 if !output.status.success() {
240 return Err(format!(
241 "`{label}` is declared prunable, but `git ls-files` failed — refusing to \
242 delete without knowing whether it holds tracked files."
243 ));
244 }
245 Ok(String::from_utf8_lossy(&output.stdout)
246 .lines()
247 .next()
248 .map(str::to_string))
249}
250
251fn first_word(command: &str) -> &str {
253 command
254 .split_whitespace()
255 .next()
256 .unwrap_or("")
257 .trim_matches(['"', '\''])
258}
259
260fn on_path(program: &str) -> bool {
266 let named = Path::new(program);
267 if named.components().count() > 1 {
268 return named.is_file();
269 }
270 let Some(path_var) = std::env::var_os("PATH") else {
271 return false;
272 };
273 let exts: &[&str] = if cfg!(windows) {
276 &["", "exe", "cmd", "bat", "com", "ps1"]
277 } else {
278 &[""]
279 };
280 std::env::split_paths(&path_var).any(|dir| {
281 exts.iter().any(|ext| {
282 if ext.is_empty() {
283 dir.join(program).is_file()
284 } else {
285 dir.join(format!("{program}.{ext}")).is_file()
286 }
287 })
288 })
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use std::fs;
295 use std::process::Command;
296 use tempfile::TempDir;
297
298 fn declared(path: &str, rebuild: &str) -> DeclaredDir {
299 DeclaredDir {
300 path: path.to_string(),
301 rebuild: rebuild.to_string(),
302 why: None,
303 }
304 }
305
306 fn one(entry: DeclaredDir) -> Prunable {
308 Prunable {
309 directories: vec![entry],
310 exclude: Vec::new(),
311 }
312 }
313
314 fn repo() -> TempDir {
316 let tmp = TempDir::new().unwrap();
317 let path = tmp.path();
318 for args in [
319 vec!["init", "-q"],
320 vec!["config", "user.email", "t@example.com"],
321 vec!["config", "user.name", "t"],
322 ] {
323 Command::new("git")
324 .args(&args)
325 .current_dir(path)
326 .output()
327 .unwrap();
328 }
329 tmp
330 }
331
332 fn refusal(repo_path: &Path, entry: DeclaredDir) -> String {
333 match resolve(repo_path, &one(entry)).pop() {
334 Some(Declaration::Refused { reason, .. }) => reason,
335 other => panic!("expected a refusal, got {other:?}"),
336 }
337 }
338
339 #[test]
340 fn a_declaration_that_holds_up_is_prunable_with_its_reason_carried_along() {
341 let tmp = repo();
342 let path = tmp.path();
343 fs::create_dir_all(path.join("build/fixtures")).unwrap();
344 fs::write(path.join("build/fixtures/a.bin"), vec![0u8; 4096]).unwrap();
345
346 let mut entry = declared("build/fixtures", "echo not needed");
347 entry.why = Some("regenerated by the test suite".into());
348 let Some(Declaration::Prunable(target)) = resolve(path, &one(entry)).pop() else {
349 panic!("a declaration nothing is wrong with must be prunable");
350 };
351 assert_eq!(target.label, "build/fixtures");
352 assert_eq!(target.why.as_deref(), Some("regenerated by the test suite"));
353 assert!(target.size_bytes >= 4096);
354 }
355
356 #[test]
357 fn the_documented_escape_hatch_works_on_every_platform() {
358 let tmp = repo();
361 fs::create_dir_all(tmp.path().join("scratch")).unwrap();
362 assert!(matches!(
363 resolve(tmp.path(), &one(declared("scratch", "echo not needed"))).pop(),
364 Some(Declaration::Prunable(_))
365 ));
366 }
367
368 #[test]
369 fn a_declaration_covering_tracked_files_is_refused() {
370 let tmp = repo();
373 let path = tmp.path();
374 fs::create_dir_all(path.join("src")).unwrap();
375 fs::write(path.join("src/main.rs"), "fn main() {}").unwrap();
376 Command::new("git")
377 .args(["add", "src/main.rs"])
378 .current_dir(path)
379 .output()
380 .unwrap();
381
382 let reason = refusal(path, declared("src", "echo not needed"));
383 assert!(reason.contains("Git is tracking"), "{reason}");
384 assert!(path.join("src/main.rs").exists());
385 }
386
387 #[test]
388 fn a_declaration_whose_rebuild_tool_is_absent_is_refused() {
389 let tmp = repo();
390 fs::create_dir_all(tmp.path().join("vendor")).unwrap();
391 let reason = refusal(
392 tmp.path(),
393 declared("vendor", "definitely-not-a-real-tool-xyz build"),
394 );
395 assert!(reason.contains("is not on this machine"), "{reason}");
396 }
397
398 #[test]
399 fn an_empty_rebuild_is_refused_and_says_what_to_write_instead() {
400 let tmp = repo();
401 fs::create_dir_all(tmp.path().join("vendor")).unwrap();
402 let reason = refusal(tmp.path(), declared("vendor", " "));
403 assert!(reason.contains("echo not needed"), "{reason}");
404 }
405
406 #[test]
407 fn paths_that_could_point_outside_the_repository_never_get_that_far() {
408 for (raw, expected) in [
412 ("../secrets", "climbs out of the repository"),
413 ("/etc", "absolute path"),
414 ("C:/Windows", "names a drive"),
415 (".git/objects", "inside `.git`"),
416 (".", "the repository root itself"),
417 ] {
418 let err = split_relative(raw).unwrap_err();
419 assert!(err.contains(expected), "{raw}: {err}");
420 }
421 }
422
423 #[test]
424 fn a_declared_directory_that_is_not_there_says_nothing_at_all() {
425 let tmp = repo();
428 assert!(
429 resolve(
430 tmp.path(),
431 &one(declared("never/existed", "echo not needed"))
432 )
433 .is_empty()
434 );
435 }
436
437 #[test]
438 fn an_exclusion_takes_a_declaration_out_of_play_however_it_is_spelled() {
439 let tmp = repo();
443 let path = tmp.path();
444 fs::create_dir_all(path.join("scratch")).unwrap();
445
446 for spelling in ["scratch", "scratch/", "./scratch", r"scratch\"] {
447 let prunable = Prunable {
448 directories: vec![declared("scratch", "echo not needed")],
449 exclude: vec![spelling.to_string()],
450 };
451 assert!(
452 resolve(path, &prunable).is_empty(),
453 "`{spelling}` did not exclude `scratch`"
454 );
455 }
456
457 fs::create_dir_all(path.join("vendor")).unwrap();
459 let prunable = Prunable {
460 directories: vec![
461 declared("scratch", "echo not needed"),
462 declared("vendor", "echo not needed"),
463 ],
464 exclude: vec!["scratch".to_string()],
465 };
466 let left: Vec<String> = resolve(path, &prunable)
467 .into_iter()
468 .map(|d| match d {
469 Declaration::Prunable(t) => t.label,
470 Declaration::Refused { label, .. } => label,
471 })
472 .collect();
473 assert_eq!(left, ["vendor"]);
474 }
475
476 #[test]
477 fn an_exclusion_silences_the_refusal_too_not_only_the_delete() {
478 let tmp = repo();
481 let path = tmp.path();
482 fs::create_dir_all(path.join("src")).unwrap();
483 fs::write(path.join("src/main.rs"), "fn main() {}").unwrap();
484 Command::new("git")
485 .args(["add", "src/main.rs"])
486 .current_dir(path)
487 .output()
488 .unwrap();
489
490 assert!(
491 !resolve(path, &one(declared("src", "echo not needed"))).is_empty(),
492 "this repository is supposed to produce a refusal"
493 );
494 let prunable = Prunable {
495 directories: vec![declared("src", "echo not needed")],
496 exclude: vec!["src".to_string()],
497 };
498 assert!(resolve(path, &prunable).is_empty());
499 }
500
501 #[test]
502 fn a_backslash_declaration_reads_the_same_as_a_forward_slash_one() {
503 assert_eq!(
504 split_relative(r"build\fixtures").unwrap(),
505 split_relative("build/fixtures").unwrap()
506 );
507 }
508}