use std::path::{Path, PathBuf};
use anyhow::Context;
use crate::domain::usecases::site::build::SiteBundle;
use crate::domain::usecases::site::writers::SiteWriter;
pub struct FsSiteWriter {
out_dir: PathBuf,
}
impl FsSiteWriter {
pub fn new(out_dir: impl Into<PathBuf>) -> Self {
Self {
out_dir: out_dir.into(),
}
}
fn staging_dir(&self) -> PathBuf {
sibling_with_suffix(&self.out_dir, "staging")
}
fn backup_dir(&self) -> PathBuf {
sibling_with_suffix(&self.out_dir, "old")
}
}
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut buf = path.as_os_str().to_owned();
buf.push(".");
buf.push(suffix);
PathBuf::from(buf)
}
fn write_entry(base: &Path, rel: &str, bytes: &[u8]) -> anyhow::Result<()> {
let dest = base.join(rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(&dest, bytes).with_context(|| format!("writing {}", dest.display()))?;
Ok(())
}
impl SiteWriter for FsSiteWriter {
fn publish(&self, bundle: SiteBundle) -> anyhow::Result<()> {
let staging = self.staging_dir();
let backup = self.backup_dir();
let _ = std::fs::remove_dir_all(&staging);
let _ = std::fs::remove_dir_all(&backup);
std::fs::create_dir_all(&staging)
.with_context(|| format!("creating staging at {}", staging.display()))?;
for page in &bundle.pages {
write_entry(&staging, page.path.as_str(), page.body.as_bytes())?;
}
for asset in &bundle.assets {
write_entry(&staging, asset.path.as_str(), &asset.bytes)?;
}
if self.out_dir.exists() {
std::fs::rename(&self.out_dir, &backup).with_context(|| {
format!(
"renaming {} to backup {}",
self.out_dir.display(),
backup.display()
)
})?;
}
std::fs::rename(&staging, &self.out_dir).with_context(|| {
format!(
"renaming staging {} to {}",
staging.display(),
self.out_dir.display()
)
})?;
let _ = std::fs::remove_dir_all(&backup);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::site::{SiteAsset, SitePage, SitePath};
fn path(s: &str) -> SitePath {
SitePath::new(s).unwrap()
}
fn bundle_with_one_page() -> SiteBundle {
SiteBundle {
pages: vec![SitePage::new(path("index.html"), "<h1>hi</h1>")],
assets: vec![],
}
}
#[test]
fn publishes_a_page_at_relative_path() {
let tmp = tempfile::tempdir().unwrap();
let writer = FsSiteWriter::new(tmp.path().join("site"));
writer.publish(bundle_with_one_page()).unwrap();
let actual = std::fs::read(tmp.path().join("site/index.html")).unwrap();
assert_eq!(actual, b"<h1>hi</h1>");
}
#[test]
fn publishes_an_asset_creating_parent_directories() {
let tmp = tempfile::tempdir().unwrap();
let writer = FsSiteWriter::new(tmp.path().join("site"));
let bundle = SiteBundle {
pages: vec![],
assets: vec![SiteAsset::new(
path("assets/css/site.css"),
b"body{}".to_vec(),
)],
};
writer.publish(bundle).unwrap();
let actual = std::fs::read(tmp.path().join("site/assets/css/site.css")).unwrap();
assert_eq!(actual, b"body{}");
}
#[test]
fn second_publish_replaces_first() {
let tmp = tempfile::tempdir().unwrap();
let writer = FsSiteWriter::new(tmp.path().join("site"));
writer.publish(bundle_with_one_page()).unwrap();
writer
.publish(SiteBundle {
pages: vec![SitePage::new(path("index.html"), "second")],
assets: vec![],
})
.unwrap();
let actual = std::fs::read(tmp.path().join("site/index.html")).unwrap();
assert_eq!(actual, b"second");
}
#[test]
fn stale_files_disappear_when_a_page_is_removed_from_the_bundle() {
let tmp = tempfile::tempdir().unwrap();
let writer = FsSiteWriter::new(tmp.path().join("site"));
writer
.publish(SiteBundle {
pages: vec![
SitePage::new(path("a.html"), "A"),
SitePage::new(path("b.html"), "B"),
],
assets: vec![],
})
.unwrap();
assert!(tmp.path().join("site/a.html").exists());
assert!(tmp.path().join("site/b.html").exists());
writer
.publish(SiteBundle {
pages: vec![SitePage::new(path("a.html"), "A")],
assets: vec![],
})
.unwrap();
assert!(tmp.path().join("site/a.html").exists());
assert!(
!tmp.path().join("site/b.html").exists(),
"stale b.html should be gone after the second publish"
);
}
#[test]
fn failed_publish_leaves_previous_output_untouched() {
let tmp = tempfile::tempdir().unwrap();
let writer = FsSiteWriter::new(tmp.path().join("site"));
writer
.publish(SiteBundle {
pages: vec![SitePage::new(path("baseline.html"), "preserved")],
assets: vec![],
})
.unwrap();
assert_eq!(
std::fs::read(tmp.path().join("site/baseline.html")).unwrap(),
b"preserved"
);
let failing = SiteBundle {
pages: vec![
SitePage::new(path("foo/bar.html"), "x"),
SitePage::new(path("foo"), "collides"),
],
assets: vec![],
};
let err = writer.publish(failing);
assert!(err.is_err(), "publish should fail on the collision");
assert_eq!(
std::fs::read(tmp.path().join("site/baseline.html")).unwrap(),
b"preserved"
);
assert!(!tmp.path().join("site/foo").exists());
}
#[test]
fn staging_dir_is_cleaned_after_successful_publish() {
let tmp = tempfile::tempdir().unwrap();
let writer = FsSiteWriter::new(tmp.path().join("site"));
writer.publish(bundle_with_one_page()).unwrap();
assert!(!tmp.path().join("site.staging").exists());
assert!(!tmp.path().join("site.old").exists());
}
}