cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Tera-backed implementation of the [`TemplateEngine`] port.
//!
//! This Phase-5 implementation supports a single template root (the active
//! theme directory). Phase 6 will extend it with the embedded default
//! theme as a fallback. Both layers share the same Tera template registry,
//! so user templates win on name collision.
//!
//! Template names are derived from paths relative to the root: a file at
//! `<root>/decisions/adr/single.html` is registered under the name
//! `decisions/adr/single.html` — exactly the convention the build use case
//! relies on for kind-specific overrides.

use std::path::Path;

use anyhow::Context;
use tera::Tera;

use crate::domain::usecases::site::templates::{TemplateContext, TemplateEngine};
use crate::infra::driven::site::default_theme;

/// In-process Tera adapter loaded from a single directory.
#[derive(Debug)]
pub struct TeraAdapter {
    tera: Tera,
}

impl TeraAdapter {
    /// Load every `*.html` template under `root` (recursively). Names are
    /// the paths relative to `root`. Returns an error if `root` does not
    /// exist or any template fails to parse.
    pub fn from_directory(root: &Path) -> anyhow::Result<Self> {
        if !root.is_dir() {
            anyhow::bail!("template root not found: {}", root.display());
        }
        // Tera glob is path-based, not platform-aware around backslashes,
        // but this project targets Unix-style paths in tests and CI.
        let glob = format!("{}/**/*.html", root.display());
        let tera = Tera::new(&glob)
            .with_context(|| format!("loading templates from {}", root.display()))?;
        Ok(Self { tera })
    }

    /// Load only the embedded default theme. This is the path the site
    /// builder takes when the user did not pass `--theme`.
    pub fn from_default() -> anyhow::Result<Self> {
        let templates = default_theme::enumerate_templates();
        Self::from_template_set(&templates)
    }

    /// Load the embedded default theme first, then overlay any `*.html`
    /// templates found under `root`. User templates override embedded
    /// ones on name collision — the contract `--theme` advertises.
    pub fn from_directory_with_default(root: &Path) -> anyhow::Result<Self> {
        if !root.is_dir() {
            anyhow::bail!("template root not found: {}", root.display());
        }
        let mut templates = default_theme::enumerate_templates();
        // Append user overrides; later entries win during the bulk add.
        templates.extend(read_directory_templates(root)?);
        Self::from_template_set(&templates)
    }

    fn from_template_set(templates: &[(String, String)]) -> anyhow::Result<Self> {
        // Use the bulk API so Tera builds the inheritance chain once at
        // the end. Single-add variants check parents on every call, which
        // breaks when a child happens to be added before its base.
        let refs: Vec<(&str, &str)> = templates
            .iter()
            .map(|(n, b)| (n.as_str(), b.as_str()))
            .collect();
        let mut tera = Tera::default();
        tera.add_raw_templates(refs)
            .context("registering templates")?;
        Ok(Self { tera })
    }
}

fn read_directory_templates(root: &Path) -> anyhow::Result<Vec<(String, String)>> {
    let mut out = Vec::new();
    for entry in walkdir::WalkDir::new(root).follow_links(false) {
        let entry = entry.with_context(|| format!("walking {}", root.display()))?;
        if !entry.file_type().is_file() {
            continue;
        }
        let path = entry.path();
        if path.extension().is_none_or(|e| e != "html") {
            continue;
        }
        let rel = path
            .strip_prefix(root)
            .with_context(|| format!("computing relative path of {}", path.display()))?;
        let name = rel
            .to_str()
            .with_context(|| format!("non-UTF8 template path {}", path.display()))?
            .to_string();
        let body = std::fs::read_to_string(path)
            .with_context(|| format!("reading template {}", path.display()))?;
        out.push((name, body));
    }
    Ok(out)
}

impl TemplateEngine for TeraAdapter {
    fn render(&self, name: &str, ctx: &TemplateContext) -> anyhow::Result<String> {
        let tera_ctx = tera::Context::from_value(ctx.0.clone())
            .with_context(|| format!("preparing context for template {name}"))?;
        self.tera
            .render(name, &tera_ctx)
            .with_context(|| format!("rendering template {name}"))
    }
}

#[cfg(test)]
mod tests {
    use std::fs;

    use serde_json::json;
    use tempfile::TempDir;

    use super::*;

    fn write_template(root: &Path, rel: &str, body: &str) {
        let path = root.join(rel);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, body).unwrap();
    }

    #[test]
    fn renders_a_simple_template_with_context() {
        let tmp = TempDir::new().unwrap();
        write_template(tmp.path(), "hello.html", "Hello {{ name }}!");
        let engine = TeraAdapter::from_directory(tmp.path()).unwrap();
        let ctx = TemplateContext(json!({ "name": "world" }));
        assert_eq!(engine.render("hello.html", &ctx).unwrap(), "Hello world!");
    }

    #[test]
    fn registers_templates_under_their_relative_path_name() {
        // The site builder asks for "decisions/adr/single.html" — verify
        // the adapter exposes it under that exact name.
        let tmp = TempDir::new().unwrap();
        write_template(tmp.path(), "decisions/adr/single.html", "ADR: {{ title }}");
        let engine = TeraAdapter::from_directory(tmp.path()).unwrap();
        let ctx = TemplateContext(json!({ "title": "Use Rust" }));
        assert_eq!(
            engine.render("decisions/adr/single.html", &ctx).unwrap(),
            "ADR: Use Rust"
        );
    }

    #[test]
    fn missing_template_surfaces_an_error_naming_the_template() {
        let tmp = TempDir::new().unwrap();
        write_template(tmp.path(), "exists.html", "ok");
        let engine = TeraAdapter::from_directory(tmp.path()).unwrap();
        let err = engine
            .render("does-not-exist.html", &TemplateContext::new())
            .unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("does-not-exist.html"), "got: {msg}");
    }

    #[test]
    fn parse_error_surfaces_at_load_time() {
        let tmp = TempDir::new().unwrap();
        // Unbalanced Tera tag — `{% if %}` without `{% endif %}`.
        write_template(tmp.path(), "broken.html", "{% if x %}oops");
        let err = TeraAdapter::from_directory(tmp.path()).unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.to_lowercase().contains("broken.html") || msg.to_lowercase().contains("template"),
            "expected parse error to mention the template, got: {msg}"
        );
    }

    #[test]
    fn missing_root_directory_surfaces_an_error() {
        let err = TeraAdapter::from_directory(Path::new("/tmp/does-not-exist-cartulary-xyz"))
            .unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("template root not found"), "got: {msg}");
    }

    #[test]
    fn from_default_loads_the_embedded_homepage_template() {
        let engine = TeraAdapter::from_default().unwrap();
        let ctx = TemplateContext(json!({
            "site": { "title": "Cartulary" },
            "sections": [],
        }));
        let html = engine.render("layouts/index.html", &ctx).unwrap();
        // Sanity markers from the embedded base + index layouts.
        assert!(html.contains("<!doctype html>"), "got: {html}");
        assert!(html.contains("Cartulary"), "got: {html}");
    }

    #[test]
    fn from_directory_with_default_overlays_user_template_on_top_of_embedded() {
        let tmp = TempDir::new().unwrap();
        // The user theme overrides the homepage layout with a sentinel.
        write_template(
            tmp.path(),
            "layouts/index.html",
            "{% extends \"layouts/base.html\" %}\
             {% block content %}USER-OVERRIDE{% endblock %}",
        );
        let engine = TeraAdapter::from_directory_with_default(tmp.path()).unwrap();
        let ctx = TemplateContext(json!({ "site": { "title": "Cartulary" } }));
        let html = engine.render("layouts/index.html", &ctx).unwrap();
        assert!(html.contains("USER-OVERRIDE"), "got: {html}");
        // The embedded base wrapper still surrounds the override.
        assert!(html.contains("<!doctype html>"), "got: {html}");
    }

    #[test]
    fn from_directory_with_default_keeps_embedded_template_when_user_does_not_override() {
        let tmp = TempDir::new().unwrap();
        // User dir is empty — every template should still come from the
        // embedded theme.
        std::fs::create_dir(tmp.path().join("layouts")).ok();
        let engine = TeraAdapter::from_directory_with_default(tmp.path()).unwrap();
        let ctx = TemplateContext(json!({
            "site": { "title": "Cartulary" },
            "sections": [],
        }));
        let html = engine.render("layouts/index.html", &ctx).unwrap();
        assert!(html.contains("Cartulary"), "got: {html}");
    }

    #[test]
    fn from_serialize_helper_produces_a_usable_context() {
        #[derive(serde::Serialize)]
        struct Page<'a> {
            title: &'a str,
        }
        let tmp = TempDir::new().unwrap();
        write_template(tmp.path(), "p.html", "T={{ title }}");
        let engine = TeraAdapter::from_directory(tmp.path()).unwrap();
        let ctx = TemplateContext::from(&Page { title: "X" }).unwrap();
        assert_eq!(engine.render("p.html", &ctx).unwrap(), "T=X");
    }
}