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