Skip to main content

weave_content/
lib.rs

1#![deny(unsafe_code)]
2#![deny(clippy::unwrap_used)]
3#![deny(clippy::expect_used)]
4#![allow(clippy::missing_errors_doc)]
5
6pub mod build_cache;
7pub mod cache;
8pub mod domain;
9pub mod entity;
10pub mod html;
11pub mod nulid_gen;
12pub mod output;
13pub mod parser;
14pub mod registry;
15pub mod relationship;
16pub mod tags;
17pub mod timeline;
18pub mod verifier;
19pub mod writeback;
20
21use std::collections::{BTreeMap, HashMap, HashSet};
22
23use crate::entity::Entity;
24use crate::output::{CaseOutput, NodeOutput};
25use crate::parser::{ParseError, ParsedCase, SectionKind};
26use crate::relationship::Rel;
27
28/// Build a case index: scan case files for front matter `id:` and H1 title.
29///
30/// Returns a map of `case_path → (nulid, title)` used to resolve cross-case
31/// references in `## Related Cases` sections.
32pub fn build_case_index(
33    case_files: &[String],
34    content_root: &std::path::Path,
35) -> Result<std::collections::HashMap<String, (String, String)>, i32> {
36    let mut map = std::collections::HashMap::new();
37    for path in case_files {
38        let content = std::fs::read_to_string(path).map_err(|e| {
39            eprintln!("{path}: {e}");
40            1
41        })?;
42        if let Some(id) = extract_front_matter_id(&content)
43            && let Some(case_path) = case_slug_from_path(std::path::Path::new(path), content_root)
44        {
45            let title = extract_title(&content).unwrap_or_else(|| case_path.clone());
46            map.insert(case_path, (id, title));
47        }
48    }
49    Ok(map)
50}
51
52/// Extract the `id:` value from YAML front matter without full parsing.
53fn extract_front_matter_id(content: &str) -> Option<String> {
54    let content = content.strip_prefix("---\n")?;
55    let end = content.find("\n---")?;
56    let fm = &content[..end];
57    for line in fm.lines() {
58        let trimmed = line.trim();
59        if let Some(id) = trimmed.strip_prefix("id:") {
60            let id = id.trim().trim_matches('"').trim_matches('\'');
61            if !id.is_empty() {
62                return Some(id.to_string());
63            }
64        }
65    }
66    None
67}
68
69/// Extract the first H1 heading (`# Title`) after the front matter closing delimiter.
70fn extract_title(content: &str) -> Option<String> {
71    let content = content.strip_prefix("---\n")?;
72    let end = content.find("\n---")?;
73    let after_fm = &content[end + 4..];
74    for line in after_fm.lines() {
75        if let Some(title) = line.strip_prefix("# ") {
76            let title = title.trim();
77            if !title.is_empty() {
78                return Some(title.to_string());
79            }
80        }
81    }
82    None
83}
84
85/// Derive a case slug from a file path relative to content root.
86///
87/// E.g. `content/cases/id/corruption/2002/foo.md` with content root
88/// `content/` → `id/corruption/2002/foo`.
89pub fn case_slug_from_path(
90    path: &std::path::Path,
91    content_root: &std::path::Path,
92) -> Option<String> {
93    let cases_dir = content_root.join("cases");
94    let rel = path.strip_prefix(&cases_dir).ok()?;
95    let s = rel.to_str()?;
96    Some(s.strip_suffix(".md").unwrap_or(s).to_string())
97}
98
99/// Parse a case file fully: front matter, entities, relationships, timeline.
100/// Returns the parsed case, inline entities, and relationships (including NEXT from timeline).
101///
102/// When a registry is provided, relationship and timeline names are resolved
103/// against both inline events AND the global entity registry.
104pub fn parse_full(
105    content: &str,
106    reg: Option<&registry::EntityRegistry>,
107) -> Result<(ParsedCase, Vec<Entity>, Vec<Rel>), Vec<ParseError>> {
108    let case = parser::parse(content)?;
109    let mut errors = Vec::new();
110
111    let mut all_entities = Vec::new();
112    for section in &case.sections {
113        if matches!(
114            section.kind,
115            SectionKind::Events | SectionKind::Documents | SectionKind::Assets
116        ) {
117            let entities =
118                entity::parse_entities(&section.body, section.kind, section.line, &mut errors);
119            all_entities.extend(entities);
120        }
121    }
122
123    // Build combined name set: inline events + registry entities
124    let mut entity_names: HashSet<&str> = all_entities.iter().map(|e| e.name.as_str()).collect();
125    if let Some(registry) = reg {
126        for name in registry.names() {
127            entity_names.insert(name);
128        }
129    }
130
131    let event_names: HashSet<&str> = all_entities
132        .iter()
133        .filter(|e| e.label == entity::Label::Event)
134        .map(|e| e.name.as_str())
135        .collect();
136
137    let mut all_rels = Vec::new();
138    for section in &case.sections {
139        if section.kind == SectionKind::Relationships {
140            let rels = relationship::parse_relationships(
141                &section.body,
142                section.line,
143                &entity_names,
144                &case.sources,
145                &mut errors,
146            );
147            all_rels.extend(rels);
148        }
149    }
150
151    for section in &case.sections {
152        if section.kind == SectionKind::Timeline {
153            let rels =
154                timeline::parse_timeline(&section.body, section.line, &event_names, &mut errors);
155            all_rels.extend(rels);
156        }
157    }
158
159    if errors.is_empty() {
160        Ok((case, all_entities, all_rels))
161    } else {
162        Err(errors)
163    }
164}
165
166/// Collect registry entities referenced by relationships in this case.
167/// Sets the `slug` field on each entity from the registry's file path.
168pub fn collect_referenced_registry_entities(
169    rels: &[Rel],
170    inline_entities: &[Entity],
171    reg: &registry::EntityRegistry,
172) -> Vec<Entity> {
173    let inline_names: HashSet<&str> = inline_entities.iter().map(|e| e.name.as_str()).collect();
174    let mut referenced = Vec::new();
175    let mut seen_names: HashSet<String> = HashSet::new();
176
177    for rel in rels {
178        for name in [&rel.source_name, &rel.target_name] {
179            if !inline_names.contains(name.as_str())
180                && seen_names.insert(name.clone())
181                && let Some(entry) = reg.get_by_name(name)
182            {
183                let mut entity = entry.entity.clone();
184                entity.slug = reg.slug_for(entry);
185                referenced.push(entity);
186            }
187        }
188    }
189
190    referenced
191}
192
193/// Build a `CaseOutput` from a case file path.
194/// Handles parsing and ID writeback.
195pub fn build_case_output(
196    path: &str,
197    reg: &registry::EntityRegistry,
198) -> Result<output::CaseOutput, i32> {
199    let mut written = HashSet::new();
200    build_case_output_tracked(path, reg, &mut written, &std::collections::HashMap::new())
201}
202
203/// Build a `CaseOutput` from a case file path, tracking which entity files
204/// have already been written back. This avoids re-reading entity files from
205/// disk when multiple cases reference the same shared entity.
206#[allow(clippy::implicit_hasher)]
207pub fn build_case_output_tracked(
208    path: &str,
209    reg: &registry::EntityRegistry,
210    written_entities: &mut HashSet<std::path::PathBuf>,
211    case_nulid_map: &std::collections::HashMap<String, (String, String)>,
212) -> Result<output::CaseOutput, i32> {
213    let content = match std::fs::read_to_string(path) {
214        Ok(c) => c,
215        Err(e) => {
216            eprintln!("{path}: error reading file: {e}");
217            return Err(2);
218        }
219    };
220
221    let (case, entities, rels) = match parse_full(&content, Some(reg)) {
222        Ok(result) => result,
223        Err(errors) => {
224            for err in &errors {
225                eprintln!("{path}:{err}");
226            }
227            return Err(1);
228        }
229    };
230
231    let referenced_entities = collect_referenced_registry_entities(&rels, &entities, reg);
232
233    // Resolve case NULID
234    let (case_nulid, case_nulid_generated) = match nulid_gen::resolve_id(case.id.as_deref(), 1) {
235        Ok(result) => result,
236        Err(err) => {
237            eprintln!("{path}:{err}");
238            return Err(1);
239        }
240    };
241    let case_nulid_str = case_nulid.to_string();
242
243    // Compute case slug from file path
244    let case_slug = reg
245        .content_root()
246        .and_then(|root| registry::path_to_slug(std::path::Path::new(path), root));
247
248    // Derive case_id from slug (filename-based) or fall back to empty string
249    let case_id = case_slug
250        .as_deref()
251        .and_then(|s| s.rsplit('/').next())
252        .unwrap_or_default();
253
254    let build_result = match output::build_output(
255        case_id,
256        &case_nulid_str,
257        &case.title,
258        &case.summary,
259        &case.tags,
260        case_slug.as_deref(),
261        case.case_type.as_deref(),
262        case.status.as_deref(),
263        case.amounts.as_deref(),
264        &case.sources,
265        &case.related_cases,
266        case_nulid_map,
267        &entities,
268        &rels,
269        &referenced_entities,
270    ) {
271        Ok(out) => out,
272        Err(errors) => {
273            for err in &errors {
274                eprintln!("{path}:{err}");
275            }
276            return Err(1);
277        }
278    };
279
280    let case_output = build_result.output;
281
282    // Write back generated IDs to source case file
283    let mut case_pending = build_result.case_pending;
284    if case_nulid_generated {
285        case_pending.push(writeback::PendingId {
286            line: writeback::find_front_matter_end(&content).unwrap_or(2),
287            id: case_nulid_str.clone(),
288            kind: writeback::WriteBackKind::CaseId,
289        });
290    }
291    if !case_pending.is_empty()
292        && let Some(modified) = writeback::apply_writebacks(&content, &mut case_pending)
293    {
294        if let Err(e) = writeback::write_file(std::path::Path::new(path), &modified) {
295            eprintln!("{e}");
296            return Err(2);
297        }
298        let count = case_pending.len();
299        eprintln!("{path}: wrote {count} generated ID(s) back to file");
300    }
301
302    // Write back generated IDs to entity files
303    if let Some(code) =
304        writeback_registry_entities(&build_result.registry_pending, reg, written_entities)
305    {
306        return Err(code);
307    }
308
309    eprintln!(
310        "{path}: built ({} nodes, {} relationships)",
311        case_output.nodes.len(),
312        case_output.relationships.len()
313    );
314    Ok(case_output)
315}
316
317/// Write back generated IDs to registry entity files.
318/// Tracks already-written paths in `written` to avoid redundant disk reads.
319/// Returns `Some(exit_code)` on error, `None` on success.
320fn writeback_registry_entities(
321    pending: &[(String, writeback::PendingId)],
322    reg: &registry::EntityRegistry,
323    written: &mut HashSet<std::path::PathBuf>,
324) -> Option<i32> {
325    for (entity_name, pending_id) in pending {
326        let Some(entry) = reg.get_by_name(entity_name) else {
327            continue;
328        };
329        let entity_path = &entry.path;
330
331        // Skip if this entity file was already written by a previous case.
332        if !written.insert(entity_path.clone()) {
333            continue;
334        }
335
336        // Also skip if the entity already has an ID in the registry
337        // (loaded from file at startup).
338        if entry.entity.id.is_some() {
339            continue;
340        }
341
342        let entity_content = match std::fs::read_to_string(entity_path) {
343            Ok(c) => c,
344            Err(e) => {
345                eprintln!("{}: error reading file: {e}", entity_path.display());
346                return Some(2);
347            }
348        };
349
350        let fm_end = writeback::find_front_matter_end(&entity_content);
351        let mut ids = vec![writeback::PendingId {
352            line: fm_end.unwrap_or(2),
353            id: pending_id.id.clone(),
354            kind: writeback::WriteBackKind::EntityFrontMatter,
355        }];
356        if let Some(modified) = writeback::apply_writebacks(&entity_content, &mut ids) {
357            if let Err(e) = writeback::write_file(entity_path, &modified) {
358                eprintln!("{e}");
359                return Some(2);
360            }
361            eprintln!("{}: wrote generated ID back to file", entity_path.display());
362        }
363    }
364    None
365}
366
367/// Check whether a file's YAML front matter already contains an `id:` field.
368#[cfg(test)]
369fn front_matter_has_id(content: &str) -> bool {
370    let mut in_front_matter = false;
371    for line in content.lines() {
372        let trimmed = line.trim();
373        if trimmed == "---" && !in_front_matter {
374            in_front_matter = true;
375        } else if trimmed == "---" && in_front_matter {
376            return false; // end of front matter, no id found
377        } else if in_front_matter && trimmed.starts_with("id:") {
378            return true;
379        }
380    }
381    false
382}
383
384/// Resolve the content root directory.
385///
386/// Priority: explicit `--root` flag > parent of given path > current directory.
387pub fn resolve_content_root(path: Option<&str>, root: Option<&str>) -> std::path::PathBuf {
388    if let Some(r) = root {
389        return std::path::PathBuf::from(r);
390    }
391    if let Some(p) = path {
392        let p = std::path::Path::new(p);
393        if p.is_file() {
394            if let Some(parent) = p.parent() {
395                for ancestor in parent.ancestors() {
396                    if ancestor.join("cases").is_dir()
397                        || ancestor.join("people").is_dir()
398                        || ancestor.join("organizations").is_dir()
399                    {
400                        return ancestor.to_path_buf();
401                    }
402                }
403                return parent.to_path_buf();
404            }
405        } else if p.is_dir() {
406            return p.to_path_buf();
407        }
408    }
409    std::path::PathBuf::from(".")
410}
411
412/// Load entity registry from content root. Returns empty registry if no entity dirs exist.
413pub fn load_registry(content_root: &std::path::Path) -> Result<registry::EntityRegistry, i32> {
414    match registry::EntityRegistry::load(content_root) {
415        Ok(reg) => Ok(reg),
416        Err(errors) => {
417            for err in &errors {
418                eprintln!("registry: {err}");
419            }
420            Err(1)
421        }
422    }
423}
424
425/// Load tag registry from content root. Returns empty registry if no tags.yaml exists.
426pub fn load_tag_registry(content_root: &std::path::Path) -> Result<tags::TagRegistry, i32> {
427    match tags::TagRegistry::load(content_root) {
428        Ok(reg) => Ok(reg),
429        Err(errors) => {
430            for err in &errors {
431                eprintln!("tags: {err}");
432            }
433            Err(1)
434        }
435    }
436}
437
438/// Resolve case file paths from path argument.
439/// If path is a file, returns just that file.
440/// If path is a directory (or None), auto-discovers `cases/**/*.md`.
441pub fn resolve_case_files(
442    path: Option<&str>,
443    content_root: &std::path::Path,
444) -> Result<Vec<String>, i32> {
445    if let Some(p) = path {
446        let p_path = std::path::Path::new(p);
447        if p_path.is_file() {
448            return Ok(vec![p.to_string()]);
449        }
450        if !p_path.is_dir() {
451            eprintln!("{p}: not a file or directory");
452            return Err(2);
453        }
454    }
455
456    let cases_dir = content_root.join("cases");
457    if !cases_dir.is_dir() {
458        return Ok(Vec::new());
459    }
460
461    let mut files = Vec::new();
462    discover_md_files(&cases_dir, &mut files, 0);
463    files.sort();
464    Ok(files)
465}
466
467/// Recursively discover .md files in a directory (max 5 levels deep for cases/country/category/year/).
468fn discover_md_files(dir: &std::path::Path, files: &mut Vec<String>, depth: usize) {
469    const MAX_DEPTH: usize = 5;
470    if depth > MAX_DEPTH {
471        return;
472    }
473
474    let Ok(entries) = std::fs::read_dir(dir) else {
475        return;
476    };
477
478    let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
479    entries.sort_by_key(std::fs::DirEntry::file_name);
480
481    for entry in entries {
482        let path = entry.path();
483        if path.is_dir() {
484            discover_md_files(&path, files, depth + 1);
485        } else if path.extension().and_then(|e| e.to_str()) == Some("md")
486            && let Some(s) = path.to_str()
487        {
488            files.push(s.to_string());
489        }
490    }
491}
492
493/// Extract 2-letter country code from a case slug like `cases/id/corruption/2024/...`.
494/// Returns `Some("id")` for `cases/id/...`, `None` if the slug doesn't match.
495fn extract_country_code(slug: &str) -> Option<String> {
496    let parts: Vec<&str> = slug.split('/').collect();
497    // slug format: "cases/{country}/..." — country is at index 1
498    if parts.len() >= 2 {
499        let candidate = parts[1];
500        if candidate.len() == 2 && candidate.chars().all(|c| c.is_ascii_lowercase()) {
501            return Some(candidate.to_string());
502        }
503    }
504    None
505}
506
507/// Generate static HTML files, sitemap, tag pages, and NULID index from built case outputs.
508///
509/// Writes output to `{output_dir}/html/`. Returns `0` on success, non-zero on error.
510///
511/// This is the high-level orchestrator that calls individual `html::render_*` functions
512/// and writes the results to disk.
513#[allow(clippy::too_many_lines)]
514pub fn generate_html_output(
515    output_dir: &str,
516    cases: &[CaseOutput],
517    base_url: &str,
518    thumbnail_base_url: Option<&str>,
519) -> i32 {
520    let html_dir = format!("{output_dir}/html");
521    let config = html::HtmlConfig {
522        thumbnail_base_url: thumbnail_base_url.map(String::from),
523    };
524
525    // Track entity appearances across cases for entity pages
526    let mut person_cases: HashMap<String, Vec<(String, String)>> = HashMap::new();
527    let mut org_cases: HashMap<String, Vec<(String, String)>> = HashMap::new();
528    let mut all_people: HashMap<String, &NodeOutput> = HashMap::new();
529    let mut all_orgs: HashMap<String, &NodeOutput> = HashMap::new();
530
531    // NULID → slug index for all content nodes
532    let mut nulid_index: BTreeMap<String, String> = BTreeMap::new();
533
534    // Generate case HTML and collect entity references
535    for case in cases {
536        let rel_path = case.slug.as_deref().unwrap_or(&case.case_id);
537        let path = format!("{html_dir}/{rel_path}.html");
538
539        if let Some(parent) = std::path::Path::new(&path).parent()
540            && let Err(e) = std::fs::create_dir_all(parent)
541        {
542            eprintln!("error creating directory {}: {e}", parent.display());
543            return 2;
544        }
545
546        match html::render_case(case, &config) {
547            Ok(fragment) => {
548                if let Err(e) = std::fs::write(&path, &fragment) {
549                    eprintln!("error writing {path}: {e}");
550                    return 2;
551                }
552                eprintln!("html: {path}");
553            }
554            Err(e) => {
555                eprintln!("error rendering case {}: {e}", case.case_id);
556                return 2;
557            }
558        }
559
560        if let Some(slug) = &case.slug {
561            nulid_index.insert(case.id.clone(), slug.clone());
562        }
563
564        let case_link_slug = case.slug.as_deref().unwrap_or(&case.case_id).to_string();
565
566        for node in &case.nodes {
567            match node.label.as_str() {
568                "person" => {
569                    person_cases
570                        .entry(node.id.clone())
571                        .or_default()
572                        .push((case_link_slug.clone(), case.title.clone()));
573                    all_people.entry(node.id.clone()).or_insert(node);
574                }
575                "organization" => {
576                    org_cases
577                        .entry(node.id.clone())
578                        .or_default()
579                        .push((case_link_slug.clone(), case.title.clone()));
580                    all_orgs.entry(node.id.clone()).or_insert(node);
581                }
582                _ => {}
583            }
584        }
585    }
586
587    // Generate person pages
588    for (id, node) in &all_people {
589        let case_list = person_cases.get(id).cloned().unwrap_or_default();
590        match html::render_person(node, &case_list, &config) {
591            Ok(fragment) => {
592                let rel_path = node.slug.as_deref().unwrap_or(id.as_str());
593                let path = format!("{html_dir}/{rel_path}.html");
594
595                if let Some(parent) = std::path::Path::new(&path).parent()
596                    && let Err(e) = std::fs::create_dir_all(parent)
597                {
598                    eprintln!("error creating directory {}: {e}", parent.display());
599                    return 2;
600                }
601
602                if let Err(e) = std::fs::write(&path, &fragment) {
603                    eprintln!("error writing {path}: {e}");
604                    return 2;
605                }
606            }
607            Err(e) => {
608                eprintln!("error rendering person {id}: {e}");
609                return 2;
610            }
611        }
612
613        if let Some(slug) = &node.slug {
614            nulid_index.insert(id.clone(), slug.clone());
615        }
616    }
617    eprintln!("html: {} person pages", all_people.len());
618
619    // Generate organization pages
620    for (id, node) in &all_orgs {
621        let case_list = org_cases.get(id).cloned().unwrap_or_default();
622        match html::render_organization(node, &case_list, &config) {
623            Ok(fragment) => {
624                let rel_path = node.slug.as_deref().unwrap_or(id.as_str());
625                let path = format!("{html_dir}/{rel_path}.html");
626
627                if let Some(parent) = std::path::Path::new(&path).parent()
628                    && let Err(e) = std::fs::create_dir_all(parent)
629                {
630                    eprintln!("error creating directory {}: {e}", parent.display());
631                    return 2;
632                }
633
634                if let Err(e) = std::fs::write(&path, &fragment) {
635                    eprintln!("error writing {path}: {e}");
636                    return 2;
637                }
638            }
639            Err(e) => {
640                eprintln!("error rendering organization {id}: {e}");
641                return 2;
642            }
643        }
644
645        if let Some(slug) = &node.slug {
646            nulid_index.insert(id.clone(), slug.clone());
647        }
648    }
649    eprintln!("html: {} organization pages", all_orgs.len());
650
651    // Generate sitemap
652    let case_entries: Vec<(String, String)> = cases
653        .iter()
654        .map(|c| {
655            let slug = c.slug.as_deref().unwrap_or(&c.case_id).to_string();
656            (slug, c.title.clone())
657        })
658        .collect();
659    let people_entries: Vec<(String, String)> = all_people
660        .iter()
661        .map(|(id, n)| {
662            let slug = n.slug.as_deref().unwrap_or(id.as_str()).to_string();
663            (slug, n.name.clone())
664        })
665        .collect();
666    let org_entries: Vec<(String, String)> = all_orgs
667        .iter()
668        .map(|(id, n)| {
669            let slug = n.slug.as_deref().unwrap_or(id.as_str()).to_string();
670            (slug, n.name.clone())
671        })
672        .collect();
673
674    let sitemap = html::render_sitemap(&case_entries, &people_entries, &org_entries, base_url);
675    let sitemap_path = format!("{html_dir}/sitemap.xml");
676    if let Err(e) = std::fs::write(&sitemap_path, &sitemap) {
677        eprintln!("error writing {sitemap_path}: {e}");
678        return 2;
679    }
680    eprintln!("html: {sitemap_path}");
681
682    // Generate tag pages (global + per-country)
683    let mut tag_cases: BTreeMap<String, Vec<html::TagCaseEntry>> = BTreeMap::new();
684    let mut country_tag_cases: BTreeMap<String, BTreeMap<String, Vec<html::TagCaseEntry>>> =
685        BTreeMap::new();
686
687    for case in cases {
688        let case_slug = case.slug.as_deref().unwrap_or(&case.case_id).to_string();
689        let country = extract_country_code(&case_slug);
690        let entry = html::TagCaseEntry {
691            slug: case_slug.clone(),
692            title: case.title.clone(),
693            amounts: case.amounts.clone(),
694        };
695        for tag in &case.tags {
696            tag_cases
697                .entry(tag.clone())
698                .or_default()
699                .push(html::TagCaseEntry {
700                    slug: case_slug.clone(),
701                    title: case.title.clone(),
702                    amounts: case.amounts.clone(),
703                });
704            if let Some(cc) = &country {
705                country_tag_cases
706                    .entry(cc.clone())
707                    .or_default()
708                    .entry(tag.clone())
709                    .or_default()
710                    .push(html::TagCaseEntry {
711                        slug: entry.slug.clone(),
712                        title: entry.title.clone(),
713                        amounts: entry.amounts.clone(),
714                    });
715            }
716        }
717    }
718
719    let mut tag_page_count = 0usize;
720    for (tag, entries) in &tag_cases {
721        match html::render_tag_page(tag, entries) {
722            Ok(fragment) => {
723                let path = format!("{html_dir}/tags/{tag}.html");
724                if let Some(parent) = std::path::Path::new(&path).parent()
725                    && let Err(e) = std::fs::create_dir_all(parent)
726                {
727                    eprintln!("error creating directory {}: {e}", parent.display());
728                    return 2;
729                }
730                if let Err(e) = std::fs::write(&path, &fragment) {
731                    eprintln!("error writing {path}: {e}");
732                    return 2;
733                }
734                tag_page_count += 1;
735            }
736            Err(e) => {
737                eprintln!("error rendering tag page {tag}: {e}");
738                return 2;
739            }
740        }
741    }
742
743    let mut country_tag_page_count = 0usize;
744    for (country, tags) in &country_tag_cases {
745        for (tag, entries) in tags {
746            match html::render_tag_page_scoped(tag, country, entries) {
747                Ok(fragment) => {
748                    let path = format!("{html_dir}/tags/{country}/{tag}.html");
749                    if let Some(parent) = std::path::Path::new(&path).parent()
750                        && let Err(e) = std::fs::create_dir_all(parent)
751                    {
752                        eprintln!("error creating directory {}: {e}", parent.display());
753                        return 2;
754                    }
755                    if let Err(e) = std::fs::write(&path, &fragment) {
756                        eprintln!("error writing {path}: {e}");
757                        return 2;
758                    }
759                    country_tag_page_count += 1;
760                }
761                Err(e) => {
762                    eprintln!("error rendering tag page {country}/{tag}: {e}");
763                    return 2;
764                }
765            }
766        }
767    }
768    eprintln!(
769        "html: {} tag pages ({} global, {} country-scoped)",
770        tag_page_count + country_tag_page_count,
771        tag_page_count,
772        country_tag_page_count
773    );
774
775    // Generate index.json (NULID → slug mapping)
776    let index_path = format!("{html_dir}/index.json");
777    match serde_json::to_string_pretty(&nulid_index) {
778        Ok(json) => {
779            if let Err(e) = std::fs::write(&index_path, &json) {
780                eprintln!("error writing {index_path}: {e}");
781                return 2;
782            }
783            eprintln!("html: {index_path} ({} entries)", nulid_index.len());
784        }
785        Err(e) => {
786            eprintln!("error serializing index.json: {e}");
787            return 2;
788        }
789    }
790
791    0
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    #[test]
799    fn front_matter_has_id_present() {
800        let content = "---\nid: 01JABC000000000000000000AA\n---\n\n# Test\n";
801        assert!(front_matter_has_id(content));
802    }
803
804    #[test]
805    fn front_matter_has_id_absent() {
806        let content = "---\n---\n\n# Test\n";
807        assert!(!front_matter_has_id(content));
808    }
809
810    #[test]
811    fn front_matter_has_id_with_other_fields() {
812        let content = "---\nother: value\nid: 01JABC000000000000000000AA\n---\n\n# Test\n";
813        assert!(front_matter_has_id(content));
814    }
815
816    #[test]
817    fn front_matter_has_id_no_front_matter() {
818        let content = "# Test\n\nNo front matter here.\n";
819        assert!(!front_matter_has_id(content));
820    }
821
822    #[test]
823    fn front_matter_has_id_outside_front_matter() {
824        // `id:` appearing in the body should not count
825        let content = "---\n---\n\n# Test\n\n- id: some-value\n";
826        assert!(!front_matter_has_id(content));
827    }
828}