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
28pub 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
55fn 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
72fn 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
88pub 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
102pub fn parse_full(
108 content: &str,
109 reg: Option<®istry::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(§ion.body, section.kind, section.line, &mut errors);
122 all_entities.extend(entities);
123 }
124 }
125
126 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 §ion.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(§ion.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
169pub fn collect_referenced_registry_entities(
172 rels: &[Rel],
173 inline_entities: &[Entity],
174 reg: ®istry::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
196pub fn build_case_output(
199 path: &str,
200 reg: ®istry::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#[allow(clippy::implicit_hasher)]
210pub fn build_case_output_tracked(
211 path: &str,
212 reg: ®istry::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 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 let case_slug = reg
248 .content_root()
249 .and_then(|root| registry::path_to_slug(std::path::Path::new(path), root));
250
251 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 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 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
321fn writeback_registry_entities(
325 pending: &[(String, writeback::PendingId)],
326 reg: ®istry::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 if !written.insert(entity_path.clone()) {
337 continue;
338 }
339
340 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#[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; } else if in_front_matter && trimmed.starts_with("id:") {
382 return true;
383 }
384 }
385 false
386}
387
388pub 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
416pub 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
429pub 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
442pub 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
471fn 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
497fn extract_country_code(slug: &str) -> Option<String> {
500 let parts: Vec<&str> = slug.split('/').collect();
501 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#[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 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 let mut nulid_index: BTreeMap<String, String> = BTreeMap::new();
537
538 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 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 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 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 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 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 let content = "---\n---\n\n# Test\n\n- id: some-value\n";
830 assert!(!front_matter_has_id(content));
831 }
832}