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/// Classify a reference's inner text (target + optional |pothole / #anchor /
100/// ?query) into a kind + resolved source path. Pure.
101pub fn classify_reference(
102    inner: &str,
103    from_source: &str,
104    is_embed: bool,
105    ctx: &ReferenceContext,
106) -> ResolvedReference {
107    let inner = inner.trim();
108
109    // External short-circuits (mirror classify_link's exception list).
110    const EXTERNAL_PREFIXES: &[&str] =
111        &["http://", "https://", "//", "mailto:", "tel:", "data:"];
112    if EXTERNAL_PREFIXES.iter().any(|p| inner.starts_with(p)) {
113        let mut r = ResolvedReference::not_found();
114        r.kind = ReferenceKind::External { url: inner.to_string() };
115        r.debug_check_invariant();
116        return r;
117    }
118    // Pure anchor / query (no path component).
119    if inner.starts_with('#') || inner.starts_with('?') {
120        let mut r = ResolvedReference::not_found();
121        r.kind = ReferenceKind::Anchor;
122        return r;
123    }
124
125    // Split off |pothole, then #anchor.
126    let (path_part, pothole) = match inner.split_once('|') {
127        Some((p, rest)) => (p.trim(), Some(rest)),
128        None => (inner, None),
129    };
130    let (path_no_anchor, anchor) = match path_part.split_once('#') {
131        Some((p, a)) => (p.trim(), Some(a.to_string())),
132        None => (path_part, None),
133    };
134    let size = pothole.and_then(crate::resolve::embed_renderer::Sizing::parse);
135
136    // Non-embed mode: a `[[note]]` / `[](path)` reference is a Link resolved
137    // against the deployed URL space (`ctx.urls`), NOT an embed kind. This runs
138    // BEFORE the folder/file arms so it cannot mis-route a non-embed reference
139    // to Transclusion/Image/Folder. The BUILD always passes `is_embed=true`
140    // (folder markers), so this branch is dead for the build — the folder arm
141    // below short-circuits there.
142    if !is_embed {
143        use crate::resolve::link_class::{classify_link, LinkClass};
144        return match classify_link(path_no_anchor, from_source, ctx.urls) {
145            LinkClass::Resolved { url } => {
146                let full = match &anchor {
147                    Some(a) => format!("{}#{}", url, a),
148                    None => url,
149                };
150                let mut r = ResolvedReference::not_found();
151                r.kind = ReferenceKind::Link { anchor: anchor.clone() };
152                r.url = Some(full);
153                r
154            }
155            LinkClass::Mismatch { canonical } => {
156                // A page exists but the link won't hit its canonical URL
157                // (case/slug). Surface it as a Link pointing at the canonical
158                // URL, with a note explaining the redirect.
159                let full = match &anchor {
160                    Some(a) => format!("{}#{}", canonical, a),
161                    None => canonical.clone(),
162                };
163                let mut r = ResolvedReference::not_found();
164                r.kind = ReferenceKind::Link { anchor: anchor.clone() };
165                r.url = Some(full);
166                r.message = Some(format!("resolves to canonical URL {}", canonical));
167                r
168            }
169            LinkClass::External => {
170                let mut r = ResolvedReference::not_found();
171                r.kind = ReferenceKind::External { url: path_no_anchor.to_string() };
172                r
173            }
174            LinkClass::Anchor => {
175                let mut r = ResolvedReference::not_found();
176                r.kind = ReferenceKind::Anchor;
177                r
178            }
179            // Broken: no deployed page for this internal reference.
180            LinkClass::Broken => ResolvedReference::not_found(),
181        };
182    }
183
184    use crate::resolve::asset_class::{resolve_asset_ref, AssetResolution};
185    use crate::resolve::ext_kind::{reference_kind_for_ext, ExtKind};
186
187    // Folder arm: trailing slash, or the target resolves to a directory.
188    let looks_like_folder = path_no_anchor.ends_with('/');
189    let folder_rel: Option<String> = if let Some(abs) = path_no_anchor.strip_prefix('/') {
190        Some(abs.trim_end_matches('/').to_string())
191    } else if looks_like_folder {
192        // source-relative lexical join against from_source's directory
193        let from_dir = crate::resolve::parent_dir(from_source);
194        let mut parts: Vec<&str> = if from_dir.is_empty() {
195            vec![]
196        } else {
197            from_dir.split('/').collect()
198        };
199        for seg in path_no_anchor.trim_end_matches('/').split('/') {
200            match seg {
201                "" | "." => {}
202                ".." => {
203                    parts.pop();
204                }
205                s => parts.push(s),
206            }
207        }
208        Some(parts.join("/"))
209    } else {
210        None
211    };
212    if let Some(folder_rel) = folder_rel {
213        // Only treat this as a folder reference when it is one: an explicit
214        // trailing slash, or a path that the folder index resolves to a real
215        // directory. A leading-slash path WITHOUT a trailing slash (e.g. an
216        // absolute file embed `/assets/photo.png`) is NOT a folder — it must
217        // fall through to the file arm and resolve as the asset it names.
218        let is_folder = looks_like_folder || ctx.folders.is_dir(&folder_rel);
219        if is_folder {
220            if ctx.folders.dir_has_markdown_index(&folder_rel) {
221                let mut r = ResolvedReference::not_found();
222                r.kind = ReferenceKind::FolderListing;
223                r.target_path = Some(folder_rel);
224                r.size = size;
225                r.debug_check_invariant();
226                return r;
227            }
228            if ctx.folders.dir_has_static_index(&folder_rel).is_some() {
229                let mut r = ResolvedReference::not_found();
230                r.kind = ReferenceKind::FolderIndexIframe;
231                r.target_path = Some(folder_rel);
232                r.size = size;
233                r.debug_check_invariant();
234                return r;
235            }
236            // A confirmed folder (explicit trailing slash, or a real directory)
237            // without an index is NotFound — it must NOT fall through to the
238            // file arm (a folder path is never a file asset).
239            return ResolvedReference::not_found();
240        }
241    }
242
243    // File arm.
244    let ext = path_no_anchor.rsplit('.').next().unwrap_or("").to_lowercase();
245    let ext_kind = reference_kind_for_ext(&ext);
246    match resolve_asset_ref(path_no_anchor, from_source, ctx.assets) {
247        AssetResolution::Resolved { root_rel, provenance } => {
248            let kind = match ext_kind {
249                ExtKind::Image => ReferenceKind::Image,
250                ExtKind::Iframe => ReferenceKind::Iframe,
251                ExtKind::Pdf => ReferenceKind::Pdf,
252                ExtKind::Video => ReferenceKind::Video,
253                ExtKind::Audio => ReferenceKind::Audio,
254                ExtKind::Model => ReferenceKind::Model,
255                ExtKind::Transclusion => ReferenceKind::Transclusion,
256                ExtKind::Notebook => ReferenceKind::Notebook,
257                ExtKind::Table => ReferenceKind::Table,
258                // A resolved file with an UNKNOWN extension is a Link target (no embed path).
259                ExtKind::Other => ReferenceKind::Link { anchor: anchor.clone() },
260            };
261            let is_link = matches!(kind, ReferenceKind::Link { .. });
262            let mut r = ResolvedReference::not_found();
263            r.kind = kind;
264            r.target_path = if is_link { None } else { Some(root_rel) };
265            r.size = size;
266            r.provenance = Some(provenance);
267            r.debug_check_invariant();
268            r
269        }
270        AssetResolution::Ambiguous { candidates, .. } => {
271            let mut r = ResolvedReference::not_found();
272            r.kind = ReferenceKind::Ambiguous;
273            r.candidates = candidates;
274            r
275        }
276        AssetResolution::NotFound => {
277            if matches!(ext_kind, ExtKind::Other) {
278                // An unresolved reference with no known file extension is a note
279                // link (classify-only here; link resolution/emission is sub-project #4).
280                let mut r = ResolvedReference::not_found();
281                r.kind = ReferenceKind::Link { anchor };
282                r
283            } else {
284                // A known-extension asset that didn't resolve is a broken embed.
285                ResolvedReference::not_found()
286            }
287        }
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    use crate::resolve::asset_class::FakeAssetIndex;
296    use crate::resolve::folder_class::FakeFolderIndex;
297    use crate::resolve::link_class::FakeUrlIndex;
298
299    fn ctx<'a>(
300        a: &'a FakeAssetIndex,
301        f: &'a FakeFolderIndex,
302        u: &'a FakeUrlIndex,
303    ) -> ReferenceContext<'a> {
304        ReferenceContext { assets: a, folders: f, urls: u }
305    }
306
307    #[test]
308    fn external_url_is_external() {
309        let a = FakeAssetIndex::new(&[]);
310        let f = FakeFolderIndex::new();
311        let u = FakeUrlIndex::new();
312        let r = classify_reference("https://example.com/x", "page.md", true, &ctx(&a, &f, &u));
313        assert_eq!(r.kind, ReferenceKind::External { url: "https://example.com/x".into() });
314        assert!(r.target_path.is_none());
315    }
316
317    #[test]
318    fn bare_anchor_is_anchor() {
319        let a = FakeAssetIndex::new(&[]);
320        let f = FakeFolderIndex::new();
321        let u = FakeUrlIndex::new();
322        let r = classify_reference("#section", "page.md", true, &ctx(&a, &f, &u));
323        assert_eq!(r.kind, ReferenceKind::Anchor);
324    }
325
326    #[test]
327    fn not_found_has_no_path() {
328        let r = ResolvedReference::not_found();
329        assert_eq!(r.kind, ReferenceKind::NotFound);
330        assert!(r.target_path.is_none());
331        r.debug_check_invariant();
332    }
333
334    #[test]
335    fn image_file_resolves_to_image_kind() {
336        let a = FakeAssetIndex::new(&["assets/photo.png"]);
337        let f = FakeFolderIndex::new();
338        let u = FakeUrlIndex::new();
339        let r = classify_reference("photo.png", "page.md", true, &ctx(&a, &f, &u));
340        assert_eq!(r.kind, ReferenceKind::Image);
341        assert_eq!(r.target_path.as_deref(), Some("assets/photo.png"));
342        r.debug_check_invariant();
343    }
344
345    #[test]
346    fn html_file_resolves_to_iframe_with_size() {
347        let a = FakeAssetIndex::new(&["widgets/app.html"]);
348        let f = FakeFolderIndex::new();
349        let u = FakeUrlIndex::new();
350        let r = classify_reference("widgets/app.html|800x600", "page.md", true, &ctx(&a, &f, &u));
351        assert_eq!(r.kind, ReferenceKind::Iframe);
352        assert!(matches!(r.size, Some(crate::resolve::embed_renderer::Sizing::Box(_, _))));
353    }
354
355    #[test]
356    fn ambiguous_file_match_sets_candidates() {
357        let a = FakeAssetIndex::new(&["a/logo.png", "b/logo.png"]);
358        let f = FakeFolderIndex::new();
359        let u = FakeUrlIndex::new();
360        let r = classify_reference("logo.png", "page.md", true, &ctx(&a, &f, &u));
361        assert_eq!(r.kind, ReferenceKind::Ambiguous);
362        assert_eq!(r.candidates.len(), 2);
363    }
364
365    #[test]
366    fn folder_with_static_index_is_iframe() {
367        let a = FakeAssetIndex::new(&[]);
368        let mut f = FakeFolderIndex::new();
369        f.dirs.insert("Resources/app".into());
370        f.static_index.insert("Resources/app".into(), "index.html".into());
371        let u = FakeUrlIndex::new();
372        let r = classify_reference("/Resources/app/", "page.md", true, &ctx(&a, &f, &u));
373        assert_eq!(r.kind, ReferenceKind::FolderIndexIframe);
374        assert_eq!(r.target_path.as_deref(), Some("Resources/app"));
375        r.debug_check_invariant();
376    }
377
378    #[test]
379    fn folder_with_markdown_index_is_listing() {
380        let a = FakeAssetIndex::new(&[]);
381        let mut f = FakeFolderIndex::new();
382        f.dirs.insert("news".into());
383        f.md_index.insert("news".into());
384        let u = FakeUrlIndex::new();
385        let r = classify_reference("/news/", "page.md", true, &ctx(&a, &f, &u));
386        assert_eq!(r.kind, ReferenceKind::FolderListing);
387        r.debug_check_invariant();
388    }
389
390    #[test]
391    fn absolute_file_embed_resolves_to_image() {
392        // A leading-slash path with NO trailing slash, naming a real asset, is a
393        // file embed — not a folder. The folder arm must let it fall through to
394        // the file arm so `![[/assets/photo.png]]` resolves as an Image.
395        let a = FakeAssetIndex::new(&["assets/photo.png"]);
396        let f = FakeFolderIndex::new(); // NOT a dir, no indexes
397        let u = FakeUrlIndex::new();
398        let r = classify_reference("/assets/photo.png", "page.md", true, &ctx(&a, &f, &u));
399        assert_eq!(r.kind, ReferenceKind::Image);
400        assert_eq!(r.target_path.as_deref(), Some("assets/photo.png"));
401        r.debug_check_invariant();
402    }
403
404    #[test]
405    fn trailing_slash_unresolved_folder_is_not_found() {
406        let a = FakeAssetIndex::new(&[]);
407        let f = FakeFolderIndex::new(); // empty: not a dir, no indexes
408        let u = FakeUrlIndex::new();
409        let r = classify_reference("/ghost/", "page.md", true, &ctx(&a, &f, &u));
410        assert_eq!(r.kind, ReferenceKind::NotFound);
411    }
412
413    #[test]
414    fn bare_note_name_is_link() {
415        let a = FakeAssetIndex::new(&[]);
416        let f = FakeFolderIndex::new();
417        let u = FakeUrlIndex::new();
418        let r = classify_reference("some-note", "page.md", true, &ctx(&a, &f, &u));
419        assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
420        assert!(r.target_path.is_none());
421        r.debug_check_invariant();
422    }
423
424    #[test]
425    fn missing_known_ext_asset_is_not_found() {
426        // A known image extension that doesn't resolve stays NotFound (it is a
427        // broken asset embed, not a note link).
428        let a = FakeAssetIndex::new(&[]);
429        let f = FakeFolderIndex::new();
430        let u = FakeUrlIndex::new();
431        let r = classify_reference("missing.png", "page.md", true, &ctx(&a, &f, &u));
432        assert_eq!(r.kind, ReferenceKind::NotFound);
433    }
434
435    #[test]
436    fn non_embed_md_note_resolves_as_link_not_transclusion() {
437        let a = FakeAssetIndex::new(&["note.md"]);
438        let f = FakeFolderIndex::new();
439        let u = FakeUrlIndex::resolving(&[("note.md", "/note/")]);
440        let r = classify_reference("note.md", "page.md", false, &ctx(&a, &f, &u));
441        assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
442        assert_eq!(r.url.as_deref(), Some("/note/"));
443    }
444
445    #[test]
446    fn embed_md_is_still_transclusion() {
447        let a = FakeAssetIndex::new(&["note.md"]);
448        let f = FakeFolderIndex::new();
449        let u = FakeUrlIndex::new();
450        let r = classify_reference("note.md", "page.md", true, &ctx(&a, &f, &u));
451        assert_eq!(r.kind, ReferenceKind::Transclusion);
452    }
453
454    #[test]
455    fn non_embed_link_carries_anchor() {
456        let a = FakeAssetIndex::new(&[]);
457        let f = FakeFolderIndex::new();
458        let u = FakeUrlIndex::resolving(&[("note", "/note/")]);
459        let r = classify_reference("note#heading", "page.md", false, &ctx(&a, &f, &u));
460        assert_eq!(r.kind, ReferenceKind::Link { anchor: Some("heading".into()) });
461        assert_eq!(r.url.as_deref(), Some("/note/#heading"));
462    }
463
464    #[test]
465    fn build_safety_folder_marker_ignores_urls() {
466        let a = FakeAssetIndex::new(&[]);
467        let mut f = FakeFolderIndex::new();
468        f.dirs.insert("app".into());
469        f.static_index.insert("app".into(), "index.html".into());
470        let u = FakeUrlIndex::new();
471        let r = classify_reference("/app/", "page.md", true, &ctx(&a, &f, &u));
472        assert_eq!(r.kind, ReferenceKind::FolderIndexIframe);
473    }
474}