use std::path::{Path, PathBuf};
use crate::config::DeclaredDir;
use crate::scanner::git;
#[derive(Debug, Clone)]
pub struct Target {
pub label: String,
pub path: PathBuf,
pub rebuild: String,
pub why: Option<String>,
pub size_bytes: u64,
}
#[derive(Debug, Clone)]
pub enum Declaration {
Prunable(Box<Target>),
Refused { label: String, reason: String },
}
const SHELL_BUILTINS: &[&str] = &["echo", "true", ":"];
pub fn resolve(repo_path: &Path, declared: &[DeclaredDir]) -> Vec<Declaration> {
let mut out = Vec::new();
for entry in declared {
match check(repo_path, entry) {
Ok(Some(target)) => out.push(Declaration::Prunable(Box::new(target))),
Ok(None) => {}
Err(reason) => out.push(Declaration::Refused {
label: entry.path.clone(),
reason,
}),
}
}
out
}
fn check(repo_path: &Path, entry: &DeclaredDir) -> Result<Option<Target>, String> {
let parts = split_relative(&entry.path)?;
let label = parts.join("/");
let path = parts.iter().fold(repo_path.to_path_buf(), |p, s| p.join(s));
if !path.exists() {
return Ok(None);
}
if !path.is_dir() {
return Err(format!(
"`{label}` is declared prunable but is a file, not a directory — \
dev-prune only deletes whole directories. Left alone."
));
}
let (Ok(real), Ok(root)) = (path.canonicalize(), repo_path.canonicalize()) else {
return Err(format!(
"`{label}` is declared prunable but could not be resolved on this machine — \
refusing to delete a path dev-prune cannot pin down."
));
};
if !real.starts_with(&root) {
return Err(format!(
"`{label}` is declared prunable but resolves to `{}`, outside the \
repository. Left alone.",
real.display()
));
}
if let Some(tracked) = first_tracked_file(repo_path, &label)? {
return Err(format!(
"`{label}` is declared prunable but Git is tracking `{tracked}` inside it — \
refusing. A lockfile cannot rebuild a file that is in the repository \
itself. Remove the declaration, or stop tracking those files."
));
}
let rebuild = entry.rebuild.trim();
if rebuild.is_empty() {
return Err(format!(
"`{label}` is declared prunable with an empty `rebuild` command — refusing. \
Say what puts it back, or use `\"rebuild\": \"echo not needed\"` if nothing \
does."
));
}
let tool = first_word(rebuild);
if !SHELL_BUILTINS.contains(&tool) && !on_path(tool) {
return Err(format!(
"`{label}` is declared prunable, rebuilt by `{rebuild}`, but `{tool}` is not \
on this machine — refusing to delete something this machine cannot put \
back. Install `{tool}` first."
));
}
Ok(Some(Target {
size_bytes: crate::adapters::dir_size(&path),
label,
path,
rebuild: rebuild.to_string(),
why: entry.why.clone(),
}))
}
fn split_relative(raw: &str) -> Result<Vec<String>, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("An entry in `prunable.directories` has an empty `path`.".to_string());
}
if trimmed.starts_with('/') || trimmed.starts_with('\\') {
return Err(format!(
"`{trimmed}` is declared prunable but is an absolute path — declarations are \
relative to the repository root. Left alone."
));
}
let mut parts = Vec::new();
for part in trimmed.split(['/', '\\']) {
if part.is_empty() || part == "." {
continue;
}
if part == ".." {
return Err(format!(
"`{trimmed}` is declared prunable but climbs out of the repository with \
`..` — refusing. Left alone."
));
}
if part.contains(':') {
return Err(format!(
"`{trimmed}` is declared prunable but names a drive or stream — \
declarations are relative to the repository root. Left alone."
));
}
if part.eq_ignore_ascii_case(".git") {
return Err(format!(
"`{trimmed}` is declared prunable but is inside `.git` — the one \
directory dev-prune never crosses. Left alone."
));
}
parts.push(part.to_string());
}
if parts.is_empty() {
return Err(format!(
"`{trimmed}` is declared prunable but resolves to the repository root \
itself — refusing. Left alone."
));
}
Ok(parts)
}
fn first_tracked_file(repo_path: &Path, label: &str) -> Result<Option<String>, String> {
let output = git::git_in(repo_path)
.args(["ls-files", "--", label])
.output()
.map_err(|e| {
format!(
"`{label}` is declared prunable, but `git ls-files` could not run ({e}) — \
refusing to delete without knowing whether it holds tracked files."
)
})?;
if !output.status.success() {
return Err(format!(
"`{label}` is declared prunable, but `git ls-files` failed — refusing to \
delete without knowing whether it holds tracked files."
));
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.map(str::to_string))
}
fn first_word(command: &str) -> &str {
command
.split_whitespace()
.next()
.unwrap_or("")
.trim_matches(['"', '\''])
}
fn on_path(program: &str) -> bool {
let named = Path::new(program);
if named.components().count() > 1 {
return named.is_file();
}
let Some(path_var) = std::env::var_os("PATH") else {
return false;
};
let exts: &[&str] = if cfg!(windows) {
&["", "exe", "cmd", "bat", "com", "ps1"]
} else {
&[""]
};
std::env::split_paths(&path_var).any(|dir| {
exts.iter().any(|ext| {
if ext.is_empty() {
dir.join(program).is_file()
} else {
dir.join(format!("{program}.{ext}")).is_file()
}
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn declared(path: &str, rebuild: &str) -> DeclaredDir {
DeclaredDir {
path: path.to_string(),
rebuild: rebuild.to_string(),
why: None,
}
}
fn repo() -> TempDir {
let tmp = TempDir::new().unwrap();
let path = tmp.path();
for args in [
vec!["init", "-q"],
vec!["config", "user.email", "t@example.com"],
vec!["config", "user.name", "t"],
] {
Command::new("git")
.args(&args)
.current_dir(path)
.output()
.unwrap();
}
tmp
}
fn refusal(repo_path: &Path, entry: DeclaredDir) -> String {
match resolve(repo_path, &[entry]).pop() {
Some(Declaration::Refused { reason, .. }) => reason,
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn a_declaration_that_holds_up_is_prunable_with_its_reason_carried_along() {
let tmp = repo();
let path = tmp.path();
fs::create_dir_all(path.join("build/fixtures")).unwrap();
fs::write(path.join("build/fixtures/a.bin"), vec![0u8; 4096]).unwrap();
let mut entry = declared("build/fixtures", "echo not needed");
entry.why = Some("regenerated by the test suite".into());
let Some(Declaration::Prunable(target)) = resolve(path, &[entry]).pop() else {
panic!("a declaration nothing is wrong with must be prunable");
};
assert_eq!(target.label, "build/fixtures");
assert_eq!(target.why.as_deref(), Some("regenerated by the test suite"));
assert!(target.size_bytes >= 4096);
}
#[test]
fn the_documented_escape_hatch_works_on_every_platform() {
let tmp = repo();
fs::create_dir_all(tmp.path().join("scratch")).unwrap();
assert!(matches!(
resolve(tmp.path(), &[declared("scratch", "echo not needed")]).pop(),
Some(Declaration::Prunable(_))
));
}
#[test]
fn a_declaration_covering_tracked_files_is_refused() {
let tmp = repo();
let path = tmp.path();
fs::create_dir_all(path.join("src")).unwrap();
fs::write(path.join("src/main.rs"), "fn main() {}").unwrap();
Command::new("git")
.args(["add", "src/main.rs"])
.current_dir(path)
.output()
.unwrap();
let reason = refusal(path, declared("src", "echo not needed"));
assert!(reason.contains("Git is tracking"), "{reason}");
assert!(path.join("src/main.rs").exists());
}
#[test]
fn a_declaration_whose_rebuild_tool_is_absent_is_refused() {
let tmp = repo();
fs::create_dir_all(tmp.path().join("vendor")).unwrap();
let reason = refusal(
tmp.path(),
declared("vendor", "definitely-not-a-real-tool-xyz build"),
);
assert!(reason.contains("is not on this machine"), "{reason}");
}
#[test]
fn an_empty_rebuild_is_refused_and_says_what_to_write_instead() {
let tmp = repo();
fs::create_dir_all(tmp.path().join("vendor")).unwrap();
let reason = refusal(tmp.path(), declared("vendor", " "));
assert!(reason.contains("echo not needed"), "{reason}");
}
#[test]
fn paths_that_could_point_outside_the_repository_never_get_that_far() {
for (raw, expected) in [
("../secrets", "climbs out of the repository"),
("/etc", "absolute path"),
("C:/Windows", "names a drive"),
(".git/objects", "inside `.git`"),
(".", "the repository root itself"),
] {
let err = split_relative(raw).unwrap_err();
assert!(err.contains(expected), "{raw}: {err}");
}
}
#[test]
fn a_declared_directory_that_is_not_there_says_nothing_at_all() {
let tmp = repo();
assert!(resolve(tmp.path(), &[declared("never/existed", "echo not needed")]).is_empty());
}
#[test]
fn a_backslash_declaration_reads_the_same_as_a_forward_slash_one() {
assert_eq!(
split_relative(r"build\fixtures").unwrap(),
split_relative("build/fixtures").unwrap()
);
}
}