Skip to main content

moss_core/resolve/
asset_class.rs

1//! Pure asset-reference resolver shared by editor + build (single source of truth).
2//! Mirrors `link_class.rs`. Zero I/O — data injected via `AssetIndex` (ADR-018).
3
4use crate::resolve::parent_dir;
5
6pub trait AssetIndex {
7    fn contains(&self, root_rel: &str) -> bool;
8    fn contains_ci(&self, root_rel: &str) -> Option<String>;
9    fn find_by_suffix(&self, suffix: &str) -> Vec<String>;
10}
11
12#[cfg_attr(feature = "specta", derive(specta::Type))]
13#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum AssetProvenance {
16    Literal,
17    BareFuzzy,
18    SeparatorFallback,
19    CaseMismatch,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum AssetResolution {
24    Resolved { root_rel: String, provenance: AssetProvenance },
25    Ambiguous { chosen: String, candidates: Vec<String> },
26    NotFound,
27}
28
29/// Lexically collapse `.`/`..` against a base dir; returns None if it escapes root.
30fn lexical_join(base_dir: &str, target: &str) -> Option<String> {
31    let mut parts: Vec<&str> = if base_dir.is_empty() { vec![] } else { base_dir.split('/').collect() };
32    for seg in target.split('/') {
33        match seg {
34            "" | "." => {}
35            ".." => { parts.pop()?; } // pop; underflow → escape → None
36            other => parts.push(other),
37        }
38    }
39    Some(parts.join("/"))
40}
41
42fn has_separator(t: &str) -> bool {
43    t.contains('/')
44}
45
46pub fn resolve_asset_ref(target: &str, from_source: &str, index: &dyn AssetIndex) -> AssetResolution {
47    let from_dir = parent_dir(from_source);
48
49    // Step 1: `/`-absolute → root.
50    if let Some(stripped) = target.strip_prefix('/') {
51        return finish(stripped.to_string(), AssetProvenance::Literal, index);
52    }
53
54    // Step 2: literal source-relative (all targets).
55    if let Some(cand) = lexical_join(from_dir, target) {
56        if let Some(res) = finish_opt(&cand, AssetProvenance::Literal, index) { return res; }
57    }
58
59    // Step 3: project-root-relative — SEPARATOR targets only (bare handled in step 4).
60    //
61    // Also detect containment escape: a separator target whose lexical resolution
62    // underflows the project root in BOTH step 2 and step 3 (i.e. both lexical_join
63    // calls return None) must never fall through to basename fuzzy matching. An explicit
64    // relative path like "../../etc/x.jpg" that escapes the project root is invalid;
65    // resolving it by basename would silently return an unrelated file. Containment rule:
66    // escaped paths → NotFound, always.
67    let escaped = if has_separator(target) {
68        let step2_escaped = lexical_join(from_dir, target).is_none();
69        let step3_cand = lexical_join("", target);
70        if let Some(cand) = step3_cand {
71            if let Some(res) = finish_opt(&cand, AssetProvenance::SeparatorFallback, index) { return res; }
72            false // step 3 resolved a valid path (even if index miss); not an escape
73        } else {
74            // Both step 2 and step 3 underflowed — target escapes the root.
75            step2_escaped
76        }
77    } else {
78        false
79    };
80
81    // Containment guard: an escaping explicit path must not fuzzy-resolve by basename.
82    if escaped {
83        return AssetResolution::NotFound;
84    }
85
86    // Step 4: fuzzy. Separator targets: try path-suffix then basename. Bare: basename.
87    let basename = target.rsplit('/').next().unwrap_or(target);
88    let mut matches = if has_separator(target) {
89        let mut m = index.find_by_suffix(target);
90        if m.is_empty() { m = index.find_by_suffix(basename); }
91        m
92    } else {
93        index.find_by_suffix(basename)
94    };
95    matches.sort_by(|a, b| a.matches('/').count().cmp(&b.matches('/').count()).then(a.cmp(b)));
96    match matches.len() {
97        0 => AssetResolution::NotFound,
98        1 => {
99            let prov = if has_separator(target) {
100                AssetProvenance::SeparatorFallback
101            } else {
102                AssetProvenance::BareFuzzy
103            };
104            AssetResolution::Resolved { root_rel: matches.remove(0), provenance: prov }
105        }
106        _ => AssetResolution::Ambiguous { chosen: matches[0].clone(), candidates: matches },
107    }
108}
109
110/// Exact hit → Resolved(provenance); case-only hit → Resolved(CaseMismatch, canonical); else None.
111fn finish_opt(cand: &str, prov: AssetProvenance, index: &dyn AssetIndex) -> Option<AssetResolution> {
112    if index.contains(cand) {
113        return Some(AssetResolution::Resolved { root_rel: cand.to_string(), provenance: prov });
114    }
115    if let Some(canon) = index.contains_ci(cand) {
116        return Some(AssetResolution::Resolved { root_rel: canon, provenance: AssetProvenance::CaseMismatch });
117    }
118    None
119}
120fn finish(cand: String, prov: AssetProvenance, index: &dyn AssetIndex) -> AssetResolution {
121    finish_opt(&cand, prov, index).unwrap_or(AssetResolution::NotFound)
122}
123
124/// Cross-module test fake for `AssetIndex`. Module-level so `reference.rs` tests can import it.
125/// Logic mirrors the private `FakeIndex` inside `mod tests` below (duplication accepted —
126/// see task comment; dedup can happen later without affecting behaviour).
127#[cfg(test)]
128pub(crate) struct FakeAssetIndex(std::collections::HashSet<String>);
129
130#[cfg(test)]
131impl FakeAssetIndex {
132    pub fn new(paths: &[&str]) -> Self {
133        FakeAssetIndex(paths.iter().map(|s| s.to_string()).collect())
134    }
135}
136
137#[cfg(test)]
138impl AssetIndex for FakeAssetIndex {
139    fn contains(&self, p: &str) -> bool {
140        self.0.contains(p)
141    }
142    fn contains_ci(&self, p: &str) -> Option<String> {
143        let lp = p.to_lowercase();
144        self.0.iter().find(|x| x.to_lowercase() == lp).cloned()
145    }
146    fn find_by_suffix(&self, s: &str) -> Vec<String> {
147        let ls = s.to_lowercase();
148        let mut v: Vec<String> = self.0.iter()
149            .filter(|x| x.to_lowercase().ends_with(&ls)
150                && (x.len() == s.len() || x.as_bytes()[x.len() - s.len() - 1] == b'/'))
151            .cloned().collect();
152        v.sort();
153        v
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::collections::HashSet;
161
162    /// Fake index over a fixed real-case path set (mirrors link_class.rs::FakeIndex).
163    struct FakeIndex(HashSet<String>);
164    impl FakeIndex {
165        fn new(paths: &[&str]) -> Self {
166            FakeIndex(paths.iter().map(|s| s.to_string()).collect())
167        }
168    }
169    impl AssetIndex for FakeIndex {
170        fn contains(&self, p: &str) -> bool {
171            self.0.contains(p)
172        }
173        fn contains_ci(&self, p: &str) -> Option<String> {
174            let lp = p.to_lowercase();
175            self.0.iter().find(|x| x.to_lowercase() == lp).cloned()
176        }
177        fn find_by_suffix(&self, s: &str) -> Vec<String> {
178            let ls = s.to_lowercase();
179            let mut v: Vec<String> = self.0.iter()
180                .filter(|x| x.to_lowercase().ends_with(&ls)
181                    && (x.len() == s.len() || x.as_bytes()[x.len() - s.len() - 1] == b'/'))
182                .cloned().collect();
183            v.sort();
184            v
185        }
186    }
187
188    #[test]
189    fn literal_exact_relative_hit() {
190        let idx = FakeIndex::new(&["assets/Hoon.JPG", "team/photo.jpg"]);
191        // ./photo.jpg authored next to team/Team.md → exists at team/photo.jpg
192        let r = resolve_asset_ref("./photo.jpg", "team/Team.md", &idx);
193        assert_eq!(r, AssetResolution::Resolved {
194            root_rel: "team/photo.jpg".into(), provenance: AssetProvenance::Literal });
195    }
196    #[test]
197    fn separator_fallback_to_root() {
198        // ./assets/AGU2025.jpg from a subfolder; real file at root assets/
199        let idx = FakeIndex::new(&["assets/AGU2025.jpg"]);
200        let r = resolve_asset_ref("./assets/AGU2025.jpg", "News/2025-12-agu.md", &idx);
201        assert_eq!(r, AssetResolution::Resolved {
202            root_rel: "assets/AGU2025.jpg".into(),
203            provenance: AssetProvenance::SeparatorFallback });
204    }
205    #[test]
206    fn case_mismatch_on_literal() {
207        // ./assets/Hoon.jpg authored at root; disk is Hoon.JPG
208        let idx = FakeIndex::new(&["assets/Hoon.JPG"]);
209        let r = resolve_asset_ref("./assets/Hoon.jpg", "Team.md", &idx);
210        assert_eq!(r, AssetResolution::Resolved {
211            root_rel: "assets/Hoon.JPG".into(),  // canonical real case
212            provenance: AssetProvenance::CaseMismatch });
213    }
214    #[test]
215    fn bare_basename_fuzzy_silent() {
216        // bare from subfolder; not adjacent; unique basename at root → BareFuzzy (no warn)
217        let idx = FakeIndex::new(&["assets/AGU2025.jpg"]);
218        let r = resolve_asset_ref("AGU2025.jpg", "News/post.md", &idx);
219        assert_eq!(r, AssetResolution::Resolved {
220            root_rel: "assets/AGU2025.jpg".into(), provenance: AssetProvenance::BareFuzzy });
221    }
222    #[test]
223    fn bare_prefers_source_adjacent_sibling() {
224        // R2: documented, TESTED behaviour — adjacent sibling wins over a root copy.
225        let idx = FakeIndex::new(&["News/photo.jpg", "assets/photo.jpg"]);
226        let r = resolve_asset_ref("photo.jpg", "News/post.md", &idx);
227        assert_eq!(r, AssetResolution::Resolved {
228            root_rel: "News/photo.jpg".into(), provenance: AssetProvenance::Literal });
229    }
230    #[test]
231    fn ambiguous_picks_shortest_then_lexical() {
232        let idx = FakeIndex::new(&["a/photo.jpg", "deep/dir/photo.jpg"]);
233        let r = resolve_asset_ref("photo.jpg", "post.md", &idx);
234        assert_eq!(r, AssetResolution::Ambiguous {
235            chosen: "a/photo.jpg".into(),
236            candidates: vec!["a/photo.jpg".into(), "deep/dir/photo.jpg".into()] });
237    }
238    #[test]
239    fn absolute_path_resolves_from_root() {
240        let idx = FakeIndex::new(&["assets/x.jpg"]);
241        let r = resolve_asset_ref("/assets/x.jpg", "News/post.md", &idx);
242        assert_eq!(r, AssetResolution::Resolved {
243            root_rel: "assets/x.jpg".into(), provenance: AssetProvenance::Literal });
244    }
245    #[test]
246    fn escapes_root_is_not_found() {
247        let idx = FakeIndex::new(&["assets/x.jpg"]);
248        // ../../etc from a depth-1 file escapes the project → NotFound (never resolve outside)
249        assert_eq!(resolve_asset_ref("../../etc/x.jpg", "News/post.md", &idx), AssetResolution::NotFound);
250    }
251}