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