use crate::resolve::parent_dir;
pub trait AssetIndex {
fn contains(&self, root_rel: &str) -> bool;
fn contains_ci(&self, root_rel: &str) -> Option<String>;
fn find_by_suffix(&self, suffix: &str) -> Vec<String>;
}
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AssetProvenance {
Literal,
BareFuzzy,
SeparatorFallback,
CaseMismatch,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssetResolution {
Resolved { root_rel: String, provenance: AssetProvenance },
Ambiguous { chosen: String, candidates: Vec<String> },
NotFound,
}
fn lexical_join(base_dir: &str, target: &str) -> Option<String> {
let mut parts: Vec<&str> = if base_dir.is_empty() { vec![] } else { base_dir.split('/').collect() };
for seg in target.split('/') {
match seg {
"" | "." => {}
".." => { parts.pop()?; } other => parts.push(other),
}
}
Some(parts.join("/"))
}
fn has_separator(t: &str) -> bool {
t.contains('/')
}
pub fn resolve_asset_ref(target: &str, from_source: &str, index: &dyn AssetIndex) -> AssetResolution {
let from_dir = parent_dir(from_source);
if let Some(stripped) = target.strip_prefix('/') {
return finish(stripped.to_string(), AssetProvenance::Literal, index);
}
if let Some(cand) = lexical_join(from_dir, target) {
if let Some(res) = finish_opt(&cand, AssetProvenance::Literal, index) { return res; }
}
let escaped = if has_separator(target) {
let step2_escaped = lexical_join(from_dir, target).is_none();
let step3_cand = lexical_join("", target);
if let Some(cand) = step3_cand {
if let Some(res) = finish_opt(&cand, AssetProvenance::SeparatorFallback, index) { return res; }
false } else {
step2_escaped
}
} else {
false
};
if escaped {
return AssetResolution::NotFound;
}
let basename = target.rsplit('/').next().unwrap_or(target);
let mut matches = if has_separator(target) {
let mut m = index.find_by_suffix(target);
if m.is_empty() { m = index.find_by_suffix(basename); }
m
} else {
index.find_by_suffix(basename)
};
matches.sort_by(|a, b| a.matches('/').count().cmp(&b.matches('/').count()).then(a.cmp(b)));
match matches.len() {
0 => AssetResolution::NotFound,
1 => {
let prov = if has_separator(target) {
AssetProvenance::SeparatorFallback
} else {
AssetProvenance::BareFuzzy
};
AssetResolution::Resolved { root_rel: matches.remove(0), provenance: prov }
}
_ => AssetResolution::Ambiguous { chosen: matches[0].clone(), candidates: matches },
}
}
fn finish_opt(cand: &str, prov: AssetProvenance, index: &dyn AssetIndex) -> Option<AssetResolution> {
if index.contains(cand) {
return Some(AssetResolution::Resolved { root_rel: cand.to_string(), provenance: prov });
}
if let Some(canon) = index.contains_ci(cand) {
return Some(AssetResolution::Resolved { root_rel: canon, provenance: AssetProvenance::CaseMismatch });
}
None
}
fn finish(cand: String, prov: AssetProvenance, index: &dyn AssetIndex) -> AssetResolution {
finish_opt(&cand, prov, index).unwrap_or(AssetResolution::NotFound)
}
#[cfg(test)]
pub(crate) struct FakeAssetIndex(std::collections::HashSet<String>);
#[cfg(test)]
impl FakeAssetIndex {
pub fn new(paths: &[&str]) -> Self {
FakeAssetIndex(paths.iter().map(|s| s.to_string()).collect())
}
}
#[cfg(test)]
impl AssetIndex for FakeAssetIndex {
fn contains(&self, p: &str) -> bool {
self.0.contains(p)
}
fn contains_ci(&self, p: &str) -> Option<String> {
let lp = p.to_lowercase();
self.0.iter().find(|x| x.to_lowercase() == lp).cloned()
}
fn find_by_suffix(&self, s: &str) -> Vec<String> {
let ls = s.to_lowercase();
let mut v: Vec<String> = self.0.iter()
.filter(|x| x.to_lowercase().ends_with(&ls)
&& (x.len() == s.len() || x.as_bytes()[x.len() - s.len() - 1] == b'/'))
.cloned().collect();
v.sort();
v
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
struct FakeIndex(HashSet<String>);
impl FakeIndex {
fn new(paths: &[&str]) -> Self {
FakeIndex(paths.iter().map(|s| s.to_string()).collect())
}
}
impl AssetIndex for FakeIndex {
fn contains(&self, p: &str) -> bool {
self.0.contains(p)
}
fn contains_ci(&self, p: &str) -> Option<String> {
let lp = p.to_lowercase();
self.0.iter().find(|x| x.to_lowercase() == lp).cloned()
}
fn find_by_suffix(&self, s: &str) -> Vec<String> {
let ls = s.to_lowercase();
let mut v: Vec<String> = self.0.iter()
.filter(|x| x.to_lowercase().ends_with(&ls)
&& (x.len() == s.len() || x.as_bytes()[x.len() - s.len() - 1] == b'/'))
.cloned().collect();
v.sort();
v
}
}
#[test]
fn literal_exact_relative_hit() {
let idx = FakeIndex::new(&["assets/Hoon.JPG", "team/photo.jpg"]);
let r = resolve_asset_ref("./photo.jpg", "team/Team.md", &idx);
assert_eq!(r, AssetResolution::Resolved {
root_rel: "team/photo.jpg".into(), provenance: AssetProvenance::Literal });
}
#[test]
fn separator_fallback_to_root() {
let idx = FakeIndex::new(&["assets/AGU2025.jpg"]);
let r = resolve_asset_ref("./assets/AGU2025.jpg", "News/2025-12-agu.md", &idx);
assert_eq!(r, AssetResolution::Resolved {
root_rel: "assets/AGU2025.jpg".into(),
provenance: AssetProvenance::SeparatorFallback });
}
#[test]
fn case_mismatch_on_literal() {
let idx = FakeIndex::new(&["assets/Hoon.JPG"]);
let r = resolve_asset_ref("./assets/Hoon.jpg", "Team.md", &idx);
assert_eq!(r, AssetResolution::Resolved {
root_rel: "assets/Hoon.JPG".into(), provenance: AssetProvenance::CaseMismatch });
}
#[test]
fn bare_basename_fuzzy_silent() {
let idx = FakeIndex::new(&["assets/AGU2025.jpg"]);
let r = resolve_asset_ref("AGU2025.jpg", "News/post.md", &idx);
assert_eq!(r, AssetResolution::Resolved {
root_rel: "assets/AGU2025.jpg".into(), provenance: AssetProvenance::BareFuzzy });
}
#[test]
fn bare_prefers_source_adjacent_sibling() {
let idx = FakeIndex::new(&["News/photo.jpg", "assets/photo.jpg"]);
let r = resolve_asset_ref("photo.jpg", "News/post.md", &idx);
assert_eq!(r, AssetResolution::Resolved {
root_rel: "News/photo.jpg".into(), provenance: AssetProvenance::Literal });
}
#[test]
fn ambiguous_picks_shortest_then_lexical() {
let idx = FakeIndex::new(&["a/photo.jpg", "deep/dir/photo.jpg"]);
let r = resolve_asset_ref("photo.jpg", "post.md", &idx);
assert_eq!(r, AssetResolution::Ambiguous {
chosen: "a/photo.jpg".into(),
candidates: vec!["a/photo.jpg".into(), "deep/dir/photo.jpg".into()] });
}
#[test]
fn absolute_path_resolves_from_root() {
let idx = FakeIndex::new(&["assets/x.jpg"]);
let r = resolve_asset_ref("/assets/x.jpg", "News/post.md", &idx);
assert_eq!(r, AssetResolution::Resolved {
root_rel: "assets/x.jpg".into(), provenance: AssetProvenance::Literal });
}
#[test]
fn escapes_root_is_not_found() {
let idx = FakeIndex::new(&["assets/x.jpg"]);
assert_eq!(resolve_asset_ref("../../etc/x.jpg", "News/post.md", &idx), AssetResolution::NotFound);
}
}