1use std::collections::HashSet;
11
12use crate::heading_anchor::obsidian_heading_anchor;
13
14use super::Diagnostic;
15
16const MAX_EMBED_DEPTH: usize = 10;
18
19const EMBED_PREFIX: &str = "<!-- moss-embed:";
21const EMBED_UNRESOLVED_PREFIX: &str = "<!-- moss-embed-unresolved:";
23const EMBED_SUFFIX: &str = " -->";
25
26#[derive(Debug)]
28pub struct EmbedResult {
29 pub content: String,
31 pub diagnostics: Vec<Diagnostic>,
33 pub embed_deps: Vec<(String, String)>,
35}
36
37pub fn resolve_embeds(
43 content: &str,
44 from_path: &str,
45 file_reader: &dyn Fn(&str) -> Option<String>,
46) -> EmbedResult {
47 let mut visited = HashSet::new();
48 resolve_embeds_inner(content, from_path, file_reader, &mut visited, 0)
49}
50
51pub fn resolve_embeds_with_visited(
58 content: &str,
59 from_path: &str,
60 file_reader: &dyn Fn(&str) -> Option<String>,
61 visited: &mut HashSet<String>,
62) -> EmbedResult {
63 resolve_embeds_inner(content, from_path, file_reader, visited, 0)
64}
65
66fn resolve_embeds_inner(
68 content: &str,
69 from_path: &str,
70 file_reader: &dyn Fn(&str) -> Option<String>,
71 visited: &mut HashSet<String>,
72 depth: usize,
73) -> EmbedResult {
74 let mut diagnostics: Vec<Diagnostic> = Vec::new();
75 let mut embed_deps: Vec<(String, String)> = Vec::new();
76 let mut output = String::with_capacity(content.len());
77
78 for line in content.lines() {
79 let trimmed = line.trim();
80
81 if trimmed.starts_with(EMBED_UNRESOLVED_PREFIX) {
83 output.push_str(line);
84 output.push('\n');
85 continue;
86 }
87
88 if let Some(target) = parse_embed_marker(trimmed) {
90 let (file_path, heading_anchor) = split_target(target);
91
92 embed_deps.push((file_path.to_string(), from_path.to_string()));
94
95 if depth >= MAX_EMBED_DEPTH {
97 diagnostics.push(Diagnostic {
98 message: format!(
99 "Embed depth limit ({MAX_EMBED_DEPTH}) exceeded for '{file_path}'"
100 ),
101 source_path: from_path.to_string(),
102 reference: target.to_string(),
103 });
104 output.push_str(line);
105 output.push('\n');
106 continue;
107 }
108
109 if visited.contains(file_path) {
111 diagnostics.push(Diagnostic {
112 message: format!("Circular embed detected: '{file_path}'"),
113 source_path: from_path.to_string(),
114 reference: target.to_string(),
115 });
116 output.push_str(line);
117 output.push('\n');
118 continue;
119 }
120
121 match file_reader(file_path) {
123 None => {
124 diagnostics.push(Diagnostic {
125 message: format!("Embed target not found: '{file_path}'"),
126 source_path: from_path.to_string(),
127 reference: target.to_string(),
128 });
129 output.push_str(line);
130 output.push('\n');
131 }
132 Some(file_content) => {
133 let body = strip_frontmatter(&file_content);
134
135 let section = if let Some(anchor) = heading_anchor {
136 if let Some(block_id) = anchor.strip_prefix('^') {
137 match extract_block_section(body, block_id) {
139 Some(section) => section,
140 None => {
141 diagnostics.push(Diagnostic {
142 message: format!(
143 "Block reference '^{block_id}' not found in '{file_path}'"
144 ),
145 source_path: from_path.to_string(),
146 reference: target.to_string(),
147 });
148 body.to_string()
149 }
150 }
151 } else {
152 match extract_heading_section(body, anchor) {
154 Some(section) => section,
155 None => {
156 diagnostics.push(Diagnostic {
157 message: format!(
158 "Heading '#{anchor}' not found in '{file_path}'"
159 ),
160 source_path: from_path.to_string(),
161 reference: target.to_string(),
162 });
163 body.to_string()
164 }
165 }
166 }
167 } else {
168 body.to_string()
169 };
170
171 visited.insert(file_path.to_string());
173 let nested =
174 resolve_embeds_inner(§ion, file_path, file_reader, visited, depth + 1);
175 visited.remove(file_path);
176
177 diagnostics.extend(nested.diagnostics);
178 embed_deps.extend(nested.embed_deps);
179
180 output.push_str(&nested.content);
183 if !nested.content.ends_with('\n') {
184 output.push('\n');
185 }
186 }
187 }
188 } else {
189 output.push_str(line);
190 output.push('\n');
191 }
192 }
193
194 if !content.ends_with('\n') && output.ends_with('\n') {
197 output.pop();
198 }
199
200 EmbedResult {
201 content: output,
202 diagnostics,
203 embed_deps,
204 }
205}
206
207fn parse_embed_marker(line: &str) -> Option<&str> {
212 let rest = line.strip_prefix(EMBED_PREFIX)?;
213 let target = rest.strip_suffix(EMBED_SUFFIX)?;
214 if target.is_empty() {
215 return None;
216 }
217 Some(target)
218}
219
220fn split_target(target: &str) -> (&str, Option<&str>) {
225 match target.split_once('#') {
226 Some((file_path, anchor)) => {
227 if anchor.is_empty() {
228 (file_path, None)
229 } else {
230 (file_path, Some(anchor))
231 }
232 }
233 None => (target, None),
234 }
235}
236
237fn strip_frontmatter(content: &str) -> &str {
243 if !content.starts_with("---") {
245 return content;
246 }
247
248 let (_opening_line, after_opening) = match content.split_once('\n') {
251 Some(pair) => pair,
252 None => return content, };
254
255 if let Some(close_pos) = find_closing_frontmatter(after_opening) {
257 #[allow(clippy::string_slice)]
260 let after_close = &after_opening[close_pos..];
263 match after_close.split_once('\n') {
265 Some((_closing_line, rest)) => rest,
266 None => "", }
268 } else {
269 content
271 }
272}
273
274fn find_closing_frontmatter(s: &str) -> Option<usize> {
276 let mut offset = 0;
277 for line in s.lines() {
278 if line.trim() == "---" {
279 return Some(offset);
280 }
281 offset += line.len() + 1; }
283 None
284}
285
286fn extract_heading_section(body: &str, target_anchor: &str) -> Option<String> {
291 let lines: Vec<&str> = body.lines().collect();
292 let mut start_idx = None;
293 let mut heading_level = 0;
294
295 for (i, line) in lines.iter().enumerate() {
297 if let Some((level, text)) = parse_heading(line) {
298 let anchor = obsidian_heading_anchor(text);
299 if anchor == target_anchor {
300 start_idx = Some(i);
301 heading_level = level;
302 break;
303 }
304 }
305 }
306
307 let start = start_idx?;
308
309 let mut end_idx = lines.len();
311 for i in (start + 1)..lines.len() {
312 if let Some((level, _)) = parse_heading(lines[i]) {
313 if level <= heading_level {
314 end_idx = i;
315 break;
316 }
317 }
318 }
319
320 let section = lines[start..end_idx].join("\n");
321 Some(section)
322}
323
324fn extract_block_section(body: &str, block_id: &str) -> Option<String> {
332 let marker = format!(" ^{}", block_id);
333 for line in body.lines() {
334 let trimmed = line.trim();
335 if let Some(content) = trimmed.strip_suffix(marker.as_str()) {
336 let content = content.trim_end();
337 if !content.is_empty() {
338 return Some(content.to_string());
339 }
340 }
341 }
342 None
343}
344
345fn parse_heading(line: &str) -> Option<(usize, &str)> {
349 let trimmed = line.trim_start();
350 if !trimmed.starts_with('#') {
351 return None;
352 }
353
354 let level = trimmed.chars().take_while(|&c| c == '#').count();
355 if level == 0 || level > 6 {
356 return None;
357 }
358
359 #[allow(clippy::string_slice)]
362 let rest = &trimmed[level..];
364 if rest.is_empty() {
365 return Some((level, ""));
366 }
367 let Some(after_space) = rest.strip_prefix(' ') else {
368 return None;
369 };
370
371 Some((level, after_space.trim()))
372}
373
374pub type MarkerHandler<'a> = Box<dyn Fn(&str, &mut Vec<Diagnostic>) -> String + Send + Sync + 'a>;
386
387pub struct MarkerHandlers<'a> {
399 handlers: Vec<(String, MarkerHandler<'a>)>,
400}
401
402impl<'a> MarkerHandlers<'a> {
403 pub fn new() -> Self {
404 Self {
405 handlers: Vec::new(),
406 }
407 }
408
409 pub fn register(&mut self, prefix: impl Into<String>, handler: MarkerHandler<'a>) {
414 self.handlers.push((prefix.into(), handler));
415 }
416
417 fn find<'b>(
420 &'b self,
421 marker_body: &'b str,
422 ) -> Option<(&'b str, &'b MarkerHandler<'a>, &'b str)> {
423 self.handlers.iter().find_map(|(p, h)| {
424 let needle = format!("{}:", p);
425 marker_body
426 .strip_prefix(needle.as_str())
427 .map(|tail| (p.as_str(), h, tail))
428 })
429 }
430
431 pub fn is_empty(&self) -> bool {
432 self.handlers.is_empty()
433 }
434}
435
436impl<'a> Default for MarkerHandlers<'a> {
437 fn default() -> Self {
438 Self::new()
439 }
440}
441
442pub fn resolve_deferred_markers(content: &str, handlers: &MarkerHandlers<'_>) -> DeferredResult {
450 let mut diagnostics: Vec<Diagnostic> = Vec::new();
451
452 if handlers.is_empty() {
453 return DeferredResult {
454 content: content.to_string(),
455 diagnostics,
456 };
457 }
458
459 let mut out = String::with_capacity(content.len());
460 let mut remaining = content;
461
462 loop {
463 let Some((before, after_start)) = remaining.split_once("<!-- ") else {
465 out.push_str(remaining);
466 break;
467 };
468 out.push_str(before);
470
471 let Some((marker_body, rest)) = after_start.split_once(" -->") else {
473 out.push_str("<!-- ");
475 out.push_str(after_start);
476 break;
477 };
478
479 match handlers.find(marker_body) {
480 Some((_prefix, handler, target)) => {
481 let resolved = handler(target, &mut diagnostics);
482 out.push_str(&resolved);
483 }
484 None => {
485 out.push_str("<!-- ");
487 out.push_str(marker_body);
488 out.push_str(" -->");
489 }
490 }
491 remaining = rest;
492 }
493
494 DeferredResult {
495 content: out,
496 diagnostics,
497 }
498}
499
500#[derive(Debug)]
502pub struct DeferredResult {
503 pub content: String,
504 pub diagnostics: Vec<Diagnostic>,
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use std::collections::HashMap;
511
512 fn mock_reader(files: &HashMap<String, String>) -> impl Fn(&str) -> Option<String> + '_ {
513 move |path: &str| files.get(path).cloned()
514 }
515
516 #[test]
517 fn test_basic_embed() {
518 let mut files = HashMap::new();
519 files.insert(
520 "note.md".to_string(),
521 "---\ntitle: Note\n---\nHello from note.".to_string(),
522 );
523
524 let content = "Before.\n<!-- moss-embed:note.md -->\nAfter.";
525 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
526
527 assert_eq!(result.content, "Before.\nHello from note.\nAfter.");
528 assert!(result.diagnostics.is_empty());
529 }
530
531 #[test]
532 fn test_heading_scoped_embed() {
533 let mut files = HashMap::new();
534 files.insert(
535 "guide.md".to_string(),
536 "---\ntitle: Guide\n---\n# Intro\nIntro text.\n## Getting Started\nStart here.\n## Advanced\nAdvanced stuff."
537 .to_string(),
538 );
539
540 let content = "<!-- moss-embed:guide.md#getting-started -->";
541 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
542
543 assert!(result.content.contains("## Getting Started"));
544 assert!(result.content.contains("Start here."));
545 assert!(!result.content.contains("Advanced stuff."));
546 assert!(!result.content.contains("Intro text."));
547 assert!(result.diagnostics.is_empty());
548 }
549
550 #[test]
551 fn test_heading_not_found() {
552 let mut files = HashMap::new();
553 files.insert(
554 "guide.md".to_string(),
555 "---\ntitle: Guide\n---\n# Intro\nIntro text.".to_string(),
556 );
557
558 let content = "<!-- moss-embed:guide.md#nonexistent -->";
559 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
560
561 assert!(result.content.contains("Intro text."));
563 assert_eq!(result.diagnostics.len(), 1);
564 assert!(result.diagnostics[0].message.contains("not found"));
565 }
566
567 #[test]
568 fn test_circular_embed_detection() {
569 let mut files = HashMap::new();
570 files.insert(
571 "a.md".to_string(),
572 "A content.\n<!-- moss-embed:b.md -->".to_string(),
573 );
574 files.insert(
575 "b.md".to_string(),
576 "B content.\n<!-- moss-embed:a.md -->".to_string(),
577 );
578
579 let content = "<!-- moss-embed:a.md -->";
580 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
581
582 assert!(result.content.contains("A content."));
585 assert!(result.content.contains("B content."));
586 let cycle_diag = result
587 .diagnostics
588 .iter()
589 .find(|d| d.message.contains("Circular"));
590 assert!(cycle_diag.is_some(), "Expected a circular embed diagnostic");
591 }
592
593 #[test]
594 fn test_max_depth_protection() {
595 let mut files = HashMap::new();
597 for i in 0..12 {
598 let next = i + 1;
599 files.insert(
600 format!("file{i}.md"),
601 format!("Content {i}.\n<!-- moss-embed:file{next}.md -->"),
602 );
603 }
604 files.insert("file12.md".to_string(), "End.".to_string());
605
606 let content = "<!-- moss-embed:file0.md -->";
607 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
608
609 let depth_diag = result
611 .diagnostics
612 .iter()
613 .find(|d| d.message.contains("depth limit"));
614 assert!(
615 depth_diag.is_some(),
616 "Expected a depth limit diagnostic, got: {:?}",
617 result.diagnostics
618 );
619 }
620
621 #[test]
622 fn test_file_not_found() {
623 let files = HashMap::new();
624
625 let content = "<!-- moss-embed:missing.md -->";
626 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
627
628 assert_eq!(result.diagnostics.len(), 1);
629 assert!(result.diagnostics[0].message.contains("not found"));
630 assert!(result.content.contains("<!-- moss-embed:missing.md -->"));
632 }
633
634 #[test]
635 fn test_unresolved_marker_preserved() {
636 let files = HashMap::new();
637
638 let content = "Before.\n<!-- moss-embed-unresolved:some-ref -->\nAfter.";
639 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
640
641 assert!(result
642 .content
643 .contains("<!-- moss-embed-unresolved:some-ref -->"));
644 assert!(result.diagnostics.is_empty());
645 assert_eq!(
646 result.content,
647 "Before.\n<!-- moss-embed-unresolved:some-ref -->\nAfter."
648 );
649 }
650
651 #[test]
652 fn test_recursive_embed() {
653 let mut files = HashMap::new();
654 files.insert(
655 "a.md".to_string(),
656 "A content.\n<!-- moss-embed:b.md -->".to_string(),
657 );
658 files.insert("b.md".to_string(), "B content.".to_string());
659
660 let content = "<!-- moss-embed:a.md -->";
661 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
662
663 assert!(result.content.contains("A content."));
664 assert!(result.content.contains("B content."));
665 assert!(result.diagnostics.is_empty());
666 }
667
668 #[test]
669 fn test_embed_deps_tracked() {
670 let mut files = HashMap::new();
671 files.insert(
672 "a.md".to_string(),
673 "A content.\n<!-- moss-embed:b.md -->".to_string(),
674 );
675 files.insert("b.md".to_string(), "B content.".to_string());
676
677 let content = "<!-- moss-embed:a.md -->";
678 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
679
680 assert!(
682 result
683 .embed_deps
684 .contains(&("a.md".to_string(), "index.md".to_string())),
685 "Missing dep: (a.md, index.md). Got: {:?}",
686 result.embed_deps
687 );
688 assert!(
689 result
690 .embed_deps
691 .contains(&("b.md".to_string(), "a.md".to_string())),
692 "Missing dep: (b.md, a.md). Got: {:?}",
693 result.embed_deps
694 );
695 }
696
697 #[test]
698 fn test_no_frontmatter() {
699 let mut files = HashMap::new();
700 files.insert(
701 "plain.md".to_string(),
702 "Just plain content.\nSecond line.".to_string(),
703 );
704
705 let content = "<!-- moss-embed:plain.md -->";
706 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
707
708 assert_eq!(result.content, "Just plain content.\nSecond line.");
709 assert!(result.diagnostics.is_empty());
710 }
711
712 #[test]
713 fn test_multiple_embeds() {
714 let mut files = HashMap::new();
715 files.insert("one.md".to_string(), "Content one.".to_string());
716 files.insert("two.md".to_string(), "Content two.".to_string());
717
718 let content = "<!-- moss-embed:one.md -->\n<!-- moss-embed:two.md -->";
719 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
720
721 assert!(result.content.contains("Content one."));
722 assert!(result.content.contains("Content two."));
723 assert!(result.diagnostics.is_empty());
724 }
725
726 #[test]
727 fn test_content_around_embed_preserved() {
728 let mut files = HashMap::new();
729 files.insert("note.md".to_string(), "Note content.".to_string());
730
731 let content = "Paragraph before.\n\n<!-- moss-embed:note.md -->\n\nParagraph after.";
732 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
733
734 assert!(result.content.contains("Paragraph before."));
735 assert!(result.content.contains("Note content."));
736 assert!(result.content.contains("Paragraph after."));
737 assert!(result.diagnostics.is_empty());
738 }
739
740 #[test]
743 fn test_parse_embed_marker() {
744 assert_eq!(
745 parse_embed_marker("<!-- moss-embed:path/to/file.md -->"),
746 Some("path/to/file.md")
747 );
748 assert_eq!(
749 parse_embed_marker("<!-- moss-embed:file.md#heading -->"),
750 Some("file.md#heading")
751 );
752 assert_eq!(parse_embed_marker("<!-- moss-embed: -->"), None);
753 assert_eq!(parse_embed_marker("not an embed marker"), None);
754 assert_eq!(
755 parse_embed_marker("<!-- moss-embed-unresolved:ref -->"),
756 None
757 );
758 }
759
760 #[test]
761 fn test_split_target() {
762 assert_eq!(split_target("file.md"), ("file.md", None));
763 assert_eq!(
764 split_target("file.md#heading"),
765 ("file.md", Some("heading"))
766 );
767 assert_eq!(split_target("file.md#"), ("file.md", None));
768 assert_eq!(
769 split_target("path/to/file.md#deep-heading"),
770 ("path/to/file.md", Some("deep-heading"))
771 );
772 }
773
774 #[test]
775 fn test_strip_frontmatter_basic() {
776 let input = "---\ntitle: Test\n---\nBody content.";
777 assert_eq!(strip_frontmatter(input), "Body content.");
778 }
779
780 #[test]
781 fn test_strip_frontmatter_none() {
782 let input = "No frontmatter here.\nJust content.";
783 assert_eq!(strip_frontmatter(input), input);
784 }
785
786 #[test]
787 fn test_strip_frontmatter_no_closing() {
788 let input = "---\ntitle: Test\nNo closing delimiter.";
789 assert_eq!(strip_frontmatter(input), input);
791 }
792
793 #[test]
794 fn test_extract_heading_section_basic() {
795 let body = "# Intro\nIntro text.\n## Getting Started\nStart here.\n## Advanced\nAdvanced.";
796 let section = extract_heading_section(body, "getting-started");
797 assert!(section.is_some());
798 let s = section.unwrap();
799 assert!(s.contains("## Getting Started"));
800 assert!(s.contains("Start here."));
801 assert!(!s.contains("Advanced."));
802 assert!(!s.contains("Intro text."));
803 }
804
805 #[test]
806 fn test_extract_heading_section_last() {
807 let body = "# Intro\nIntro text.\n## Last Section\nLast content.";
808 let section = extract_heading_section(body, "last-section");
809 assert!(section.is_some());
810 let s = section.unwrap();
811 assert!(s.contains("## Last Section"));
812 assert!(s.contains("Last content."));
813 }
814
815 #[test]
816 fn test_extract_heading_section_not_found() {
817 let body = "# Intro\nIntro text.";
818 assert!(extract_heading_section(body, "nonexistent").is_none());
819 }
820
821 #[test]
822 fn test_parse_heading() {
823 assert_eq!(parse_heading("# Title"), Some((1, "Title")));
824 assert_eq!(parse_heading("## Sub Title"), Some((2, "Sub Title")));
825 assert_eq!(parse_heading("###### Deep"), Some((6, "Deep")));
826 assert_eq!(parse_heading("Not a heading"), None);
827 assert_eq!(parse_heading("#NoSpace"), None);
828 assert_eq!(parse_heading(""), None);
829 }
830
831 #[test]
832 fn test_block_ref_embed() {
833 let mut files = HashMap::new();
834 files.insert(
835 "concepts.md".to_string(),
836 "---\ntitle: Concepts\n---\nA **stem** is a folder's own page. ^def-stem\n\nA **leaf** is an article. ^def-leaf"
837 .to_string(),
838 );
839
840 let content = "<!-- moss-embed:concepts.md#^def-stem -->";
841 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
842
843 assert!(result.content.contains("stem"), "Should contain 'stem'");
844 assert!(
845 !result.content.contains("leaf"),
846 "Should not contain 'leaf'"
847 );
848 assert!(
849 !result.content.contains("^def-stem"),
850 "Should strip block ref marker"
851 );
852 assert!(result.diagnostics.is_empty(), "Should have no diagnostics");
853 }
854
855 #[test]
856 fn test_block_ref_not_found() {
857 let mut files = HashMap::new();
858 files.insert(
859 "note.md".to_string(),
860 "---\ntitle: Note\n---\nSome content.".to_string(),
861 );
862
863 let content = "<!-- moss-embed:note.md#^nonexistent -->";
864 let result = resolve_embeds(content, "index.md", &mock_reader(&files));
865
866 assert_eq!(result.diagnostics.len(), 1);
867 assert!(result.diagnostics[0].message.contains("Block reference"));
868 }
869
870 #[test]
871 fn test_extract_block_section_basic() {
872 let body =
873 "First paragraph.\n\nA **stem** is a folder's own page. ^def-stem\n\nLast paragraph.";
874 let section = extract_block_section(body, "def-stem");
875 assert!(section.is_some());
876 let s = section.unwrap();
877 assert!(s.contains("stem"));
878 assert!(!s.contains("^def-stem"));
879 assert!(!s.contains("Last paragraph"));
880 }
881
882 #[test]
883 fn test_extract_block_section_not_found() {
884 let body = "No block refs here.";
885 assert!(extract_block_section(body, "missing").is_none());
886 }
887
888 #[test]
889 fn test_extract_block_section_no_substring_collision() {
890 let body = "About stems. ^stem\nA **stem** is a folder's own page. ^def-stem";
891 let section = extract_block_section(body, "stem");
893 assert!(section.is_some());
894 assert!(section.as_ref().unwrap().contains("About stems"));
895 assert!(!section.unwrap().contains("folder"));
896 }
897
898 #[test]
901 fn test_deferred_markers_empty_handlers_noop() {
902 let content = "before <!-- moss-embed-ipynb:x.ipynb --> after";
903 let handlers = MarkerHandlers::new();
904 let r = resolve_deferred_markers(content, &handlers);
905 assert_eq!(r.content, content);
906 }
907
908 #[test]
909 fn test_deferred_markers_dispatches_single() {
910 let content = "before <!-- moss-embed-ipynb:nb.ipynb --> after";
911 let mut h = MarkerHandlers::new();
912 h.register(
913 "moss-embed-ipynb",
914 Box::new(|target, _| format!("<div class=\"nb\">{}</div>", target)),
915 );
916 let r = resolve_deferred_markers(content, &h);
917 assert_eq!(r.content, "before <div class=\"nb\">nb.ipynb</div> after");
918 }
919
920 #[test]
921 fn test_deferred_markers_dispatches_multiple_different_prefixes() {
922 let content = "a <!-- moss-embed-ipynb:n.ipynb --> b <!-- moss-embed-table:d.csv --> c";
923 let mut h = MarkerHandlers::new();
924 h.register("moss-embed-ipynb", Box::new(|t, _| format!("[nb:{}]", t)));
925 h.register("moss-embed-table", Box::new(|t, _| format!("[tbl:{}]", t)));
926 let r = resolve_deferred_markers(content, &h);
927 assert_eq!(r.content, "a [nb:n.ipynb] b [tbl:d.csv] c");
928 }
929
930 #[test]
931 fn test_deferred_markers_unknown_prefix_left_intact() {
932 let content = "before <!-- moss-embed-unknown:foo --> after";
933 let mut h = MarkerHandlers::new();
934 h.register("moss-embed-ipynb", Box::new(|_, _| String::new()));
935 let r = resolve_deferred_markers(content, &h);
936 assert!(r.content.contains("<!-- moss-embed-unknown:foo -->"));
938 }
939
940 #[test]
941 fn test_deferred_markers_handler_can_emit_diagnostics() {
942 let content = "<!-- moss-embed-ipynb:bad -->";
943 let mut h = MarkerHandlers::new();
944 h.register(
945 "moss-embed-ipynb",
946 Box::new(|t, diags| {
947 diags.push(Diagnostic {
948 message: format!("synthetic failure for {}", t),
949 source_path: "".to_string(),
950 reference: t.to_string(),
951 });
952 "<div class=\"error\"></div>".to_string()
953 }),
954 );
955 let r = resolve_deferred_markers(content, &h);
956 assert_eq!(r.diagnostics.len(), 1);
957 assert!(r.diagnostics[0].message.contains("synthetic failure"));
958 }
959
960 #[test]
961 fn test_deferred_markers_prefix_matching_exact() {
962 let content = "<!-- moss-embed-ipynb:nb.ipynb -->";
964 let mut h = MarkerHandlers::new();
965 h.register("moss-embed", Box::new(|_, _| "WRONG".to_string()));
966 let r = resolve_deferred_markers(content, &h);
967 assert!(r.content.contains("moss-embed-ipynb"), "got: {}", r.content);
971 }
972
973 #[test]
974 fn test_deferred_markers_unclosed_marker_preserved() {
975 let content = "before <!-- moss-embed-ipynb:no-closing";
976 let mut h = MarkerHandlers::new();
977 h.register("moss-embed-ipynb", Box::new(|_, _| "NO".to_string()));
978 let r = resolve_deferred_markers(content, &h);
979 assert_eq!(r.content, content);
980 }
981}