#![deny(unsafe_code)]
#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::implicit_hasher)]
#![allow(clippy::struct_field_names)]
pub mod build_cache;
pub mod cache;
pub mod domain;
pub mod entity;
pub mod html;
pub mod nulid_gen;
pub mod output;
pub mod parser;
pub mod registry;
pub mod relationship;
pub mod tags;
pub mod timeline;
pub mod verifier;
pub mod writeback;
use std::collections::{BTreeMap, HashMap, HashSet};
use crate::entity::Entity;
use crate::output::{CaseOutput, NodeOutput};
use crate::parser::{ParseError, ParsedCase, SectionKind};
use crate::relationship::Rel;
pub fn build_case_index(
case_files: &[String],
content_root: &std::path::Path,
) -> Result<std::collections::HashMap<String, (String, String)>, i32> {
let mut map = std::collections::HashMap::new();
for path in case_files {
let content = std::fs::read_to_string(path).map_err(|e| {
eprintln!("{path}: {e}");
1
})?;
if let Some(case_path) = case_slug_from_path(std::path::Path::new(path), content_root) {
let id = extract_front_matter_id(&content).unwrap_or_else(|| {
nulid::Nulid::new()
.map(|n| n.to_string())
.unwrap_or_default()
});
let title = extract_title(&content).unwrap_or_else(|| case_path.clone());
map.insert(case_path, (id, title));
}
}
Ok(map)
}
fn extract_front_matter_id(content: &str) -> Option<String> {
let content = content.strip_prefix("---\n")?;
let end = content.find("\n---")?;
let fm = &content[..end];
for line in fm.lines() {
let trimmed = line.trim();
if let Some(id) = trimmed.strip_prefix("id:") {
let id = id.trim().trim_matches('"').trim_matches('\'');
if !id.is_empty() {
return Some(id.to_string());
}
}
}
None
}
fn extract_title(content: &str) -> Option<String> {
let content = content.strip_prefix("---\n")?;
let end = content.find("\n---")?;
let after_fm = &content[end + 4..];
for line in after_fm.lines() {
if let Some(title) = line.strip_prefix("# ") {
let title = title.trim();
if !title.is_empty() {
return Some(title.to_string());
}
}
}
None
}
pub fn case_slug_from_path(
path: &std::path::Path,
content_root: &std::path::Path,
) -> Option<String> {
let cases_dir = content_root.join("cases");
let rel = path.strip_prefix(&cases_dir).ok()?;
let s = rel.to_str()?;
Some(s.strip_suffix(".md").unwrap_or(s).to_string())
}
pub fn parse_full(
content: &str,
reg: Option<®istry::EntityRegistry>,
) -> Result<(ParsedCase, Vec<Entity>, Vec<Rel>), Vec<ParseError>> {
let case = parser::parse(content)?;
let mut errors = Vec::new();
let mut all_entities = Vec::new();
for section in &case.sections {
if matches!(
section.kind,
SectionKind::Events | SectionKind::Documents | SectionKind::Assets
) {
let entities =
entity::parse_entities(§ion.body, section.kind, section.line, &mut errors);
all_entities.extend(entities);
}
}
let mut entity_names: HashSet<&str> = all_entities.iter().map(|e| e.name.as_str()).collect();
if let Some(registry) = reg {
for name in registry.names() {
entity_names.insert(name);
}
}
let event_names: HashSet<&str> = all_entities
.iter()
.filter(|e| e.label == entity::Label::Event)
.map(|e| e.name.as_str())
.collect();
let mut all_rels = Vec::new();
for section in &case.sections {
if section.kind == SectionKind::Relationships {
let rels = relationship::parse_relationships(
§ion.body,
section.line,
&entity_names,
&case.sources,
&mut errors,
);
all_rels.extend(rels);
}
}
for section in &case.sections {
if section.kind == SectionKind::Timeline {
let rels =
timeline::parse_timeline(§ion.body, section.line, &event_names, &mut errors);
all_rels.extend(rels);
}
}
if errors.is_empty() {
Ok((case, all_entities, all_rels))
} else {
Err(errors)
}
}
pub fn collect_referenced_registry_entities(
rels: &[Rel],
inline_entities: &[Entity],
reg: ®istry::EntityRegistry,
) -> Vec<Entity> {
let inline_names: HashSet<&str> = inline_entities.iter().map(|e| e.name.as_str()).collect();
let mut referenced = Vec::new();
let mut seen_names: HashSet<String> = HashSet::new();
for rel in rels {
for name in [&rel.source_name, &rel.target_name] {
if !inline_names.contains(name.as_str())
&& seen_names.insert(name.clone())
&& let Some(entry) = reg.get_by_name(name)
{
let mut entity = entry.entity.clone();
entity.slug = reg.slug_for(entry);
referenced.push(entity);
}
}
}
referenced
}
pub fn build_case_output(
path: &str,
reg: ®istry::EntityRegistry,
) -> Result<output::CaseOutput, i32> {
let mut written = HashSet::new();
build_case_output_tracked(path, reg, &mut written, &std::collections::HashMap::new())
}
pub fn build_case_output_tracked(
path: &str,
reg: ®istry::EntityRegistry,
written_entities: &mut HashSet<std::path::PathBuf>,
case_nulid_map: &std::collections::HashMap<String, (String, String)>,
) -> Result<output::CaseOutput, i32> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
eprintln!("{path}: error reading file: {e}");
return Err(2);
}
};
let (case, entities, rels) = match parse_full(&content, Some(reg)) {
Ok(result) => result,
Err(errors) => {
for err in &errors {
eprintln!("{path}:{err}");
}
return Err(1);
}
};
let referenced_entities = collect_referenced_registry_entities(&rels, &entities, reg);
let (case_nulid, case_nulid_generated) = match nulid_gen::resolve_id(case.id.as_deref(), 1) {
Ok(result) => result,
Err(err) => {
eprintln!("{path}:{err}");
return Err(1);
}
};
let case_nulid_str = case_nulid.to_string();
let case_slug = reg
.content_root()
.and_then(|root| registry::path_to_slug(std::path::Path::new(path), root));
let case_id = case_slug
.as_deref()
.and_then(|s| s.rsplit('/').next())
.unwrap_or_default();
let build_result = match output::build_output(
case_id,
&case_nulid_str,
&case.title,
&case.summary,
&case.tags,
case_slug.as_deref(),
case.case_type.as_deref(),
case.status.as_deref(),
case.amounts.as_deref(),
&case.sources,
&case.related_cases,
case_nulid_map,
&entities,
&rels,
&referenced_entities,
&case.involved,
) {
Ok(out) => out,
Err(errors) => {
for err in &errors {
eprintln!("{path}:{err}");
}
return Err(1);
}
};
let case_output = build_result.output;
let mut case_pending = build_result.case_pending;
if case_nulid_generated {
case_pending.push(writeback::PendingId {
line: writeback::find_front_matter_end(&content).unwrap_or(2),
id: case_nulid_str.clone(),
kind: writeback::WriteBackKind::CaseId,
});
}
if !case_pending.is_empty()
&& let Some(modified) = writeback::apply_writebacks(&content, &mut case_pending)
{
if let Err(e) = writeback::write_file(std::path::Path::new(path), &modified) {
eprintln!("{e}");
return Err(2);
}
let count = case_pending.len();
eprintln!("{path}: wrote {count} generated ID(s) back to file");
}
if let Some(code) =
writeback_registry_entities(&build_result.registry_pending, reg, written_entities)
{
return Err(code);
}
eprintln!(
"{path}: built ({} nodes, {} relationships)",
case_output.nodes.len(),
case_output.relationships.len()
);
Ok(case_output)
}
fn writeback_registry_entities(
pending: &[(String, writeback::PendingId)],
reg: ®istry::EntityRegistry,
written: &mut HashSet<std::path::PathBuf>,
) -> Option<i32> {
for (entity_name, pending_id) in pending {
let Some(entry) = reg.get_by_name(entity_name) else {
continue;
};
let entity_path = &entry.path;
if !written.insert(entity_path.clone()) {
continue;
}
if entry.entity.id.is_some() {
continue;
}
let entity_content = match std::fs::read_to_string(entity_path) {
Ok(c) => c,
Err(e) => {
eprintln!("{}: error reading file: {e}", entity_path.display());
return Some(2);
}
};
let fm_end = writeback::find_front_matter_end(&entity_content);
let mut ids = vec![writeback::PendingId {
line: fm_end.unwrap_or(2),
id: pending_id.id.clone(),
kind: writeback::WriteBackKind::EntityFrontMatter,
}];
if let Some(modified) = writeback::apply_writebacks(&entity_content, &mut ids) {
if let Err(e) = writeback::write_file(entity_path, &modified) {
eprintln!("{e}");
return Some(2);
}
eprintln!("{}: wrote generated ID back to file", entity_path.display());
}
}
None
}
#[cfg(test)]
fn front_matter_has_id(content: &str) -> bool {
let mut in_front_matter = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "---" && !in_front_matter {
in_front_matter = true;
} else if trimmed == "---" && in_front_matter {
return false; } else if in_front_matter && trimmed.starts_with("id:") {
return true;
}
}
false
}
pub fn resolve_content_root(path: Option<&str>, root: Option<&str>) -> std::path::PathBuf {
if let Some(r) = root {
return std::path::PathBuf::from(r);
}
if let Some(p) = path {
let p = std::path::Path::new(p);
if p.is_file() {
if let Some(parent) = p.parent() {
for ancestor in parent.ancestors() {
if ancestor.join("cases").is_dir()
|| ancestor.join("people").is_dir()
|| ancestor.join("organizations").is_dir()
{
return ancestor.to_path_buf();
}
}
return parent.to_path_buf();
}
} else if p.is_dir() {
return p.to_path_buf();
}
}
std::path::PathBuf::from(".")
}
pub fn load_registry(content_root: &std::path::Path) -> Result<registry::EntityRegistry, i32> {
match registry::EntityRegistry::load(content_root) {
Ok(reg) => Ok(reg),
Err(errors) => {
for err in &errors {
eprintln!("registry: {err}");
}
Err(1)
}
}
}
pub fn load_tag_registry(content_root: &std::path::Path) -> Result<tags::TagRegistry, i32> {
match tags::TagRegistry::load(content_root) {
Ok(reg) => Ok(reg),
Err(errors) => {
for err in &errors {
eprintln!("tags: {err}");
}
Err(1)
}
}
}
pub fn resolve_case_files(
path: Option<&str>,
content_root: &std::path::Path,
) -> Result<Vec<String>, i32> {
if let Some(p) = path {
let p_path = std::path::Path::new(p);
if p_path.is_file() {
return Ok(vec![p.to_string()]);
}
if !p_path.is_dir() {
eprintln!("{p}: not a file or directory");
return Err(2);
}
}
let cases_dir = content_root.join("cases");
if !cases_dir.is_dir() {
return Ok(Vec::new());
}
let mut files = Vec::new();
discover_md_files(&cases_dir, &mut files, 0);
files.sort();
Ok(files)
}
fn discover_md_files(dir: &std::path::Path, files: &mut Vec<String>, depth: usize) {
const MAX_DEPTH: usize = 5;
if depth > MAX_DEPTH {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
entries.sort_by_key(std::fs::DirEntry::file_name);
for entry in entries {
let path = entry.path();
if path.is_dir() {
discover_md_files(&path, files, depth + 1);
} else if path.extension().and_then(|e| e.to_str()) == Some("md")
&& let Some(s) = path.to_str()
{
files.push(s.to_string());
}
}
}
fn extract_country_code(slug: &str) -> Option<String> {
let parts: Vec<&str> = slug.split('/').collect();
if parts.len() >= 2 {
let candidate = parts[1];
if candidate.len() == 2 && candidate.chars().all(|c| c.is_ascii_lowercase()) {
return Some(candidate.to_string());
}
}
None
}
pub fn generate_html_output(
output_dir: &str,
cases: &[CaseOutput],
base_url: &str,
thumbnail_base_url: Option<&str>,
) -> i32 {
let html_dir = format!("{output_dir}/html");
let config = html::HtmlConfig {
thumbnail_base_url: thumbnail_base_url.map(String::from),
};
let mut nulid_index: BTreeMap<String, String> = BTreeMap::new();
let (all_people, all_orgs, person_cases, org_cases) =
match generate_case_pages(&html_dir, cases, &config, &mut nulid_index) {
Ok(collections) => collections,
Err(code) => return code,
};
if let Err(code) = generate_entity_pages(
&html_dir,
&all_people,
&person_cases,
&config,
&mut nulid_index,
"person",
|node, case_list, cfg| html::render_person(node, case_list, cfg),
) {
return code;
}
eprintln!("html: {} person pages", all_people.len());
if let Err(code) = generate_entity_pages(
&html_dir,
&all_orgs,
&org_cases,
&config,
&mut nulid_index,
"organization",
|node, case_list, cfg| html::render_organization(node, case_list, cfg),
) {
return code;
}
eprintln!("html: {} organization pages", all_orgs.len());
if let Err(code) = generate_sitemap(&html_dir, cases, &all_people, &all_orgs, base_url) {
return code;
}
if let Err(code) = generate_tag_pages(&html_dir, cases) {
return code;
}
if let Err(code) = write_nulid_index(&html_dir, &nulid_index) {
return code;
}
0
}
fn write_html_file(path: &str, fragment: &str) -> Result<(), i32> {
if let Some(parent) = std::path::Path::new(path).parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
eprintln!("error creating directory {}: {e}", parent.display());
return Err(2);
}
if let Err(e) = std::fs::write(path, fragment) {
eprintln!("error writing {path}: {e}");
return Err(2);
}
Ok(())
}
#[allow(clippy::type_complexity)]
fn generate_case_pages<'a>(
html_dir: &str,
cases: &'a [CaseOutput],
config: &html::HtmlConfig,
nulid_index: &mut BTreeMap<String, String>,
) -> Result<
(
HashMap<String, &'a NodeOutput>,
HashMap<String, &'a NodeOutput>,
HashMap<String, Vec<(String, String)>>,
HashMap<String, Vec<(String, String)>>,
),
i32,
> {
let mut person_cases: HashMap<String, Vec<(String, String)>> = HashMap::new();
let mut org_cases: HashMap<String, Vec<(String, String)>> = HashMap::new();
let mut all_people: HashMap<String, &NodeOutput> = HashMap::new();
let mut all_orgs: HashMap<String, &NodeOutput> = HashMap::new();
for case in cases {
let rel_path = case.slug.as_deref().unwrap_or(&case.case_id);
let path = format!("{html_dir}/{rel_path}.html");
match html::render_case(case, config) {
Ok(fragment) => {
write_html_file(&path, &fragment)?;
eprintln!("html: {path}");
}
Err(e) => {
eprintln!("error rendering case {}: {e}", case.case_id);
return Err(2);
}
}
if let Some(slug) = &case.slug {
nulid_index.insert(case.id.clone(), slug.clone());
}
let case_link_slug = case.slug.as_deref().unwrap_or(&case.case_id).to_string();
for node in &case.nodes {
match node.label.as_str() {
"person" => {
person_cases
.entry(node.id.clone())
.or_default()
.push((case_link_slug.clone(), case.title.clone()));
all_people.entry(node.id.clone()).or_insert(node);
}
"organization" => {
org_cases
.entry(node.id.clone())
.or_default()
.push((case_link_slug.clone(), case.title.clone()));
all_orgs.entry(node.id.clone()).or_insert(node);
}
_ => {}
}
}
}
Ok((all_people, all_orgs, person_cases, org_cases))
}
fn generate_entity_pages<F>(
html_dir: &str,
entities: &HashMap<String, &NodeOutput>,
entity_cases: &HashMap<String, Vec<(String, String)>>,
config: &html::HtmlConfig,
nulid_index: &mut BTreeMap<String, String>,
label: &str,
render_fn: F,
) -> Result<(), i32>
where
F: Fn(&NodeOutput, &[(String, String)], &html::HtmlConfig) -> Result<String, String>,
{
for (id, node) in entities {
let case_list = entity_cases.get(id).cloned().unwrap_or_default();
match render_fn(node, &case_list, config) {
Ok(fragment) => {
let rel_path = node.slug.as_deref().unwrap_or(id.as_str());
let path = format!("{html_dir}/{rel_path}.html");
write_html_file(&path, &fragment)?;
}
Err(e) => {
eprintln!("error rendering {label} {id}: {e}");
return Err(2);
}
}
if let Some(slug) = &node.slug {
nulid_index.insert(id.clone(), slug.clone());
}
}
Ok(())
}
fn generate_sitemap(
html_dir: &str,
cases: &[CaseOutput],
all_people: &HashMap<String, &NodeOutput>,
all_orgs: &HashMap<String, &NodeOutput>,
base_url: &str,
) -> Result<(), i32> {
let case_entries: Vec<(String, String)> = cases
.iter()
.map(|c| {
let slug = c.slug.as_deref().unwrap_or(&c.case_id).to_string();
(slug, c.title.clone())
})
.collect();
let people_entries: Vec<(String, String)> = all_people
.iter()
.map(|(id, n)| {
let slug = n.slug.as_deref().unwrap_or(id.as_str()).to_string();
(slug, n.name.clone())
})
.collect();
let org_entries: Vec<(String, String)> = all_orgs
.iter()
.map(|(id, n)| {
let slug = n.slug.as_deref().unwrap_or(id.as_str()).to_string();
(slug, n.name.clone())
})
.collect();
let sitemap = html::render_sitemap(&case_entries, &people_entries, &org_entries, base_url);
let sitemap_path = format!("{html_dir}/sitemap.xml");
if let Err(e) = std::fs::write(&sitemap_path, &sitemap) {
eprintln!("error writing {sitemap_path}: {e}");
return Err(2);
}
eprintln!("html: {sitemap_path}");
Ok(())
}
fn generate_tag_pages(html_dir: &str, cases: &[CaseOutput]) -> Result<(), i32> {
let mut tag_cases: BTreeMap<String, Vec<html::TagCaseEntry>> = BTreeMap::new();
let mut country_tag_cases: BTreeMap<String, BTreeMap<String, Vec<html::TagCaseEntry>>> =
BTreeMap::new();
for case in cases {
let case_slug = case.slug.as_deref().unwrap_or(&case.case_id).to_string();
let country = extract_country_code(&case_slug);
let entry = html::TagCaseEntry {
slug: case_slug.clone(),
title: case.title.clone(),
amounts: case.amounts.clone(),
};
for tag in &case.tags {
tag_cases.entry(tag.clone()).or_default().push(html::TagCaseEntry {
slug: case_slug.clone(),
title: case.title.clone(),
amounts: case.amounts.clone(),
});
if let Some(cc) = &country {
country_tag_cases
.entry(cc.clone())
.or_default()
.entry(tag.clone())
.or_default()
.push(html::TagCaseEntry {
slug: entry.slug.clone(),
title: entry.title.clone(),
amounts: entry.amounts.clone(),
});
}
}
}
let mut tag_page_count = 0usize;
for (tag, entries) in &tag_cases {
let fragment = html::render_tag_page(tag, entries).map_err(|e| {
eprintln!("error rendering tag page {tag}: {e}");
2
})?;
let path = format!("{html_dir}/tags/{tag}.html");
write_html_file(&path, &fragment)?;
tag_page_count += 1;
}
let mut country_tag_page_count = 0usize;
for (country, tags) in &country_tag_cases {
for (tag, entries) in tags {
let fragment = html::render_tag_page_scoped(tag, country, entries).map_err(|e| {
eprintln!("error rendering tag page {country}/{tag}: {e}");
2
})?;
let path = format!("{html_dir}/tags/{country}/{tag}.html");
write_html_file(&path, &fragment)?;
country_tag_page_count += 1;
}
}
eprintln!(
"html: {} tag pages ({} global, {} country-scoped)",
tag_page_count + country_tag_page_count,
tag_page_count,
country_tag_page_count
);
Ok(())
}
fn write_nulid_index(html_dir: &str, nulid_index: &BTreeMap<String, String>) -> Result<(), i32> {
let index_path = format!("{html_dir}/index.json");
let json = serde_json::to_string_pretty(nulid_index).map_err(|e| {
eprintln!("error serializing index.json: {e}");
2
})?;
if let Err(e) = std::fs::write(&index_path, &json) {
eprintln!("error writing {index_path}: {e}");
return Err(2);
}
eprintln!("html: {index_path} ({} entries)", nulid_index.len());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn front_matter_has_id_present() {
let content = "---\nid: 01JABC000000000000000000AA\n---\n\n# Test\n";
assert!(front_matter_has_id(content));
}
#[test]
fn front_matter_has_id_absent() {
let content = "---\n---\n\n# Test\n";
assert!(!front_matter_has_id(content));
}
#[test]
fn front_matter_has_id_with_other_fields() {
let content = "---\nother: value\nid: 01JABC000000000000000000AA\n---\n\n# Test\n";
assert!(front_matter_has_id(content));
}
#[test]
fn front_matter_has_id_no_front_matter() {
let content = "# Test\n\nNo front matter here.\n";
assert!(!front_matter_has_id(content));
}
#[test]
fn front_matter_has_id_outside_front_matter() {
let content = "---\n---\n\n# Test\n\n- id: some-value\n";
assert!(!front_matter_has_id(content));
}
}