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;
15
16/// Maximum recursion depth for nested embeds.
17const MAX_EMBED_DEPTH: usize = 10;
18
19/// Prefix for resolved embed markers.
20const EMBED_PREFIX: &str = "<!-- moss-embed:";
21/// Prefix for unresolved embed markers (left as-is).
22const EMBED_UNRESOLVED_PREFIX: &str = "<!-- moss-embed-unresolved:";
23/// Suffix for all embed markers.
24const EMBED_SUFFIX: &str = " -->";
25
26/// The result of resolving embed placeholders in a document.
27#[derive(Debug)]
28pub struct EmbedResult {
29    /// The content with embed placeholders replaced by inlined file content.
30    pub content: String,
31    /// Diagnostics produced during resolution (missing files, cycles, etc.).
32    pub diagnostics: Vec<Diagnostic>,
33    /// `(target_path, source_path)` pairs for watch-mode invalidation.
34    pub embed_deps: Vec<(String, String)>,
35}
36
37/// Resolve embed placeholders by inlining target file content.
38///
39/// Convenience wrapper that creates the visited set internally.
40/// Use [`resolve_embeds_with_visited`] if you need to supply your own set
41/// (e.g. to pre-seed cycle detection from an outer context).
42pub 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
51/// Resolve embed placeholders with an externally-managed visited set.
52///
53/// `file_reader` is a closure that reads a file by its relative path.
54/// This keeps moss-core I/O-free — the caller provides the reader.
55///
56/// `visited` tracks paths in the current embed chain for cycle detection.
57pub 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
66/// Inner recursive implementation with depth tracking.
67fn 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        // Leave unresolved markers as-is.
82        if trimmed.starts_with(EMBED_UNRESOLVED_PREFIX) {
83            output.push_str(line);
84            output.push('\n');
85            continue;
86        }
87
88        // Check for a resolved embed marker.
89        if let Some(target) = parse_embed_marker(trimmed) {
90            let (file_path, heading_anchor) = split_target(target);
91
92            // Record the dependency regardless of whether we can resolve it.
93            embed_deps.push((file_path.to_string(), from_path.to_string()));
94
95            // Check depth limit.
96            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            // Check for cycles.
110            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            // Try to read the file.
122            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                            // Block reference: find paragraph containing ^block_id
138                            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                            // Heading reference
153                            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                    // Recurse into the inlined content.
172                    visited.insert(file_path.to_string());
173                    let nested =
174                        resolve_embeds_inner(&section, 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                    // Append the resolved content. Ensure it ends with a newline
181                    // so subsequent lines are not joined.
182                    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 the original content did not end with a newline, remove the trailing
195    // one we added.
196    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
207/// Parse an embed marker line and return the target string.
208///
209/// `<!-- moss-embed:path/to/file.md -->` => `Some("path/to/file.md")`
210/// `<!-- moss-embed:path/to/file.md#heading -->` => `Some("path/to/file.md#heading")`
211fn 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
220/// Split a target string into (file_path, optional heading_anchor).
221///
222/// `"guide.md#getting-started"` => `("guide.md", Some("getting-started"))`
223/// `"guide.md"` => `("guide.md", None)`
224fn 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
237/// Strip YAML frontmatter from file content.
238///
239/// Frontmatter is delimited by `---` at the very start of the file and a
240/// subsequent `---` line.  Everything between (and including) the delimiters
241/// is removed.
242fn strip_frontmatter(content: &str) -> &str {
243    // Must start with `---` on the first line.
244    if !content.starts_with("---") {
245        return content;
246    }
247
248    // Find the end of the first line (the opening `---`).
249    // `split_once('\n')` keeps both halves on char boundaries — `\n` is ASCII.
250    let (_opening_line, after_opening) = match content.split_once('\n') {
251        Some(pair) => pair,
252        None => return content, // Only "---" with no closing delimiter.
253    };
254
255    // Find the closing `---` line.
256    if let Some(close_pos) = find_closing_frontmatter(after_opening) {
257        // `close_pos` is a sum of `line.len() + 1` over `.lines()`, so it lands
258        // on a `\n` boundary in `after_opening` — char-aligned by construction.
259        #[allow(clippy::string_slice)]
260        // Char-aligned: close_pos is built from line lengths in find_closing_frontmatter,
261        // each terminated by an ASCII '\n'; always on a UTF-8 char boundary.
262        let after_close = &after_opening[close_pos..];
263        // Skip past the closing `---\n`.
264        match after_close.split_once('\n') {
265            Some((_closing_line, rest)) => rest,
266            None => "", // File ends right at the closing `---`.
267        }
268    } else {
269        // No closing delimiter found — treat entire content as body.
270        content
271    }
272}
273
274/// Find the position of the closing `---` line within a string (relative offset).
275fn 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; // +1 for the '\n'
282    }
283    None
284}
285
286/// Extract the section under a specific heading, identified by its anchor.
287///
288/// Returns everything from the heading line through the next heading of equal
289/// or higher level (or end of file).
290fn 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    // Find the heading whose anchor matches.
296    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    // Find where the section ends: next heading of equal or higher level.
310    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
324/// Extract the line containing a block reference marker, with the marker stripped.
325///
326/// Block references are `^id` markers at the end of a line, preceded by a space.
327/// Returns the line content without the marker, or `None` if not found.
328///
329/// The space-before-`^` check prevents substring collisions: looking up `^stem`
330/// must not match a line tagged `^def-stem`.
331fn 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
345/// Parse a markdown heading line into (level, text).
346///
347/// `"## Getting Started"` => `Some((2, "Getting Started"))`
348fn 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    // Must be followed by a space (or be just hashes at end of line).
360    // `level` counts ASCII '#' characters, each 1 byte, so byte-offset == count.
361    #[allow(clippy::string_slice)]
362    // Char-aligned: leading chars are all ASCII '#' (1-byte each), so `level` is a valid byte index.
363    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
374// ---------------------------------------------------------------------------
375// Deferred-marker post-pass
376// ---------------------------------------------------------------------------
377
378/// A resolver for one kind of Deferred embed marker.
379///
380/// Given the marker's target body (everything between `<!-- <prefix>:` and
381/// ` -->`), returns the HTML to splice in. The `diagnostics` buffer is for
382/// reporting I/O errors or invalid targets; the handler is expected to
383/// return a best-effort fallback HTML even on failure so the build doesn't
384/// stall.
385pub type MarkerHandler<'a> = Box<dyn Fn(&str, &mut Vec<Diagnostic>) -> String + Send + Sync + 'a>;
386
387/// Registry of marker-prefix → handler, used by
388/// [`resolve_deferred_markers`] to dispatch Deferred embeds
389/// ([`crate::resolve::embed_renderer::RenderedEmbed::Deferred`]) in a post-pass.
390///
391/// The built-in `moss-embed:` (markdown transclusion) is **not** dispatched
392/// here — it's resolved by [`resolve_embeds`] in an earlier pass. This
393/// registry handles typed prefixes: `moss-embed-ipynb`, `moss-embed-table`,
394/// `moss-embed-plugin-<name>`, etc.
395///
396/// Use [`super::embed_renderer::MARKER_IPYNB`] / [`super::embed_renderer::MARKER_TABLE`]
397/// (etc.) as prefixes to avoid stringly-typed drift.
398pub 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    /// Register a handler for markers whose prefix is `<prefix>:`.
410    ///
411    /// `prefix` should NOT include the trailing colon — the scanner matches
412    /// `<!-- <prefix>:` automatically.
413    pub fn register(&mut self, prefix: impl Into<String>, handler: MarkerHandler<'a>) {
414        self.handlers.push((prefix.into(), handler));
415    }
416
417    /// Find the handler whose prefix matches the body of this marker.
418    /// Returns `(prefix, handler, target_body)`.
419    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
442/// Scan `content` for Deferred markers and dispatch each to its registered
443/// handler. Markers with no registered handler are left intact (so they
444/// survive to the final HTML as comments — visible, greppable, not silently
445/// swallowed if a resolver is missing).
446///
447/// This is a pure string transform. The handler closures may do I/O; this
448/// function does not.
449pub 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        // Find the next "<!-- " marker start.
464        let Some((before, after_start)) = remaining.split_once("<!-- ") else {
465            out.push_str(remaining);
466            break;
467        };
468        // Copy everything up to the marker verbatim.
469        out.push_str(before);
470
471        // Find the closing " -->".
472        let Some((marker_body, rest)) = after_start.split_once(" -->") else {
473            // Unclosed; copy from "<!-- " onwards verbatim.
474            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                // Unrecognized marker — leave it as-is.
486                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/// Output of [`resolve_deferred_markers`].
501#[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        // Full body inlined when heading not found.
562        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        // A's content should be inlined (including B's content), but the
583        // circular reference back to A should produce a diagnostic.
584        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        // Build a chain: file0 embeds file1, file1 embeds file2, ..., up to 12.
596        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        // Should have a depth-exceeded diagnostic.
610        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        // The marker should be preserved when the file is not found.
631        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        // index.md -> a.md, a.md -> b.md
681        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    // ----- Helper unit tests -----
741
742    #[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        // No closing `---`, so treat as no frontmatter.
790        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        // Looking for "stem" must match the line tagged ^stem, not ^def-stem
892        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    // --- MarkerHandlers / resolve_deferred_markers ---
899
900    #[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        // Unknown marker preserved verbatim so bugs are visible.
937        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        // A handler for "moss-embed" must NOT swallow "moss-embed-ipynb".
963        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        // "moss-embed:" would match; "moss-embed-ipynb:" would not.
968        // Our format!("{}:", prefix) matches "moss-embed:", which doesn't
969        // prefix "moss-embed-ipynb:" — correct disjoint behavior.
970        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}