pub fn publish_by_rename(staging: &std::path::Path, live: &std::path::Path, previous: &std::path::Path) -> Result<(), String> {
if !staging.is_dir() {
return Err(format!(
"{} is not a directory, so there is nothing to publish",
staging.display()
));
}
if previous.exists() {
std::fs::remove_dir_all(previous)
.map_err(|e| format!("removing {}: {e}", previous.display()))?;
}
let moved_aside = live.exists();
if moved_aside {
std::fs::rename(live, previous).map_err(|e| {
format!("moving {} aside to {}: {e}", live.display(), previous.display())
})?;
}
if let Err(e) = std::fs::rename(staging, live) {
if moved_aside {
std::fs::rename(previous, live).map_err(|restore| {
format!(
"publishing {} as {} failed ({e}), and restoring the previous \
build from {} failed too ({restore}) — {} now has no build",
staging.display(),
live.display(),
previous.display(),
live.display()
)
})?;
}
return Err(format!(
"publishing {} as {}: {e}",
staging.display(),
live.display()
));
}
if moved_aside {
std::fs::remove_dir_all(previous)
.map_err(|e| format!("removing the superseded {}: {e}", previous.display()))?;
}
Ok(())
}
pub fn copy_sources(from: &std::path::Path, to: &std::path::Path, entries: &[&str]) -> Result<(), String> {
std::fs::create_dir_all(to).map_err(|e| format!("creating {}: {e}", to.display()))?;
for entry in entries {
let source = from.join(entry);
let target = to.join(entry);
let kind = std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?;
match std::fs::symlink_metadata(&target) {
Ok(was) if was.is_dir() => std::fs::remove_dir_all(&target),
Ok(_) => std::fs::remove_file(&target),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
.map_err(|e| format!("replacing {}: {e}", target.display()))?;
if kind.is_dir() {
copy_tree(&source, &target)?;
} else {
std::fs::copy(&source, &target)
.map_err(|e| format!("copying {} to {}: {e}", source.display(), target.display()))?;
}
}
Ok(())
}
fn copy_tree(from: &std::path::Path, to: &std::path::Path) -> Result<(), String> {
std::fs::create_dir_all(to).map_err(|e| format!("creating {}: {e}", to.display()))?;
let entries = std::fs::read_dir(from).map_err(|e| format!("reading {}: {e}", from.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("reading {}: {e}", from.display()))?;
let source = entry.path();
let target = to.join(entry.file_name());
if std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?.is_dir() {
copy_tree(&source, &target)?;
} else {
std::fs::copy(&source, &target)
.map_err(|e| format!("copying {} to {}: {e}", source.display(), target.display()))?;
}
}
Ok(())
}
pub fn clear_scratch(staging: &std::path::Path, previous: &std::path::Path) -> Result<(), String> {
for leftover in [staging, previous] {
if leftover.exists() {
std::fs::remove_dir_all(leftover)
.map_err(|e| format!("removing {}: {e}", leftover.display()))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tree(root: &std::path::Path, name: &str, marker: &str) -> std::path::PathBuf {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("assets")).expect("the directory");
std::fs::write(dir.join("index.html"), marker).expect("index.html");
std::fs::write(dir.join("assets").join("app.js"), marker).expect("an asset");
dir
}
fn marker_at(dir: &std::path::Path) -> String {
std::fs::read_to_string(dir.join("index.html")).expect("index.html is readable")
}
#[test]
fn test9774_publishing_replaces_the_live_tree_and_leaves_no_scratch() {
let root = tempfile::tempdir().expect("a scratch directory");
let live = tree(root.path(), "dist", "the previous build");
std::fs::write(live.join("stale.js"), "from a build nobody wants").expect("a stale file");
let staging = tree(root.path(), "dist.building", "the new build");
let previous = root.path().join("dist.previous");
publish_by_rename(&staging, &live, &previous).expect("it publishes");
assert_eq!(marker_at(&live), "the new build");
assert!(
!live.join("stale.js").exists(),
"a file from the previous build survived into the published one"
);
assert!(!staging.exists(), "the scratch tree was left behind");
assert!(!previous.exists(), "the superseded tree was left behind");
}
#[test]
fn test9775_the_first_build_publishes_with_nothing_to_move_aside() {
let root = tempfile::tempdir().expect("a scratch directory");
let live = root.path().join("dist");
let staging = tree(root.path(), "dist.building", "the first build");
let previous = root.path().join("dist.previous");
publish_by_rename(&staging, &live, &previous).expect("it publishes");
assert_eq!(marker_at(&live), "the first build");
assert!(!staging.exists());
assert!(!previous.exists());
}
#[test]
fn test9776_a_failed_build_leaves_the_live_tree_untouched() {
let root = tempfile::tempdir().expect("a scratch directory");
let live = tree(root.path(), "dist", "the good build");
let previous = root.path().join("dist.previous");
let staging = root.path().join("dist.building");
let error = publish_by_rename(&staging, &live, &previous)
.expect_err("there is nothing to publish");
assert!(
error.contains("nothing to publish"),
"the refusal does not say what is wrong: {error}"
);
assert!(live.is_dir(), "the live tree was removed by a failed publish");
assert_eq!(
marker_at(&live),
"the good build",
"the live tree was replaced by a build that never happened"
);
}
#[cfg(unix)]
#[test]
fn test9777_a_publish_that_fails_restores_the_previous_build() {
let root = tempfile::tempdir().expect("a scratch directory");
let live = tree(root.path(), "dist", "the good build");
let staging = tree(root.path(), "dist.building", "the new build");
let previous = root.path().join("dist.previous");
std::fs::write(&previous, "not a directory").expect("a file in the way");
let error = publish_by_rename(&staging, &live, &previous)
.expect_err("it cannot use a file as the previous-build directory");
assert!(live.is_dir(), "the live tree was lost: {error}");
assert_eq!(
marker_at(&live),
"the good build",
"the live tree was damaged by a publish that failed"
);
assert!(staging.is_dir(), "the new build was lost as well");
}
#[test]
fn test20050_the_build_copy_follows_the_sources_and_keeps_the_install() {
let root = tempfile::tempdir().expect("a scratch directory");
let sources = root.path().join("frontend");
std::fs::create_dir_all(sources.join("src/lib")).expect("sources");
std::fs::write(sources.join("package.json"), "{}").expect("package.json");
std::fs::write(sources.join("src/main.ts"), "first").expect("main.ts");
std::fs::write(sources.join("src/lib/gone.ts"), "removed later").expect("gone.ts");
let copy = root.path().join("out/workbench/frontend");
copy_sources(&sources, ©, &["package.json", "src"]).expect("the first copy");
std::fs::create_dir_all(copy.join("node_modules/vite")).expect("an install");
std::fs::write(copy.join("node_modules/vite/package.json"), "{}").expect("an installed file");
std::fs::remove_file(sources.join("src/lib/gone.ts")).expect("a source is deleted");
std::fs::write(sources.join("src/main.ts"), "second").expect("a source is edited");
copy_sources(&sources, ©, &["package.json", "src"]).expect("the second copy");
assert_eq!(std::fs::read_to_string(copy.join("src/main.ts")).expect("main.ts"), "second");
assert!(!copy.join("src/lib/gone.ts").exists(), "a deleted source lived on in the build copy");
assert!(
copy.join("node_modules/vite/package.json").is_file(),
"the install was thrown away by a copy that did not name it"
);
}
#[test]
fn test9778_a_killed_runs_leftovers_are_cleared_before_the_next_build() {
let root = tempfile::tempdir().expect("a scratch directory");
let staging = tree(root.path(), "dist.building", "half of an interrupted build");
let previous = tree(root.path(), "dist.previous", "a tree nobody cleaned up");
clear_scratch(&staging, &previous).expect("the leftovers are cleared");
assert!(!staging.exists(), "an interrupted build's scratch survived");
assert!(!previous.exists(), "an abandoned previous tree survived");
}
}