Skip to main content

moss_core/resolve/
embeds.rs

1//! Embed (transclusion) resolution.
2//!
3//! Resolves `<!-- moss-embed:TARGET -->` placeholders produced by the wikilink
4//! resolver into inlined file content.  Supports full-file and heading-scoped
5//! embeds, recursive embedding with cycle detection, and frontmatter stripping.
6//!
7//! This module is pure Rust with zero I/O — the caller supplies a `file_reader`
8//! closure that maps a relative path to file content.
9
10use std::collections::HashSet;
11
12use crate::heading::anchor::obsidian_heading_anchor;
13
14use super::{Diagnostic, DiagnosticKind};
15
16/// Maximum recursion depth for nested embeds.
17///
18/// Public because every consumer that walks the embed relation has to stop at
19/// the same depth the resolver itself stopped at — notably
20/// [`crate::dep_graph::DepGraph::embed_closure`], which bounds its traversal by
21/// this constant rather than inventing a second limit that could disagree
22/// (moss#922 Stage 7).
23pub const MAX_EMBED_DEPTH: usize = 10;
24
25/// Prefix for resolved embed markers.
26const EMBED_PREFIX: &str = "<!-- moss-embed:";
27/// Prefix for unresolved embed markers (left as-is).
28const EMBED_UNRESOLVED_PREFIX: &str = "<!-- moss-embed-unresolved:";
29/// Suffix for all embed markers.
30const EMBED_SUFFIX: &str = " -->";
31
32/// The result of resolving embed placeholders in a document.
33#[derive(Debug)]
34pub struct EmbedResult {
35    /// The content with embed placeholders replaced by inlined file content.
36    pub content: String,
37    /// Diagnostics produced during resolution (missing files, cycles, etc.).
38    pub diagnostics: Vec<Diagnostic>,
39    /// `(target_path, source_path)` pairs for watch-mode invalidation.
40    pub embed_deps: Vec<(String, String)>,
41}
42
43/// Resolve embed placeholders by inlining target file content.
44///
45/// Convenience wrapper that creates the visited set internally.
46/// Use [`resolve_embeds_with_visited`] if you need to supply your own set
47/// (e.g. to pre-seed cycle detection from an outer context).
48pub 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
57/// Resolve embed placeholders with an externally-managed visited set.
58///
59/// `file_reader` is a closure that reads a file by its relative path.
60/// This keeps moss-core I/O-free — the caller provides the reader.
61///
62/// `visited` tracks paths in the current embed chain for cycle detection.
63pub 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
72/// Inner recursive implementation with depth tracking.
73fn 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        // Leave unresolved markers as-is.
88        if trimmed.starts_with(EMBED_UNRESOLVED_PREFIX) {
89            output.push_str(line);
90            output.push('\n');
91            continue;
92        }
93
94        // Check for a resolved embed marker.
95        if let Some(target) = parse_embed_marker(trimmed) {
96            let (file_path, heading_anchor) = split_target(target);
97
98            // Record the dependency regardless of whether we can resolve it.
99            embed_deps.push((file_path.to_string(), from_path.to_string()));
100
101            // Check depth limit.
102            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            // Check for cycles.
117            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            // Try to read the file.
130            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                            // Block reference: find paragraph containing ^block_id
147                            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                            // Heading reference
163                            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                    // Recurse into the inlined content.
183                    visited.insert(file_path.to_string());
184                    let nested =
185                        resolve_embeds_inner(&section, 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                    // Append the resolved content. Ensure it ends with a newline
192                    // so subsequent lines are not joined.
193                    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 the original content did not end with a newline, remove the trailing
206    // one we added.
207    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
218/// Parse an embed marker line and return the target string.
219///
220/// `<!-- moss-embed:path/to/file.md -->` => `Some("path/to/file.md")`
221/// `<!-- moss-embed:path/to/file.md#heading -->` => `Some("path/to/file.md#heading")`
222fn 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
231/// Split a target string into (file_path, optional heading_anchor).
232///
233/// `"guide.md#getting-started"` => `("guide.md", Some("getting-started"))`
234/// `"guide.md"` => `("guide.md", None)`
235fn 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
248/// Strip YAML frontmatter from file content.
249///
250/// Frontmatter is delimited by `---` at the very start of the file and a
251/// subsequent `---` line.  Everything between (and including) the delimiters
252/// is removed.
253fn strip_frontmatter(content: &str) -> &str {
254    // Must start with `---` on the first line.
255    if !content.starts_with("---") {
256        return content;
257    }
258
259    // Find the end of the first line (the opening `---`).
260    // `split_once('\n')` keeps both halves on char boundaries — `\n` is ASCII.
261    let (_opening_line, after_opening) = match content.split_once('\n') {
262        Some(pair) => pair,
263        None => return content, // Only "---" with no closing delimiter.
264    };
265
266    // Find the closing `---` line.
267    if let Some(close_pos) = find_closing_frontmatter(after_opening) {
268        // `close_pos` is a sum of `line.len() + 1` over `.lines()`, so it lands
269        // on a `\n` boundary in `after_opening` — char-aligned by construction.
270        #[allow(clippy::string_slice)]
271        // Char-aligned: close_pos is built from line lengths in find_closing_frontmatter,
272        // each terminated by an ASCII '\n'; always on a UTF-8 char boundary.
273        let after_close = &after_opening[close_pos..];
274        // Skip past the closing `---\n`.
275        match after_close.split_once('\n') {
276            Some((_closing_line, rest)) => rest,
277            None => "", // File ends right at the closing `---`.
278        }
279    } else {
280        // No closing delimiter found — treat entire content as body.
281        content
282    }
283}
284
285/// Find the position of the closing `---` line within a string (relative offset).
286fn 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; // +1 for the '\n'
293    }
294    None
295}
296
297/// Extract the section under a specific heading, identified by its anchor.
298///
299/// Returns everything from the heading line through the next heading of equal
300/// or higher level (or end of file).
301fn 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    // Slug the target the same way headings are slugged. The wikilink resolver
307    // emits the marker with the RAW anchor the author typed (`#Template slots`),
308    // so comparing it directly against slugged heading anchors (`template-slots`)
309    // would falsely miss. `obsidian_heading_anchor` is idempotent, so a target
310    // that is already in slug form (`#getting-started`) still matches.
311    let target = obsidian_heading_anchor(target_anchor);
312
313    // Find the heading whose anchor matches.
314    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    // Find where the section ends: next heading of equal or higher level.
328    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
342/// Extract the line containing a block reference marker, with the marker stripped.
343///
344/// Block references are `^id` markers at the end of a line, preceded by a space.
345/// Returns the line content without the marker, or `None` if not found.
346///
347/// The space-before-`^` check prevents substring collisions: looking up `^stem`
348/// must not match a line tagged `^def-stem`.
349fn 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
363/// Parse a markdown heading line into (level, text).
364///
365/// `"## Getting Started"` => `Some((2, "Getting Started"))`
366fn 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    // Must be followed by a space (or be just hashes at end of line).
378    // `level` counts ASCII '#' characters, each 1 byte, so byte-offset == count.
379    #[allow(clippy::string_slice)]
380    // Char-aligned: leading chars are all ASCII '#' (1-byte each), so `level` is a valid byte index.
381    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
392// ---------------------------------------------------------------------------
393// Deferred-marker post-pass
394// ---------------------------------------------------------------------------
395
396/// A resolver for one kind of Deferred embed marker.
397///
398/// Given the marker's target body (everything between `<!-- <prefix>:` and
399/// ` -->`), returns the HTML to splice in. The `diagnostics` buffer is for
400/// reporting I/O errors or invalid targets; the handler is expected to
401/// return a best-effort fallback HTML even on failure so the build doesn't
402/// stall.
403pub type MarkerHandler<'a> = Box<dyn Fn(&str, &mut Vec<Diagnostic>) -> String + Send + Sync + 'a>;
404
405/// Registry of marker-prefix → handler, used by
406/// [`resolve_deferred_markers`] to dispatch Deferred embeds
407/// ([`crate::resolve::embed_renderer::RenderedEmbed::Deferred`]) in a post-pass.
408///
409/// The built-in `moss-embed:` (markdown transclusion) is **not** dispatched
410/// here — it's resolved by [`resolve_embeds`] in an earlier pass. This
411/// registry handles typed prefixes: `moss-embed-ipynb`, `moss-embed-table`,
412/// `moss-embed-plugin-<name>`, etc.
413///
414/// Use [`super::embed_renderer::MARKER_IPYNB`] / [`super::embed_renderer::MARKER_TABLE`]
415/// (etc.) as prefixes to avoid stringly-typed drift.
416pub 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    /// Register a handler for markers whose prefix is `<prefix>:`.
428    ///
429    /// `prefix` should NOT include the trailing colon — the scanner matches
430    /// `<!-- <prefix>:` automatically.
431    pub fn register(&mut self, prefix: impl Into<String>, handler: MarkerHandler<'a>) {
432        self.handlers.push((prefix.into(), handler));
433    }
434
435    /// Find the handler whose prefix matches the body of this marker.
436    /// Returns `(prefix, handler, target_body)`.
437    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
460/// Scan `content` for Deferred markers and dispatch each to its registered
461/// handler. Markers with no registered handler are left intact (so they
462/// survive to the final HTML as comments — visible, greppable, not silently
463/// swallowed if a resolver is missing).
464///
465/// This is a pure string transform. The handler closures may do I/O; this
466/// function does not.
467pub 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        // Find the next "<!-- " marker start.
482        let Some((before, after_start)) = remaining.split_once("<!-- ") else {
483            out.push_str(remaining);
484            break;
485        };
486        // Copy everything up to the marker verbatim.
487        out.push_str(before);
488
489        // Find the closing " -->".
490        let Some((marker_body, rest)) = after_start.split_once(" -->") else {
491            // Unclosed; copy from "<!-- " onwards verbatim.
492            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                // Unrecognized marker — leave it as-is.
504                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/// Output of [`resolve_deferred_markers`].
519#[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        // Regression: the wikilink resolver emits the embed marker with the
571        // RAW, un-slugged anchor exactly as the author typed it — e.g.
572        // `![[slots#Template slots]]` becomes
573        // `<!-- moss-embed:slots.md#Template slots -->` (space + capital T).
574        // The resolver must slug the target anchor the same way it slugs each
575        // heading, or it falsely reports "Heading not found" and inlines the
576        // WHOLE file as a fallback (leaking every sibling section).
577        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        // No false "not found" diagnostic.
587        assert!(
588            result.diagnostics.is_empty(),
589            "raw anchor should resolve cleanly, got diagnostics: {:?}",
590            result.diagnostics
591        );
592        // Only the target section is inlined — the sibling section must NOT leak.
593        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        // `extract_heading_section` must accept a raw (un-slugged) target
608        // anchor — spaces and mixed case — not just a pre-slugged one.
609        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        // Full body inlined when heading not found.
628        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        // A's content should be inlined (including B's content), but the
649        // circular reference back to A should produce a diagnostic.
650        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        // Build a chain: file0 embeds file1, file1 embeds file2, ..., up to 12.
662        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        // Should have a depth-exceeded diagnostic.
676        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        // The marker should be preserved when the file is not found.
697        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        // index.md -> a.md, a.md -> b.md
747        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    // ----- Helper unit tests -----
807
808    #[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        // No closing `---`, so treat as no frontmatter.
856        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        // Looking for "stem" must match the line tagged ^stem, not ^def-stem
958        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    // --- MarkerHandlers / resolve_deferred_markers ---
965
966    #[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        // Unknown marker preserved verbatim so bugs are visible.
1003        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        // A handler for "moss-embed" must NOT swallow "moss-embed-ipynb".
1030        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        // "moss-embed:" would match; "moss-embed-ipynb:" would not.
1035        // Our format!("{}:", prefix) matches "moss-embed:", which doesn't
1036        // prefix "moss-embed-ipynb:" — correct disjoint behavior.
1037        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}