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