Skip to main content

secunit_core/wisp/
mod.rs

1//! WISP → PDF export.
2//!
3//! Renders an organisation's Written Information Security Program — the
4//! markdown policy set under `security/` — into a single branded PDF with a
5//! cover page, table of contents, page numbers, and a provenance stamp.
6//!
7//! Design and decisions live in `docs/wisp-pdf-export.md`. The short version:
8//!
9//! - The default renderer is **Typst** (pure Rust, compiled into the binary),
10//!   so a plain `cargo install` produces a working renderer with no external
11//!   toolchain. WeasyPrint / Chromium are opt-in HTML backends (not yet wired).
12//! - The branding **partials are required, operator-owned files** (Typst
13//!   templates by default), scaffolded by [`init`] and checked by [`export`].
14//! - Provenance reuses the same primitives as the evidence chain: the WISP
15//!   repo's `git_head` commit and a SHA-256 over the assembled markdown.
16//!
17//! This module owns the format-neutral pipeline:
18//! source resolution → markdown assembly → [`doc::WispDoc`] → Typst emission.
19//! The final Typst→PDF compile (the `typst` crate) is wired separately; see
20//! [`render`].
21
22use std::fmt;
23use std::path::PathBuf;
24use std::str::FromStr;
25
26use serde::{Deserialize, Serialize};
27
28pub mod doc;
29pub mod markdown;
30pub mod render;
31pub mod scaffold;
32pub mod source;
33pub mod template;
34pub mod typst_emit;
35
36pub use doc::WispDoc;
37pub use scaffold::init;
38pub use source::export;
39
40/// Partial flavour. Determined by the chosen renderer: the in-binary Typst
41/// backend uses `.typ` partials; the opt-in HTML backends use HTML + CSS.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum Format {
45    Typst,
46    Html,
47}
48
49impl Format {
50    /// The required partial filenames for this format. `init` writes all of
51    /// these and `export` checks for the same set.
52    pub fn required_partials(self) -> &'static [&'static str] {
53        match self {
54            Format::Typst => &[
55                "theme.typ",
56                "header.typ",
57                "footer.typ",
58                "cover.typ",
59                "toc.typ",
60            ],
61            Format::Html => &[
62                "theme.css",
63                "header.html",
64                "footer.html",
65                "cover.html",
66                "toc.html",
67            ],
68        }
69    }
70}
71
72impl fmt::Display for Format {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.write_str(match self {
75            Format::Typst => "typst",
76            Format::Html => "html",
77        })
78    }
79}
80
81impl FromStr for Format {
82    type Err = String;
83    fn from_str(s: &str) -> Result<Self, Self::Err> {
84        match s.trim().to_ascii_lowercase().as_str() {
85            "typst" | "typ" => Ok(Format::Typst),
86            "html" => Ok(Format::Html),
87            other => Err(format!(
88                "unknown partial format `{other}` (expected `typst` or `html`)"
89            )),
90        }
91    }
92}
93
94/// Rendering backend. Only `Typst` is implemented today; the others are
95/// reserved for the opt-in HTML backends documented in the spec (§8).
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
97#[serde(rename_all = "lowercase")]
98pub enum Renderer {
99    #[default]
100    Typst,
101    Weasyprint,
102    Chromium,
103}
104
105impl Renderer {
106    /// The partial format this backend consumes.
107    pub fn format(self) -> Format {
108        match self {
109            Renderer::Typst => Format::Typst,
110            Renderer::Weasyprint | Renderer::Chromium => Format::Html,
111        }
112    }
113}
114
115impl fmt::Display for Renderer {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.write_str(match self {
118            Renderer::Typst => "typst",
119            Renderer::Weasyprint => "weasyprint",
120            Renderer::Chromium => "chromium",
121        })
122    }
123}
124
125impl FromStr for Renderer {
126    type Err = String;
127    fn from_str(s: &str) -> Result<Self, Self::Err> {
128        match s.trim().to_ascii_lowercase().as_str() {
129            "typst" => Ok(Renderer::Typst),
130            "weasyprint" => Ok(Renderer::Weasyprint),
131            "chromium" | "chrome" => Ok(Renderer::Chromium),
132            other => Err(format!(
133                "unknown renderer `{other}` (expected `typst`, `weasyprint`, or `chromium`)"
134            )),
135        }
136    }
137}
138
139/// Document status, surfaced on the cover/footer and as a watermark.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "UPPERCASE")]
142pub enum Status {
143    Approved,
144    Draft,
145}
146
147impl fmt::Display for Status {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        f.write_str(match self {
150            Status::Approved => "APPROVED",
151            Status::Draft => "DRAFT",
152        })
153    }
154}
155
156/// Inputs to [`init`]. `dir` is the template directory to scaffold into.
157#[derive(Debug, Clone)]
158pub struct InitOptions {
159    pub dir: PathBuf,
160    pub format: Format,
161    /// Optional logo to seed instead of the bundled shield.
162    pub logo: Option<PathBuf>,
163    /// Overwrite existing partials instead of skipping them.
164    pub force: bool,
165}
166
167/// Outcome of [`init`].
168#[derive(Debug, Clone, Serialize)]
169pub struct InitReport {
170    pub dir: PathBuf,
171    pub format: Format,
172    pub written: Vec<String>,
173    pub skipped: Vec<String>,
174}
175
176/// Inputs to [`export`]. `None` fields fall back to `_config.yaml`'s `wisp:`
177/// block and then to built-in defaults.
178#[derive(Debug, Clone)]
179pub struct ExportOptions {
180    /// Registry root (used for git provenance + config defaults).
181    pub root: PathBuf,
182    /// Date to stamp as "generated"/effective fallback (the CLI's `--today`).
183    pub today: chrono::NaiveDate,
184    /// WISP source file or directory (overrides config).
185    pub source: Option<PathBuf>,
186    /// Template directory holding the partials (overrides config).
187    pub template: Option<PathBuf>,
188    /// Output PDF path (defaults to `wisp-<version>.pdf`).
189    pub output: Option<PathBuf>,
190    /// Include a table of contents (overrides config; default on).
191    pub toc: Option<bool>,
192    /// Render page numbers (overrides config; default on).
193    pub page_numbers: Option<bool>,
194    /// Force the DRAFT watermark regardless of working-tree state.
195    pub draft: bool,
196    pub allow_dirty: bool,
197    /// Render backend (overrides config; default Typst).
198    pub renderer: Option<Renderer>,
199}
200
201/// Outcome of [`export`].
202#[derive(Debug, Clone, Serialize)]
203pub struct ExportReport {
204    pub output: PathBuf,
205    pub version: String,
206    pub effective_date: String,
207    pub commit: String,
208    pub content_hash: String,
209    pub status: Status,
210    pub renderer: Renderer,
211    /// Source files assembled, in order, so the operator can see the layout.
212    pub sections: Vec<String>,
213    /// Page count, once a backend that reports it is wired.
214    pub pages: Option<u32>,
215}