Skip to main content

moss_core/
resolve.rs

1//! Centralized link resolution — ALL wikilink handling (body AND frontmatter) happens here.
2//!
3//! This module provides shared types for the resolve phase of the
4//! build pipeline, a fuzzy path resolver that wraps
5//! [`ContentGraph::resolve_path`](crate::content_graph::ContentGraph::resolve_path),
6//! and the top-level [`resolve_content`] function that ties all phases together.
7//!
8//! **Architectural boundary:** Downstream code (markdown.rs, render.rs) receives
9//! already-resolved paths. Do NOT add wikilink parsing or resolution elsewhere.
10
11use crate::asset_snapshot::AssetSnapshot;
12use crate::content_graph::ContentGraph;
13
14pub mod block_refs;
15pub mod embed_renderer;
16pub mod embeds;
17pub mod fuzzy_path;
18pub mod registry;
19pub mod title_params;
20pub mod wikilink_dispatch;
21
22/// A link going out from a document.
23#[derive(Debug, Clone)]
24pub struct OutgoingLink {
25    pub target_path: String,
26    pub display_text: String,
27    pub link_type: LinkType,
28}
29
30/// The kind of link syntax used.
31#[derive(Debug, Clone, PartialEq)]
32pub enum LinkType {
33    /// `[[target]]` or `[[target|display]]`
34    Wikilink,
35    /// `![[target]]` — an embedded/transcluded reference
36    Embed,
37    /// Standard markdown `[text](url)`
38    Standard,
39}
40
41/// A diagnostic message from the resolve phase.
42#[derive(Debug, Clone)]
43pub struct Diagnostic {
44    pub message: String,
45    pub source_path: String,
46    pub reference: String,
47}
48
49/// Result of resolving all Obsidian syntax in a markdown file.
50#[derive(Debug)]
51pub struct ResolveResult {
52    /// Clean markdown with all Obsidian syntax resolved.
53    pub content_markdown: String,
54    /// All outgoing links from this document.
55    pub outgoing_links: Vec<OutgoingLink>,
56    /// Warnings and errors encountered during resolution.
57    pub diagnostics: Vec<Diagnostic>,
58    /// Block IDs extracted from this document.
59    pub block_ids: Vec<String>,
60    /// (target_path, source_path) pairs for embed dependency tracking.
61    pub embed_deps: Vec<(String, String)>,
62}
63
64/// Resolve all Obsidian syntax in a markdown file, producing clean standard markdown.
65///
66/// Pipeline order:
67/// 1. Separate frontmatter from body
68/// 2. Resolve wikilinks (first pass) -- standard `[[…]]` and `![[…]]` to markdown links / embed markers
69/// 3. Resolve embed placeholders -- inline `<!-- moss-embed:… -->` markers with file content
70/// 4. Resolve wikilinks (second pass) -- catch wikilinks introduced by embedded content
71/// 5. Transform block references -- `^id` markers to HTML anchors
72/// 6. Rejoin frontmatter + resolved body
73///
74/// Phase 4 PR7a (2026-05-28) deleted Stage 1 callout transformation,
75/// bare-filename image resolution, AND standard markdown link
76/// resolution (`[text](target.md)`); all three are now part of the
77/// typed AST (`crates/moss-core/src/ast/`). For standard markdown
78/// links, the AST visitor (`ast/resolve_urls::resolve_link_urls`)
79/// emits the same `moss-resolved:` sentinel Stage 1 used to emit, so
80/// src-tauri's `classify_url_prod` decoder still drives page_map /
81/// external_url_map / wikilink-class decoding unchanged.
82pub fn resolve_content(
83    source_path: &str,
84    raw_markdown: &str,
85    graph: &ContentGraph,
86    file_reader: &dyn Fn(&str) -> Option<String>,
87) -> ResolveResult {
88    let handlers = embeds::MarkerHandlers::new();
89    let registry = registry::RendererRegistry::builtin().build();
90    resolve_content_with_handlers(
91        source_path,
92        raw_markdown,
93        graph,
94        file_reader,
95        &registry,
96        &handlers,
97    )
98}
99
100/// Variant of [`resolve_content`] that threads a custom [`registry::RendererRegistry`]
101/// (plugin-aware renderer dispatch) and [`embeds::MarkerHandlers`] (resolvers for
102/// Deferred markers: notebook, table, plugin renderers) through the pipeline.
103///
104/// Built-in-only pipelines should call [`resolve_content`]. Pipelines that load
105/// plugins at init time build a registry + handlers once and call this variant.
106///
107/// The handler registry fires in a **new step 4.25** that runs after embed
108/// resolution and before the second wikilink pass. This ordering lets
109/// Deferred handlers splice content that may itself contain wikilinks.
110pub fn resolve_content_with_handlers(
111    source_path: &str,
112    raw_markdown: &str,
113    graph: &ContentGraph,
114    file_reader: &dyn Fn(&str) -> Option<String>,
115    registry: &registry::RendererRegistry,
116    handlers: &embeds::MarkerHandlers<'_>,
117) -> ResolveResult {
118    // Default-empty snapshot for callers that don't yet thread asset data.
119    // Phase 0 Task F1: the snapshot-aware variant exists below and is the
120    // entry point production code should migrate to as Phase 1 lights up
121    // consumption.
122    let empty_snapshot = AssetSnapshot::new();
123    resolve_content_with_handlers_and_snapshot(
124        source_path,
125        raw_markdown,
126        graph,
127        file_reader,
128        registry,
129        handlers,
130        &empty_snapshot,
131    )
132}
133
134/// Variant of [`resolve_content_with_handlers`] that additionally threads
135/// an [`AssetSnapshot`] through the resolve pipeline.
136///
137/// **Phase 0**: the snapshot is threaded but **not yet consumed** by any
138/// resolver — Stage 1 still emits markdown without reading variants/dims.
139/// Phase 1 wires the consumption side in moss-core's synthesizer. The
140/// signature exists now so src-tauri's build pipeline can populate the
141/// snapshot (from `MediaDimensionLookup` + `AssetRegistry`) and prove the
142/// threading path before consumers depend on it.
143///
144/// See `docs/plans/2026-05-25-phase0-asset-snapshot-and-translator.md`
145/// § Phase F for the thread-first / consume-later rationale.
146pub fn resolve_content_with_handlers_and_snapshot(
147    source_path: &str,
148    raw_markdown: &str,
149    graph: &ContentGraph,
150    file_reader: &dyn Fn(&str) -> Option<String>,
151    registry: &registry::RendererRegistry,
152    handlers: &embeds::MarkerHandlers<'_>,
153    // Phase 0: threaded but not yet consumed. Phase 1 wires up reads.
154    _assets: &AssetSnapshot,
155) -> ResolveResult {
156    // Step 1: Separate frontmatter from body.
157    let (frontmatter, body) = split_frontmatter(raw_markdown);
158
159    // Phase 3 PR2: Stage 1's wikilink rewriter + stage1_sweep retire.
160    // pulldown-cmark now parses `[[…]]` / `![[…]]` natively via
161    // `Options::ENABLE_WIKILINKS` (flipped in PR2 at every Parser::new_ext
162    // site), and `transform_events::dispatch_wikilink_at` routes each event
163    // through the EmbedRenderer registry. The `stage1_sweep`
164    // (`![alt](file.pdf)` → `moss:kind=pdf` title rewrite) is retired per
165    // plan Option A: authors who want non-image embeds use the wikilink
166    // form `![[report.pdf]]`. See plan v2 § PR2.
167    let outgoing_links: Vec<OutgoingLink> = Vec::new();
168    let diagnostics: Vec<Diagnostic> = Vec::new();
169    let _ = registry; // Phase 3 PR2: registry flows directly to src-tauri's
170                      // `transform_events` via `process_markdown_file`; this
171                      // crate-side path no longer dispatches embeds in Stage 1.
172
173    // Phase 3 PR2: pre-pass that lowers block-level wikilinks into
174    // marker comments BEFORE pulldown-cmark sees them. Two classes of
175    // wikilink need this treatment because their output is block-level
176    // HTML, and pulldown-cmark wraps single-image paragraphs in `<p>`
177    // unconditionally:
178    //   - **markdown transclusions** (`![[note]]`, `![[note.md]]`,
179    //     `![[note#section]]`) → `<!-- moss-embed:TARGET -->` for
180    //     `embeds::resolve_embeds` to inline the body.
181    //   - **folder-list embeds** (`![[/dir/|limit:N]]`) →
182    //     `<!-- MOSS_MARKER_FOLDER_LIST:… -->` for src-tauri's marker
183    //     handlers to expand into card grids.
184    // Both cases used to be emitted by Stage 1's wikilink resolver; with
185    // that resolver retired, pulldown-cmark's Stage 2 dispatcher would
186    // emit the markers inside `<p>` (paragraph context), and
187    // `resolve_embeds` would never see them (it scans markdown lines,
188    // not rendered HTML). Pre-converting both shapes here mirrors the
189    // pre-Phase-3 layering.
190    let body = lower_transclusion_and_folder_wikilinks(body, graph, source_path);
191
192    // Step 3: Resolve markdown transclusion embeds. The inlined body of
193    // each embedded `.md` file is appended verbatim — its wikilinks (if
194    // any) survive into the markdown handed back to src-tauri, where
195    // pulldown-cmark + Stage 2 dispatcher resolves them along with the
196    // host page's own wikilinks.
197    let embed_result = embeds::resolve_embeds(&body, source_path, file_reader);
198    let mut diagnostics = diagnostics;
199    diagnostics.extend(embed_result.diagnostics);
200    let embed_deps = embed_result.embed_deps;
201
202    // Step 3.5: Resolve Deferred markers (notebook, table, plugins). All
203    // built-in handlers emit pure HTML (`<iframe>`, `<table>`); plugin
204    // handlers must do the same (no raw `[[…]]` in handler output).
205    // Skipped cheaply if handlers is empty.
206    let deferred_result = embeds::resolve_deferred_markers(&embed_result.content, handlers);
207    diagnostics.extend(deferred_result.diagnostics);
208
209    // Step 4.6 (DELETED, Phase 4 PR7a-stage1b 2026-05-28):
210    // `markdown_links::resolve_markdown_links` is gone. The typed AST
211    // visitor (`crates/moss-core/src/ast/resolve_urls.rs::resolve_link_urls`)
212    // now produces byte-equivalent results — including the
213    // `moss-resolved:<path>` sentinel that src-tauri's `classify_url_prod`
214    // decoder consumes for `page_map` / `external_url_map` / wikilink-class
215    // decoding. `outgoing_links` remains empty at this layer; the AST
216    // visitor's OutgoingLink Vec is consumed downstream in
217    // `process_markdown_file`.
218
219    // Step 5: Transform block references.
220    //
221    // Phase 4 PR7a (2026-05-28) deleted the Stage 1 `transform_callouts`
222    // pass that ran here. Obsidian-callout syntax is now handled by the
223    // typed AST parser (`crates/moss-core/src/ast/parser.rs`'s
224    // `Tag::BlockQuote` arm); the AST renderer emits the same canonical
225    // callout HTML (and additively handles foldable +/- suffixes and
226    // Obsidian aliases). See investigation notes referenced in the
227    // PR7a commit message for the byte-shape parity proof.
228    let (block_result, block_ids) = block_refs::transform_block_refs(&deferred_result.content);
229
230    // Step 6: Resolve frontmatter wikilinks + rejoin with resolved body.
231    let content_markdown = match frontmatter {
232        Some(fm) => {
233            let resolved_fm = resolve_frontmatter_wikilinks(fm, graph, source_path);
234            diagnostics.extend(resolved_fm.diagnostics);
235            format!("{}{}", resolved_fm.content, block_result)
236        }
237        None => block_result,
238    };
239
240    ResolveResult {
241        content_markdown,
242        outgoing_links,
243        diagnostics,
244        block_ids,
245        embed_deps,
246    }
247}
248
249/// Phase 3 PR2: lower wikilink-form markdown transclusions
250/// (`![[note]]` / `![[note.md]]` / `![[note#section]]`) into the
251/// `<!-- moss-embed:TARGET -->` marker shape that
252/// [`embeds::resolve_embeds`] consumes. Pure text rewrite — no I/O.
253///
254/// Why this pre-pass exists: pre-Phase-3, Stage 1's wikilink resolver
255/// did this conversion. Phase 3 retires that resolver and routes most
256/// wikilink handling through pulldown-cmark's Stage 2 dispatcher in
257/// `src-tauri/src/build/markdown/pipeline.rs::transform_events`. But
258/// `embeds::resolve_embeds` runs BEFORE pulldown-cmark, so the
259/// dispatcher cannot emit the marker in time. We pre-convert the
260/// transclusion wikilinks here.
261///
262/// Only `.md`-extension wikilinks (and extension-less wikilinks
263/// resolving to `.md` files) are rewritten. Image / pdf / iframe /
264/// video / audio / 3d / notebook / table embeds still flow through the
265/// Stage 2 dispatcher untouched.
266///
267/// The conversion is line-based and respects fenced code blocks
268/// (`{```/~~~}` blocks pass through unchanged). It does not honor
269/// inline-code spans on a line — wikilinks inside `` `like this` ``
270/// would also be rewritten — which mirrors the pre-Phase-3 wikilink
271/// resolver's coarse line-level scan.
272fn lower_transclusion_and_folder_wikilinks(
273    body: &str,
274    graph: &ContentGraph,
275    source_path: &str,
276) -> String {
277    let mut output_lines: Vec<String> = Vec::with_capacity(body.lines().count() + 1);
278    let mut fence_char: Option<char> = None;
279    for line in body.lines() {
280        // Fenced code block tracking — same logic as markdown_refs.
281        if let Some(fc) = fence_char {
282            let trimmed = line.trim_start();
283            let closes = trimmed.starts_with(fc)
284                && trimmed.chars().take(3).all(|c| c == fc)
285                && trimmed.trim_matches(fc).trim().is_empty();
286            if closes {
287                fence_char = None;
288            }
289            output_lines.push(line.to_string());
290            continue;
291        }
292        let trimmed = line.trim_start();
293        let fence_rest = trimmed
294            .strip_prefix("```")
295            .map(|r| ('`', r))
296            .or_else(|| trimmed.strip_prefix("~~~").map(|r| ('~', r)));
297        if let Some((candidate_char, rest)) = fence_rest {
298            if !rest.contains(candidate_char) {
299                fence_char = Some(candidate_char);
300                output_lines.push(line.to_string());
301                continue;
302            }
303        }
304
305        // Rewrite `![[…]]` wikilinks where the resolved target is a
306        // markdown file. Single-occurrence per line is the common case;
307        // a loop handles multi-occurrence safely.
308        let mut rewritten = String::with_capacity(line.len());
309        let mut rest = line;
310        while let Some(start) = rest.find("![[") {
311            rewritten.push_str(&rest[..start]);
312            let after = &rest[start + 3..];
313            let Some(end) = after.find("]]") else {
314                rewritten.push_str(&rest[start..]);
315                rest = "";
316                break;
317            };
318            let inner = &after[..end];
319            // Pothole-aware: pre-Phase-3 dropped pothole text for the
320            // marker (params live in the marker's heading-anchor /
321            // query suffix). Today the marker only cares about the
322            // `file#section` shape.
323            let inner_no_pothole = match inner.split_once('|') {
324                Some((f, _)) => f,
325                None => inner,
326            };
327            let (file_part, anchor) = match inner_no_pothole.find('#') {
328                Some(p) => (&inner_no_pothole[..p], Some(&inner_no_pothole[p + 1..])),
329                None => (inner_no_pothole, None),
330            };
331
332            // Skip empty target (`![[]]` is meaningless).
333            if file_part.is_empty() {
334                rewritten.push_str(&rest[start..start + 3 + end + 2]);
335                rest = &rest[start + 3 + end + 2..];
336                continue;
337            }
338
339            // Folder-list embed: trailing slash dispatches to the
340            // `MOSS_MARKER_FOLDER_LIST` marker that src-tauri's marker
341            // handler resolves into a card grid. The pothole carries
342            // params (limit:N, more, sort:axis) in pipe-encoded form.
343            if file_part.ends_with('/') {
344                let pothole_raw = match inner.split_once('|') {
345                    Some((_, params)) => params,
346                    None => "",
347                };
348                let params = embed_renderer::folder_list::parse_params(pothole_raw);
349                let marker =
350                    embed_renderer::folder_list::emit_marker(file_part, source_path, &params);
351                rewritten.push_str(&marker);
352                rest = &rest[start + 3 + end + 2..];
353                continue;
354            }
355
356            // Resolve via ContentGraph. Bail to no-rewrite if the
357            // reference doesn't resolve — Stage 2's dispatcher will
358            // emit the `[unresolved](moss-unresolved:…)` link form.
359            let resolved = fuzzy_path::resolve_reference(file_part, graph, source_path);
360            let target_path = match resolved {
361                fuzzy_path::ResolvedRef::Found(p) => p,
362                fuzzy_path::ResolvedRef::Unresolved => {
363                    rewritten.push_str(&rest[start..start + 3 + end + 2]);
364                    rest = &rest[start + 3 + end + 2..];
365                    continue;
366                }
367            };
368            let ext = target_path
369                .rsplit('.')
370                .next()
371                .unwrap_or("")
372                .to_ascii_lowercase();
373            // Markdown transclusion: `![[note.md]]` →
374            // `<!-- moss-embed:note.md[#anchor] -->`.
375            if ext == "md" || ext == "markdown" {
376                let target_with_anchor = match anchor {
377                    Some(a) => format!("{}#{}", target_path, a),
378                    None => target_path,
379                };
380                rewritten.push_str("<!-- moss-embed:");
381                rewritten.push_str(&target_with_anchor);
382                rewritten.push_str(" -->");
383                rest = &rest[start + 3 + end + 2..];
384                continue;
385            }
386            // Deferred-handler embeds: `.ipynb` → notebook marker,
387            // `.csv` / `.tsv` → table marker. These extensions route to
388            // src-tauri marker handlers; the Stage 2 dispatcher would
389            // also produce these markers, but it runs AFTER
390            // `resolve_deferred_markers`, so pre-converting here keeps
391            // the existing marker-handler pipeline working.
392            let marker_prefix = match ext.as_str() {
393                "ipynb" => Some("moss-embed-ipynb"),
394                "csv" | "tsv" => Some("moss-embed-table"),
395                _ => None,
396            };
397            if let Some(prefix) = marker_prefix {
398                rewritten.push_str("<!-- ");
399                rewritten.push_str(prefix);
400                rewritten.push(':');
401                rewritten.push_str(&target_path);
402                rewritten.push_str(" -->");
403                rest = &rest[start + 3 + end + 2..];
404                continue;
405            }
406            // Other extensions (.pdf / .mp4 / .png / etc.) flow through
407            // the Stage 2 dispatcher untouched — those renderers
408            // produce HTML inline, not deferred markers.
409            rewritten.push_str(&rest[start..start + 3 + end + 2]);
410            rest = &rest[start + 3 + end + 2..];
411        }
412        rewritten.push_str(rest);
413        output_lines.push(rewritten);
414    }
415    let mut out = output_lines.join("\n");
416    if body.ends_with('\n') {
417        out.push('\n');
418    }
419    out
420}
421
422pub struct FrontmatterResolveResult {
423    /// The frontmatter text with `[[wikilinks]]` replaced by resolved paths.
424    pub content: String,
425    /// Diagnostics for unresolved references.
426    pub diagnostics: Vec<Diagnostic>,
427}
428
429/// Resolve `[[wikilink]]` patterns in frontmatter text to content graph paths.
430///
431/// Unlike body wikilink resolution (which produces markdown links like
432/// `[text](url)`), this function replaces `[[ref]]` with just the resolved
433/// path string.  Surrounding quotes are preserved.
434///
435/// # Examples
436///
437/// - `sidebar: "[[news]]"` → `sidebar: "news.md"` (or resolved path)
438/// - `sidebar: [[news]]` → `sidebar: news.md`
439/// - `cover: "[[photo.jpg]]"` → `cover: "assets/photo.jpg"`
440/// - Unresolved: `[[missing]]` → `missing` (brackets stripped, diagnostic emitted)
441///
442/// The input `frontmatter` should include the delimiter(s) (e.g. `---`).
443/// Wikilinks in delimiter lines are not expected but won't cause issues.
444pub fn resolve_frontmatter_wikilinks(
445    frontmatter: &str,
446    graph: &ContentGraph,
447    source_path: &str,
448) -> FrontmatterResolveResult {
449    let mut diagnostics = Vec::new();
450    let mut result = String::with_capacity(frontmatter.len());
451    let bytes = frontmatter.as_bytes();
452    let len = bytes.len();
453    let mut i = 0;
454
455    while i < len {
456        // Look for `![[` (embed wikilink) or `[[` (regular wikilink)
457        // Embed prefix `!` is consumed — both resolve to the same path.
458        // For embeds `![[path|attrs]]`, pipe content = display params (preserved).
459        // For links `[[path|alias]]`, pipe content = alias text (discarded per Obsidian convention).
460        let is_embed =
461            i + 2 < len && bytes[i] == b'!' && bytes[i + 1] == b'[' && bytes[i + 2] == b'[';
462        let is_wikilink = !is_embed && i + 1 < len && bytes[i] == b'[' && bytes[i + 1] == b'[';
463        if is_embed || is_wikilink {
464            let bracket_start = if is_embed { i + 3 } else { i + 2 };
465            // Find closing `]]`
466            if let Some(close_pos) = find_closing_brackets(bytes, bracket_start) {
467                // Char-aligned: `bracket_start = i + 2` or `i + 3` where `i` is the
468                // byte-cursor invariant of the outer loop (see else-branch comment),
469                // and the offsets cross only ASCII bytes (`[`, `!`). `close_pos` is
470                // returned by `find_closing_brackets` which scans for the ASCII pair
471                // `]]`, so it lands on a char boundary.
472                #[allow(clippy::string_slice)]
473                let inner = &frontmatter[bracket_start..close_pos];
474
475                // Split on | to separate path from pipe content
476                let (ref_part, attrs_part) = crate::media::split_pipe(inner);
477
478                // Resolve only the path part via the content graph
479                let resolved_path = match graph.resolve_path(ref_part, source_path) {
480                    Some(mut path) => {
481                        // Only preserve pipe attrs for embed syntax (![[...|attrs]])
482                        // For regular wikilinks ([[...|alias]]), discard the alias
483                        if is_embed && !attrs_part.is_empty() {
484                            path.push('|');
485                            path.push_str(attrs_part);
486                        }
487                        path
488                    }
489                    None => {
490                        diagnostics.push(Diagnostic {
491                            message: format!("Unresolved frontmatter wikilink: [[{}]]", ref_part),
492                            source_path: source_path.to_string(),
493                            reference: ref_part.to_string(),
494                        });
495                        // Strip brackets, use the path text as-is
496                        let mut fallback = ref_part.to_string();
497                        // Only preserve attrs for embed syntax
498                        if is_embed && !attrs_part.is_empty() {
499                            fallback.push('|');
500                            fallback.push_str(attrs_part);
501                        }
502                        fallback
503                    }
504                };
505
506                result.push_str(&resolved_path);
507                i = close_pos + 2; // skip past `]]`
508            } else {
509                // No closing `]]` found — emit the opening chars as-is
510                if is_embed {
511                    result.push_str("![[");
512                    i += 3;
513                } else {
514                    result.push('[');
515                    i += 1;
516                }
517            }
518        } else {
519            // Byte-cursor invariant: `i` is always at a UTF-8 char boundary.
520            //   * Initial value `i = 0` is a boundary.
521            //   * In the wikilink branch above, `i` is reassigned to either
522            //     `close_pos + 2` (close_pos is the byte index of the first `]`
523            //     in the ASCII pair `]]`, so +2 also lands on an ASCII byte) or
524            //     advanced by `+= 3` / `+= 1` past ASCII chars (`!`, `[`).
525            //   * In this else branch, we read one full char from the boundary
526            //     and advance by exactly its UTF-8 length, preserving the boundary.
527            // Therefore slicing `frontmatter[i..]` here is safe, and the
528            // `let-else { break }` is a defensive fallback: the loop guard
529            // `i < len` already ensures at least one byte is available, but
530            // bailing cleanly is cheaper than a panic if the invariant ever
531            // breaks.
532            #[allow(clippy::string_slice)]
533            let Some(ch) = frontmatter[i..].chars().next() else {
534                break;
535            };
536            result.push(ch);
537            i += ch.len_utf8();
538        }
539    }
540
541    FrontmatterResolveResult {
542        content: result,
543        diagnostics,
544    }
545}
546
547/// Find the position of the first `]]` in `bytes` starting from `start`.
548/// Returns the byte index of the first `]` in the `]]` pair, or `None`.
549fn find_closing_brackets(bytes: &[u8], start: usize) -> Option<usize> {
550    let mut j = start;
551    while j + 1 < bytes.len() {
552        if bytes[j] == b']' && bytes[j + 1] == b']' {
553            return Some(j);
554        }
555        // Wikilinks in frontmatter values are expected to be on a single line.
556        // We allow multi-line scanning for robustness.
557        j += 1;
558    }
559    None
560}
561
562/// Scan `content` starting from byte offset `scan_start` for the first
563/// standalone `---` line.  Returns the byte position just past the
564/// delimiter (including its trailing newline, if present).
565fn find_delimiter(content: &str, scan_start: usize) -> Option<usize> {
566    // Char-aligned: callers pass either 0 or `pos + 1` where `pos = content.find('\n')`
567    // (an ASCII byte). Both values land on a UTF-8 char boundary.
568    #[allow(clippy::string_slice)]
569    let rest = &content[scan_start..];
570    let mut offset = 0;
571    for line in rest.lines() {
572        if line.trim() == "---" {
573            let close_abs = scan_start + offset + line.len();
574            return if close_abs < content.len() && content.as_bytes()[close_abs] == b'\n' {
575                Some(close_abs + 1)
576            } else {
577                Some(close_abs)
578            };
579        }
580        offset += line.len() + 1; // +1 for '\n'
581    }
582    None
583}
584
585/// Split content into (frontmatter_including_delimiters, body).
586///
587/// Supports two frontmatter formats:
588///
589/// **Standard YAML** — content starts with `---\n`:
590/// ```text
591/// ---
592/// title: Hello
593/// ---
594/// Body here.
595/// ```
596///
597/// **Simplified** — content does NOT start with `---`, but contains a
598/// standalone `---` line that separates frontmatter from body:
599/// ```text
600/// children: false
601/// sidebar: "[[news]]"
602/// ---
603///
604/// # Page Title
605/// ```
606///
607/// In both cases the frontmatter portion includes the delimiter(s) and
608/// any trailing newline after the closing `---`.  Returns
609/// `(None, full_content)` when no frontmatter is detected.
610fn split_frontmatter(content: &str) -> (Option<&str>, &str) {
611    if content.starts_with("---") {
612        // --- Standard YAML frontmatter ---
613
614        // Find end of the opening `---` line.
615        let after_opening = match content.find('\n') {
616            Some(pos) => pos + 1,
617            None => return (None, content),
618        };
619
620        // Search for a closing `---` line in the remainder.
621        // Char-aligned: `split_pos` is computed by `find_delimiter` from
622        // `scan_start + line.len() + (line.len() + 1)*N + (0 or 1)`. All
623        // components are either char-aligned (`scan_start`, slices from `lines()`)
624        // or single ASCII bytes (`'\n'`), so `split_pos` is on a char boundary.
625        #[allow(clippy::string_slice)]
626        match find_delimiter(content, after_opening) {
627            Some(split_pos) => (Some(&content[..split_pos]), &content[split_pos..]),
628            None => (None, content), // No closing delimiter — treat entire content as body.
629        }
630    } else {
631        // --- Simplified frontmatter ---
632        // Look for the first standalone `---` line.  Everything up to and
633        // including that line (plus its trailing newline) is frontmatter;
634        // everything after is body.
635        // Same char-alignment rationale as above.
636        #[allow(clippy::string_slice)]
637        match find_delimiter(content, 0) {
638            Some(split_pos) => (Some(&content[..split_pos]), &content[split_pos..]),
639            None => (None, content), // No `---` found at all — no frontmatter.
640        }
641    }
642}
643
644/// Extract the parent directory from a `/`-separated path.
645///
646/// `"posts/hello.md"` -> `"posts"`, `"hello.md"` -> `""`.
647pub(crate) fn parent_dir(path: &str) -> &str {
648    match path.rfind('/') {
649        // Char-aligned: '/' is an ASCII byte, so `pos` is a char boundary.
650        #[allow(clippy::string_slice)]
651        Some(pos) => &path[..pos],
652        None => "",
653    }
654}
655
656// ---------------------------------------------------------------------------
657// Tests
658// ---------------------------------------------------------------------------
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use crate::content_graph::ContentGraphBuilder;
664    use std::collections::HashMap;
665
666    fn test_graph() -> ContentGraph {
667        let mut b = ContentGraphBuilder::new();
668        b.add_file("guide.md", "guide");
669        b.add_file("note.md", "note");
670        b.add_file("disclaimer.md", "disclaimer");
671        b.add_file("assets/photo.jpg", "photo");
672        b.add_headings("guide.md", vec![("Setup".into(), "setup".into())]);
673        b.add_blocks("guide.md", vec!["key-point".into()]);
674        b.build()
675    }
676
677    fn test_files() -> HashMap<String, String> {
678        let mut files = HashMap::new();
679        files.insert(
680            "disclaimer.md".into(),
681            "---\ntitle: Disclaimer\n---\nThis is the disclaimer.\n\nSee [[guide]] for details."
682                .into(),
683        );
684        files
685    }
686
687    fn mock_reader(files: &HashMap<String, String>) -> impl Fn(&str) -> Option<String> + '_ {
688        move |path: &str| files.get(path).cloned()
689    }
690
691    // ----- split_frontmatter unit tests -----
692
693    #[test]
694    fn test_split_fm_present() {
695        let input = "---\ntitle: Hello\n---\nBody here.";
696        let (fm, body) = split_frontmatter(input);
697        assert_eq!(fm, Some("---\ntitle: Hello\n---\n"));
698        assert_eq!(body, "Body here.");
699    }
700
701    #[test]
702    fn test_split_fm_absent() {
703        let input = "Just body content.";
704        let (fm, body) = split_frontmatter(input);
705        assert!(fm.is_none());
706        assert_eq!(body, input);
707    }
708
709    #[test]
710    fn test_split_fm_no_closing() {
711        let input = "---\ntitle: Hello\nno closing delimiter";
712        let (fm, body) = split_frontmatter(input);
713        assert!(fm.is_none());
714        assert_eq!(body, input);
715    }
716
717    // ----- split_frontmatter: simplified frontmatter tests -----
718
719    #[test]
720    fn test_split_simplified_frontmatter() {
721        // Simplified format: no opening `---`, frontmatter lines before a `---` delimiter.
722        let input = "sidebar: [[news]]\n---\n\n# Hello";
723        let (fm, body) = split_frontmatter(input);
724        assert_eq!(fm, Some("sidebar: [[news]]\n---\n"));
725        assert_eq!(body, "\n# Hello");
726    }
727
728    #[test]
729    fn test_split_simplified_preserves_body() {
730        let input = "children: false\nuid: a48746ca\n---\n\n# Page Title\n\nBody content here\n";
731        let (fm, body) = split_frontmatter(input);
732        assert_eq!(fm, Some("children: false\nuid: a48746ca\n---\n"));
733        assert_eq!(body, "\n# Page Title\n\nBody content here\n");
734    }
735
736    #[test]
737    fn test_split_no_delimiter() {
738        // No `---` at all — everything is body, no frontmatter.
739        let input = "Just some content\nwith multiple lines\nbut no delimiter";
740        let (fm, body) = split_frontmatter(input);
741        assert!(fm.is_none());
742        assert_eq!(body, input);
743    }
744
745    #[test]
746    fn test_split_simplified_with_quoted_wikilink() {
747        let input = "sidebar: \"[[news]]\"\n---\nBody text";
748        let (fm, body) = split_frontmatter(input);
749        assert_eq!(fm, Some("sidebar: \"[[news]]\"\n---\n"));
750        assert_eq!(body, "Body text");
751    }
752
753    #[test]
754    fn test_split_simplified_empty_body() {
755        // Simplified frontmatter with nothing after the delimiter.
756        let input = "title: Test\n---\n";
757        let (fm, body) = split_frontmatter(input);
758        assert_eq!(fm, Some("title: Test\n---\n"));
759        assert_eq!(body, "");
760    }
761
762    #[test]
763    fn test_split_simplified_delimiter_at_eof_no_newline() {
764        // Simplified frontmatter where `---` is the last line with no trailing newline.
765        let input = "title: Test\n---";
766        let (fm, body) = split_frontmatter(input);
767        assert_eq!(fm, Some("title: Test\n---"));
768        assert_eq!(body, "");
769    }
770
771    #[test]
772    fn test_split_simplified_multiple_dashes_in_body() {
773        // Only the FIRST `---` should be treated as the delimiter.
774        let input = "title: Test\n---\n\nSome body\n---\nMore body";
775        let (fm, body) = split_frontmatter(input);
776        assert_eq!(fm, Some("title: Test\n---\n"));
777        assert_eq!(body, "\nSome body\n---\nMore body");
778    }
779
780    // ----- Integration tests for resolve_content -----
781
782    #[test]
783    fn test_full_resolve_pipeline() {
784        let graph = test_graph();
785        let files = test_files();
786
787        let input = "---\ntitle: Test\n---\nSee [[guide#Setup]] for help.\n\nImportant point. ^my-block\n\n> [!warning] Watch Out\n> Be careful here.";
788
789        let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
790
791        // Frontmatter preserved
792        assert!(result
793            .content_markdown
794            .starts_with("---\ntitle: Test\n---\n"));
795
796        // Phase 3 PR2: `resolve_content` no longer resolves body wikilinks
797        // — that's the Stage 2 dispatcher's job in
798        // `src-tauri/src/build/markdown/pipeline.rs::transform_events`.
799        // The `[[guide#Setup]]` wikilink passes through unchanged here.
800        assert!(result.content_markdown.contains("[[guide#Setup]]"));
801
802        // Block ref transformed
803        assert!(result
804            .content_markdown
805            .contains("<span id=\"my-block\"></span>"));
806        assert_eq!(result.block_ids, vec!["my-block"]);
807
808        // Phase 4 PR7a (2026-05-28): Stage 1 `transform_callouts` is
809        // deleted. Callout transformation now lives in the typed AST
810        // parser (`ast/parser.rs`'s Tag::BlockQuote arm) and renderer.
811        // `resolve_content` returns raw markdown here — the `> [!warning]`
812        // syntax passes through verbatim for downstream parsing.
813        assert!(
814            result.content_markdown.contains("> [!warning] Watch Out"),
815            "Expected callout markdown to pass through verbatim post-PR7a, got: {}",
816            result.content_markdown
817        );
818    }
819
820    #[test]
821    fn test_frontmatter_preserved() {
822        let graph = test_graph();
823        let files = HashMap::new();
824
825        let input = "---\ntitle: My Page\ntags:\n  - rust\n  - wasm\n---\nPlain body.";
826        let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
827
828        assert!(result
829            .content_markdown
830            .starts_with("---\ntitle: My Page\ntags:\n  - rust\n  - wasm\n---\n"));
831        assert!(result.content_markdown.ends_with("Plain body."));
832    }
833
834    #[test]
835    fn test_no_obsidian_syntax() {
836        let graph = test_graph();
837        let files = HashMap::new();
838
839        let input = "---\ntitle: Plain\n---\nJust a plain paragraph.\n\nAnother paragraph.";
840        let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
841
842        assert_eq!(result.content_markdown, input);
843        assert!(result.outgoing_links.is_empty());
844        assert!(result.diagnostics.is_empty());
845        assert!(result.block_ids.is_empty());
846        assert!(result.embed_deps.is_empty());
847    }
848
849    #[test]
850    fn test_embedded_wikilinks_resolved() {
851        let graph = test_graph();
852        let files = test_files();
853
854        // disclaimer.md body contains `See [[guide]] for details.`
855        // Phase 3 PR2: the embedded body's wikilink is no longer
856        // resolved by `resolve_content`; the Stage 2 dispatcher in
857        // src-tauri handles it. `resolve_content` lowers
858        // `![[disclaimer]]` into the `<!-- moss-embed:disclaimer.md -->`
859        // marker, then `resolve_embeds` inlines the disclaimer body
860        // verbatim — wikilinks inside survive into the markdown
861        // returned here.
862        let input = "![[disclaimer]]";
863        let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
864
865        // The embedded body's wikilink survives as raw `[[guide]]`
866        // (handed off to Stage 2 downstream).
867        assert!(
868            result.content_markdown.contains("[[guide]]"),
869            "Expected raw wikilink from embedded content, got: {}",
870            result.content_markdown
871        );
872        // The disclaimer body text should be present
873        assert!(result.content_markdown.contains("This is the disclaimer."));
874    }
875
876    #[test]
877    fn test_diagnostics_merged() {
878        let graph = test_graph();
879        let files = HashMap::new();
880
881        // Phase 3 PR2: wikilink unresolved diagnostics now surface from
882        // the Stage 2 dispatcher in src-tauri. `resolve_content` only
883        // surfaces diagnostics from passes it still runs (transclusion
884        // / deferred markers / block refs). `![[missing]]` with no
885        // extension resolves to Unresolved in the lowering pass — but
886        // the lowering pass leaves the raw `![[missing]]` for Stage 2
887        // to handle and does NOT emit a diagnostic itself. So this
888        // test asserts the new contract: zero diagnostics for body
889        // wikilinks at this layer.
890        let input = "[[nonexistent]] and ![[missing]]";
891        let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
892
893        // Body wikilinks pass through; no diagnostics from this layer.
894        assert!(
895            result.diagnostics.is_empty(),
896            "Expected zero diagnostics post-PR2 (body wikilinks deferred), got: {:?}",
897            result.diagnostics
898        );
899        // Raw wikilinks pass through to the markdown handed back.
900        assert!(result.content_markdown.contains("[[nonexistent]]"));
901        assert!(result.content_markdown.contains("![[missing]]"));
902    }
903
904    #[test]
905    fn test_outgoing_links_tracked() {
906        let graph = test_graph();
907        let files = test_files();
908
909        // Phase 3 PR2: body wikilink outgoing-links are populated by
910        // the Stage 2 dispatcher in src-tauri (not by `resolve_content`).
911        // What this layer still populates: block_refs results. The
912        // wikilink body links `[[guide]]` and `![[disclaimer]]` pass
913        // through to Stage 2; standard markdown links pass through to
914        // the AST visitor (`ast/resolve_urls`).
915        let input = "[[guide]]\n\n![[disclaimer]]";
916        let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
917
918        // The disclaimer body got inlined (via `<!-- moss-embed -->`
919        // lowering + resolve_embeds), but its `[[guide]]` is now raw
920        // markdown for Stage 2 — none of these appear in
921        // outgoing_links from this layer.
922        let wikilinks: Vec<_> = result
923            .outgoing_links
924            .iter()
925            .filter(|l| l.link_type == LinkType::Wikilink)
926            .collect();
927        let embeds: Vec<_> = result
928            .outgoing_links
929            .iter()
930            .filter(|l| l.link_type == LinkType::Embed)
931            .collect();
932
933        assert!(
934            wikilinks.is_empty(),
935            "Expected zero wikilink outgoing links from resolve_content post-PR2; got {}: {:?}",
936            wikilinks.len(),
937            wikilinks
938        );
939        // No-op smoke check that the rest of the assertions still
940        // exercise the embed-tracking path through `embed_deps`.
941        let _ = embeds; // not populated by this layer either
942        assert!(
943            !result.embed_deps.is_empty(),
944            "Expected at least 1 embed outgoing link"
945        );
946    }
947
948    #[test]
949    fn test_embed_deps_tracked() {
950        let graph = test_graph();
951        let files = test_files();
952
953        let input = "![[disclaimer]]";
954        let result = resolve_content("note.md", input, &graph, &mock_reader(&files));
955
956        assert!(
957            result
958                .embed_deps
959                .contains(&("disclaimer.md".to_string(), "note.md".to_string())),
960            "Expected embed dep (disclaimer.md, note.md), got: {:?}",
961            result.embed_deps
962        );
963    }
964
965    // ----- Regression test for deeply-nested Unicode paths (#342) -----
966
967    #[test]
968    fn test_deeply_nested_unicode_bare_filename() {
969        let mut b = ContentGraphBuilder::new();
970        b.add_file(
971            "assets/d9512f2d-fdcf-4a22-b1d5-340f74ddedae.jpg",
972            "d9512f2d",
973        );
974        b.add_file(
975            "articles/\u{65e0}\u{7528}\u{4e4b}\u{65c5}/\u{771f}\u{6b63}\u{7684}\u{65c5}\u{7a0b}.md",
976            "articles/\u{65e0}\u{7528}\u{4e4b}\u{65c5}/\u{771f}\u{6b63}\u{7684}\u{65c5}\u{7a0b}",
977        );
978        let graph = b.build();
979        let files = HashMap::new();
980
981        let input = "---\ndate: 2025-12-03\n---\n![](d9512f2d-fdcf-4a22-b1d5-340f74ddedae.jpg)\n\nSome text.";
982        let result = resolve_content(
983            "articles/\u{65e0}\u{7528}\u{4e4b}\u{65c5}/\u{771f}\u{6b63}\u{7684}\u{65c5}\u{7a0b}.md",
984            input,
985            &graph,
986            &mock_reader(&files),
987        );
988
989        // Phase 4 PR7a (2026-05-28): Stage 1 `resolve_markdown_refs` is
990        // deleted. Bare-filename image resolution now happens in the
991        // typed AST visitor (`ast/resolve_urls::resolve_image_urls`)
992        // downstream of `resolve_content`. The image src passes through
993        // verbatim here.
994        assert!(
995            result
996                .content_markdown
997                .contains("![](d9512f2d-fdcf-4a22-b1d5-340f74ddedae.jpg)"),
998            "Expected bare filename to pass through verbatim, got: {}",
999            result.content_markdown
1000        );
1001    }
1002
1003    // ----- Integration test for markdown image bare-filename resolution -----
1004
1005    #[test]
1006    fn test_bare_filename_image_passes_through_in_pipeline() {
1007        let mut b = ContentGraphBuilder::new();
1008        b.add_file("guide.md", "guide");
1009        b.add_file("note.md", "note");
1010        b.add_file("assets/photo.jpg", "photo");
1011        b.add_headings("guide.md", vec![("Setup".into(), "setup".into())]);
1012        b.add_blocks("guide.md", vec!["key-point".into()]);
1013        let graph = b.build();
1014        let files = HashMap::new();
1015
1016        let input = "---\ntitle: Test\n---\n![My Image](photo.jpg)\n\nSome text.";
1017        let result = resolve_content("articles/post.md", input, &graph, &mock_reader(&files));
1018
1019        // Frontmatter preserved
1020        assert!(result
1021            .content_markdown
1022            .starts_with("---\ntitle: Test\n---\n"));
1023
1024        // Phase 4 PR7a (2026-05-28): Stage 1 `resolve_markdown_refs` is
1025        // deleted. The bare filename now passes through `resolve_content`
1026        // verbatim; the typed AST visitor
1027        // (`ast/resolve_urls::resolve_image_urls`) resolves it later in
1028        // `process_markdown_file`. The visitor has its own coverage in
1029        // `resolve_urls.rs::tests::resolves_bare_filename_image_against_graph`.
1030        assert!(
1031            result.content_markdown.contains("![My Image](photo.jpg)"),
1032            "Expected bare filename to pass through verbatim, got: {}",
1033            result.content_markdown
1034        );
1035
1036        // No Standard outgoing link from this layer either — the visitor
1037        // emits them downstream.
1038        let standard_links: Vec<_> = result
1039            .outgoing_links
1040            .iter()
1041            .filter(|l| l.link_type == LinkType::Standard)
1042            .collect();
1043        assert!(
1044            standard_links.is_empty(),
1045            "Expected zero standard outgoing links from resolve_content post-PR7a, got: {:?}",
1046            standard_links
1047        );
1048    }
1049
1050    // ----- resolve_frontmatter_wikilinks unit tests -----
1051
1052    fn fm_test_graph() -> ContentGraph {
1053        let mut b = ContentGraphBuilder::new();
1054        b.add_file("index.md", "index");
1055        b.add_file("news.md", "news");
1056        b.add_file("news/index.md", "news-index");
1057        b.add_file("assets/photo.jpg", "photo");
1058        b.add_file("posts/ch-1.md", "ch-1");
1059        b.add_file("posts/ch-2.md", "ch-2");
1060        b.build()
1061    }
1062
1063    #[test]
1064    fn test_fm_wikilink_basic_quoted() {
1065        let graph = fm_test_graph();
1066        let fm = "---\nsidebar: \"[[news]]\"\n---\n";
1067        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1068        assert_eq!(result.content, "---\nsidebar: \"news.md\"\n---\n");
1069        assert!(result.diagnostics.is_empty());
1070    }
1071
1072    #[test]
1073    fn test_fm_wikilink_unquoted() {
1074        let graph = fm_test_graph();
1075        let fm = "---\nsidebar: [[news]]\n---\n";
1076        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1077        assert_eq!(result.content, "---\nsidebar: news.md\n---\n");
1078        assert!(result.diagnostics.is_empty());
1079    }
1080
1081    #[test]
1082    fn test_fm_wikilink_cover_image() {
1083        let graph = fm_test_graph();
1084        let fm = "---\ncover: \"[[photo.jpg]]\"\n---\n";
1085        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1086        assert_eq!(result.content, "---\ncover: \"assets/photo.jpg\"\n---\n");
1087        assert!(result.diagnostics.is_empty());
1088    }
1089
1090    #[test]
1091    fn test_fm_wikilink_folder_note() {
1092        // [[news]] when news/index.md exists should resolve to folder note path.
1093        // But news.md also exists and is an exact stem match, so it resolves to news.md.
1094        // Let's build a graph where only the folder note exists.
1095        let mut b = ContentGraphBuilder::new();
1096        b.add_file("index.md", "index");
1097        b.add_file("news/index.md", "news-index");
1098        let graph = b.build();
1099
1100        let fm = "---\nsidebar: \"[[news]]\"\n---\n";
1101        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1102        assert_eq!(result.content, "---\nsidebar: \"news/index.md\"\n---\n");
1103        assert!(result.diagnostics.is_empty());
1104    }
1105
1106    #[test]
1107    fn test_fm_wikilink_array_items() {
1108        let graph = fm_test_graph();
1109        let fm = "---\nseries: [\"[[ch-1]]\", \"[[ch-2]]\"]\n---\n";
1110        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1111        assert_eq!(
1112            result.content,
1113            "---\nseries: [\"posts/ch-1.md\", \"posts/ch-2.md\"]\n---\n"
1114        );
1115        assert!(result.diagnostics.is_empty());
1116    }
1117
1118    #[test]
1119    fn test_fm_wikilink_unresolved() {
1120        let graph = fm_test_graph();
1121        let fm = "---\nsidebar: \"[[missing]]\"\n---\n";
1122        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1123        // Brackets stripped, inner text used as fallback
1124        assert_eq!(result.content, "---\nsidebar: \"missing\"\n---\n");
1125        assert_eq!(result.diagnostics.len(), 1);
1126        assert_eq!(result.diagnostics[0].reference, "missing");
1127        assert_eq!(result.diagnostics[0].source_path, "index.md");
1128        assert!(result.diagnostics[0].message.contains("[[missing]]"));
1129    }
1130
1131    #[test]
1132    fn test_fm_wikilink_multiple() {
1133        let graph = fm_test_graph();
1134        let fm = "---\nsidebar: \"[[news]]\"\ncover: \"[[photo.jpg]]\"\n---\n";
1135        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1136        assert_eq!(
1137            result.content,
1138            "---\nsidebar: \"news.md\"\ncover: \"assets/photo.jpg\"\n---\n"
1139        );
1140        assert!(result.diagnostics.is_empty());
1141    }
1142
1143    #[test]
1144    fn test_fm_no_wikilinks() {
1145        let graph = fm_test_graph();
1146        let fm = "---\ntitle: Hello\ntags:\n  - rust\n---\n";
1147        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1148        assert_eq!(result.content, fm);
1149        assert!(result.diagnostics.is_empty());
1150    }
1151
1152    #[test]
1153    fn test_fm_simplified_frontmatter_wikilink() {
1154        let graph = fm_test_graph();
1155        // Simplified frontmatter (no opening ---)
1156        let fm = "sidebar: \"[[news]]\"\nchildren: false\n---\n";
1157        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1158        assert_eq!(
1159            result.content,
1160            "sidebar: \"news.md\"\nchildren: false\n---\n"
1161        );
1162        assert!(result.diagnostics.is_empty());
1163    }
1164
1165    #[test]
1166    fn test_fm_unclosed_wikilink_preserved() {
1167        let graph = fm_test_graph();
1168        let fm = "---\nsidebar: \"[[unclosed\"\n---\n";
1169        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1170        // No closing ]] — the [[ is preserved as-is
1171        assert_eq!(result.content, "---\nsidebar: \"[[unclosed\"\n---\n");
1172        assert!(result.diagnostics.is_empty());
1173    }
1174
1175    #[test]
1176    fn test_fm_mixed_resolved_and_unresolved() {
1177        let graph = fm_test_graph();
1178        let fm = "---\nsidebar: \"[[news]]\"\nrelated: \"[[missing]]\"\n---\n";
1179        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1180        assert_eq!(
1181            result.content,
1182            "---\nsidebar: \"news.md\"\nrelated: \"missing\"\n---\n"
1183        );
1184        assert_eq!(result.diagnostics.len(), 1);
1185        assert_eq!(result.diagnostics[0].reference, "missing");
1186    }
1187
1188    // ----- Pipe-aware frontmatter wikilink resolution -----
1189
1190    #[test]
1191    fn test_fm_wikilink_alias_discarded() {
1192        // [[photo.jpg|left]] — pipe content is alias (Obsidian convention), discarded
1193        let graph = fm_test_graph();
1194        let fm = "---\ncover: \"[[photo.jpg|left]]\"\n---\n";
1195        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1196        assert_eq!(result.content, "---\ncover: \"assets/photo.jpg\"\n---\n");
1197        assert!(result.diagnostics.is_empty());
1198    }
1199
1200    #[test]
1201    fn test_fm_embed_wikilink_with_attrs() {
1202        // ![[photo.jpg|cover left]] — embed syntax preserves display params
1203        let graph = fm_test_graph();
1204        let fm = "---\ncover: \"![[photo.jpg|cover left]]\"\n---\n";
1205        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1206        assert_eq!(
1207            result.content,
1208            "---\ncover: \"assets/photo.jpg|cover left\"\n---\n"
1209        );
1210        assert!(result.diagnostics.is_empty());
1211    }
1212
1213    #[test]
1214    fn test_fm_wikilink_no_attrs_unchanged() {
1215        // [[photo.jpg]] without pipe should work exactly as before
1216        let graph = fm_test_graph();
1217        let fm = "---\ncover: \"[[photo.jpg]]\"\n---\n";
1218        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1219        assert_eq!(result.content, "---\ncover: \"assets/photo.jpg\"\n---\n");
1220        assert!(result.diagnostics.is_empty());
1221    }
1222
1223    #[test]
1224    fn test_fm_wikilink_alias_unresolved_discarded() {
1225        // [[missing.jpg|left]] — unresolved, alias still discarded
1226        let graph = fm_test_graph();
1227        let fm = "---\ncover: \"[[missing.jpg|left]]\"\n---\n";
1228        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1229        assert_eq!(result.content, "---\ncover: \"missing.jpg\"\n---\n");
1230        assert_eq!(result.diagnostics.len(), 1);
1231        assert_eq!(result.diagnostics[0].reference, "missing.jpg");
1232    }
1233
1234    #[test]
1235    fn test_fm_embed_wikilink_with_fit_and_position() {
1236        // ![[photo.jpg|contain top-right]] — embed syntax preserves both keywords
1237        let graph = fm_test_graph();
1238        let fm = "---\ncover: \"![[photo.jpg|contain top-right]]\"\n---\n";
1239        let result = resolve_frontmatter_wikilinks(fm, &graph, "index.md");
1240        assert_eq!(
1241            result.content,
1242            "---\ncover: \"assets/photo.jpg|contain top-right\"\n---\n"
1243        );
1244        assert!(result.diagnostics.is_empty());
1245    }
1246
1247    // ----- Frontmatter wikilinks are now resolved to paths -----
1248
1249    #[test]
1250    fn test_simplified_frontmatter_wikilink_resolved_to_path() {
1251        let mut b = ContentGraphBuilder::new();
1252        b.add_file("index.md", "index");
1253        b.add_file("news.md", "news");
1254        let graph = b.build();
1255        let files = HashMap::new();
1256
1257        // Simplified frontmatter (no leading ---) with a wikilink in sidebar value.
1258        // Frontmatter wikilinks ARE still resolved here (to a path).
1259        let input = "children: false\nsidebar: \"[[news]]\"\nuid: a48746ca\n---\n\n# Welcome\n\nBody with [[news]] link.";
1260        let result = resolve_content("index.md", input, &graph, &mock_reader(&files));
1261
1262        // Frontmatter wikilink [[news]] resolved to path "news.md", quotes preserved.
1263        assert!(
1264            result
1265                .content_markdown
1266                .starts_with("children: false\nsidebar: \"news.md\"\nuid: a48746ca\n---\n"),
1267            "Frontmatter wikilink not resolved to path: {}",
1268            result.content_markdown
1269        );
1270
1271        // Phase 3 PR2: body wikilink `[[news]]` passes through as raw
1272        // markdown — Stage 2 in src-tauri resolves it via the
1273        // `dispatch_wikilink_embed` arm in `transform_events`.
1274        assert!(
1275            result.content_markdown.contains("[[news]]"),
1276            "Expected body wikilink to pass through verbatim, got: {}",
1277            result.content_markdown
1278        );
1279    }
1280
1281    #[test]
1282    fn test_frontmatter_embed_wikilink_stripped() {
1283        let mut b = ContentGraphBuilder::new();
1284        b.add_file("index.md", "index");
1285        b.add_file("photos/hero.jpg", "hero");
1286        let graph = b.build();
1287        let files = HashMap::new();
1288
1289        // Embed wikilink ![[hero.jpg]] in frontmatter cover — the ! prefix should be consumed.
1290        let input = "cover: \"![[hero.jpg]]\"\n---\n\n# Page";
1291        let result = resolve_content("index.md", input, &graph, &mock_reader(&files));
1292
1293        // Should resolve to path without ! prefix
1294        assert!(
1295            result
1296                .content_markdown
1297                .starts_with("cover: \"photos/hero.jpg\"\n---"),
1298            "Embed wikilink ! prefix not stripped: {}",
1299            result.content_markdown
1300        );
1301    }
1302
1303    #[test]
1304    fn test_frontmatter_embed_wikilink_with_attrs() {
1305        let mut b = ContentGraphBuilder::new();
1306        b.add_file("index.md", "index");
1307        b.add_file("photos/hero.jpg", "hero");
1308        let graph = b.build();
1309        let files = HashMap::new();
1310
1311        // Embed wikilink with display attrs: ![[hero.jpg|cover left]]
1312        let input = "cover: \"![[hero.jpg|cover left]]\"\n---\n\n# Page";
1313        let result = resolve_content("index.md", input, &graph, &mock_reader(&files));
1314
1315        // Should resolve path and preserve attrs
1316        assert!(
1317            result
1318                .content_markdown
1319                .starts_with("cover: \"photos/hero.jpg|cover left\"\n---"),
1320            "Embed wikilink with attrs not resolved correctly: {}",
1321            result.content_markdown
1322        );
1323    }
1324
1325    #[test]
1326    fn standard_markdown_link_passes_through_in_pipeline() {
1327        // Phase 4 PR7a-stage1b (2026-05-28): Stage 1
1328        // `markdown_links::resolve_markdown_links` is deleted. The bare
1329        // markdown link now passes through `resolve_content` verbatim;
1330        // the typed AST visitor
1331        // (`ast/resolve_urls::resolve_link_urls`) emits the
1332        // `moss-resolved:文字/文字.md` sentinel later in
1333        // `process_markdown_file`, and src-tauri's `classify_url_prod`
1334        // decodes the sentinel into the final pretty URL. Visitor
1335        // coverage lives in
1336        // `resolve_urls.rs::tests::standard_markdown_link_emits_sentinel`.
1337        let mut b = ContentGraphBuilder::new();
1338        b.add_file("index.md", "index");
1339        b.add_file("文字/文字.md", "writings");
1340        let graph = b.build();
1341
1342        let files = HashMap::new();
1343        let result = resolve_content(
1344            "index.md",
1345            "[文字](文字.md)\n",
1346            &graph,
1347            &mock_reader(&files),
1348        );
1349
1350        // resolve_content now passes the link through verbatim.
1351        assert!(
1352            result.content_markdown.contains("[文字](文字.md)"),
1353            "expected verbatim pass-through, got: {}",
1354            result.content_markdown
1355        );
1356    }
1357
1358    #[test]
1359    fn test_frontmatter_link_wikilink_alias_discarded() {
1360        let mut b = ContentGraphBuilder::new();
1361        b.add_file("index.md", "index");
1362        b.add_file("photos/hero.jpg", "hero");
1363        let graph = b.build();
1364        let files = HashMap::new();
1365
1366        // Regular wikilink [[hero.jpg|My Hero]] — pipe content is alias, should be discarded
1367        let input = "cover: \"[[hero.jpg|My Hero]]\"\n---\n\n# Page";
1368        let result = resolve_content("index.md", input, &graph, &mock_reader(&files));
1369
1370        // Should resolve path but discard alias (Obsidian convention: pipe = alias in [[...]])
1371        assert!(
1372            result
1373                .content_markdown
1374                .starts_with("cover: \"photos/hero.jpg\"\n---"),
1375            "Link wikilink alias should be discarded, got: {}",
1376            result.content_markdown
1377        );
1378    }
1379}