1use std::collections::HashSet;
11
12use crate::heading::anchor::obsidian_heading_anchor;
13
14use super::{Diagnostic, DiagnosticKind};
15
16pub const MAX_EMBED_DEPTH: usize = 10;
24
25const EMBED_PREFIX: &str = "<!-- moss-embed:";
27const EMBED_UNRESOLVED_PREFIX: &str = "<!-- moss-embed-unresolved:";
29const EMBED_SUFFIX: &str = " -->";
31
32#[derive(Debug)]
34pub struct EmbedResult {
35 pub content: String,
37 pub diagnostics: Vec<Diagnostic>,
39 pub embed_deps: Vec<(String, String)>,
41}
42
43pub fn resolve_embeds(
49 content: &str,
50 from_path: &str,
51 file_reader: &dyn Fn(&str) -> Option<String>,
52) -> EmbedResult {
53 let mut visited = HashSet::new();
54 resolve_embeds_inner(content, from_path, file_reader, &mut visited, 0)
55}
56
57pub fn resolve_embeds_with_visited(
64 content: &str,
65 from_path: &str,
66 file_reader: &dyn Fn(&str) -> Option<String>,
67 visited: &mut HashSet<String>,
68) -> EmbedResult {
69 resolve_embeds_inner(content, from_path, file_reader, visited, 0)
70}
71
72fn resolve_embeds_inner(
74 content: &str,
75 from_path: &str,
76 file_reader: &dyn Fn(&str) -> Option<String>,
77 visited: &mut HashSet<String>,
78 depth: usize,
79) -> EmbedResult {
80 let mut diagnostics: Vec<Diagnostic> = Vec::new();
81 let mut embed_deps: Vec<(String, String)> = Vec::new();
82 let mut output = String::with_capacity(content.len());
83
84 for line in content.lines() {
85 let trimmed = line.trim();
86
87 if trimmed.starts_with(EMBED_UNRESOLVED_PREFIX) {
89 output.push_str(line);
90 output.push('\n');
91 continue;
92 }
93
94 if let Some(target) = parse_embed_marker(trimmed) {
96 let (file_path, heading_anchor) = split_target(target);
97
98 embed_deps.push((file_path.to_string(), from_path.to_string()));
100
101 if depth >= MAX_EMBED_DEPTH {
103 diagnostics.push(Diagnostic {
104 message: format!(
105 "Embed depth limit ({MAX_EMBED_DEPTH}) exceeded for '{file_path}'"
106 ),
107 source_path: from_path.to_string(),
108 reference: target.to_string(),
109 kind: DiagnosticKind::Other,
110 });
111 output.push_str(line);
112 output.push('\n');
113 continue;
114 }
115
116 if visited.contains(file_path) {
118 diagnostics.push(Diagnostic {
119 message: format!("Circular embed detected: '{file_path}'"),
120 source_path: from_path.to_string(),
121 reference: target.to_string(),
122 kind: DiagnosticKind::Other,
123 });
124 output.push_str(line);
125 output.push('\n');
126 continue;
127 }
128
129 match file_reader(file_path) {
131 None => {
132 diagnostics.push(Diagnostic {
133 message: format!("Embed target not found: '{file_path}'"),
134 source_path: from_path.to_string(),
135 reference: target.to_string(),
136 kind: DiagnosticKind::Other,
137 });
138 output.push_str(line);
139 output.push('\n');
140 }
141 Some(file_content) => {
142 let body = strip_frontmatter(&file_content);
143
144 let section = if let Some(anchor) = heading_anchor {
145 if let Some(block_id) = anchor.strip_prefix('^') {
146 match extract_block_section(body, block_id) {
148 Some(section) => section,
149 None => {
150 diagnostics.push(Diagnostic {
151 message: format!(
152 "Block reference '^{block_id}' not found in '{file_path}'"
153 ),
154 source_path: from_path.to_string(),
155 reference: target.to_string(),
156 kind: DiagnosticKind::Other,
157 });
158 body.to_string()
159 }
160 }
161 } else {
162 match extract_heading_section(body, anchor) {
164 Some(section) => section,
165 None => {
166 diagnostics.push(Diagnostic {
167 message: format!(
168 "Heading '#{anchor}' not found in '{file_path}'"
169 ),
170 source_path: from_path.to_string(),
171 reference: target.to_string(),
172 kind: DiagnosticKind::Other,
173 });
174 body.to_string()
175 }
176 }
177 }
178 } else {
179 body.to_string()
180 };
181
182 visited.insert(file_path.to_string());
184 let nested =
185 resolve_embeds_inner(§ion, file_path, file_reader, visited, depth + 1);
186 visited.remove(file_path);
187
188 diagnostics.extend(nested.diagnostics);
189 embed_deps.extend(nested.embed_deps);
190
191 output.push_str(&nested.content);
194 if !nested.content.ends_with('\n') {
195 output.push('\n');
196 }
197 }
198 }
199 } else {
200 output.push_str(line);
201 output.push('\n');
202 }
203 }
204
205 if !content.ends_with('\n') && output.ends_with('\n') {
208 output.pop();
209 }
210
211 EmbedResult {
212 content: output,
213 diagnostics,
214 embed_deps,
215 }
216}
217
218fn parse_embed_marker(line: &str) -> Option<&str> {
223 let rest = line.strip_prefix(EMBED_PREFIX)?;
224 let target = rest.strip_suffix(EMBED_SUFFIX)?;
225 if target.is_empty() {
226 return None;
227 }
228 Some(target)
229}
230
231fn split_target(target: &str) -> (&str, Option<&str>) {
236 match target.split_once('#') {
237 Some((file_path, anchor)) => {
238 if anchor.is_empty() {
239 (file_path, None)
240 } else {
241 (file_path, Some(anchor))
242 }
243 }
244 None => (target, None),
245 }
246}
247
248fn strip_frontmatter(content: &str) -> &str {
254 if !content.starts_with("---") {
256 return content;
257 }
258
259 let (_opening_line, after_opening) = match content.split_once('\n') {
262 Some(pair) => pair,
263 None => return content, };
265
266 if let Some(close_pos) = find_closing_frontmatter(after_opening) {
268 #[allow(clippy::string_slice)]
271 let after_close = &after_opening[close_pos..];
274 match after_close.split_once('\n') {
276 Some((_closing_line, rest)) => rest,
277 None => "", }
279 } else {
280 content
282 }
283}
284
285fn find_closing_frontmatter(s: &str) -> Option<usize> {
287 let mut offset = 0;
288 for line in s.lines() {
289 if line.trim() == "---" {
290 return Some(offset);
291 }
292 offset += line.len() + 1; }
294 None
295}
296
297fn extract_heading_section(body: &str, target_anchor: &str) -> Option<String> {
302 let lines: Vec<&str> = body.lines().collect();
303 let mut start_idx = None;
304 let mut heading_level = 0;
305
306 let target = obsidian_heading_anchor(target_anchor);
312
313 for (i, line) in lines.iter().enumerate() {
315 if let Some((level, text)) = parse_heading(line) {
316 let anchor = obsidian_heading_anchor(text);
317 if anchor == target {
318 start_idx = Some(i);
319 heading_level = level;
320 break;
321 }
322 }
323 }
324
325 let start = start_idx?;
326
327 let mut end_idx = lines.len();
329 for i in (start + 1)..lines.len() {
330 if let Some((level, _)) = parse_heading(lines[i]) {
331 if level <= heading_level {
332 end_idx = i;
333 break;
334 }
335 }
336 }
337
338 let section = lines[start..end_idx].join("\n");
339 Some(section)
340}
341
342fn extract_block_section(body: &str, block_id: &str) -> Option<String> {
350 let marker = format!(" ^{}", block_id);
351 for line in body.lines() {
352 let trimmed = line.trim();
353 if let Some(content) = trimmed.strip_suffix(marker.as_str()) {
354 let content = content.trim_end();
355 if !content.is_empty() {
356 return Some(content.to_string());
357 }
358 }
359 }
360 None
361}
362
363fn parse_heading(line: &str) -> Option<(usize, &str)> {
367 let trimmed = line.trim_start();
368 if !trimmed.starts_with('#') {
369 return None;
370 }
371
372 let level = trimmed.chars().take_while(|&c| c == '#').count();
373 if level == 0 || level > 6 {
374 return None;
375 }
376
377 #[allow(clippy::string_slice)]
380 let rest = &trimmed[level..];
382 if rest.is_empty() {
383 return Some((level, ""));
384 }
385 let Some(after_space) = rest.strip_prefix(' ') else {
386 return None;
387 };
388
389 Some((level, after_space.trim()))
390}
391
392pub type MarkerHandler<'a> = Box<dyn Fn(&str, &mut Vec<Diagnostic>) -> String + Send + Sync + 'a>;
404
405pub struct MarkerHandlers<'a> {
417 handlers: Vec<(String, MarkerHandler<'a>)>,
418}
419
420impl<'a> MarkerHandlers<'a> {
421 pub fn new() -> Self {
422 Self {
423 handlers: Vec::new(),
424 }
425 }
426
427 pub fn register(&mut self, prefix: impl Into<String>, handler: MarkerHandler<'a>) {
432 self.handlers.push((prefix.into(), handler));
433 }
434
435 fn find<'b>(
438 &'b self,
439 marker_body: &'b str,
440 ) -> Option<(&'b str, &'b MarkerHandler<'a>, &'b str)> {
441 self.handlers.iter().find_map(|(p, h)| {
442 let needle = format!("{}:", p);
443 marker_body
444 .strip_prefix(needle.as_str())
445 .map(|tail| (p.as_str(), h, tail))
446 })
447 }
448
449 pub fn is_empty(&self) -> bool {
450 self.handlers.is_empty()
451 }
452}
453
454impl<'a> Default for MarkerHandlers<'a> {
455 fn default() -> Self {
456 Self::new()
457 }
458}
459
460pub fn resolve_deferred_markers(content: &str, handlers: &MarkerHandlers<'_>) -> DeferredResult {
468 let mut diagnostics: Vec<Diagnostic> = Vec::new();
469
470 if handlers.is_empty() {
471 return DeferredResult {
472 content: content.to_string(),
473 diagnostics,
474 };
475 }
476
477 let mut out = String::with_capacity(content.len());
478 let mut remaining = content;
479
480 loop {
481 let Some((before, after_start)) = remaining.split_once("<!-- ") else {
483 out.push_str(remaining);
484 break;
485 };
486 out.push_str(before);
488
489 let Some((marker_body, rest)) = after_start.split_once(" -->") else {
491 out.push_str("<!-- ");
493 out.push_str(after_start);
494 break;
495 };
496
497 match handlers.find(marker_body) {
498 Some((_prefix, handler, target)) => {
499 let resolved = handler(target, &mut diagnostics);
500 out.push_str(&resolved);
501 }
502 None => {
503 out.push_str("<!-- ");
505 out.push_str(marker_body);
506 out.push_str(" -->");
507 }
508 }
509 remaining = rest;
510 }
511
512 DeferredResult {
513 content: out,
514 diagnostics,
515 }
516}
517
518#[derive(Debug)]
520pub struct DeferredResult {
521 pub content: String,
522 pub diagnostics: Vec<Diagnostic>,
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use std::collections::HashMap;
529
530 fn mock_reader(files: &HashMap<String, String>) -> impl Fn(&str) -> Option<String> + '_ {
531 move |path: &str| files.get(path).cloned()
532 }
533
534 #[test]
535 fn test_basic_embed() {
536 let mut files = HashMap::new();
537 files.insert(
538 "note.md".to_string(),
539 "---\ntitle: Note\n---\nHello from note.".to_string(),
540 );
541
542 let content = "Before.\n<!-- moss-embed:note.md -->\nAfter.";
543 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
544
545 assert_eq!(result.content, "Before.\nHello from note.\nAfter.");
546 assert!(result.diagnostics.is_empty());
547 }
548
549 #[test]
550 fn test_heading_scoped_embed() {
551 let mut files = HashMap::new();
552 files.insert(
553 "guide.md".to_string(),
554 "---\ntitle: Guide\n---\n# Intro\nIntro text.\n## Getting Started\nStart here.\n## Advanced\nAdvanced stuff."
555 .to_string(),
556 );
557
558 let content = "<!-- moss-embed:guide.md#getting-started -->";
559 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
560
561 assert!(result.content.contains("## Getting Started"));
562 assert!(result.content.contains("Start here."));
563 assert!(!result.content.contains("Advanced stuff."));
564 assert!(!result.content.contains("Intro text."));
565 assert!(result.diagnostics.is_empty());
566 }
567
568 #[test]
569 fn test_heading_scoped_embed_raw_anchor() {
570 let mut files = HashMap::new();
578 files.insert(
579 "slots.md".to_string(),
580 "---\ntitle: Slots\n---\n## Template slots\nSlots are injection points. ^def-template-slots\n## How slots work\nOther content.".to_string(),
581 );
582
583 let content = "<!-- moss-embed:slots.md#Template slots -->";
584 let result = resolve_embeds(content, "hooks.md", &mock_reader(&files));
585
586 assert!(
588 result.diagnostics.is_empty(),
589 "raw anchor should resolve cleanly, got diagnostics: {:?}",
590 result.diagnostics
591 );
592 assert!(
594 result.content.contains("Slots are injection points."),
595 "target section content missing: {}",
596 result.content
597 );
598 assert!(
599 !result.content.contains("Other content."),
600 "whole-file leak: sibling section inlined:\n{}",
601 result.content
602 );
603 }
604
605 #[test]
606 fn test_extract_heading_section_raw_anchor() {
607 let body = "## Template Slots\nBody.\n## Next\nNope.";
610 let section =
611 extract_heading_section(body, "Template Slots").expect("raw anchor should match");
612 assert!(section.contains("Body."));
613 assert!(!section.contains("Nope."));
614 }
615
616 #[test]
617 fn test_heading_not_found() {
618 let mut files = HashMap::new();
619 files.insert(
620 "guide.md".to_string(),
621 "---\ntitle: Guide\n---\n# Intro\nIntro text.".to_string(),
622 );
623
624 let content = "<!-- moss-embed:guide.md#nonexistent -->";
625 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
626
627 assert!(result.content.contains("Intro text."));
629 assert_eq!(result.diagnostics.len(), 1);
630 assert!(result.diagnostics[0].message.contains("not found"));
631 }
632
633 #[test]
634 fn test_circular_embed_detection() {
635 let mut files = HashMap::new();
636 files.insert(
637 "a.md".to_string(),
638 "A content.\n<!-- moss-embed:b.md -->".to_string(),
639 );
640 files.insert(
641 "b.md".to_string(),
642 "B content.\n<!-- moss-embed:a.md -->".to_string(),
643 );
644
645 let content = "<!-- moss-embed:a.md -->";
646 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
647
648 assert!(result.content.contains("A content."));
651 assert!(result.content.contains("B content."));
652 let cycle_diag = result
653 .diagnostics
654 .iter()
655 .find(|d| d.message.contains("Circular"));
656 assert!(cycle_diag.is_some(), "Expected a circular embed diagnostic");
657 }
658
659 #[test]
660 fn test_max_depth_protection() {
661 let mut files = HashMap::new();
663 for i in 0..12 {
664 let next = i + 1;
665 files.insert(
666 format!("file{i}.md"),
667 format!("Content {i}.\n<!-- moss-embed:file{next}.md -->"),
668 );
669 }
670 files.insert("file12.md".to_string(), "End.".to_string());
671
672 let content = "<!-- moss-embed:file0.md -->";
673 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
674
675 let depth_diag = result
677 .diagnostics
678 .iter()
679 .find(|d| d.message.contains("depth limit"));
680 assert!(
681 depth_diag.is_some(),
682 "Expected a depth limit diagnostic, got: {:?}",
683 result.diagnostics
684 );
685 }
686
687 #[test]
688 fn test_file_not_found() {
689 let files = HashMap::new();
690
691 let content = "<!-- moss-embed:missing.md -->";
692 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
693
694 assert_eq!(result.diagnostics.len(), 1);
695 assert!(result.diagnostics[0].message.contains("not found"));
696 assert!(result.content.contains("<!-- moss-embed:missing.md -->"));
698 }
699
700 #[test]
701 fn test_unresolved_marker_preserved() {
702 let files = HashMap::new();
703
704 let content = "Before.\n<!-- moss-embed-unresolved:some-ref -->\nAfter.";
705 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
706
707 assert!(result
708 .content
709 .contains("<!-- moss-embed-unresolved:some-ref -->"));
710 assert!(result.diagnostics.is_empty());
711 assert_eq!(
712 result.content,
713 "Before.\n<!-- moss-embed-unresolved:some-ref -->\nAfter."
714 );
715 }
716
717 #[test]
718 fn test_recursive_embed() {
719 let mut files = HashMap::new();
720 files.insert(
721 "a.md".to_string(),
722 "A content.\n<!-- moss-embed:b.md -->".to_string(),
723 );
724 files.insert("b.md".to_string(), "B content.".to_string());
725
726 let content = "<!-- moss-embed:a.md -->";
727 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
728
729 assert!(result.content.contains("A content."));
730 assert!(result.content.contains("B content."));
731 assert!(result.diagnostics.is_empty());
732 }
733
734 #[test]
735 fn test_embed_deps_tracked() {
736 let mut files = HashMap::new();
737 files.insert(
738 "a.md".to_string(),
739 "A content.\n<!-- moss-embed:b.md -->".to_string(),
740 );
741 files.insert("b.md".to_string(), "B content.".to_string());
742
743 let content = "<!-- moss-embed:a.md -->";
744 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
745
746 assert!(
748 result
749 .embed_deps
750 .contains(&("a.md".to_string(), "index.md".to_string())),
751 "Missing dep: (a.md, index.md). Got: {:?}",
752 result.embed_deps
753 );
754 assert!(
755 result
756 .embed_deps
757 .contains(&("b.md".to_string(), "a.md".to_string())),
758 "Missing dep: (b.md, a.md). Got: {:?}",
759 result.embed_deps
760 );
761 }
762
763 #[test]
764 fn test_no_frontmatter() {
765 let mut files = HashMap::new();
766 files.insert(
767 "plain.md".to_string(),
768 "Just plain content.\nSecond line.".to_string(),
769 );
770
771 let content = "<!-- moss-embed:plain.md -->";
772 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
773
774 assert_eq!(result.content, "Just plain content.\nSecond line.");
775 assert!(result.diagnostics.is_empty());
776 }
777
778 #[test]
779 fn test_multiple_embeds() {
780 let mut files = HashMap::new();
781 files.insert("one.md".to_string(), "Content one.".to_string());
782 files.insert("two.md".to_string(), "Content two.".to_string());
783
784 let content = "<!-- moss-embed:one.md -->\n<!-- moss-embed:two.md -->";
785 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
786
787 assert!(result.content.contains("Content one."));
788 assert!(result.content.contains("Content two."));
789 assert!(result.diagnostics.is_empty());
790 }
791
792 #[test]
793 fn test_content_around_embed_preserved() {
794 let mut files = HashMap::new();
795 files.insert("note.md".to_string(), "Note content.".to_string());
796
797 let content = "Paragraph before.\n\n<!-- moss-embed:note.md -->\n\nParagraph after.";
798 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
799
800 assert!(result.content.contains("Paragraph before."));
801 assert!(result.content.contains("Note content."));
802 assert!(result.content.contains("Paragraph after."));
803 assert!(result.diagnostics.is_empty());
804 }
805
806 #[test]
809 fn test_parse_embed_marker() {
810 assert_eq!(
811 parse_embed_marker("<!-- moss-embed:path/to/file.md -->"),
812 Some("path/to/file.md")
813 );
814 assert_eq!(
815 parse_embed_marker("<!-- moss-embed:file.md#heading -->"),
816 Some("file.md#heading")
817 );
818 assert_eq!(parse_embed_marker("<!-- moss-embed: -->"), None);
819 assert_eq!(parse_embed_marker("not an embed marker"), None);
820 assert_eq!(
821 parse_embed_marker("<!-- moss-embed-unresolved:ref -->"),
822 None
823 );
824 }
825
826 #[test]
827 fn test_split_target() {
828 assert_eq!(split_target("file.md"), ("file.md", None));
829 assert_eq!(
830 split_target("file.md#heading"),
831 ("file.md", Some("heading"))
832 );
833 assert_eq!(split_target("file.md#"), ("file.md", None));
834 assert_eq!(
835 split_target("path/to/file.md#deep-heading"),
836 ("path/to/file.md", Some("deep-heading"))
837 );
838 }
839
840 #[test]
841 fn test_strip_frontmatter_basic() {
842 let input = "---\ntitle: Test\n---\nBody content.";
843 assert_eq!(strip_frontmatter(input), "Body content.");
844 }
845
846 #[test]
847 fn test_strip_frontmatter_none() {
848 let input = "No frontmatter here.\nJust content.";
849 assert_eq!(strip_frontmatter(input), input);
850 }
851
852 #[test]
853 fn test_strip_frontmatter_no_closing() {
854 let input = "---\ntitle: Test\nNo closing delimiter.";
855 assert_eq!(strip_frontmatter(input), input);
857 }
858
859 #[test]
860 fn test_extract_heading_section_basic() {
861 let body = "# Intro\nIntro text.\n## Getting Started\nStart here.\n## Advanced\nAdvanced.";
862 let section = extract_heading_section(body, "getting-started");
863 assert!(section.is_some());
864 let s = section.unwrap();
865 assert!(s.contains("## Getting Started"));
866 assert!(s.contains("Start here."));
867 assert!(!s.contains("Advanced."));
868 assert!(!s.contains("Intro text."));
869 }
870
871 #[test]
872 fn test_extract_heading_section_last() {
873 let body = "# Intro\nIntro text.\n## Last Section\nLast content.";
874 let section = extract_heading_section(body, "last-section");
875 assert!(section.is_some());
876 let s = section.unwrap();
877 assert!(s.contains("## Last Section"));
878 assert!(s.contains("Last content."));
879 }
880
881 #[test]
882 fn test_extract_heading_section_not_found() {
883 let body = "# Intro\nIntro text.";
884 assert!(extract_heading_section(body, "nonexistent").is_none());
885 }
886
887 #[test]
888 fn test_parse_heading() {
889 assert_eq!(parse_heading("# Title"), Some((1, "Title")));
890 assert_eq!(parse_heading("## Sub Title"), Some((2, "Sub Title")));
891 assert_eq!(parse_heading("###### Deep"), Some((6, "Deep")));
892 assert_eq!(parse_heading("Not a heading"), None);
893 assert_eq!(parse_heading("#NoSpace"), None);
894 assert_eq!(parse_heading(""), None);
895 }
896
897 #[test]
898 fn test_block_ref_embed() {
899 let mut files = HashMap::new();
900 files.insert(
901 "concepts.md".to_string(),
902 "---\ntitle: Concepts\n---\nA **stem** is a folder's own page. ^def-stem\n\nA **leaf** is an article. ^def-leaf"
903 .to_string(),
904 );
905
906 let content = "<!-- moss-embed:concepts.md#^def-stem -->";
907 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
908
909 assert!(result.content.contains("stem"), "Should contain 'stem'");
910 assert!(
911 !result.content.contains("leaf"),
912 "Should not contain 'leaf'"
913 );
914 assert!(
915 !result.content.contains("^def-stem"),
916 "Should strip block ref marker"
917 );
918 assert!(result.diagnostics.is_empty(), "Should have no diagnostics");
919 }
920
921 #[test]
922 fn test_block_ref_not_found() {
923 let mut files = HashMap::new();
924 files.insert(
925 "note.md".to_string(),
926 "---\ntitle: Note\n---\nSome content.".to_string(),
927 );
928
929 let content = "<!-- moss-embed:note.md#^nonexistent -->";
930 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
931
932 assert_eq!(result.diagnostics.len(), 1);
933 assert!(result.diagnostics[0].message.contains("Block reference"));
934 }
935
936 #[test]
937 fn test_extract_block_section_basic() {
938 let body =
939 "First paragraph.\n\nA **stem** is a folder's own page. ^def-stem\n\nLast paragraph.";
940 let section = extract_block_section(body, "def-stem");
941 assert!(section.is_some());
942 let s = section.unwrap();
943 assert!(s.contains("stem"));
944 assert!(!s.contains("^def-stem"));
945 assert!(!s.contains("Last paragraph"));
946 }
947
948 #[test]
949 fn test_extract_block_section_not_found() {
950 let body = "No block refs here.";
951 assert!(extract_block_section(body, "missing").is_none());
952 }
953
954 #[test]
955 fn test_extract_block_section_no_substring_collision() {
956 let body = "About stems. ^stem\nA **stem** is a folder's own page. ^def-stem";
957 let section = extract_block_section(body, "stem");
959 assert!(section.is_some());
960 assert!(section.as_ref().unwrap().contains("About stems"));
961 assert!(!section.unwrap().contains("folder"));
962 }
963
964 #[test]
967 fn test_deferred_markers_empty_handlers_noop() {
968 let content = "before <!-- moss-embed-ipynb:x.ipynb --> after";
969 let handlers = MarkerHandlers::new();
970 let r = resolve_deferred_markers(content, &handlers);
971 assert_eq!(r.content, content);
972 }
973
974 #[test]
975 fn test_deferred_markers_dispatches_single() {
976 let content = "before <!-- moss-embed-ipynb:nb.ipynb --> after";
977 let mut h = MarkerHandlers::new();
978 h.register(
979 "moss-embed-ipynb",
980 Box::new(|target, _| format!("<div class=\"nb\">{}</div>", target)),
981 );
982 let r = resolve_deferred_markers(content, &h);
983 assert_eq!(r.content, "before <div class=\"nb\">nb.ipynb</div> after");
984 }
985
986 #[test]
987 fn test_deferred_markers_dispatches_multiple_different_prefixes() {
988 let content = "a <!-- moss-embed-ipynb:n.ipynb --> b <!-- moss-embed-table:d.csv --> c";
989 let mut h = MarkerHandlers::new();
990 h.register("moss-embed-ipynb", Box::new(|t, _| format!("[nb:{}]", t)));
991 h.register("moss-embed-table", Box::new(|t, _| format!("[tbl:{}]", t)));
992 let r = resolve_deferred_markers(content, &h);
993 assert_eq!(r.content, "a [nb:n.ipynb] b [tbl:d.csv] c");
994 }
995
996 #[test]
997 fn test_deferred_markers_unknown_prefix_left_intact() {
998 let content = "before <!-- moss-embed-unknown:foo --> after";
999 let mut h = MarkerHandlers::new();
1000 h.register("moss-embed-ipynb", Box::new(|_, _| String::new()));
1001 let r = resolve_deferred_markers(content, &h);
1002 assert!(r.content.contains("<!-- moss-embed-unknown:foo -->"));
1004 }
1005
1006 #[test]
1007 fn test_deferred_markers_handler_can_emit_diagnostics() {
1008 let content = "<!-- moss-embed-ipynb:bad -->";
1009 let mut h = MarkerHandlers::new();
1010 h.register(
1011 "moss-embed-ipynb",
1012 Box::new(|t, diags| {
1013 diags.push(Diagnostic {
1014 message: format!("synthetic failure for {}", t),
1015 source_path: "".to_string(),
1016 reference: t.to_string(),
1017 kind: DiagnosticKind::Other,
1018 });
1019 "<div class=\"error\"></div>".to_string()
1020 }),
1021 );
1022 let r = resolve_deferred_markers(content, &h);
1023 assert_eq!(r.diagnostics.len(), 1);
1024 assert!(r.diagnostics[0].message.contains("synthetic failure"));
1025 }
1026
1027 #[test]
1028 fn test_deferred_markers_prefix_matching_exact() {
1029 let content = "<!-- moss-embed-ipynb:nb.ipynb -->";
1031 let mut h = MarkerHandlers::new();
1032 h.register("moss-embed", Box::new(|_, _| "WRONG".to_string()));
1033 let r = resolve_deferred_markers(content, &h);
1034 assert!(r.content.contains("moss-embed-ipynb"), "got: {}", r.content);
1038 }
1039
1040 #[test]
1041 fn test_deferred_markers_unclosed_marker_preserved() {
1042 let content = "before <!-- moss-embed-ipynb:no-closing";
1043 let mut h = MarkerHandlers::new();
1044 h.register("moss-embed-ipynb", Box::new(|_, _| "NO".to_string()));
1045 let r = resolve_deferred_markers(content, &h);
1046 assert_eq!(r.content, content);
1047 }
1048}