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