use std::collections::{HashMap, HashSet};
use unicode_normalization::UnicodeNormalization;
use crate::path_ext::path_extension;
fn normalize_component(s: &str) -> String {
s.nfc().collect::<String>().to_lowercase()
}
pub(crate) fn normalize_path(path: &str) -> String {
path.replace('\\', "/")
.split('/')
.filter(|c| !c.is_empty())
.map(normalize_component)
.collect::<Vec<_>>()
.join("/")
}
fn filename_stem(normalized: &str) -> &str {
let filename = normalized.rsplit('/').next().unwrap_or(normalized);
match filename.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem,
_ => filename,
}
}
fn filename_with_ext(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
pub(crate) fn dir_components(path: &str) -> Vec<&str> {
let parts: Vec<&str> = path.split('/').collect();
if parts.len() <= 1 {
vec![]
} else {
parts[..parts.len() - 1].to_vec()
}
}
pub(crate) fn common_prefix_len(a: &[&str], b: &[&str]) -> usize {
a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
}
fn ext_match_score(ref_ext: Option<&str>, candidate: &str) -> u8 {
let Some(want) = ref_ext else { return 0 };
match path_extension(candidate) {
Some(have) if have == want => 1,
_ => 0,
}
}
fn lang_tree_match(candidate: &str, from_lang: Option<&str>) -> u8 {
let cand_lang = crate::home::lang_tree_prefix(candidate);
match (from_lang, cand_lang) {
(Some(f), Some(c)) if f.eq_ignore_ascii_case(c) => 1,
(None, None) => 1,
_ => 0,
}
}
pub fn generate_slug(relative_path: &str) -> String {
let normalized = relative_path.replace('\\', "/");
let last_segment = normalized.rsplit('/').next().unwrap_or(&normalized);
let stem_in_segment = match last_segment.rsplit_once('.') {
Some((stem, _ext)) if !stem.is_empty() => Some(stem),
_ => None,
};
let prefix = match normalized.rsplit_once('/') {
Some((p, _)) => Some(p),
None => None,
};
let without_ext: String = match (prefix, stem_in_segment) {
(Some(p), Some(stem)) => format!("{p}/{stem}"),
(None, Some(stem)) => stem.to_string(),
_ => normalized.clone(),
};
without_ext
.split('/')
.map(sanitize_slug_segment)
.collect::<Vec<_>>()
.join("/")
}
fn sanitize_slug_segment(segment: &str) -> String {
let lowered = segment.to_lowercase();
let mut buf = String::with_capacity(lowered.len());
for c in lowered.chars() {
if c.is_alphanumeric() {
buf.push(c);
} else if c == ' ' || c == '-' || c == '_' {
buf.push('-');
}
}
let mut collapsed = String::with_capacity(buf.len());
let mut prev_hyphen = false;
for c in buf.chars() {
if c == '-' {
if !prev_hyphen {
collapsed.push('-');
}
prev_hyphen = true;
} else {
collapsed.push(c);
prev_hyphen = false;
}
}
collapsed.trim_matches('-').to_string()
}
#[derive(Debug, Clone)]
pub struct ContentGraph {
files: Vec<String>,
filename_index: HashMap<String, Vec<usize>>,
path_index: HashMap<String, usize>,
slug_map: HashMap<String, String>,
asset_exact: HashSet<String>,
asset_ci: HashMap<String, Vec<String>>,
output_overrides: HashMap<String, String>,
}
impl ContentGraph {
pub fn with_output_overrides(mut self, overrides: HashMap<String, String>) -> Self {
self.output_overrides = overrides;
self
}
pub fn pinned_url(&self, root_rel: &str) -> String {
crate::resolve::output_url::pinned_url(root_rel, &self.output_overrides)
}
pub fn resolve_path(&self, reference: &str, from_path: &str) -> Option<String> {
let norm_ref = normalize_path(reference);
let norm_from = normalize_path(from_path);
let ref_ext = path_extension(&norm_ref);
let from_lang = crate::home::lang_tree_prefix(&norm_from);
if self.path_index.contains_key(&norm_ref) {
return Some(self.files[self.path_index[&norm_ref]].clone());
}
if !norm_ref.contains('/') {
if let Some(lang) = from_lang {
let scoped = format!("{}/{}", lang, norm_ref);
if let Some(&idx) = self.path_index.get(&scoped) {
return Some(self.files[idx].clone());
}
let scoped_md = format!("{}/{}.md", lang, norm_ref);
if let Some(&idx) = self.path_index.get(&scoped_md) {
return Some(self.files[idx].clone());
}
}
}
let with_md = format!("{}.md", norm_ref);
if self.path_index.contains_key(&with_md) {
return Some(self.files[self.path_index[&with_md]].clone());
}
if norm_ref.contains('/') {
let parts: Vec<&str> = norm_ref.split('/').collect();
for start in 0..parts.len().saturating_sub(1) {
let subpath = parts[start..].join("/");
if !subpath.contains('/') {
break; }
if self.path_index.contains_key(&subpath) {
return Some(self.files[self.path_index[&subpath]].clone());
}
let with_md = format!("{}.md", subpath);
if self.path_index.contains_key(&with_md) {
return Some(self.files[self.path_index[&with_md]].clone());
}
let suffix = format!("/{}", subpath);
let candidates: Vec<usize> = self.files.iter().enumerate()
.filter(|(_, f)| normalize_path(f).ends_with(&suffix))
.map(|(i, _)| i)
.collect();
if candidates.len() == 1 {
return Some(self.files[candidates[0]].clone());
}
if candidates.len() > 1 {
let from_dirs = dir_components(&norm_from);
let best = candidates.iter().copied().max_by_key(|&idx| {
let normalized = normalize_path(&self.files[idx]);
let candidate_dirs = dir_components(&normalized);
let tree_match = lang_tree_match(&normalized, from_lang);
let ext_match = ext_match_score(ref_ext.as_deref(), &normalized);
(
ext_match,
tree_match,
common_prefix_len(&candidate_dirs, &from_dirs),
std::cmp::Reverse(normalized.clone()),
)
});
if let Some(idx) = best {
return Some(self.files[idx].clone());
}
}
}
}
let ref_stem = normalize_component(
filename_stem(filename_with_ext(&norm_ref)),
);
let skip_stem = norm_ref.contains('/') && crate::home::is_index_stem(&ref_stem);
if !skip_stem {
if let Some(candidates) = self.filename_index.get(&ref_stem) {
if candidates.len() == 1 {
return Some(self.files[candidates[0]].clone());
}
let from_dirs = dir_components(&norm_from);
let best = candidates
.iter()
.copied()
.max_by_key(|&idx| {
let normalized = normalize_path(&self.files[idx]);
let candidate_dirs = dir_components(&normalized);
let tree_match = lang_tree_match(&normalized, from_lang);
let ext_match = ext_match_score(ref_ext.as_deref(), &normalized);
(
ext_match,
tree_match,
common_prefix_len(&candidate_dirs, &from_dirs),
std::cmp::Reverse(normalized.clone()),
)
});
if let Some(idx) = best {
return Some(self.files[idx].clone());
}
}
}
let folder_note = |base: &str| -> Option<String> {
for stem in crate::home::INDEX_STEMS {
let folder_index = format!("{}/{}.md", base, stem);
if let Some(&idx) = self.path_index.get(&folder_index) {
return Some(self.files[idx].clone());
}
}
let leaf = base.rsplit('/').next().unwrap_or(base);
let self_named = format!("{}/{}.md", base, leaf);
self.path_index
.get(&self_named)
.map(|&idx| self.files[idx].clone())
};
if let Some(lang) = from_lang {
if crate::home::lang_tree_prefix(&norm_ref).is_none() {
let scoped = format!("{}/{}", lang, norm_ref);
if let Some(found) = folder_note(&scoped) {
return Some(found);
}
}
}
if let Some(found) = folder_note(&norm_ref) {
return Some(found);
}
None
}
pub fn get_slug(&self, path: &str) -> Option<&str> {
let norm = normalize_path(path);
self.slug_map.get(&norm).map(|s| s.as_str())
}
pub fn all_files(&self) -> &[String] {
&self.files
}
pub fn asset_contains(&self, p: &str) -> bool {
self.asset_exact.contains(p)
}
pub fn asset_contains_ci(&self, p: &str) -> Option<String> {
self.asset_ci.get(&p.to_lowercase()).and_then(|v| v.first().cloned())
}
pub fn asset_find_by_suffix(&self, suffix: &str) -> Vec<String> {
let ls = suffix.to_lowercase();
let mut v: Vec<String> = self.asset_exact.iter().filter(|p| {
let lp = p.to_lowercase();
lp.ends_with(&ls)
&& (lp.len() == ls.len()
|| lp.as_bytes()[lp.len() - ls.len() - 1] == b'/')
}).cloned().collect();
v.sort();
v
}
pub fn from_paths(paths: &[&str]) -> ContentGraph {
let mut b = ContentGraphBuilder::new();
for &p in paths {
b.add_file(p, "");
}
b.build()
}
}
#[derive(Debug, Default)]
pub struct ContentGraphBuilder {
files: Vec<String>,
filename_index: HashMap<String, Vec<usize>>,
path_index: HashMap<String, usize>,
slug_map: HashMap<String, String>,
asset_exact: HashSet<String>,
asset_ci: HashMap<String, Vec<String>>,
}
impl ContentGraphBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn add_file(&mut self, relative_path: &str, slug: &str) {
let norm = normalize_path(relative_path);
if self.path_index.contains_key(&norm) {
return;
}
let idx = self.files.len();
let stem = filename_stem(&norm).to_owned();
self.filename_index.entry(stem).or_default().push(idx);
self.path_index.insert(norm.clone(), idx);
self.slug_map.insert(norm.clone(), slug.to_owned());
self.files.push(relative_path.to_string());
self.asset_exact.insert(relative_path.to_string());
self.asset_ci
.entry(relative_path.to_lowercase())
.or_default()
.push(relative_path.to_string());
}
pub fn build(self) -> ContentGraph {
ContentGraph {
files: self.files,
filename_index: self.filename_index,
path_index: self.path_index,
slug_map: self.slug_map,
asset_exact: self.asset_exact,
asset_ci: self.asset_ci,
output_overrides: HashMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_graph() -> ContentGraph {
let mut b = ContentGraphBuilder::new();
b.add_file("posts/hello.md", "/posts/hello");
b.add_file("posts/world.md", "/posts/world");
b.add_file("guides/hello.md", "/guides/hello");
b.add_file("projects/index.md", "/projects");
b.add_file("notes/daily/daily.md", "/notes/daily");
b.build()
}
#[test]
fn test_builder_adds_file() {
let mut b = ContentGraphBuilder::new();
b.add_file("notes/first.md", "/notes/first");
let g = b.build();
assert_eq!(g.all_files(), &["notes/first.md"]);
assert_eq!(
g.resolve_path("notes/first.md", ""),
Some("notes/first.md".into())
);
}
#[test]
fn test_filename_index_case_insensitive() {
let mut b = ContentGraphBuilder::new();
b.add_file("Notes/MyFile.md", "/notes/myfile");
let g = b.build();
assert_eq!(
g.resolve_path("myfile", ""),
Some("Notes/MyFile.md".into())
);
assert_eq!(
g.resolve_path("MYFILE", ""),
Some("Notes/MyFile.md".into())
);
assert_eq!(
g.resolve_path("MyFile", ""),
Some("Notes/MyFile.md".into())
);
}
#[test]
fn test_filename_index_without_extension() {
let g = sample_graph();
assert_eq!(
g.resolve_path("world", ""),
Some("posts/world.md".into())
);
}
#[test]
fn test_ambiguous_resolved_by_common_prefix() {
let g = sample_graph();
assert_eq!(
g.resolve_path("hello", "posts/other.md"),
Some("posts/hello.md".into())
);
assert_eq!(
g.resolve_path("hello", "guides/other.md"),
Some("guides/hello.md".into())
);
}
#[test]
fn test_folder_note_resolution() {
let g = sample_graph();
assert_eq!(
g.resolve_path("projects", ""),
Some("projects/index.md".into())
);
}
#[test]
fn test_folder_note_prefers_same_language_tree() {
let g = ContentGraph::from_paths(&[
"docs/index.md",
"zh-hans/docs/index.md",
"zh-hans/index.md",
]);
assert_eq!(
g.resolve_path("docs/", "zh-hans/index.md"),
Some("zh-hans/docs/index.md".into())
);
assert_eq!(
g.resolve_path("docs/", "index.md"),
Some("docs/index.md".into())
);
}
#[test]
fn test_folder_note_falls_back_to_root_when_no_language_sibling() {
let g = ContentGraph::from_paths(&["docs/index.md", "zh-hans/index.md"]);
assert_eq!(
g.resolve_path("docs/", "zh-hans/index.md"),
Some("docs/index.md".into())
);
}
#[test]
fn test_self_named_folder_note_resolution() {
let g = sample_graph();
assert_eq!(
g.resolve_path("daily", ""),
Some("notes/daily/daily.md".into())
);
}
#[test]
fn test_self_named_folder_note_via_path() {
let mut b = ContentGraphBuilder::new();
b.add_file("archive/archive.md", "/archive");
let g = b.build();
assert_eq!(
g.resolve_path("archive", ""),
Some("archive/archive.md".into())
);
}
#[test]
fn test_unresolved_returns_none() {
let g = sample_graph();
assert_eq!(g.resolve_path("nonexistent", ""), None);
assert_eq!(g.resolve_path("posts/missing.md", ""), None);
}
#[test]
fn test_exact_path_match() {
let g = sample_graph();
assert_eq!(
g.resolve_path("guides/hello.md", "posts/other.md"),
Some("guides/hello.md".into())
);
}
#[test]
fn test_partial_path_match() {
let g = sample_graph();
assert_eq!(
g.resolve_path("posts/hello", ""),
Some("posts/hello.md".into())
);
assert_eq!(
g.resolve_path("posts/world", ""),
Some("posts/world.md".into())
);
}
#[test]
fn test_get_slug() {
let g = sample_graph();
assert_eq!(g.get_slug("posts/hello.md"), Some("/posts/hello"));
assert_eq!(g.get_slug("Posts/Hello.md"), Some("/posts/hello"));
assert_eq!(g.get_slug("nope.md"), None);
}
#[test]
fn test_all_files_order() {
let g = sample_graph();
assert_eq!(
g.all_files(),
&[
"posts/hello.md",
"posts/world.md",
"guides/hello.md",
"projects/index.md",
"notes/daily/daily.md",
]
);
}
#[test]
fn test_unicode_normalization() {
let mut b = ContentGraphBuilder::new();
b.add_file("caf\u{0065}\u{0301}.md", "/cafe");
let g = b.build();
assert_eq!(
g.resolve_path("caf\u{00e9}.md", ""),
Some("caf\u{0065}\u{0301}.md".into())
);
assert_eq!(
g.resolve_path("caf\u{0065}\u{0301}.md", ""),
Some("caf\u{0065}\u{0301}.md".into())
);
}
#[test]
fn test_generate_slug_strips_extension() {
assert_eq!(generate_slug("posts/hello.md"), "posts/hello");
assert_eq!(generate_slug("image.png"), "image");
}
#[test]
fn test_generate_slug_lowercases() {
assert_eq!(generate_slug("Posts/Hello.md"), "posts/hello");
}
#[test]
fn test_generate_slug_replaces_spaces() {
assert_eq!(generate_slug("posts/Hello World.md"), "posts/hello-world");
}
#[test]
fn test_generate_slug_normalizes_backslashes() {
assert_eq!(generate_slug("posts\\hello.md"), "posts/hello");
}
#[test]
fn test_generate_slug_no_extension() {
assert_eq!(generate_slug("readme"), "readme");
}
#[test]
fn test_generate_slug_dotfile_keeps_leading_dot() {
assert_eq!(generate_slug(".gitignore"), "gitignore");
assert_eq!(generate_slug(".bashrc"), "bashrc");
assert_eq!(generate_slug("posts/.hidden"), "posts/hidden");
}
#[test]
fn test_generate_slug_deep_path() {
assert_eq!(
generate_slug("deep/path/to/file.txt"),
"deep/path/to/file"
);
}
#[test]
fn test_generate_slug_strips_ascii_punctuation() {
assert_eq!(
generate_slug("news/Farewell, and Erase on BroadwayWorld.md"),
"news/farewell-and-erase-on-broadwayworld"
);
assert_eq!(generate_slug("posts/Hello (World)!.md"), "posts/hello-world");
assert_eq!(generate_slug("posts/it's-mine.md"), "posts/its-mine");
assert_eq!(generate_slug("posts/foo:bar.md"), "posts/foobar");
}
#[test]
fn test_generate_slug_collapses_consecutive_hyphens() {
assert_eq!(generate_slug("posts/foo--bar.md"), "posts/foo-bar");
assert_eq!(generate_slug("posts/foo - bar.md"), "posts/foo-bar");
assert_eq!(generate_slug("posts/a---b.md"), "posts/a-b");
}
#[test]
fn test_generate_slug_trims_leading_trailing_hyphens_per_segment() {
assert_eq!(generate_slug("posts/-hello.md"), "posts/hello");
assert_eq!(generate_slug("posts/hello-.md"), "posts/hello");
}
#[test]
fn test_generate_slug_preserves_non_ascii() {
assert_eq!(generate_slug("视频/视频.md"), "视频/视频");
assert_eq!(
generate_slug("posts/AI 带来写作的黄金时代.md"),
"posts/ai-带来写作的黄金时代"
);
}
#[test]
fn test_generate_slug_preserves_path_separators() {
assert_eq!(generate_slug("a/b/c.md"), "a/b/c");
assert_eq!(generate_slug("a, b/c.md"), "a-b/c");
}
#[test]
fn test_resolve_self_named_via_filename_stem() {
let mut b = ContentGraphBuilder::new();
b.add_file("recipes/index.md", "/recipes");
b.add_file("recipes/recipes.md", "/recipes/recipes");
let g = b.build();
assert_eq!(
g.resolve_path("recipes", "other.md"),
Some("recipes/recipes.md".into())
);
}
#[test]
fn test_resolve_folder_note_fallback_to_index() {
let mut b = ContentGraphBuilder::new();
b.add_file("recipes/index.md", "/recipes");
b.add_file("recipes/pasta.md", "/recipes/pasta");
let g = b.build();
assert_eq!(
g.resolve_path("recipes", "other.md"),
Some("recipes/index.md".into())
);
}
#[test]
fn test_suffix_match_partial_path() {
let mut b = ContentGraphBuilder::new();
b.add_file("文字/游记/index.md", "/文字/游记");
b.add_file("index.md", "/");
let g = b.build();
assert_eq!(
g.resolve_path("游记/index.md", "index.md"),
Some("文字/游记/index.md".into())
);
}
#[test]
fn test_suffix_match_ambiguous_uses_tiebreaker() {
let mut b = ContentGraphBuilder::new();
b.add_file("a/游记/index.md", "/a/游记");
b.add_file("b/游记/index.md", "/b/游记");
let g = b.build();
assert_eq!(
g.resolve_path("游记/index.md", "a/other.md"),
Some("a/游记/index.md".into())
);
assert_eq!(
g.resolve_path("游记/index.md", "b/other.md"),
Some("b/游记/index.md".into())
);
}
#[test]
fn test_vault_root_prefix_resolves_correctly() {
let mut b = ContentGraphBuilder::new();
b.add_file("交互实验/index.md", "/交互实验");
b.add_file("文字/分布式信息网络/index.md", "/文字/分布式信息网络");
let g = b.build();
assert_eq!(
g.resolve_path("刘果/交互实验/index.md", ""),
Some("交互实验/index.md".into())
);
}
#[test]
fn test_vault_root_prefix_non_index() {
let mut b = ContentGraphBuilder::new();
b.add_file("posts/hello.md", "/posts/hello");
b.add_file("guides/hello.md", "/guides/hello");
let g = b.build();
assert_eq!(
g.resolve_path("mysite/posts/hello.md", ""),
Some("posts/hello.md".into())
);
}
#[test]
fn test_vault_root_prefix_deep_nesting() {
let mut b = ContentGraphBuilder::new();
b.add_file("文字/游记/index.md", "/文字/游记");
let g = b.build();
assert_eq!(
g.resolve_path("vault/文字/游记/index.md", ""),
Some("文字/游记/index.md".into())
);
}
#[test]
fn test_resolve_path_preserves_original_case() {
let mut b = ContentGraphBuilder::new();
b.add_file("音乐/Winter-Song.mov", "音乐/winter-song");
let g = b.build();
assert_eq!(
g.resolve_path("winter-song.mov", ""),
Some("音乐/Winter-Song.mov".into())
);
assert_eq!(
g.resolve_path("Winter-Song.mov", ""),
Some("音乐/Winter-Song.mov".into())
);
}
#[test]
fn test_all_files_preserves_original_case() {
let mut b = ContentGraphBuilder::new();
b.add_file("Notes/MyFile.md", "/notes/myfile");
b.add_file("Posts/Hello-World.md", "/posts/hello-world");
let g = b.build();
assert_eq!(
g.all_files(),
&["Notes/MyFile.md", "Posts/Hello-World.md"]
);
}
#[test]
fn stem_collision_prefers_matching_extension_png() {
let mut b = ContentGraphBuilder::new();
b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
let g = b.build();
assert_eq!(
g.resolve_path("scale-compare.png", "interactive/article.md"),
Some("interactive/scale-compare.png".into())
);
}
#[test]
fn stem_collision_prefers_matching_extension_html() {
let mut b = ContentGraphBuilder::new();
b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
let g = b.build();
assert_eq!(
g.resolve_path("scale-compare.html", "interactive/article.md"),
Some("interactive/scale-compare.html".into())
);
}
#[test]
fn stem_collision_independent_of_registration_order() {
let mut b = ContentGraphBuilder::new();
b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
let g = b.build();
assert_eq!(
g.resolve_path("scale-compare.png", "interactive/article.md"),
Some("interactive/scale-compare.png".into())
);
assert_eq!(
g.resolve_path("scale-compare.html", "interactive/article.md"),
Some("interactive/scale-compare.html".into())
);
}
#[test]
fn stem_collision_bare_ref_unchanged() {
let mut b = ContentGraphBuilder::new();
b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
let g = b.build();
assert!(g.resolve_path("scale-compare", "interactive/article.md").is_some());
}
#[test]
fn stem_collision_md_wins_over_html_sibling() {
let mut b = ContentGraphBuilder::new();
b.add_file("notes/guide.md", "/notes/guide");
b.add_file("notes/guide.html", "/notes/guide.html");
let g = b.build();
assert_eq!(
g.resolve_path("guide.md", "notes/index.md"),
Some("notes/guide.md".into())
);
}
#[test]
fn stem_collision_suffix_match_arm() {
let mut b = ContentGraphBuilder::new();
b.add_file("vault/a/scale.png", "/vault/a/scale.png");
b.add_file("vault/a/scale.html", "/vault/a/scale.html");
let g = b.build();
assert_eq!(
g.resolve_path("a/scale.png", "vault/notes/article.md"),
Some("vault/a/scale.png".into())
);
}
#[test]
fn stem_collision_ext_match_overrides_lang_tree() {
let mut b = ContentGraphBuilder::new();
b.add_file("zh-hans/foo.html", "/zh-hans/foo.html");
b.add_file("en/foo.png", "/en/foo.png");
let g = b.build();
assert_eq!(
g.resolve_path("foo.png", "zh-hans/note.md"),
Some("en/foo.png".into())
);
}
#[test]
fn stem_collision_alphabetical_final_tiebreaker() {
let mut b1 = ContentGraphBuilder::new();
b1.add_file("notes/photo.png", "/notes/photo.png");
b1.add_file("notes/photo.html", "/notes/photo.html");
let g1 = b1.build();
let mut b2 = ContentGraphBuilder::new();
b2.add_file("notes/photo.html", "/notes/photo.html");
b2.add_file("notes/photo.png", "/notes/photo.png");
let g2 = b2.build();
let r1 = g1.resolve_path("photo", "notes/index.md");
let r2 = g2.resolve_path("photo", "notes/index.md");
assert_eq!(r1, r2, "result must not depend on registration order");
assert_eq!(r1, Some("notes/photo.html".into()));
}
#[test]
fn stem_collision_case_insensitive_extension() {
let mut b = ContentGraphBuilder::new();
b.add_file("interactive/photo.PNG", "/interactive/photo.png");
b.add_file("interactive/photo.html", "/interactive/photo.html");
let g = b.build();
assert_eq!(
g.resolve_path("photo.png", "interactive/article.md"),
Some("interactive/photo.PNG".into())
);
}
#[test]
fn exact_case_asset_index() {
let g = ContentGraph::from_paths(&["assets/Hoon.JPG", "News/post.md"]);
assert!(g.asset_contains("assets/Hoon.JPG"));
assert!(!g.asset_contains("assets/hoon.jpg")); assert_eq!(
g.asset_contains_ci("assets/hoon.jpg").as_deref(),
Some("assets/Hoon.JPG")
);
assert_eq!(
g.asset_find_by_suffix("Hoon.JPG"),
vec!["assets/Hoon.JPG".to_string()]
);
}
}