cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `generate_site` — the site build, end to end, behind ports.
//!
//! Loads decision records, issues and free-form pages through the
//! repository / reader ports, renders the bundle through the
//! template engine, merges static assets, and streams every file
//! through the writer port. The driving adapter only wires the
//! adapters and parses arguments.

use std::path::Path;

use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::issue::companion::{CompanionContent, CANONICAL_FILENAMES};
use crate::domain::model::page::Page;
use crate::domain::model::site::MarkdownEntry;
use crate::domain::usecases::decision_record::DecisionRecordRepository;
use crate::domain::usecases::issue::IssueRepository;

fn locator_to_path(loc: &EntryLocator) -> std::path::PathBuf {
    let s = loc.as_str();
    let bare = s.strip_prefix("file://").unwrap_or(s);
    std::path::PathBuf::from(bare)
}
use crate::domain::usecases::site::build::{
    build_site, Attachment, BuildError, Companion, DrSection, NavLink, SiteInput, SiteIssue,
    SiteRecord,
};
use crate::domain::usecases::site::pages_walker::{build_pages, PagesError};
use crate::domain::usecases::site::readers::{AssetsReader, PagesReader};
use crate::domain::usecases::site::templates::TemplateEngine;
use crate::domain::usecases::site::writers::SiteWriter;

pub struct GenerateSiteRequest<'a> {
    pub dr_sources: Vec<DrSource>,
    pub issue_repo: &'a dyn IssueRepository,
    pub page_sources: Vec<PageSource>,
    pub assets: &'a dyn AssetsReader,
    pub theme: &'a dyn TemplateEngine,
    pub writer: &'a dyn SiteWriter,
    pub site_title: Option<String>,
    pub site_nav: Vec<SiteNavLink>,
}

pub struct DrSource {
    pub kind: String,
    pub repo: Box<dyn DecisionRecordRepository>,
}

pub struct PageSource {
    pub label: String,
    pub publish: String,
    pub reader: Box<dyn PagesReader>,
}

pub struct SiteNavLink {
    pub label: String,
    pub url: String,
}

#[derive(Debug)]
pub enum GenerateError {
    Pages {
        docs_label: String,
        error: PagesError,
    },
    Build(BuildError),
    Other(anyhow::Error),
}

impl From<anyhow::Error> for GenerateError {
    fn from(e: anyhow::Error) -> Self {
        Self::Other(e)
    }
}

pub fn generate_site(req: GenerateSiteRequest<'_>) -> Result<usize, GenerateError> {
    let input = SiteInput {
        site_title: req.site_title,
        site_nav: req
            .site_nav
            .into_iter()
            .map(|n| NavLink {
                label: n.label,
                url: n.url,
            })
            .collect(),
        dr_sections: load_decision_records(&req.dr_sources),
        issues: load_issues(req.issue_repo)?,
        pages: load_pages(&req.page_sources)?,
    };

    let mut bundle = build_site(&input, req.theme).map_err(GenerateError::Build)?;

    for theme_asset in req.assets.assets() {
        let theme_asset = theme_asset.map_err(GenerateError::from)?;
        let path = crate::domain::model::site::SitePath::new(theme_asset.source.as_str()).map_err(
            |e| {
                GenerateError::Build(crate::domain::usecases::site::build::BuildError::Other(
                    anyhow::anyhow!("invalid theme asset path: {e}"),
                ))
            },
        )?;
        bundle.push_asset(crate::domain::model::site::SiteAsset::new(
            path,
            theme_asset.bytes,
        ));
    }

    let total = bundle.pages.len() + bundle.assets.len();
    req.writer.publish(bundle)?;
    Ok(total)
}

// ── Loading: decision records ────────────────────────────────────────────────

fn load_decision_records(sources: &[DrSource]) -> Vec<DrSection> {
    sources
        .iter()
        .map(|src| {
            let id_prefix = src
                .repo
                .configured_id_prefix()
                .map(str::to_string)
                .unwrap_or_else(|| src.kind.to_uppercase());
            let records = src
                .repo
                .list()
                .unwrap_or_default()
                .into_iter()
                .map(|record| {
                    let path = locator_to_path(&record.location);
                    SiteRecord {
                        slug: slug_from_index_path(&path),
                        record,
                    }
                })
                .collect();
            DrSection {
                kind: src.kind.to_string(),
                id_prefix,
                records,
            }
        })
        .collect()
}

// ── Loading: issues ──────────────────────────────────────────────────────────

fn load_issues(repo: &dyn IssueRepository) -> Result<Vec<SiteIssue>, GenerateError> {
    let mut out = Vec::new();
    for issue in repo.list().unwrap_or_default() {
        let path = locator_to_path(&issue.location);
        let slug = slug_from_index_path(&path);

        let mut companions: Vec<Companion> = Vec::new();
        let mut attachments: Vec<Attachment> = Vec::new();

        for companion in repo.issue_companions(&issue.id)?.iter() {
            let Some(content) = repo.read_companion(&issue.id, &companion.identifier)? else {
                continue;
            };
            let name = companion.identifier.as_str().to_string();
            match content {
                CompanionContent::Text(body) => {
                    let stem = name.strip_suffix(".md").unwrap_or(&name).to_string();
                    companions.push(Companion {
                        name: stem,
                        markdown: body.as_str().to_string(),
                    });
                }
                CompanionContent::Binary(bytes) => {
                    attachments.push(Attachment {
                        filename: name,
                        bytes,
                    });
                }
            }
        }

        companions.sort_by_key(|c| {
            CANONICAL_FILENAMES
                .iter()
                .position(|f| f.strip_suffix(".md") == Some(c.name.as_str()))
                .unwrap_or(usize::MAX)
        });
        attachments.sort_by(|a, b| a.filename.cmp(&b.filename));

        out.push(SiteIssue {
            issue,
            slug,
            companions,
            attachments,
        });
    }
    Ok(out)
}

// ── Loading: free-form pages ─────────────────────────────────────────────────

fn load_pages(sources: &[PageSource]) -> Result<Vec<Page>, GenerateError> {
    let mut all = Vec::new();
    for src in sources {
        let entries: Vec<MarkdownEntry> = src
            .reader
            .entries()
            .collect::<anyhow::Result<_>>()
            .map_err(GenerateError::from)?;
        if entries.is_empty() {
            continue;
        }
        let pages = build_pages(entries, &src.publish).map_err(|error| GenerateError::Pages {
            docs_label: src.label.clone(),
            error,
        })?;
        all.extend(pages);
    }
    Ok(all)
}

// ── Helpers ──────────────────────────────────────────────────────────────────

fn slug_from_index_path(index_path: &Path) -> String {
    index_path
        .parent()
        .and_then(|p| p.file_name())
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .to_string()
}