cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Filesystem-backed [`PagesReader`]: walk `root` recursively and
//! emit a [`MarkdownEntry`] for each `.md` file, with the entry's
//! [`Source`] addressed relative to `root` (forward slashes).

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

use crate::domain::model::body::Body;
use crate::domain::model::site::{MarkdownEntry, Source};
use crate::domain::usecases::site::readers::PagesReader;

pub struct FsPagesReader {
    root: PathBuf,
}

impl FsPagesReader {
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self { root: root.into() }
    }
}

impl PagesReader for FsPagesReader {
    fn entries<'a>(&'a self) -> Box<dyn Iterator<Item = anyhow::Result<MarkdownEntry>> + 'a> {
        if !self.root.is_dir() {
            return Box::new(std::iter::empty());
        }
        let root = self.root.as_path();
        let it = walkdir::WalkDir::new(root)
            .follow_links(false)
            .into_iter()
            .filter_map(move |res| match res {
                Ok(e) if is_markdown_file(e.path()) => Some(read_entry(root, e.path())),
                Ok(_) => None,
                Err(e) => Some(Err(anyhow::Error::new(e))),
            });
        Box::new(it)
    }
}

fn is_markdown_file(path: &Path) -> bool {
    path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("md")
}

fn read_entry(root: &Path, path: &Path) -> anyhow::Result<MarkdownEntry> {
    let relative = path
        .strip_prefix(root)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/");
    let source = Source::relative_path(&relative)?;
    let content = Body::new(std::fs::read_to_string(path)?);
    Ok(MarkdownEntry::new(source, content))
}

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

    #[test]
    fn empty_root_yields_no_entries() {
        let tmp = tempfile::tempdir().unwrap();
        let reader = FsPagesReader::new(tmp.path());
        assert_eq!(reader.entries().count(), 0);
    }

    #[test]
    fn non_existing_root_yields_no_entries() {
        let reader = FsPagesReader::new("/definitely/not/a/real/path/here");
        assert_eq!(reader.entries().count(), 0);
    }

    #[test]
    fn walks_recursively_and_returns_relative_sources() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(tmp.path().join("sub")).unwrap();
        std::fs::write(tmp.path().join("a.md"), "---\ntitle: A\n---\nbody-a").unwrap();
        std::fs::write(tmp.path().join("sub/b.md"), "---\ntitle: B\n---\nbody-b").unwrap();
        std::fs::write(tmp.path().join("sub/ignore.txt"), "ignored").unwrap();

        let reader = FsPagesReader::new(tmp.path());
        let mut entries: Vec<MarkdownEntry> = reader
            .entries()
            .collect::<anyhow::Result<_>>()
            .expect("all entries readable");
        entries.sort_by(|x, y| x.source.as_str().cmp(y.source.as_str()));

        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].source.as_str(), "a.md");
        assert_eq!(entries[1].source.as_str(), "sub/b.md");
        assert!(entries[1].content.as_str().contains("body-b"));
    }
}