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