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