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