1use 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#[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#[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
99pub 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(®.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 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 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 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 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 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 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
254fn 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
275fn 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 let slugs = super::markdown::section_slugs(§ions);
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 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 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_src.push_str(&trimmed);
316 hash_src.push('\n');
317 }
318
319 Ok((body, hash_src, sections, files.first().cloned()))
320}
321
322fn 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 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); Ok(placed)
359}
360
361fn 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
384fn opts_version(cfg: &WispConfig, fm: &FrontMatter) -> Option<String> {
387 cfg.version.clone().or_else(|| fm.version.clone())
388}
389
390fn 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 crate::skills::frontmatter(&content)
411 .and_then(|fm| serde_yaml::from_str(fm).ok())
412 .unwrap_or_default()
413}
414
415fn glob_match(pattern: &str, name: &str) -> bool {
419 let parts: Vec<&str> = pattern.split('*').collect();
420 if parts.len() == 1 {
421 return pattern == name; }
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 if !name[pos..].starts_with(part) {
431 return false;
432 }
433 pos += part.len();
434 } else if i == parts.len() - 1 {
435 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}