Skip to main content

secunit_core/wisp/
source.rs

1//! WISP source resolution, assembly, provenance, and the `export` entry point.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use anyhow::{anyhow, bail, Context, Result};
7use serde::Deserialize;
8
9use crate::evidence::{hasher, runner};
10use crate::model::Config;
11
12use super::doc::{WispDoc, WispMeta};
13use super::{render, template, typst_emit, ExportOptions, ExportReport, Renderer, Status};
14
15// ---------- config view -----------------------------------------------------
16
17/// The `wisp:` block in `_config.yaml`, read from `Config::extras`.
18#[derive(Debug, Default, Deserialize)]
19struct WispConfig {
20    #[serde(default)]
21    source: Option<String>,
22    #[serde(default)]
23    order: Vec<String>,
24    #[serde(default)]
25    version: Option<String>,
26    #[serde(default)]
27    template: Option<String>,
28    #[serde(default)]
29    renderer: Option<String>,
30    #[serde(default)]
31    metadata: WispMetaConfig,
32    #[serde(default)]
33    toc: TocConfig,
34    #[serde(default)]
35    output: OutputConfig,
36}
37
38#[derive(Debug, Default, Deserialize)]
39struct WispMetaConfig {
40    #[serde(default)]
41    org: Option<String>,
42    #[serde(default)]
43    title: Option<String>,
44    #[serde(default)]
45    classification: Option<String>,
46}
47
48#[derive(Debug, Deserialize)]
49struct TocConfig {
50    #[serde(default = "yes")]
51    enabled: bool,
52}
53
54impl Default for TocConfig {
55    fn default() -> Self {
56        Self { enabled: true }
57    }
58}
59
60#[derive(Debug, Deserialize)]
61struct OutputConfig {
62    #[serde(default = "yes")]
63    page_numbers: bool,
64}
65
66impl Default for OutputConfig {
67    fn default() -> Self {
68        Self { page_numbers: true }
69    }
70}
71
72fn yes() -> bool {
73    true
74}
75
76impl WispConfig {
77    fn from_config(config: &Config) -> Self {
78        config
79            .extras
80            .get("wisp")
81            .and_then(|v| serde_json::from_value(v.clone()).ok())
82            .unwrap_or_default()
83    }
84}
85
86/// Front-matter we recognise on the first source file.
87#[derive(Debug, Default, Deserialize)]
88struct FrontMatter {
89    #[serde(default)]
90    version: Option<String>,
91    #[serde(default)]
92    effective_date: Option<String>,
93    #[serde(default)]
94    title: Option<String>,
95    #[serde(default)]
96    classification: Option<String>,
97}
98
99// ---------- public entry point ----------------------------------------------
100
101/// Render the latest WISP to PDF (or, until the in-binary Typst compile is
102/// wired, to a composed `.typ` document). See [`super`] for the design.
103pub fn export(opts: &ExportOptions) -> Result<ExportReport> {
104    let root = opts
105        .root
106        .canonicalize()
107        .with_context(|| format!("resolve root {}", opts.root.display()))?;
108
109    let (reg, _report) = crate::registry::loader::load(&root);
110    let wisp_cfg = WispConfig::from_config(&reg.config);
111
112    let renderer: Renderer = opts
113        .renderer
114        .or_else(|| wisp_cfg.renderer.as_deref().and_then(|s| s.parse().ok()))
115        .unwrap_or_default();
116
117    // ---- assemble the source ----
118    let source_dir = resolve_source(&root, opts.source.as_deref(), &wisp_cfg)?;
119    let (body_markdown, hash_src, sections, first_file) = assemble(&source_dir, &wisp_cfg)?;
120    if sections.is_empty() {
121        bail!(
122            "no WISP markdown found under {} — set `wisp.source` in _config.yaml \
123             or pass --source",
124            source_dir.display()
125        );
126    }
127    let fm = first_file
128        .as_deref()
129        .map(extract_front_matter)
130        .unwrap_or_default();
131
132    // ---- provenance + metadata ----
133    // Refuse to stamp a commit pointer onto a tree with uncommitted changes
134    // unless the operator opts in; mark such exports as `-dirty` so the
135    // provenance line is honest about it.
136    let dirty = runner::git_is_dirty(&root).unwrap_or(false);
137    if dirty && !opts.allow_dirty {
138        bail!(
139            "WISP source tree has uncommitted changes — commit them so the \
140             provenance commit matches the rendered content, or pass \
141             --allow-dirty to export anyway."
142        );
143    }
144    let commit = runner::git_head(&root)
145        .ok()
146        .map(|sha| sha.get(..12).unwrap_or(sha.as_str()).to_string())
147        .map(|sha| if dirty { format!("{sha}-dirty") } else { sha })
148        .unwrap_or_else(|| "uncommitted".to_string());
149    // Hash the policy content itself (without the internal anchor markers), so
150    // the provenance digest stays a pure function of the source text.
151    let full_hash = hasher::sha256_bytes(hash_src.as_bytes());
152    let content_hash = format!("sha256:{}", &full_hash[..full_hash.len().min(12)]);
153
154    let version = opts_version(&wisp_cfg, &fm).unwrap_or_else(|| "0.0.0".to_string());
155    let effective_date = fm
156        .effective_date
157        .clone()
158        .unwrap_or_else(|| opts.today.to_string());
159    let status = if opts.draft {
160        Status::Draft
161    } else {
162        Status::Approved
163    };
164
165    let org = wisp_cfg
166        .metadata
167        .org
168        .clone()
169        .or_else(|| reg.config.org.as_ref().and_then(|o| o.name.clone()))
170        .unwrap_or_else(|| "Organization".to_string());
171    let title = wisp_cfg
172        .metadata
173        .title
174        .clone()
175        .or(fm.title.clone())
176        .unwrap_or_else(|| "Written Information Security Program".to_string());
177    let classification = wisp_cfg
178        .metadata
179        .classification
180        .clone()
181        .or(fm.classification.clone())
182        .unwrap_or_else(|| "Confidential".to_string());
183
184    // ---- require the partials ----
185    let template_dir = template::resolve_dir(
186        &root,
187        opts.template
188            .as_deref()
189            .or(wisp_cfg.template.as_deref().map(Path::new)),
190    );
191    let format = renderer.format();
192    template::require_complete(&template_dir, format)?;
193    // The cover/header reference whatever logo the operator scaffolded; a custom
194    // `wisp init --logo brand.png` writes `logo.png`, so resolve the real file
195    // rather than assuming `logo.svg`.
196    let logo = resolve_logo(&template_dir).ok_or_else(|| {
197        anyhow!(
198            "no logo found in {} — run `secunit wisp init` to scaffold one, or \
199             add a logo.<svg|png|jpg|jpeg|webp|gif> file.",
200            template_dir.display()
201        )
202    })?;
203
204    // ---- build doc + emit ----
205    let doc = WispDoc {
206        meta: WispMeta {
207            org,
208            title,
209            version: version.clone(),
210            effective_date: effective_date.clone(),
211            classification,
212            status,
213            logo,
214            commit: commit.clone(),
215            content_hash: content_hash.clone(),
216            generated_at: opts.today.to_string(),
217        },
218        body_markdown,
219        sections: sections.clone(),
220    };
221
222    let toc = opts.toc.unwrap_or(wisp_cfg.toc.enabled);
223    let page_numbers = opts.page_numbers.unwrap_or(wisp_cfg.output.page_numbers);
224    let typst_source = typst_emit::emit(&doc, typst_emit::EmitOptions { toc, page_numbers });
225
226    let output = opts
227        .output
228        .clone()
229        .unwrap_or_else(|| PathBuf::from(format!("wisp-{version}.pdf")));
230
231    let result = render::render(
232        renderer,
233        &render::RenderRequest {
234            doc: &doc,
235            template_dir: &template_dir,
236            typst_source: &typst_source,
237            output: &output,
238        },
239    )?;
240
241    Ok(ExportReport {
242        output,
243        version,
244        effective_date,
245        commit,
246        content_hash,
247        status,
248        renderer,
249        sections,
250        pages: result.pages,
251    })
252}
253
254// ---------- source resolution + assembly ------------------------------------
255
256fn resolve_source(root: &Path, override_src: Option<&Path>, cfg: &WispConfig) -> Result<PathBuf> {
257    let candidate = match override_src {
258        Some(p) if p.is_absolute() => p.to_path_buf(),
259        Some(p) => root.join(p),
260        None => match &cfg.source {
261            Some(s) => root.join(s),
262            None => root.join("security"),
263        },
264    };
265    if !candidate.exists() {
266        bail!(
267            "WISP source {} does not exist — set `wisp.source` in _config.yaml or \
268             pass --source <file|dir>",
269            candidate.display()
270        );
271    }
272    Ok(candidate)
273}
274
275/// Returns `(render_markdown, hash_source, ordered_section_names, first_file)`.
276///
277/// `render_markdown` carries the `<!--wisp:anchor …-->` markers the converter
278/// turns into per-section anchors; `hash_source` is the same content *without*
279/// the markers, so the provenance digest stays a pure function of the policy
280/// text regardless of the internal-link machinery.
281fn assemble(
282    source: &Path,
283    cfg: &WispConfig,
284) -> Result<(String, String, Vec<String>, Option<PathBuf>)> {
285    let files = if source.is_file() {
286        vec![source.to_path_buf()]
287    } else {
288        ordered_markdown(source, &cfg.order)?
289    };
290
291    let sections: Vec<String> = files.iter().map(|p| rel_name(source, p)).collect();
292    // Unique, collision-resolved label per section; the converter recomputes
293    // the same slugs from `sections`, so anchors and links agree.
294    let slugs = super::markdown::section_slugs(&sections);
295
296    let mut body = String::new();
297    let mut hash_src = String::new();
298    for (i, path) in files.iter().enumerate() {
299        let content = fs::read_to_string(path)
300            .with_context(|| format!("read WISP source {}", path.display()))?;
301        // Strip front-matter from each file before concatenating.
302        let trimmed = strip_front_matter(&content).trim_end().to_string();
303        if i > 0 {
304            body.push_str("\n\n");
305            hash_src.push_str("\n\n");
306        }
307        // Render body: anchor marker so `.md` links to this file resolve to an
308        // in-document anchor instead of trying to open a sibling file.
309        body.push_str("<!--wisp:anchor ");
310        body.push_str(&slugs[i]);
311        body.push_str("-->\n\n");
312        body.push_str(&trimmed);
313        body.push('\n');
314        // Hash source: the policy text only.
315        hash_src.push_str(&trimmed);
316        hash_src.push('\n');
317    }
318
319    Ok((body, hash_src, sections, files.first().cloned()))
320}
321
322/// Collect `*.md` files under `dir`, ordered by `order` patterns if given,
323/// else lexically with any `*overview*`/`*introduction*` floated to the front.
324fn ordered_markdown(dir: &Path, order: &[String]) -> Result<Vec<PathBuf>> {
325    let mut md: Vec<PathBuf> = fs::read_dir(dir)
326        .with_context(|| format!("read WISP dir {}", dir.display()))?
327        .filter_map(|e| e.ok().map(|e| e.path()))
328        .filter(|p| p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("md"))
329        .collect();
330    md.sort();
331
332    if order.is_empty() {
333        md.sort_by_key(|p| {
334            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
335            let lead = name.contains("overview") || name.contains("introduction");
336            (!lead, name.to_string())
337        });
338        return Ok(md);
339    }
340
341    // Apply the explicit order: each pattern pulls matching files (in lexical
342    // order) that haven't been placed yet; a trailing catch-all like `*.md`
343    // sweeps the remainder.
344    let mut placed: Vec<PathBuf> = Vec::new();
345    let mut remaining = md;
346    for pat in order {
347        let (mut hits, rest): (Vec<_>, Vec<_>) = remaining.into_iter().partition(|p| {
348            p.file_name()
349                .and_then(|n| n.to_str())
350                .map(|n| glob_match(pat, n))
351                .unwrap_or(false)
352        });
353        hits.sort();
354        placed.append(&mut hits);
355        remaining = rest;
356    }
357    placed.append(&mut remaining); // anything unmatched, lexical order
358    Ok(placed)
359}
360
361/// The logo filename within `template_dir` to reference from the partials —
362/// whichever supported image `wisp init` scaffolded. `None` if none is present.
363fn resolve_logo(template_dir: &Path) -> Option<String> {
364    [
365        "logo.svg",
366        "logo.png",
367        "logo.jpg",
368        "logo.jpeg",
369        "logo.webp",
370        "logo.gif",
371    ]
372    .into_iter()
373    .find(|name| template_dir.join(name).exists())
374    .map(str::to_string)
375}
376
377fn rel_name(base: &Path, path: &Path) -> String {
378    path.strip_prefix(base)
379        .unwrap_or(path)
380        .to_string_lossy()
381        .replace('\\', "/")
382}
383
384// ---------- front-matter + helpers ------------------------------------------
385
386fn opts_version(cfg: &WispConfig, fm: &FrontMatter) -> Option<String> {
387    cfg.version.clone().or_else(|| fm.version.clone())
388}
389
390/// Split leading `---\n ... \n---` YAML front-matter from a markdown string,
391/// returning the body. Tolerant: if there's no closing fence, returns input.
392fn strip_front_matter(content: &str) -> &str {
393    let Some(rest) = content.strip_prefix("---\n") else {
394        return content;
395    };
396    match rest.find("\n---") {
397        Some(idx) => {
398            let after = &rest[idx + 4..];
399            after.strip_prefix('\n').unwrap_or(after)
400        }
401        None => content,
402    }
403}
404
405fn extract_front_matter(path: &Path) -> FrontMatter {
406    let Ok(content) = fs::read_to_string(path) else {
407        return FrontMatter::default();
408    };
409    // Reuse the shared front-matter fence parser so all call sites agree.
410    crate::skills::frontmatter(&content)
411        .and_then(|fm| serde_yaml::from_str(fm).ok())
412        .unwrap_or_default()
413}
414
415/// Minimal `*` glob over a single path segment. Supports any number of `*`
416/// wildcards (each matching any run of characters); all other characters match
417/// literally. Sufficient for filename patterns like `*-overview.md` or `*.md`.
418fn glob_match(pattern: &str, name: &str) -> bool {
419    let parts: Vec<&str> = pattern.split('*').collect();
420    if parts.len() == 1 {
421        return pattern == name; // no wildcard
422    }
423    let mut pos = 0usize;
424    for (i, part) in parts.iter().enumerate() {
425        if part.is_empty() {
426            continue;
427        }
428        if i == 0 {
429            // Must match at the start.
430            if !name[pos..].starts_with(part) {
431                return false;
432            }
433            pos += part.len();
434        } else if i == parts.len() - 1 {
435            // Must match at the end.
436            return name[pos..].ends_with(part);
437        } else {
438            match name[pos..].find(part) {
439                Some(found) => pos += found + part.len(),
440                None => return false,
441            }
442        }
443    }
444    true
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn glob_matches_suffix_and_catchall() {
453        assert!(glob_match("*.md", "access-control-policy.md"));
454        assert!(glob_match("*-overview.md", "company-overview.md"));
455        assert!(!glob_match("*-overview.md", "access-policy.md"));
456        assert!(glob_match(
457            "access-control-policy.md",
458            "access-control-policy.md"
459        ));
460        assert!(glob_match("*", "anything.md"));
461    }
462
463    #[test]
464    fn strips_front_matter() {
465        let md = "---\nversion: 1.2.0\n---\n# Body\n";
466        assert_eq!(strip_front_matter(md), "# Body\n");
467        let none = "# No front matter\n";
468        assert_eq!(strip_front_matter(none), none);
469    }
470}