Skip to main content

moss_core/resolve/
reference.rs

1//! The single reference classifier shared by build + editor (asset/file-embed/
2//! folder kinds). Pure; indexes injected via ReferenceContext. Page-Link
3//! emission is out of scope here (the build keeps relative_pretty_url/page_map);
4//! Link is classify-only. Named `classify_reference` to avoid colliding with
5//! `fuzzy_path::resolve_reference` (the [[note]]/ContentGraph resolver).
6
7use crate::resolve::asset_class::{AssetIndex, AssetProvenance};
8use crate::resolve::embed_renderer::Sizing;
9use crate::resolve::folder_class::FolderIndex;
10use crate::resolve::link_class::UrlIndex;
11
12#[cfg_attr(feature = "specta", derive(specta::Type))]
13#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14#[serde(rename_all = "kebab-case", tag = "kind", content = "data")]
15pub enum ReferenceKind {
16    Link { anchor: Option<String> },
17    Image,
18    Iframe,
19    Pdf,
20    Video,
21    Audio,
22    Model,
23    FolderListing,
24    FolderIndexIframe,
25    Transclusion,
26    Notebook,
27    Table,
28    External { url: String },
29    Anchor,
30    Ambiguous,
31    NotFound,
32}
33
34/// Index handles a classify call needs. Bundled so the signature stays small
35/// and a future index can be added without re-touching every caller.
36pub struct ReferenceContext<'a> {
37    pub assets: &'a dyn AssetIndex,
38    pub folders: &'a dyn FolderIndex,
39    /// Link arm only; the build supplies a graph-backed impl in sub-project #4.
40    /// For unit #1+#2 a Link result is classify-only and this may be a no-op.
41    pub urls: &'a dyn UrlIndex,
42}
43
44#[cfg_attr(feature = "specta", derive(specta::Type))]
45#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
46pub struct ResolvedReference {
47    pub kind: ReferenceKind,
48    /// Root-relative SOURCE path (real case) for file/folder kinds; None for
49    /// Link/External/Anchor/Ambiguous/NotFound.
50    pub target_path: Option<String>,
51    pub size: Option<Sizing>,
52    pub provenance: Option<AssetProvenance>,
53    /// Human-readable resolution note (separator-fallback / case-mismatch / …).
54    pub message: Option<String>,
55    /// Populated for Ambiguous (all candidate paths).
56    pub candidates: Vec<String>,
57    /// Resolved page/asset URL for a non-embed Link (None for embeds — the
58    /// build emits embed URLs itself; editor embeds use `target_path`).
59    pub url: Option<String>,
60}
61
62impl ResolvedReference {
63    pub(crate) fn not_found() -> Self {
64        ResolvedReference {
65            kind: ReferenceKind::NotFound,
66            target_path: None,
67            size: None,
68            provenance: None,
69            message: None,
70            candidates: Vec::new(),
71            url: None,
72        }
73    }
74    /// Invariant: target_path is Some iff kind is a file/folder kind.
75    pub(crate) fn debug_check_invariant(&self) {
76        let has_path = matches!(
77            self.kind,
78            ReferenceKind::Image
79                | ReferenceKind::Iframe
80                | ReferenceKind::Pdf
81                | ReferenceKind::Video
82                | ReferenceKind::Audio
83                | ReferenceKind::Model
84                | ReferenceKind::FolderListing
85                | ReferenceKind::FolderIndexIframe
86                | ReferenceKind::Transclusion
87                | ReferenceKind::Notebook
88                | ReferenceKind::Table
89        );
90        debug_assert_eq!(
91            has_path,
92            self.target_path.is_some(),
93            "target_path presence must match kind: {:?}",
94            self.kind
95        );
96    }
97}
98
99/// Filename extension of a root-relative path, lowercased, no leading dot.
100/// Basename-aware: a dot in a directory name is never mistaken for an extension
101/// (`a.b/README` → ``). Empty when the basename has no extension.
102fn filename_ext(path: &str) -> String {
103    let name = path.rsplit('/').next().unwrap_or(path);
104    match name.rsplit_once('.') {
105        // Guard the empty stem so a dotfile (`.gitignore`) is treated as
106        // extension-less, not as extension `gitignore` — mirrors
107        // `content_graph::filename_stem`.
108        Some((stem, ext)) if !stem.is_empty() => ext.to_lowercase(),
109        _ => String::new(),
110    }
111}
112
113/// Classify a reference's inner text (target + optional |pothole / #anchor /
114/// ?query) into a kind + resolved source path. Pure.
115pub fn classify_reference(
116    inner: &str,
117    from_source: &str,
118    is_embed: bool,
119    ctx: &ReferenceContext,
120) -> ResolvedReference {
121    let inner = inner.trim();
122
123    // External short-circuits (mirror classify_link's exception list).
124    const EXTERNAL_PREFIXES: &[&str] =
125        &["http://", "https://", "//", "mailto:", "tel:", "data:"];
126    if EXTERNAL_PREFIXES.iter().any(|p| inner.starts_with(p)) {
127        let mut r = ResolvedReference::not_found();
128        r.kind = ReferenceKind::External { url: inner.to_string() };
129        r.debug_check_invariant();
130        return r;
131    }
132    // Pure anchor / query (no path component).
133    if inner.starts_with('#') || inner.starts_with('?') {
134        let mut r = ResolvedReference::not_found();
135        r.kind = ReferenceKind::Anchor;
136        return r;
137    }
138
139    // Split off |pothole, then #anchor.
140    let (path_part, pothole) = match inner.split_once('|') {
141        Some((p, rest)) => (p.trim(), Some(rest)),
142        None => (inner, None),
143    };
144    let (path_no_anchor, anchor) = match path_part.split_once('#') {
145        Some((p, a)) => (p.trim(), Some(a.to_string())),
146        None => (path_part, None),
147    };
148    let size = pothole.and_then(crate::resolve::embed_renderer::Sizing::parse);
149
150    // Non-embed mode: a `[[note]]` / `[](path)` reference is a Link resolved
151    // against the deployed URL space (`ctx.urls`), NOT an embed kind. This runs
152    // BEFORE the folder/file arms so it cannot mis-route a non-embed reference
153    // to Transclusion/Image/Folder. The BUILD always passes `is_embed=true`
154    // (folder markers), so this branch is dead for the build — the folder arm
155    // below short-circuits there.
156    if !is_embed {
157        use crate::resolve::link_class::{classify_link, LinkClass};
158        return match classify_link(path_no_anchor, from_source, ctx.urls) {
159            LinkClass::Resolved { url } => {
160                let full = match &anchor {
161                    Some(a) => format!("{}#{}", url, a),
162                    None => url,
163                };
164                let mut r = ResolvedReference::not_found();
165                r.kind = ReferenceKind::Link { anchor: anchor.clone() };
166                r.url = Some(full);
167                r
168            }
169            LinkClass::Mismatch { canonical } => {
170                // A page exists but the link won't hit its canonical URL
171                // (case/slug). Surface it as a Link pointing at the canonical
172                // URL, with a note explaining the redirect.
173                let full = match &anchor {
174                    Some(a) => format!("{}#{}", canonical, a),
175                    None => canonical.clone(),
176                };
177                let mut r = ResolvedReference::not_found();
178                r.kind = ReferenceKind::Link { anchor: anchor.clone() };
179                r.url = Some(full);
180                r.message = Some(format!("resolves to canonical URL {}", canonical));
181                r
182            }
183            LinkClass::External => {
184                let mut r = ResolvedReference::not_found();
185                r.kind = ReferenceKind::External { url: path_no_anchor.to_string() };
186                r
187            }
188            LinkClass::Anchor => {
189                let mut r = ResolvedReference::not_found();
190                r.kind = ReferenceKind::Anchor;
191                r
192            }
193            // Broken: no deployed page for this internal reference.
194            LinkClass::Broken => ResolvedReference::not_found(),
195        };
196    }
197
198    use crate::resolve::asset_class::{resolve_asset_ref, AssetResolution};
199    use crate::resolve::ext_kind::{reference_kind_for_ext, ExtKind};
200
201    // Folder arm: trailing slash, or the target resolves to a directory.
202    let looks_like_folder = path_no_anchor.ends_with('/');
203    let folder_rel: Option<String> = if let Some(abs) = path_no_anchor.strip_prefix('/') {
204        Some(abs.trim_end_matches('/').to_string())
205    } else if looks_like_folder {
206        // source-relative lexical join against from_source's directory
207        let from_dir = crate::resolve::parent_dir(from_source);
208        let mut parts: Vec<&str> = if from_dir.is_empty() {
209            vec![]
210        } else {
211            from_dir.split('/').collect()
212        };
213        for seg in path_no_anchor.trim_end_matches('/').split('/') {
214            match seg {
215                "" | "." => {}
216                ".." => {
217                    parts.pop();
218                }
219                s => parts.push(s),
220            }
221        }
222        Some(parts.join("/"))
223    } else {
224        None
225    };
226    if let Some(folder_rel) = folder_rel {
227        // Only treat this as a folder reference when it is one: an explicit
228        // trailing slash, or a path that the folder index resolves to a real
229        // directory. A leading-slash path WITHOUT a trailing slash (e.g. an
230        // absolute file embed `/assets/photo.png`) is NOT a folder — it must
231        // fall through to the file arm and resolve as the asset it names.
232        let is_folder = looks_like_folder || ctx.folders.is_dir(&folder_rel);
233        if is_folder {
234            if ctx.folders.dir_has_markdown_index(&folder_rel) {
235                let mut r = ResolvedReference::not_found();
236                r.kind = ReferenceKind::FolderListing;
237                r.target_path = Some(folder_rel);
238                r.size = size;
239                r.debug_check_invariant();
240                return r;
241            }
242            if ctx.folders.dir_has_static_index(&folder_rel).is_some() {
243                let mut r = ResolvedReference::not_found();
244                r.kind = ReferenceKind::FolderIndexIframe;
245                r.target_path = Some(folder_rel);
246                r.size = size;
247                r.debug_check_invariant();
248                return r;
249            }
250            // A confirmed folder (explicit trailing slash, or a real directory)
251            // without an index is NotFound — it must NOT fall through to the
252            // file arm (a folder path is never a file asset).
253            return ResolvedReference::not_found();
254        }
255    }
256
257    // File arm.
258    //
259    // Resolve the reference to a source file, then key the embed kind off the
260    // RESOLVED file's extension — NOT the query string's. A bare `![[note]]`
261    // carries no extension; the build resolves it to `note.md`
262    // (ContentGraph::resolve_path step 1b/2) and renders a Transclusion. Keying
263    // off the query instead (extensionless → Other → Link) was the editor-only
264    // drift that showed `![[support-band]]` as "not found" while the build
265    // transcluded it. `query_ext_kind` is used only to decide the *unresolved*
266    // fallback (known-ext miss = broken embed; unknown-ext miss = note Link).
267    let query_ext_kind = reference_kind_for_ext(&filename_ext(path_no_anchor));
268
269    let resolved: Option<(String, AssetProvenance)> =
270        match resolve_asset_ref(path_no_anchor, from_source, ctx.assets) {
271            AssetResolution::Resolved { root_rel, provenance } => Some((root_rel, provenance)),
272            AssetResolution::Ambiguous { candidates, .. } => {
273                let mut r = ResolvedReference::not_found();
274                r.kind = ReferenceKind::Ambiguous;
275                r.candidates = candidates;
276                return r;
277            }
278            // Bare extensionless EMBED (`![[note]]`): retry as a markdown note so
279            // the editor and build agree on Transclusion. resolve_asset_ref's
280            // source-relative resolution reproduces the build's lang-scoping for
281            // free (sibling `<lang>/note.md` wins before root). Non-embed refs and
282            // refs that already carry a known extension are untouched.
283            AssetResolution::NotFound
284                if is_embed && matches!(query_ext_kind, ExtKind::Other) =>
285            {
286                let mut hit = None;
287                for note_ext in ["md", "markdown"] {
288                    match resolve_asset_ref(
289                        &format!("{path_no_anchor}.{note_ext}"),
290                        from_source,
291                        ctx.assets,
292                    ) {
293                        AssetResolution::Resolved { root_rel, provenance } => {
294                            hit = Some((root_rel, provenance));
295                            break;
296                        }
297                        AssetResolution::Ambiguous { candidates, .. } => {
298                            let mut r = ResolvedReference::not_found();
299                            r.kind = ReferenceKind::Ambiguous;
300                            r.candidates = candidates;
301                            return r;
302                        }
303                        AssetResolution::NotFound => {}
304                    }
305                }
306                hit
307            }
308            AssetResolution::NotFound => None,
309        };
310
311    match resolved {
312        Some((root_rel, provenance)) => {
313            // Kind keyed off the RESOLVED file's extension (see comment above).
314            let kind = match reference_kind_for_ext(&filename_ext(&root_rel)) {
315                ExtKind::Image => ReferenceKind::Image,
316                ExtKind::Iframe => ReferenceKind::Iframe,
317                ExtKind::Pdf => ReferenceKind::Pdf,
318                ExtKind::Video => ReferenceKind::Video,
319                ExtKind::Audio => ReferenceKind::Audio,
320                ExtKind::Model => ReferenceKind::Model,
321                ExtKind::Transclusion => ReferenceKind::Transclusion,
322                ExtKind::Notebook => ReferenceKind::Notebook,
323                ExtKind::Table => ReferenceKind::Table,
324                // A resolved file with an UNKNOWN extension is a Link target (no embed path).
325                ExtKind::Other => ReferenceKind::Link { anchor: anchor.clone() },
326            };
327            let is_link = matches!(kind, ReferenceKind::Link { .. });
328            let mut r = ResolvedReference::not_found();
329            r.kind = kind;
330            r.target_path = if is_link { None } else { Some(root_rel) };
331            r.size = size;
332            r.provenance = Some(provenance);
333            r.debug_check_invariant();
334            r
335        }
336        None => {
337            if matches!(query_ext_kind, ExtKind::Other) {
338                // An unresolved reference with no known file extension is a note
339                // link (classify-only here; link resolution/emission is sub-project #4).
340                let mut r = ResolvedReference::not_found();
341                r.kind = ReferenceKind::Link { anchor };
342                r
343            } else {
344                // A known-extension asset that didn't resolve is a broken embed.
345                ResolvedReference::not_found()
346            }
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    use crate::resolve::asset_class::FakeAssetIndex;
356    use crate::resolve::folder_class::FakeFolderIndex;
357    use crate::resolve::link_class::FakeUrlIndex;
358
359    fn ctx<'a>(
360        a: &'a FakeAssetIndex,
361        f: &'a FakeFolderIndex,
362        u: &'a FakeUrlIndex,
363    ) -> ReferenceContext<'a> {
364        ReferenceContext { assets: a, folders: f, urls: u }
365    }
366
367    #[test]
368    fn external_url_is_external() {
369        let a = FakeAssetIndex::new(&[]);
370        let f = FakeFolderIndex::new();
371        let u = FakeUrlIndex::new();
372        let r = classify_reference("https://example.com/x", "page.md", true, &ctx(&a, &f, &u));
373        assert_eq!(r.kind, ReferenceKind::External { url: "https://example.com/x".into() });
374        assert!(r.target_path.is_none());
375    }
376
377    #[test]
378    fn bare_anchor_is_anchor() {
379        let a = FakeAssetIndex::new(&[]);
380        let f = FakeFolderIndex::new();
381        let u = FakeUrlIndex::new();
382        let r = classify_reference("#section", "page.md", true, &ctx(&a, &f, &u));
383        assert_eq!(r.kind, ReferenceKind::Anchor);
384    }
385
386    #[test]
387    fn not_found_has_no_path() {
388        let r = ResolvedReference::not_found();
389        assert_eq!(r.kind, ReferenceKind::NotFound);
390        assert!(r.target_path.is_none());
391        r.debug_check_invariant();
392    }
393
394    #[test]
395    fn image_file_resolves_to_image_kind() {
396        let a = FakeAssetIndex::new(&["assets/photo.png"]);
397        let f = FakeFolderIndex::new();
398        let u = FakeUrlIndex::new();
399        let r = classify_reference("photo.png", "page.md", true, &ctx(&a, &f, &u));
400        assert_eq!(r.kind, ReferenceKind::Image);
401        assert_eq!(r.target_path.as_deref(), Some("assets/photo.png"));
402        r.debug_check_invariant();
403    }
404
405    #[test]
406    fn html_file_resolves_to_iframe_with_size() {
407        let a = FakeAssetIndex::new(&["widgets/app.html"]);
408        let f = FakeFolderIndex::new();
409        let u = FakeUrlIndex::new();
410        let r = classify_reference("widgets/app.html|800x600", "page.md", true, &ctx(&a, &f, &u));
411        assert_eq!(r.kind, ReferenceKind::Iframe);
412        assert!(matches!(r.size, Some(crate::resolve::embed_renderer::Sizing::Box(_, _))));
413    }
414
415    #[test]
416    fn ambiguous_file_match_sets_candidates() {
417        let a = FakeAssetIndex::new(&["a/logo.png", "b/logo.png"]);
418        let f = FakeFolderIndex::new();
419        let u = FakeUrlIndex::new();
420        let r = classify_reference("logo.png", "page.md", true, &ctx(&a, &f, &u));
421        assert_eq!(r.kind, ReferenceKind::Ambiguous);
422        assert_eq!(r.candidates.len(), 2);
423    }
424
425    #[test]
426    fn folder_with_static_index_is_iframe() {
427        let a = FakeAssetIndex::new(&[]);
428        let mut f = FakeFolderIndex::new();
429        f.dirs.insert("Resources/app".into());
430        f.static_index.insert("Resources/app".into(), "index.html".into());
431        let u = FakeUrlIndex::new();
432        let r = classify_reference("/Resources/app/", "page.md", true, &ctx(&a, &f, &u));
433        assert_eq!(r.kind, ReferenceKind::FolderIndexIframe);
434        assert_eq!(r.target_path.as_deref(), Some("Resources/app"));
435        r.debug_check_invariant();
436    }
437
438    #[test]
439    fn folder_with_markdown_index_is_listing() {
440        let a = FakeAssetIndex::new(&[]);
441        let mut f = FakeFolderIndex::new();
442        f.dirs.insert("news".into());
443        f.md_index.insert("news".into());
444        let u = FakeUrlIndex::new();
445        let r = classify_reference("/news/", "page.md", true, &ctx(&a, &f, &u));
446        assert_eq!(r.kind, ReferenceKind::FolderListing);
447        r.debug_check_invariant();
448    }
449
450    #[test]
451    fn absolute_file_embed_resolves_to_image() {
452        // A leading-slash path with NO trailing slash, naming a real asset, is a
453        // file embed — not a folder. The folder arm must let it fall through to
454        // the file arm so `![[/assets/photo.png]]` resolves as an Image.
455        let a = FakeAssetIndex::new(&["assets/photo.png"]);
456        let f = FakeFolderIndex::new(); // NOT a dir, no indexes
457        let u = FakeUrlIndex::new();
458        let r = classify_reference("/assets/photo.png", "page.md", true, &ctx(&a, &f, &u));
459        assert_eq!(r.kind, ReferenceKind::Image);
460        assert_eq!(r.target_path.as_deref(), Some("assets/photo.png"));
461        r.debug_check_invariant();
462    }
463
464    #[test]
465    fn trailing_slash_unresolved_folder_is_not_found() {
466        let a = FakeAssetIndex::new(&[]);
467        let f = FakeFolderIndex::new(); // empty: not a dir, no indexes
468        let u = FakeUrlIndex::new();
469        let r = classify_reference("/ghost/", "page.md", true, &ctx(&a, &f, &u));
470        assert_eq!(r.kind, ReferenceKind::NotFound);
471    }
472
473    #[test]
474    fn bare_note_name_is_link() {
475        let a = FakeAssetIndex::new(&[]);
476        let f = FakeFolderIndex::new();
477        let u = FakeUrlIndex::new();
478        let r = classify_reference("some-note", "page.md", true, &ctx(&a, &f, &u));
479        assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
480        assert!(r.target_path.is_none());
481        r.debug_check_invariant();
482    }
483
484    #[test]
485    fn missing_known_ext_asset_is_not_found() {
486        // A known image extension that doesn't resolve stays NotFound (it is a
487        // broken asset embed, not a note link).
488        let a = FakeAssetIndex::new(&[]);
489        let f = FakeFolderIndex::new();
490        let u = FakeUrlIndex::new();
491        let r = classify_reference("missing.png", "page.md", true, &ctx(&a, &f, &u));
492        assert_eq!(r.kind, ReferenceKind::NotFound);
493    }
494
495    #[test]
496    fn non_embed_md_note_resolves_as_link_not_transclusion() {
497        let a = FakeAssetIndex::new(&["note.md"]);
498        let f = FakeFolderIndex::new();
499        let u = FakeUrlIndex::resolving(&[("note.md", "/note/")]);
500        let r = classify_reference("note.md", "page.md", false, &ctx(&a, &f, &u));
501        assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
502        assert_eq!(r.url.as_deref(), Some("/note/"));
503    }
504
505    #[test]
506    fn embed_md_is_still_transclusion() {
507        let a = FakeAssetIndex::new(&["note.md"]);
508        let f = FakeFolderIndex::new();
509        let u = FakeUrlIndex::new();
510        let r = classify_reference("note.md", "page.md", true, &ctx(&a, &f, &u));
511        assert_eq!(r.kind, ReferenceKind::Transclusion);
512    }
513
514    // ── Bare-name embed transclusion parity (Bug 3) ──────────────────────────
515    // The build resolves `![[support-band]]` (no extension) to `support-band.md`
516    // via ContentGraph::resolve_path (exact-path + `.md`, lang-scoped) and renders
517    // a Transclusion. The editor classifier previously keyed the embed kind off
518    // the QUERY string's extension (extensionless → Other → Link), so the same
519    // reference showed "not found" in the editor. These lock the parity.
520
521    #[test]
522    fn bare_embed_resolves_markdown_note_as_transclusion() {
523        let a = FakeAssetIndex::new(&["support-band.md"]);
524        let f = FakeFolderIndex::new();
525        let u = FakeUrlIndex::new();
526        let r = classify_reference("support-band", "index.md", true, &ctx(&a, &f, &u));
527        assert_eq!(r.kind, ReferenceKind::Transclusion);
528        assert_eq!(r.target_path.as_deref(), Some("support-band.md"));
529        r.debug_check_invariant();
530    }
531
532    #[test]
533    fn bare_embed_prefers_source_relative_md_note() {
534        // From a language-tree source, the sibling note wins — mirrors
535        // ContentGraph::resolve_path step 1b lang-scoping (source-relative
536        // resolution in resolve_asset_ref gives this for free).
537        let a = FakeAssetIndex::new(&["support-band.md", "zh-hans/support-band.md"]);
538        let f = FakeFolderIndex::new();
539        let u = FakeUrlIndex::new();
540        let r = classify_reference("support-band", "zh-hans/index.md", true, &ctx(&a, &f, &u));
541        assert_eq!(r.kind, ReferenceKind::Transclusion);
542        assert_eq!(r.target_path.as_deref(), Some("zh-hans/support-band.md"));
543        r.debug_check_invariant();
544    }
545
546    #[test]
547    fn bare_embed_resolves_root_note_from_root_source() {
548        // Both root and lang-tree notes exist; a root source resolves the root one
549        // deterministically (source-relative join), never Ambiguous.
550        let a = FakeAssetIndex::new(&["support-band.md", "zh-hans/support-band.md"]);
551        let f = FakeFolderIndex::new();
552        let u = FakeUrlIndex::new();
553        let r = classify_reference("support-band", "index.md", true, &ctx(&a, &f, &u));
554        assert_eq!(r.kind, ReferenceKind::Transclusion);
555        assert_eq!(r.target_path.as_deref(), Some("support-band.md"));
556        r.debug_check_invariant();
557    }
558
559    #[test]
560    fn bare_embed_path_qualified_note_is_transclusion() {
561        // `![[work/daowu]]` (path, no extension) resolves work/daowu.md.
562        let a = FakeAssetIndex::new(&["work/daowu.md"]);
563        let f = FakeFolderIndex::new();
564        let u = FakeUrlIndex::new();
565        let r = classify_reference("work/daowu", "index.md", true, &ctx(&a, &f, &u));
566        assert_eq!(r.kind, ReferenceKind::Transclusion);
567        assert_eq!(r.target_path.as_deref(), Some("work/daowu.md"));
568    }
569
570    #[test]
571    fn bare_embed_unresolved_stays_link() {
572        // No matching note → still a classify-only Link, not Transclusion.
573        let a = FakeAssetIndex::new(&["other.md"]);
574        let f = FakeFolderIndex::new();
575        let u = FakeUrlIndex::new();
576        let r = classify_reference("support-band", "index.md", true, &ctx(&a, &f, &u));
577        assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
578        assert!(r.target_path.is_none());
579    }
580
581    #[test]
582    fn bare_link_not_embed_does_not_use_md_fallback() {
583        // is_embed=false resolves against the URL space, NOT the note-extension
584        // fallback — a non-embed `[[support-band]]` must never become Transclusion.
585        let a = FakeAssetIndex::new(&["support-band.md"]);
586        let f = FakeFolderIndex::new();
587        let u = FakeUrlIndex::new();
588        let r = classify_reference("support-band", "index.md", false, &ctx(&a, &f, &u));
589        assert_ne!(r.kind, ReferenceKind::Transclusion);
590    }
591
592    #[test]
593    fn non_embed_link_carries_anchor() {
594        let a = FakeAssetIndex::new(&[]);
595        let f = FakeFolderIndex::new();
596        let u = FakeUrlIndex::resolving(&[("note", "/note/")]);
597        let r = classify_reference("note#heading", "page.md", false, &ctx(&a, &f, &u));
598        assert_eq!(r.kind, ReferenceKind::Link { anchor: Some("heading".into()) });
599        assert_eq!(r.url.as_deref(), Some("/note/#heading"));
600    }
601}