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