Skip to main content

weave_content/
lib.rs

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