1use 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
29fn 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()?; } 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 if let Some(stripped) = target.strip_prefix('/') {
51 return finish(stripped.to_string(), AssetProvenance::Literal, index);
52 }
53
54 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 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 } else {
74 step2_escaped
76 }
77 } else {
78 false
79 };
80
81 if escaped {
83 return AssetResolution::NotFound;
84 }
85
86 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
110fn 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#[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 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 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 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 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(), provenance: AssetProvenance::CaseMismatch });
213 }
214 #[test]
215 fn bare_basename_fuzzy_silent() {
216 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 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 assert_eq!(resolve_asset_ref("../../etc/x.jpg", "News/post.md", &idx), AssetResolution::NotFound);
250 }
251}