1use std::collections::HashMap;
10use unicode_normalization::UnicodeNormalization;
11
12use crate::path_ext::path_extension;
13
14fn normalize_component(s: &str) -> String {
20 s.nfc().collect::<String>().to_lowercase()
21}
22
23fn normalize_path(path: &str) -> String {
27 path.replace('\\', "/")
28 .split('/')
29 .filter(|c| !c.is_empty())
30 .map(normalize_component)
31 .collect::<Vec<_>>()
32 .join("/")
33}
34
35fn filename_stem(normalized: &str) -> &str {
37 let filename = normalized.rsplit('/').next().unwrap_or(normalized);
38 match filename.rsplit_once('.') {
39 Some((stem, _)) if !stem.is_empty() => stem,
42 _ => filename,
43 }
44}
45
46fn filename_with_ext(path: &str) -> &str {
48 path.rsplit('/').next().unwrap_or(path)
49}
50
51
52fn dir_components(path: &str) -> Vec<&str> {
54 let parts: Vec<&str> = path.split('/').collect();
55 if parts.len() <= 1 {
56 vec![]
57 } else {
58 parts[..parts.len() - 1].to_vec()
59 }
60}
61
62fn common_prefix_len(a: &[&str], b: &[&str]) -> usize {
64 a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
65}
66
67fn ext_match_score(ref_ext: Option<&str>, candidate: &str) -> u8 {
73 let Some(want) = ref_ext else { return 0 };
74 match path_extension(candidate) {
75 Some(have) if have == want => 1,
76 _ => 0,
77 }
78}
79
80fn lang_tree_match(candidate: &str, from_lang: Option<&str>) -> u8 {
91 let cand_lang = crate::home::lang_tree_prefix(candidate);
92 match (from_lang, cand_lang) {
93 (Some(f), Some(c)) if f.eq_ignore_ascii_case(c) => 1,
94 (None, None) => 1,
95 _ => 0,
96 }
97}
98
99pub fn generate_slug(relative_path: &str) -> String {
121 let normalized = relative_path.replace('\\', "/");
123
124 let last_segment = normalized.rsplit('/').next().unwrap_or(&normalized);
130 let stem_in_segment = match last_segment.rsplit_once('.') {
131 Some((stem, _ext)) if !stem.is_empty() => Some(stem),
132 _ => None,
133 };
134 let prefix = match normalized.rsplit_once('/') {
135 Some((p, _)) => Some(p),
136 None => None,
137 };
138 let without_ext: String = match (prefix, stem_in_segment) {
139 (Some(p), Some(stem)) => format!("{p}/{stem}"),
140 (None, Some(stem)) => stem.to_string(),
141 _ => normalized.clone(),
142 };
143
144 without_ext
147 .split('/')
148 .map(sanitize_slug_segment)
149 .collect::<Vec<_>>()
150 .join("/")
151}
152
153fn sanitize_slug_segment(segment: &str) -> String {
156 let lowered = segment.to_lowercase();
157
158 let mut buf = String::with_capacity(lowered.len());
159 for c in lowered.chars() {
160 if c.is_alphanumeric() {
161 buf.push(c);
162 } else if c == ' ' || c == '-' || c == '_' {
163 buf.push('-');
164 }
165 }
168
169 let mut collapsed = String::with_capacity(buf.len());
171 let mut prev_hyphen = false;
172 for c in buf.chars() {
173 if c == '-' {
174 if !prev_hyphen {
175 collapsed.push('-');
176 }
177 prev_hyphen = true;
178 } else {
179 collapsed.push(c);
180 prev_hyphen = false;
181 }
182 }
183 collapsed.trim_matches('-').to_string()
184}
185
186#[derive(Debug, Clone)]
195pub struct ContentGraph {
196 files: Vec<String>,
198
199 filename_index: HashMap<String, Vec<usize>>,
201
202 path_index: HashMap<String, usize>,
204
205 slug_map: HashMap<String, String>,
207
208 headings: HashMap<String, Vec<(String, String)>>,
210
211 blocks: HashMap<String, Vec<String>>,
213}
214
215impl ContentGraph {
216 pub fn resolve_path(&self, reference: &str, from_path: &str) -> Option<String> {
246 let norm_ref = normalize_path(reference);
247 let norm_from = normalize_path(from_path);
248 let ref_ext = path_extension(&norm_ref);
249
250 let from_lang = crate::home::lang_tree_prefix(&norm_from);
254
255 if self.path_index.contains_key(&norm_ref) {
257 return Some(self.files[self.path_index[&norm_ref]].clone());
258 }
259
260 if !norm_ref.contains('/') {
265 if let Some(lang) = from_lang {
266 let scoped = format!("{}/{}", lang, norm_ref);
267 if let Some(&idx) = self.path_index.get(&scoped) {
268 return Some(self.files[idx].clone());
269 }
270 let scoped_md = format!("{}/{}.md", lang, norm_ref);
271 if let Some(&idx) = self.path_index.get(&scoped_md) {
272 return Some(self.files[idx].clone());
273 }
274 }
275 }
276
277 let with_md = format!("{}.md", norm_ref);
279 if self.path_index.contains_key(&with_md) {
280 return Some(self.files[self.path_index[&with_md]].clone());
281 }
282
283 if norm_ref.contains('/') {
288 let parts: Vec<&str> = norm_ref.split('/').collect();
289 for start in 0..parts.len().saturating_sub(1) {
291 let subpath = parts[start..].join("/");
292 if !subpath.contains('/') {
293 break; }
295
296 if self.path_index.contains_key(&subpath) {
298 return Some(self.files[self.path_index[&subpath]].clone());
299 }
300 let with_md = format!("{}.md", subpath);
302 if self.path_index.contains_key(&with_md) {
303 return Some(self.files[self.path_index[&with_md]].clone());
304 }
305
306 let suffix = format!("/{}", subpath);
308 let candidates: Vec<usize> = self.files.iter().enumerate()
309 .filter(|(_, f)| normalize_path(f).ends_with(&suffix))
310 .map(|(i, _)| i)
311 .collect();
312 if candidates.len() == 1 {
313 return Some(self.files[candidates[0]].clone());
314 }
315 if candidates.len() > 1 {
316 let from_dirs = dir_components(&norm_from);
317 let best = candidates.iter().copied().max_by_key(|&idx| {
318 let normalized = normalize_path(&self.files[idx]);
322 let candidate_dirs = dir_components(&normalized);
323 let tree_match = lang_tree_match(&normalized, from_lang);
324 let ext_match = ext_match_score(ref_ext.as_deref(), &normalized);
325 (
330 ext_match,
331 tree_match,
332 common_prefix_len(&candidate_dirs, &from_dirs),
333 std::cmp::Reverse(normalized.clone()),
334 )
335 });
336 if let Some(idx) = best {
337 return Some(self.files[idx].clone());
338 }
339 }
340 }
341 }
342
343 let ref_stem = normalize_component(
348 filename_stem(filename_with_ext(&norm_ref)),
349 );
350 let skip_stem = norm_ref.contains('/') && crate::home::is_index_stem(&ref_stem);
351 if !skip_stem {
352 if let Some(candidates) = self.filename_index.get(&ref_stem) {
353 if candidates.len() == 1 {
354 return Some(self.files[candidates[0]].clone());
355 }
356 let from_dirs = dir_components(&norm_from);
362 let best = candidates
363 .iter()
364 .copied()
365 .max_by_key(|&idx| {
366 let normalized = normalize_path(&self.files[idx]);
370 let candidate_dirs = dir_components(&normalized);
371 let tree_match = lang_tree_match(&normalized, from_lang);
372 let ext_match = ext_match_score(ref_ext.as_deref(), &normalized);
373 (
378 ext_match,
379 tree_match,
380 common_prefix_len(&candidate_dirs, &from_dirs),
381 std::cmp::Reverse(normalized.clone()),
382 )
383 });
384 if let Some(idx) = best {
385 return Some(self.files[idx].clone());
386 }
387 }
388 }
389
390 for stem in crate::home::INDEX_STEMS {
392 let folder_index = format!("{}/{}.md", norm_ref, stem);
393 if self.path_index.contains_key(&folder_index) {
394 return Some(self.files[self.path_index[&folder_index]].clone());
395 }
396 }
397
398 let self_named = {
400 let leaf = norm_ref.rsplit('/').next().unwrap_or(&norm_ref);
401 format!("{}/{}.md", norm_ref, leaf)
402 };
403 if self.path_index.contains_key(&self_named) {
404 return Some(self.files[self.path_index[&self_named]].clone());
405 }
406
407 None
408 }
409
410 pub fn has_heading(&self, path: &str, anchor: &str) -> bool {
412 let norm = normalize_path(path);
413 let anchor_lower = normalize_component(anchor);
414 self.headings
415 .get(&norm)
416 .map_or(false, |hs| hs.iter().any(|(_, a)| *a == anchor_lower))
417 }
418
419 pub fn has_block(&self, path: &str, block_id: &str) -> bool {
421 let norm = normalize_path(path);
422 let id_lower = normalize_component(block_id);
423 self.blocks
424 .get(&norm)
425 .map_or(false, |bs| bs.iter().any(|b| *b == id_lower))
426 }
427
428 pub fn get_slug(&self, path: &str) -> Option<&str> {
430 let norm = normalize_path(path);
431 self.slug_map.get(&norm).map(|s| s.as_str())
432 }
433
434 pub fn all_files(&self) -> &[String] {
436 &self.files
437 }
438}
439
440#[derive(Debug, Default)]
449pub struct ContentGraphBuilder {
450 files: Vec<String>,
451 filename_index: HashMap<String, Vec<usize>>,
452 path_index: HashMap<String, usize>,
453 slug_map: HashMap<String, String>,
454 headings: HashMap<String, Vec<(String, String)>>,
455 blocks: HashMap<String, Vec<String>>,
456}
457
458impl ContentGraphBuilder {
459 pub fn new() -> Self {
461 Self::default()
462 }
463
464 pub fn add_file(&mut self, relative_path: &str, slug: &str) {
469 let norm = normalize_path(relative_path);
470
471 if self.path_index.contains_key(&norm) {
474 return;
475 }
476
477 let idx = self.files.len();
478
479 let stem = filename_stem(&norm).to_owned();
481 self.filename_index.entry(stem).or_default().push(idx);
482
483 self.path_index.insert(norm.clone(), idx);
485
486 self.slug_map.insert(norm.clone(), slug.to_owned());
488
489 self.files.push(relative_path.to_string());
491 }
492
493 pub fn add_headings(&mut self, relative_path: &str, entries: Vec<(String, String)>) {
495 let norm = normalize_path(relative_path);
496 let normalized_entries = entries
497 .into_iter()
498 .map(|(text, anchor)| (text, normalize_component(&anchor)))
499 .collect();
500 self.headings.insert(norm, normalized_entries);
501 }
502
503 pub fn add_blocks(&mut self, relative_path: &str, ids: Vec<String>) {
505 let norm = normalize_path(relative_path);
506 let normalized_ids = ids.into_iter().map(|id| normalize_component(&id)).collect();
507 self.blocks.insert(norm, normalized_ids);
508 }
509
510 pub fn build(self) -> ContentGraph {
512 ContentGraph {
513 files: self.files,
514 filename_index: self.filename_index,
515 path_index: self.path_index,
516 slug_map: self.slug_map,
517 headings: self.headings,
518 blocks: self.blocks,
519 }
520 }
521}
522
523#[cfg(test)]
528mod tests {
529 use super::*;
530
531 fn sample_graph() -> ContentGraph {
533 let mut b = ContentGraphBuilder::new();
534 b.add_file("posts/hello.md", "/posts/hello");
535 b.add_file("posts/world.md", "/posts/world");
536 b.add_file("guides/hello.md", "/guides/hello");
537 b.add_file("projects/index.md", "/projects");
538 b.add_file("notes/daily/daily.md", "/notes/daily");
539 b.add_headings(
540 "posts/hello.md",
541 vec![
542 ("Introduction".into(), "introduction".into()),
543 ("Getting Started".into(), "getting-started".into()),
544 ],
545 );
546 b.add_blocks(
547 "posts/hello.md",
548 vec!["abc123".into(), "def456".into()],
549 );
550 b.build()
551 }
552
553 #[test]
555 fn test_builder_adds_file() {
556 let mut b = ContentGraphBuilder::new();
557 b.add_file("notes/first.md", "/notes/first");
558 let g = b.build();
559
560 assert_eq!(g.all_files(), &["notes/first.md"]);
561 assert_eq!(
562 g.resolve_path("notes/first.md", ""),
563 Some("notes/first.md".into())
564 );
565 }
566
567 #[test]
569 fn test_filename_index_case_insensitive() {
570 let mut b = ContentGraphBuilder::new();
571 b.add_file("Notes/MyFile.md", "/notes/myfile");
572 let g = b.build();
573
574 assert_eq!(
576 g.resolve_path("myfile", ""),
577 Some("Notes/MyFile.md".into())
578 );
579 assert_eq!(
580 g.resolve_path("MYFILE", ""),
581 Some("Notes/MyFile.md".into())
582 );
583 assert_eq!(
584 g.resolve_path("MyFile", ""),
585 Some("Notes/MyFile.md".into())
586 );
587 }
588
589 #[test]
591 fn test_filename_index_without_extension() {
592 let g = sample_graph();
593
594 assert_eq!(
596 g.resolve_path("world", ""),
597 Some("posts/world.md".into())
598 );
599 }
600
601 #[test]
603 fn test_ambiguous_resolved_by_common_prefix() {
604 let g = sample_graph();
605
606 assert_eq!(
609 g.resolve_path("hello", "posts/other.md"),
610 Some("posts/hello.md".into())
611 );
612
613 assert_eq!(
615 g.resolve_path("hello", "guides/other.md"),
616 Some("guides/hello.md".into())
617 );
618 }
619
620 #[test]
622 fn test_headings_registered() {
623 let g = sample_graph();
624
625 assert!(g.has_heading("posts/hello.md", "introduction"));
626 assert!(g.has_heading("posts/hello.md", "getting-started"));
627 assert!(g.has_heading("posts/hello.md", "Introduction"));
629 assert!(!g.has_heading("posts/hello.md", "nonexistent"));
631 assert!(!g.has_heading("nope.md", "introduction"));
633 }
634
635 #[test]
637 fn test_blocks_registered() {
638 let g = sample_graph();
639
640 assert!(g.has_block("posts/hello.md", "abc123"));
641 assert!(g.has_block("posts/hello.md", "def456"));
642 assert!(g.has_block("posts/hello.md", "ABC123"));
644 assert!(!g.has_block("posts/hello.md", "zzz"));
646 assert!(!g.has_block("nope.md", "abc123"));
648 }
649
650 #[test]
652 fn test_folder_note_resolution() {
653 let g = sample_graph();
654
655 assert_eq!(
656 g.resolve_path("projects", ""),
657 Some("projects/index.md".into())
658 );
659 }
660
661 #[test]
663 fn test_self_named_folder_note_resolution() {
664 let g = sample_graph();
667
668 assert_eq!(
669 g.resolve_path("daily", ""),
670 Some("notes/daily/daily.md".into())
671 );
672 }
673
674 #[test]
676 fn test_self_named_folder_note_via_path() {
677 let mut b = ContentGraphBuilder::new();
678 b.add_file("archive/archive.md", "/archive");
680 let g = b.build();
681
682 assert_eq!(
684 g.resolve_path("archive", ""),
685 Some("archive/archive.md".into())
686 );
687 }
688
689 #[test]
691 fn test_unresolved_returns_none() {
692 let g = sample_graph();
693
694 assert_eq!(g.resolve_path("nonexistent", ""), None);
695 assert_eq!(g.resolve_path("posts/missing.md", ""), None);
696 }
697
698 #[test]
700 fn test_exact_path_match() {
701 let g = sample_graph();
702
703 assert_eq!(
705 g.resolve_path("guides/hello.md", "posts/other.md"),
706 Some("guides/hello.md".into())
707 );
708 }
709
710 #[test]
712 fn test_partial_path_match() {
713 let g = sample_graph();
714
715 assert_eq!(
716 g.resolve_path("posts/hello", ""),
717 Some("posts/hello.md".into())
718 );
719 assert_eq!(
720 g.resolve_path("posts/world", ""),
721 Some("posts/world.md".into())
722 );
723 }
724
725 #[test]
727 fn test_get_slug() {
728 let g = sample_graph();
729
730 assert_eq!(g.get_slug("posts/hello.md"), Some("/posts/hello"));
731 assert_eq!(g.get_slug("Posts/Hello.md"), Some("/posts/hello"));
732 assert_eq!(g.get_slug("nope.md"), None);
733 }
734
735 #[test]
737 fn test_all_files_order() {
738 let g = sample_graph();
739
740 assert_eq!(
741 g.all_files(),
742 &[
743 "posts/hello.md",
744 "posts/world.md",
745 "guides/hello.md",
746 "projects/index.md",
747 "notes/daily/daily.md",
748 ]
749 );
750 }
751
752 #[test]
754 fn test_unicode_normalization() {
755 let mut b = ContentGraphBuilder::new();
756 b.add_file("caf\u{0065}\u{0301}.md", "/cafe");
758 let g = b.build();
759
760 assert_eq!(
762 g.resolve_path("caf\u{00e9}.md", ""),
763 Some("caf\u{0065}\u{0301}.md".into())
764 );
765 assert_eq!(
767 g.resolve_path("caf\u{0065}\u{0301}.md", ""),
768 Some("caf\u{0065}\u{0301}.md".into())
769 );
770 }
771
772 #[test]
774 fn test_generate_slug_strips_extension() {
775 assert_eq!(generate_slug("posts/hello.md"), "posts/hello");
776 assert_eq!(generate_slug("image.png"), "image");
777 }
778
779 #[test]
780 fn test_generate_slug_lowercases() {
781 assert_eq!(generate_slug("Posts/Hello.md"), "posts/hello");
782 }
783
784 #[test]
785 fn test_generate_slug_replaces_spaces() {
786 assert_eq!(generate_slug("posts/Hello World.md"), "posts/hello-world");
787 }
788
789 #[test]
790 fn test_generate_slug_normalizes_backslashes() {
791 assert_eq!(generate_slug("posts\\hello.md"), "posts/hello");
792 }
793
794 #[test]
795 fn test_generate_slug_no_extension() {
796 assert_eq!(generate_slug("readme"), "readme");
797 }
798
799 #[test]
800 fn test_generate_slug_dotfile_keeps_leading_dot() {
801 assert_eq!(generate_slug(".gitignore"), "gitignore");
808 assert_eq!(generate_slug(".bashrc"), "bashrc");
809 assert_eq!(generate_slug("posts/.hidden"), "posts/hidden");
810 }
811
812 #[test]
813 fn test_generate_slug_deep_path() {
814 assert_eq!(
815 generate_slug("deep/path/to/file.txt"),
816 "deep/path/to/file"
817 );
818 }
819
820 #[test]
821 fn test_generate_slug_strips_ascii_punctuation() {
822 assert_eq!(
823 generate_slug("news/Farewell, and Erase on BroadwayWorld.md"),
824 "news/farewell-and-erase-on-broadwayworld"
825 );
826 assert_eq!(generate_slug("posts/Hello (World)!.md"), "posts/hello-world");
827 assert_eq!(generate_slug("posts/it's-mine.md"), "posts/its-mine");
828 assert_eq!(generate_slug("posts/foo:bar.md"), "posts/foobar");
829 }
830
831 #[test]
832 fn test_generate_slug_collapses_consecutive_hyphens() {
833 assert_eq!(generate_slug("posts/foo--bar.md"), "posts/foo-bar");
834 assert_eq!(generate_slug("posts/foo - bar.md"), "posts/foo-bar");
835 assert_eq!(generate_slug("posts/a---b.md"), "posts/a-b");
836 }
837
838 #[test]
839 fn test_generate_slug_trims_leading_trailing_hyphens_per_segment() {
840 assert_eq!(generate_slug("posts/-hello.md"), "posts/hello");
841 assert_eq!(generate_slug("posts/hello-.md"), "posts/hello");
842 }
843
844 #[test]
845 fn test_generate_slug_preserves_non_ascii() {
846 assert_eq!(generate_slug("视频/视频.md"), "视频/视频");
847 assert_eq!(
848 generate_slug("posts/AI 带来写作的黄金时代.md"),
849 "posts/ai-带来写作的黄金时代"
850 );
851 }
852
853 #[test]
854 fn test_generate_slug_preserves_path_separators() {
855 assert_eq!(generate_slug("a/b/c.md"), "a/b/c");
856 assert_eq!(generate_slug("a, b/c.md"), "a-b/c");
857 }
858
859 #[test]
863 fn test_resolve_self_named_via_filename_stem() {
864 let mut b = ContentGraphBuilder::new();
865 b.add_file("recipes/index.md", "/recipes");
866 b.add_file("recipes/recipes.md", "/recipes/recipes");
867 let g = b.build();
868
869 assert_eq!(
871 g.resolve_path("recipes", "other.md"),
872 Some("recipes/recipes.md".into())
873 );
874 }
875
876 #[test]
878 fn test_resolve_folder_note_fallback_to_index() {
879 let mut b = ContentGraphBuilder::new();
880 b.add_file("recipes/index.md", "/recipes");
881 b.add_file("recipes/pasta.md", "/recipes/pasta");
882 let g = b.build();
883
884 assert_eq!(
885 g.resolve_path("recipes", "other.md"),
886 Some("recipes/index.md".into())
887 );
888 }
889
890 #[test]
892 fn test_suffix_match_partial_path() {
893 let mut b = ContentGraphBuilder::new();
894 b.add_file("文字/游记/index.md", "/文字/游记");
895 b.add_file("index.md", "/");
896 let g = b.build();
897
898 assert_eq!(
900 g.resolve_path("游记/index.md", "index.md"),
901 Some("文字/游记/index.md".into())
902 );
903 }
904
905 #[test]
907 fn test_suffix_match_ambiguous_uses_tiebreaker() {
908 let mut b = ContentGraphBuilder::new();
909 b.add_file("a/游记/index.md", "/a/游记");
910 b.add_file("b/游记/index.md", "/b/游记");
911 let g = b.build();
912
913 assert_eq!(
915 g.resolve_path("游记/index.md", "a/other.md"),
916 Some("a/游记/index.md".into())
917 );
918 assert_eq!(
920 g.resolve_path("游记/index.md", "b/other.md"),
921 Some("b/游记/index.md".into())
922 );
923 }
924
925 #[test]
929 fn test_vault_root_prefix_resolves_correctly() {
930 let mut b = ContentGraphBuilder::new();
931 b.add_file("交互实验/index.md", "/交互实验");
932 b.add_file("文字/分布式信息网络/index.md", "/文字/分布式信息网络");
933 let g = b.build();
934
935 assert_eq!(
937 g.resolve_path("刘果/交互实验/index.md", ""),
938 Some("交互实验/index.md".into())
939 );
940 }
941
942 #[test]
944 fn test_vault_root_prefix_non_index() {
945 let mut b = ContentGraphBuilder::new();
946 b.add_file("posts/hello.md", "/posts/hello");
947 b.add_file("guides/hello.md", "/guides/hello");
948 let g = b.build();
949
950 assert_eq!(
952 g.resolve_path("mysite/posts/hello.md", ""),
953 Some("posts/hello.md".into())
954 );
955 }
956
957 #[test]
959 fn test_vault_root_prefix_deep_nesting() {
960 let mut b = ContentGraphBuilder::new();
961 b.add_file("文字/游记/index.md", "/文字/游记");
962 let g = b.build();
963
964 assert_eq!(
966 g.resolve_path("vault/文字/游记/index.md", ""),
967 Some("文字/游记/index.md".into())
968 );
969 }
970
971 #[test]
973 fn test_resolve_path_preserves_original_case() {
974 let mut b = ContentGraphBuilder::new();
975 b.add_file("音乐/Winter-Song.mov", "音乐/winter-song");
976 let g = b.build();
977
978 assert_eq!(
980 g.resolve_path("winter-song.mov", ""),
981 Some("音乐/Winter-Song.mov".into())
982 );
983 assert_eq!(
984 g.resolve_path("Winter-Song.mov", ""),
985 Some("音乐/Winter-Song.mov".into())
986 );
987 }
988
989 #[test]
991 fn test_all_files_preserves_original_case() {
992 let mut b = ContentGraphBuilder::new();
993 b.add_file("Notes/MyFile.md", "/notes/myfile");
994 b.add_file("Posts/Hello-World.md", "/posts/hello-world");
995 let g = b.build();
996
997 assert_eq!(
998 g.all_files(),
999 &["Notes/MyFile.md", "Posts/Hello-World.md"]
1000 );
1001 }
1002
1003 #[test]
1013 fn stem_collision_prefers_matching_extension_png() {
1014 let mut b = ContentGraphBuilder::new();
1015 b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1016 b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1017 let g = b.build();
1018
1019 assert_eq!(
1020 g.resolve_path("scale-compare.png", "interactive/article.md"),
1021 Some("interactive/scale-compare.png".into())
1022 );
1023 }
1024
1025 #[test]
1026 fn stem_collision_prefers_matching_extension_html() {
1027 let mut b = ContentGraphBuilder::new();
1028 b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1029 b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1030 let g = b.build();
1031
1032 assert_eq!(
1033 g.resolve_path("scale-compare.html", "interactive/article.md"),
1034 Some("interactive/scale-compare.html".into())
1035 );
1036 }
1037
1038 #[test]
1039 fn stem_collision_independent_of_registration_order() {
1040 let mut b = ContentGraphBuilder::new();
1042 b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1043 b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1044 let g = b.build();
1045
1046 assert_eq!(
1047 g.resolve_path("scale-compare.png", "interactive/article.md"),
1048 Some("interactive/scale-compare.png".into())
1049 );
1050 assert_eq!(
1051 g.resolve_path("scale-compare.html", "interactive/article.md"),
1052 Some("interactive/scale-compare.html".into())
1053 );
1054 }
1055
1056 #[test]
1057 fn stem_collision_bare_ref_unchanged() {
1058 let mut b = ContentGraphBuilder::new();
1062 b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1063 b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1064 let g = b.build();
1065
1066 assert!(g.resolve_path("scale-compare", "interactive/article.md").is_some());
1069 }
1070
1071 #[test]
1072 fn stem_collision_md_wins_over_html_sibling() {
1073 let mut b = ContentGraphBuilder::new();
1077 b.add_file("notes/guide.md", "/notes/guide");
1078 b.add_file("notes/guide.html", "/notes/guide.html");
1079 let g = b.build();
1080
1081 assert_eq!(
1082 g.resolve_path("guide.md", "notes/index.md"),
1083 Some("notes/guide.md".into())
1084 );
1085 }
1086
1087 #[test]
1088 fn stem_collision_suffix_match_arm() {
1089 let mut b = ContentGraphBuilder::new();
1094 b.add_file("vault/a/scale.png", "/vault/a/scale.png");
1095 b.add_file("vault/a/scale.html", "/vault/a/scale.html");
1096 let g = b.build();
1097
1098 assert_eq!(
1099 g.resolve_path("a/scale.png", "vault/notes/article.md"),
1100 Some("vault/a/scale.png".into())
1101 );
1102 }
1103
1104 #[test]
1105 fn stem_collision_ext_match_overrides_lang_tree() {
1106 let mut b = ContentGraphBuilder::new();
1111 b.add_file("zh-hans/foo.html", "/zh-hans/foo.html");
1112 b.add_file("en/foo.png", "/en/foo.png");
1113 let g = b.build();
1114
1115 assert_eq!(
1116 g.resolve_path("foo.png", "zh-hans/note.md"),
1117 Some("en/foo.png".into())
1118 );
1119 }
1120
1121 #[test]
1122 fn stem_collision_alphabetical_final_tiebreaker() {
1123 let mut b1 = ContentGraphBuilder::new();
1127 b1.add_file("notes/photo.png", "/notes/photo.png");
1128 b1.add_file("notes/photo.html", "/notes/photo.html");
1129 let g1 = b1.build();
1130
1131 let mut b2 = ContentGraphBuilder::new();
1132 b2.add_file("notes/photo.html", "/notes/photo.html");
1133 b2.add_file("notes/photo.png", "/notes/photo.png");
1134 let g2 = b2.build();
1135
1136 let r1 = g1.resolve_path("photo", "notes/index.md");
1139 let r2 = g2.resolve_path("photo", "notes/index.md");
1140 assert_eq!(r1, r2, "result must not depend on registration order");
1141 assert_eq!(r1, Some("notes/photo.html".into()));
1142 }
1143
1144 #[test]
1145 fn stem_collision_case_insensitive_extension() {
1146 let mut b = ContentGraphBuilder::new();
1148 b.add_file("interactive/photo.PNG", "/interactive/photo.png");
1149 b.add_file("interactive/photo.html", "/interactive/photo.html");
1150 let g = b.build();
1151
1152 assert_eq!(
1153 g.resolve_path("photo.png", "interactive/article.md"),
1154 Some("interactive/photo.PNG".into())
1155 );
1156 }
1157}