use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
pub const MANIFEST: &str = ".plates-build";
const HEADER: &str = "# written by plates — every path below is a file this build wrote.";
pub fn read(dest: &Path) -> std::io::Result<Option<BTreeSet<String>>> {
let text = match std::fs::read_to_string(dest.join(MANIFEST)) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
Ok(Some(
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.filter(|line| is_contained(Path::new(line)))
.map(str::to_string)
.collect(),
))
}
pub fn write(dest: &Path, paths: &BTreeSet<String>) -> std::io::Result<()> {
let mut text = String::from(HEADER);
text.push('\n');
for path in paths {
text.push_str(path);
text.push('\n');
}
std::fs::write(dest.join(MANIFEST), text)
}
pub fn remove(dest: &Path, paths: &BTreeSet<String>) -> std::io::Result<usize> {
let mut removed = 0;
let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
for rel in paths {
let path = dest.join(rel);
match std::fs::remove_file(&path) {
Ok(()) => removed += 1,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let mut parent = path.parent();
while let Some(dir) = parent {
if dir == dest {
break;
}
dirs.insert(dir.to_path_buf());
parent = dir.parent();
}
}
for dir in dirs.iter().rev() {
let _ = std::fs::remove_dir(dir);
}
Ok(removed)
}
fn is_contained(rel: &Path) -> bool {
!rel.is_absolute()
&& rel
.components()
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
}
pub fn check_destination(dest: &Path) -> Result<(), String> {
if !dest.exists() {
return Ok(());
}
if !dest.is_dir() {
return Err(format!("{} is not a directory", dest.display()));
}
if dest.join(MANIFEST).exists() {
return Ok(());
}
let mut entries = std::fs::read_dir(dest).map_err(|e| format!("{}: {e}", dest.display()))?;
if entries.next().is_none() {
return Ok(());
}
Err(format!(
"{} already holds files, and no build of ours wrote them",
dest.display()
))
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, b"x").unwrap();
}
fn set(paths: &[&str]) -> BTreeSet<String> {
paths.iter().map(|p| (*p).to_string()).collect()
}
#[test]
fn a_manifest_round_trips_and_explains_itself() {
let dir = tempfile::tempdir().unwrap();
let paths = set(&["index.html", "notes/post.html"]);
write(dir.path(), &paths).unwrap();
let text = std::fs::read_to_string(dir.path().join(MANIFEST)).unwrap();
assert!(text.starts_with('#'), "{text}");
assert_eq!(read(dir.path()).unwrap(), Some(paths));
}
#[test]
fn a_destination_with_no_manifest_reads_as_none() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(read(dir.path()).unwrap(), None);
write(dir.path(), &BTreeSet::new()).unwrap();
assert_eq!(read(dir.path()).unwrap(), Some(BTreeSet::new()));
}
#[test]
fn removal_prunes_emptied_directories_and_nothing_else() {
let dir = tempfile::tempdir().unwrap();
touch(&dir.path().join("notes/post.html"));
touch(&dir.path().join("img/logo.png"));
touch(&dir.path().join("img/theirs.txt"));
let removed = remove(dir.path(), &set(&["notes/post.html", "img/logo.png"])).unwrap();
assert_eq!(removed, 2);
assert!(!dir.path().join("notes").exists(), "emptied, so pruned");
assert!(
dir.path().join("img/theirs.txt").exists(),
"a file no build wrote is untouched, and its directory with it"
);
}
#[test]
fn removing_what_is_already_gone_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(remove(dir.path(), &set(&["index.html"])).unwrap(), 0);
}
#[test]
fn a_manifest_line_that_escapes_the_destination_is_dropped() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(MANIFEST),
"# header\nindex.html\n../outside.html\n/etc/passwd\n",
)
.unwrap();
assert_eq!(read(dir.path()).unwrap(), Some(set(&["index.html"])));
}
#[test]
fn a_destination_is_ours_when_it_is_empty_or_carries_a_manifest() {
let dir = tempfile::tempdir().unwrap();
assert!(check_destination(&dir.path().join("missing")).is_ok());
assert!(check_destination(dir.path()).is_ok(), "empty");
touch(&dir.path().join("theirs.txt"));
assert!(check_destination(dir.path()).is_err());
write(dir.path(), &BTreeSet::new()).unwrap();
assert!(
check_destination(dir.path()).is_ok(),
"a manifest bounds what the next build would prune, which is what makes it safe"
);
}
}