cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Filesystem-backed [`SiteWriter`].
//!
//! `publish` writes the entire bundle into `<out>.staging/` and then
//! swaps it into place via two renames. A failure mid-write leaves
//! `<out>` untouched; the staging directory remains for inspection
//! and is cleaned up at the start of the next publish.

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")
    }
}

/// Build a sibling of `path` carrying a fixed suffix (e.g. `.staging`,
/// `.old`). The trailing component is replaced; the parent is kept.
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();

        // Clean any leftover staging or backup from a prior failed run.
        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)?;
        }

        // Two-rename swap. The brief gap between the two renames is
        // acceptable for static-site publication; if the second
        // rename fails, `<out>.old` is the recovery copy.
        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"));
        // First publish: lays down a known-good baseline.
        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"
        );

        // Second publish induces a mid-write failure: the second
        // entry tries to write a file at "foo" but the first entry
        // already created "foo" as a directory.
        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");

        // The previous output survives.
        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());
    }
}