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(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
52fn 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
69fn 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
85pub 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
99pub fn parse_full(
105 content: &str,
106 reg: Option<®istry::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(§ion.body, section.kind, section.line, &mut errors);
119 all_entities.extend(entities);
120 }
121 }
122
123 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 §ion.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(§ion.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
166pub fn collect_referenced_registry_entities(
169 rels: &[Rel],
170 inline_entities: &[Entity],
171 reg: ®istry::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
193pub fn build_case_output(
196 path: &str,
197 reg: ®istry::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#[allow(clippy::implicit_hasher)]
207pub fn build_case_output_tracked(
208 path: &str,
209 reg: ®istry::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 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 let case_slug = reg
245 .content_root()
246 .and_then(|root| registry::path_to_slug(std::path::Path::new(path), root));
247
248 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 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 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
318fn writeback_registry_entities(
322 pending: &[(String, writeback::PendingId)],
323 reg: ®istry::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 if !written.insert(entity_path.clone()) {
334 continue;
335 }
336
337 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#[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; } else if in_front_matter && trimmed.starts_with("id:") {
379 return true;
380 }
381 }
382 false
383}
384
385pub 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
413pub 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
426pub 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
439pub 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
468fn 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
494fn extract_country_code(slug: &str) -> Option<String> {
497 let parts: Vec<&str> = slug.split('/').collect();
498 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#[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 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 let mut nulid_index: BTreeMap<String, String> = BTreeMap::new();
534
535 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 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 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 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 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 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 let content = "---\n---\n\n# Test\n\n- id: some-value\n";
827 assert!(!front_matter_has_id(content));
828 }
829}