secunit-core 0.7.1

Registry, evidence, hashing, and verification primitives for secunit.
Documentation
//! Compose the top-level Typst document (`main.typ`) from a [`WispDoc`] and
//! the operator's partials.
//!
//! The emitted document `#import`s the partials by filename, so it must be
//! compiled with the template directory as the import root (the Typst backend
//! arranges this). It builds the `ctx` dictionary the partials read, wires the
//! running header/footer and page numbering, renders the cover and table of
//! contents, then the converted markdown body.

use super::{doc::WispDoc, markdown, Status};

/// Options that affect emission (subset of `ExportOptions`).
#[derive(Debug, Clone, Copy)]
pub struct EmitOptions {
    pub toc: bool,
    pub page_numbers: bool,
}

/// Render the full `main.typ` source.
pub fn emit(doc: &WispDoc, opts: EmitOptions) -> String {
    let m = &doc.meta;
    let mut s = String::new();

    s.push_str("// Generated by `secunit wisp export`. Do not edit by hand —\n");
    s.push_str("// edit the partials in this directory instead, then re-export.\n\n");

    s.push_str("#import \"theme.typ\": apply-theme, wisp-theme\n");
    s.push_str("#import \"header.typ\": wisp-header\n");
    s.push_str("#import \"footer.typ\": wisp-footer\n");
    s.push_str("#import \"cover.typ\": wisp-cover\n");
    s.push_str("#import \"toc.typ\": wisp-toc\n\n");

    // The document context the partials read.
    s.push_str("#let ctx = (\n");
    push_field(&mut s, "org", &m.org);
    push_field(&mut s, "title", &m.title);
    push_field(&mut s, "version", &m.version);
    push_field(&mut s, "effective_date", &m.effective_date);
    push_field(&mut s, "classification", &m.classification);
    push_field(&mut s, "status", &m.status.to_string());
    push_field(&mut s, "logo", &m.logo);
    push_field(&mut s, "commit", &m.commit);
    push_field(&mut s, "content_hash", &m.content_hash);
    push_field(&mut s, "generated_at", &m.generated_at);
    s.push_str(")\n\n");

    s.push_str("#set document(title: ctx.title, author: ctx.org)\n");

    // Page geometry + running header/footer. Page numbering is left to the
    // footer's "Page X of Y" when enabled.
    s.push_str("#set page(\n");
    s.push_str("  paper: wisp-theme.paper,\n");
    s.push_str("  margin: wisp-theme.margin,\n");
    s.push_str("  header: wisp-header(ctx),\n");
    if opts.page_numbers {
        s.push_str("  footer: wisp-footer(ctx),\n");
    }
    if m.status == Status::Draft {
        // Diagonal DRAFT watermark behind the content.
        s.push_str("  background: rotate(-30deg, text(120pt, fill: rgb(\"#0000000d\"))[DRAFT]),\n");
    }
    s.push_str("  numbering: none,\n");
    s.push_str(")\n");
    s.push_str("#show: apply-theme\n\n");

    // Cover (manages its own bare page + trailing pagebreak).
    s.push_str("#wisp-cover(ctx)\n\n");

    if opts.toc {
        s.push_str("#wisp-toc(ctx)\n\n");
    }

    s.push_str(&markdown::to_typst(&doc.body_markdown, &doc.sections));
    s.push('\n');

    s
}

/// Append `  name: "value",` with the value escaped for a Typst string literal.
fn push_field(s: &mut String, name: &str, value: &str) {
    s.push_str("  ");
    s.push_str(name);
    s.push_str(": \"");
    s.push_str(&markdown::escape_typst_string(value));
    s.push_str("\",\n");
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::wisp::doc::WispMeta;

    fn doc() -> WispDoc {
        WispDoc {
            meta: WispMeta {
                org: "Battle \"Creek\"".into(),
                title: "WISP".into(),
                version: "1.0.0".into(),
                effective_date: "2026-05-29".into(),
                classification: "Confidential".into(),
                status: Status::Draft,
                logo: "logo.svg".into(),
                commit: "abc123".into(),
                content_hash: "sha256:deadbeef".into(),
                generated_at: "2026-05-29".into(),
            },
            body_markdown: "# Intro\n\nHello.\n".into(),
            sections: vec!["intro.md".into()],
        }
    }

    #[test]
    fn emits_imports_ctx_and_body() {
        let out = emit(
            &doc(),
            EmitOptions {
                toc: true,
                page_numbers: true,
            },
        );
        assert!(out.contains("#import \"theme.typ\""));
        assert!(out.contains("#wisp-cover(ctx)"));
        assert!(out.contains("#wisp-toc(ctx)"));
        assert!(out.contains("= Intro"));
        // Quotes in values are escaped.
        assert!(out.contains("Battle \\\"Creek\\\""));
        // Draft watermark present.
        assert!(out.contains("DRAFT"));
    }

    #[test]
    fn omits_toc_and_footer_when_disabled() {
        let out = emit(
            &doc(),
            EmitOptions {
                toc: false,
                page_numbers: false,
            },
        );
        assert!(!out.contains("#wisp-toc(ctx)"));
        assert!(!out.contains("footer: wisp-footer"));
    }
}