calepin 0.0.51

A Rust CLI for preprocessing Typst documents with executable code chunks
use std::io::{ErrorKind, Write};
use std::path::Path;

use anyhow::{Context, Result};

pub(crate) fn ensure_parent(path: &Path) -> Result<()> {
    if let Some(parent) = path.parent() {
        if parent.as_os_str().is_empty() {
            return Ok(());
        }
        std::fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    Ok(())
}

pub(crate) fn write_if_changed(path: &Path, bytes: impl AsRef<[u8]>) -> Result<()> {
    let bytes = bytes.as_ref();
    ensure_parent(path)?;

    match std::fs::read(path) {
        Ok(existing) if existing == bytes => return Ok(()),
        Ok(_) => {}
        Err(err) if err.kind() == ErrorKind::NotFound => {}
        Err(err) => {
            return Err(err).with_context(|| format!("failed to read existing {}", path.display()));
        }
    }

    atomic_write(path, bytes)
}

fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
    let temp_dir = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    let mut temp = tempfile::NamedTempFile::new_in(temp_dir)
        .with_context(|| format!("failed to create temporary file in {}", temp_dir.display()))?;
    temp.write_all(bytes)
        .with_context(|| format!("failed to write temporary file for {}", path.display()))?;
    temp.as_file_mut()
        .sync_all()
        .with_context(|| format!("failed to flush temporary file for {}", path.display()))?;
    temp.persist(path)
        .map(|_| ())
        .map_err(|err| err.error)
        .with_context(|| format!("failed to write {}", path.display()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ensure_parent_ignores_parentless_relative_paths() {
        ensure_parent(Path::new("output.typ")).unwrap();
    }

    #[test]
    fn write_if_changed_creates_parent_directory() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("nested").join("result.json");

        write_if_changed(&path, br#"{"ok":true}"#).unwrap();

        assert_eq!(std::fs::read_to_string(path).unwrap(), r#"{"ok":true}"#);
    }

    #[test]
    fn write_if_changed_skips_unchanged_contents() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("result.json");
        std::fs::write(&path, "same").unwrap();
        let before = std::fs::metadata(&path).unwrap().modified().unwrap();

        write_if_changed(&path, "same").unwrap();

        let after = std::fs::metadata(&path).unwrap().modified().unwrap();
        assert_eq!(after, before);
    }

    #[test]
    fn write_if_changed_updates_changed_contents() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("result.json");
        std::fs::write(&path, "old").unwrap();

        write_if_changed(&path, "new").unwrap();

        assert_eq!(std::fs::read_to_string(path).unwrap(), "new");
    }

    #[test]
    fn write_if_changed_reports_existing_read_errors() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("target");
        std::fs::create_dir(&path).unwrap();

        let err = write_if_changed(&path, "new").unwrap_err().to_string();

        assert!(err.contains("failed to read existing"), "{err}");
        assert!(err.contains("target"), "{err}");
    }
}