cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Filesystem-backed [`AssetsReader`]: walk `walk_root` recursively
//! and emit an [`Asset`] for each regular file. The [`Source`] of
//! each asset is the path relative to `key_root` (which may differ
//! from `walk_root` — e.g. walk `theme/assets/` but key relative to
//! `theme/` so the source retains its `assets/` prefix).

use std::path::{Path, PathBuf};

use crate::domain::model::site::{Asset, Source};
use crate::domain::usecases::site::readers::AssetsReader;

pub struct FsAssetsReader {
    walk_root: PathBuf,
    key_root: PathBuf,
}

impl FsAssetsReader {
    pub fn new(walk_root: impl Into<PathBuf>, key_root: impl Into<PathBuf>) -> Self {
        Self {
            walk_root: walk_root.into(),
            key_root: key_root.into(),
        }
    }

    /// A cartulary theme is a directory whose static files live under
    /// `<theme>/assets/`; sources are keyed relative to `<theme>/`
    /// so the rendered site preserves the `assets/` prefix.
    pub fn from_theme_dir(theme: impl Into<PathBuf>) -> Self {
        let theme = theme.into();
        let walk_root = theme.join("assets");
        Self {
            walk_root,
            key_root: theme,
        }
    }
}

impl AssetsReader for FsAssetsReader {
    fn assets<'a>(&'a self) -> Box<dyn Iterator<Item = anyhow::Result<Asset>> + 'a> {
        if !self.walk_root.is_dir() {
            return Box::new(std::iter::empty());
        }
        let key_root = self.key_root.as_path();
        let it = walkdir::WalkDir::new(&self.walk_root)
            .follow_links(false)
            .into_iter()
            .filter_map(move |res| match res {
                Ok(e) if e.file_type().is_file() => Some(read_asset(key_root, e.path())),
                Ok(_) => None,
                Err(e) => Some(Err(anyhow::Error::new(e))),
            });
        Box::new(it)
    }
}

fn read_asset(key_root: &Path, path: &Path) -> anyhow::Result<Asset> {
    let relative = path
        .strip_prefix(key_root)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/");
    let source = Source::relative_path(&relative)?;
    let bytes = std::fs::read(path)?;
    Ok(Asset::new(source, bytes))
}

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

    #[test]
    fn missing_walk_root_is_empty() {
        let reader = FsAssetsReader::new("/no/such/dir", "/no/such");
        assert_eq!(reader.assets().count(), 0);
    }

    #[test]
    fn key_root_distinct_from_walk_root_preserves_prefix() {
        let tmp = tempfile::tempdir().unwrap();
        let theme = tmp.path();
        let assets_dir = theme.join("assets");
        std::fs::create_dir_all(&assets_dir).unwrap();
        std::fs::write(assets_dir.join("style.css"), b"body{}").unwrap();
        std::fs::write(assets_dir.join("logo.svg"), b"<svg/>").unwrap();

        let reader = FsAssetsReader::new(&assets_dir, theme);
        let mut assets: Vec<Asset> = reader.assets().collect::<anyhow::Result<_>>().unwrap();
        assets.sort_by(|x, y| x.source.as_str().cmp(y.source.as_str()));

        assert_eq!(assets.len(), 2);
        assert_eq!(assets[0].source.as_str(), "assets/logo.svg");
        assert_eq!(assets[1].source.as_str(), "assets/style.css");
        assert_eq!(assets[1].bytes, b"body{}");
    }
}