cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `cartu site build` — generate a static site under `<out>/`.
//!
//! This module wires the driving adapter for the
//! [`generate_site`] use case: parse arguments, instantiate the
//! filesystem-backed adapters (template engine, readers, writer)
//! and render structured errors back to the user.

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

use clap::{Arg, ArgMatches, Command};

use crate::domain::usecases::site::build::BuildError;
use crate::domain::usecases::site::generate::{
    generate_site, DrSource, GenerateError, GenerateSiteRequest, PageSource, SiteNavLink,
};
use crate::domain::usecases::site::overlay_assets::OverlayAssetsReader;
use crate::domain::usecases::site::readers::AssetsReader;
use crate::domain::usecases::site::templates::TemplateEngine;
use crate::infra::driven::site::embedded_assets_reader::EmbeddedDefaultThemeAssets;
use crate::infra::driven::site::fs_assets_reader::FsAssetsReader;
use crate::infra::driven::site::fs_pages_reader::FsPagesReader;
use crate::infra::driven::site::fs_site_writer::FsSiteWriter;
use crate::infra::driven::site::tera_adapter::TeraAdapter;

use super::super::errors::{die1, CliError};
use super::super::pages_renderer;
use super::super::Context;

pub(in super::super) fn site_subcommand() -> Command {
    Command::new("site")
        .about("Publish the workspace as a browsable static site")
        .subcommand_required(true)
        .arg_required_else_help(true)
        .subcommand(
            Command::new("build")
                .about("Render every entry in the workspace to HTML under <out>/")
                .long_about(
                    "Render every decision record and issue in the workspace \
                     to a self-contained static site (HTML pages, indexes, \
                     companion files, embedded CSS) under the output \
                     directory. The default theme is embedded; pass --theme \
                     to override with a custom Tera template tree, or set \
                     `[site] theme = \"...\"` in cartulary.toml to record \
                     the choice project-wide.",
                )
                .arg(Arg::new("out").long("out").value_name("PATH").help(
                    "Output directory (wins over `[site] out` in cartulary.toml; \
                     defaults to site/)",
                ))
                .arg(Arg::new("theme").long("theme").value_name("PATH").help(
                    "Override the embedded default theme with templates from PATH \
                     (wins over `[site] theme` in cartulary.toml)",
                )),
        )
}

pub(in super::super) fn execute_site(matches: &ArgMatches, ctx: &Context<'_>) {
    match matches.subcommand() {
        Some(("build", sub)) => execute_build(sub, ctx),
        _ => {
            die1(
                CliError::new("unknown site subcommand").kind("validation"),
                ctx.output_fmt,
            );
        }
    }
}

fn execute_build(matches: &ArgMatches, ctx: &Context<'_>) {
    let out_dir = matches
        .get_one::<String>("out")
        .map(PathBuf::from)
        .or_else(|| ctx.config().site_out.clone())
        .unwrap_or_else(|| ctx.root_dir.join("site"));
    let theme_dir = matches
        .get_one::<String>("theme")
        .map(PathBuf::from)
        .or_else(|| ctx.config().site_theme.clone());

    if let Err(e) = run_build(ctx, &out_dir, theme_dir.as_deref()) {
        die1(CliError::new(render_error(&e)), ctx.output_fmt);
    }
}

fn run_build(
    ctx: &Context<'_>,
    out_dir: &Path,
    theme_dir: Option<&Path>,
) -> Result<(), GenerateError> {
    let config = ctx.config();

    let dr_sources: Vec<DrSource> = config
        .decision_kinds
        .iter()
        .map(|kind_cfg| {
            let repo = ctx.decision_record_repository(kind_cfg);
            DrSource {
                kind: kind_cfg.kind.clone(),
                repo: Box::new(repo),
            }
        })
        .collect();

    let issue_repo = ctx.issue_repository();

    let page_sources: Vec<PageSource> = config
        .docs
        .iter()
        .map(|docs_cfg| PageSource {
            label: docs_cfg.name.clone(),
            publish: docs_cfg.publish.clone(),
            reader: Box::new(FsPagesReader::new(&docs_cfg.source)),
        })
        .collect();

    let default_assets = EmbeddedDefaultThemeAssets;
    let override_assets = theme_dir.map(FsAssetsReader::from_theme_dir);
    let overlay = override_assets
        .as_ref()
        .map(|overlay| OverlayAssetsReader::new(&default_assets, overlay));
    let assets: &dyn AssetsReader = match &overlay {
        Some(overlay) => overlay,
        None => &default_assets,
    };

    let theme: Box<dyn TemplateEngine> = match theme_dir {
        Some(path) => Box::new(TeraAdapter::from_directory_with_default(path)?),
        None => Box::new(TeraAdapter::from_default()?),
    };

    let writer = FsSiteWriter::new(out_dir);

    let site_nav: Vec<SiteNavLink> = config
        .site_nav
        .iter()
        .map(|n| SiteNavLink {
            label: n.label.clone(),
            url: n.url.clone(),
        })
        .collect();

    let request = GenerateSiteRequest {
        dr_sources,
        issue_repo: &issue_repo,
        page_sources,
        assets,
        theme: theme.as_ref(),
        writer: &writer,
        site_title: config.site_title.clone(),
        site_nav,
    };

    let total = generate_site(request)?;
    println!("Built {total} files → {}", out_dir.display());
    Ok(())
}

fn render_error(err: &GenerateError) -> String {
    match err {
        GenerateError::Pages { docs_label, error } => {
            format!("[docs.{docs_label}]: {}", pages_renderer::render(error))
        }
        GenerateError::Build(e @ BuildError::BrokenReference { .. }) => format!("{e}"),
        GenerateError::Build(e) => format!("{e}"),
        GenerateError::Other(e) => e.to_string(),
    }
}