1use std::collections::{HashMap, HashSet};
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 asset_exact: HashSet<String>,
216
217 asset_ci: HashMap<String, Vec<String>>,
219}
220
221impl ContentGraph {
222 pub fn resolve_path(&self, reference: &str, from_path: &str) -> Option<String> {
252 let norm_ref = normalize_path(reference);
253 let norm_from = normalize_path(from_path);
254 let ref_ext = path_extension(&norm_ref);
255
256 let from_lang = crate::home::lang_tree_prefix(&norm_from);
260
261 if self.path_index.contains_key(&norm_ref) {
263 return Some(self.files[self.path_index[&norm_ref]].clone());
264 }
265
266 if !norm_ref.contains('/') {
271 if let Some(lang) = from_lang {
272 let scoped = format!("{}/{}", lang, norm_ref);
273 if let Some(&idx) = self.path_index.get(&scoped) {
274 return Some(self.files[idx].clone());
275 }
276 let scoped_md = format!("{}/{}.md", lang, norm_ref);
277 if let Some(&idx) = self.path_index.get(&scoped_md) {
278 return Some(self.files[idx].clone());
279 }
280 }
281 }
282
283 let with_md = format!("{}.md", norm_ref);
285 if self.path_index.contains_key(&with_md) {
286 return Some(self.files[self.path_index[&with_md]].clone());
287 }
288
289 if norm_ref.contains('/') {
294 let parts: Vec<&str> = norm_ref.split('/').collect();
295 for start in 0..parts.len().saturating_sub(1) {
297 let subpath = parts[start..].join("/");
298 if !subpath.contains('/') {
299 break; }
301
302 if self.path_index.contains_key(&subpath) {
304 return Some(self.files[self.path_index[&subpath]].clone());
305 }
306 let with_md = format!("{}.md", subpath);
308 if self.path_index.contains_key(&with_md) {
309 return Some(self.files[self.path_index[&with_md]].clone());
310 }
311
312 let suffix = format!("/{}", subpath);
314 let candidates: Vec<usize> = self.files.iter().enumerate()
315 .filter(|(_, f)| normalize_path(f).ends_with(&suffix))
316 .map(|(i, _)| i)
317 .collect();
318 if candidates.len() == 1 {
319 return Some(self.files[candidates[0]].clone());
320 }
321 if candidates.len() > 1 {
322 let from_dirs = dir_components(&norm_from);
323 let best = candidates.iter().copied().max_by_key(|&idx| {
324 let normalized = normalize_path(&self.files[idx]);
328 let candidate_dirs = dir_components(&normalized);
329 let tree_match = lang_tree_match(&normalized, from_lang);
330 let ext_match = ext_match_score(ref_ext.as_deref(), &normalized);
331 (
336 ext_match,
337 tree_match,
338 common_prefix_len(&candidate_dirs, &from_dirs),
339 std::cmp::Reverse(normalized.clone()),
340 )
341 });
342 if let Some(idx) = best {
343 return Some(self.files[idx].clone());
344 }
345 }
346 }
347 }
348
349 let ref_stem = normalize_component(
354 filename_stem(filename_with_ext(&norm_ref)),
355 );
356 let skip_stem = norm_ref.contains('/') && crate::home::is_index_stem(&ref_stem);
357 if !skip_stem {
358 if let Some(candidates) = self.filename_index.get(&ref_stem) {
359 if candidates.len() == 1 {
360 return Some(self.files[candidates[0]].clone());
361 }
362 let from_dirs = dir_components(&norm_from);
368 let best = candidates
369 .iter()
370 .copied()
371 .max_by_key(|&idx| {
372 let normalized = normalize_path(&self.files[idx]);
376 let candidate_dirs = dir_components(&normalized);
377 let tree_match = lang_tree_match(&normalized, from_lang);
378 let ext_match = ext_match_score(ref_ext.as_deref(), &normalized);
379 (
384 ext_match,
385 tree_match,
386 common_prefix_len(&candidate_dirs, &from_dirs),
387 std::cmp::Reverse(normalized.clone()),
388 )
389 });
390 if let Some(idx) = best {
391 return Some(self.files[idx].clone());
392 }
393 }
394 }
395
396 for stem in crate::home::INDEX_STEMS {
398 let folder_index = format!("{}/{}.md", norm_ref, stem);
399 if self.path_index.contains_key(&folder_index) {
400 return Some(self.files[self.path_index[&folder_index]].clone());
401 }
402 }
403
404 let self_named = {
406 let leaf = norm_ref.rsplit('/').next().unwrap_or(&norm_ref);
407 format!("{}/{}.md", norm_ref, leaf)
408 };
409 if self.path_index.contains_key(&self_named) {
410 return Some(self.files[self.path_index[&self_named]].clone());
411 }
412
413 None
414 }
415
416 pub fn has_heading(&self, path: &str, anchor: &str) -> bool {
418 let norm = normalize_path(path);
419 let anchor_lower = normalize_component(anchor);
420 self.headings
421 .get(&norm)
422 .map_or(false, |hs| hs.iter().any(|(_, a)| *a == anchor_lower))
423 }
424
425 pub fn has_block(&self, path: &str, block_id: &str) -> bool {
427 let norm = normalize_path(path);
428 let id_lower = normalize_component(block_id);
429 self.blocks
430 .get(&norm)
431 .map_or(false, |bs| bs.iter().any(|b| *b == id_lower))
432 }
433
434 pub fn get_slug(&self, path: &str) -> Option<&str> {
436 let norm = normalize_path(path);
437 self.slug_map.get(&norm).map(|s| s.as_str())
438 }
439
440 pub fn all_files(&self) -> &[String] {
442 &self.files
443 }
444
445 pub fn asset_contains(&self, p: &str) -> bool {
452 self.asset_exact.contains(p)
453 }
454
455 pub fn asset_contains_ci(&self, p: &str) -> Option<String> {
458 self.asset_ci.get(&p.to_lowercase()).and_then(|v| v.first().cloned())
459 }
460
461 pub fn asset_find_by_suffix(&self, suffix: &str) -> Vec<String> {
464 let ls = suffix.to_lowercase();
465 let mut v: Vec<String> = self.asset_exact.iter().filter(|p| {
466 let lp = p.to_lowercase();
467 lp.ends_with(&ls)
468 && (lp.len() == ls.len()
469 || lp.as_bytes()[lp.len() - ls.len() - 1] == b'/')
470 }).cloned().collect();
471 v.sort();
472 v
473 }
474
475 pub fn from_paths(paths: &[&str]) -> ContentGraph {
481 let mut b = ContentGraphBuilder::new();
482 for &p in paths {
483 b.add_file(p, "");
484 }
485 b.build()
486 }
487}
488
489#[derive(Debug, Default)]
498pub struct ContentGraphBuilder {
499 files: Vec<String>,
500 filename_index: HashMap<String, Vec<usize>>,
501 path_index: HashMap<String, usize>,
502 slug_map: HashMap<String, String>,
503 headings: HashMap<String, Vec<(String, String)>>,
504 blocks: HashMap<String, Vec<String>>,
505 asset_exact: HashSet<String>,
506 asset_ci: HashMap<String, Vec<String>>,
507}
508
509impl ContentGraphBuilder {
510 pub fn new() -> Self {
512 Self::default()
513 }
514
515 pub fn add_file(&mut self, relative_path: &str, slug: &str) {
520 let norm = normalize_path(relative_path);
521
522 if self.path_index.contains_key(&norm) {
525 return;
526 }
527
528 let idx = self.files.len();
529
530 let stem = filename_stem(&norm).to_owned();
532 self.filename_index.entry(stem).or_default().push(idx);
533
534 self.path_index.insert(norm.clone(), idx);
536
537 self.slug_map.insert(norm.clone(), slug.to_owned());
539
540 self.files.push(relative_path.to_string());
542
543 self.asset_exact.insert(relative_path.to_string());
545 self.asset_ci
546 .entry(relative_path.to_lowercase())
547 .or_default()
548 .push(relative_path.to_string());
549 }
550
551 pub fn add_headings(&mut self, relative_path: &str, entries: Vec<(String, String)>) {
553 let norm = normalize_path(relative_path);
554 let normalized_entries = entries
555 .into_iter()
556 .map(|(text, anchor)| (text, normalize_component(&anchor)))
557 .collect();
558 self.headings.insert(norm, normalized_entries);
559 }
560
561 pub fn add_blocks(&mut self, relative_path: &str, ids: Vec<String>) {
563 let norm = normalize_path(relative_path);
564 let normalized_ids = ids.into_iter().map(|id| normalize_component(&id)).collect();
565 self.blocks.insert(norm, normalized_ids);
566 }
567
568 pub fn build(self) -> ContentGraph {
570 ContentGraph {
571 files: self.files,
572 filename_index: self.filename_index,
573 path_index: self.path_index,
574 slug_map: self.slug_map,
575 headings: self.headings,
576 blocks: self.blocks,
577 asset_exact: self.asset_exact,
578 asset_ci: self.asset_ci,
579 }
580 }
581}
582
583#[cfg(test)]
588mod tests {
589 use super::*;
590
591 fn sample_graph() -> ContentGraph {
593 let mut b = ContentGraphBuilder::new();
594 b.add_file("posts/hello.md", "/posts/hello");
595 b.add_file("posts/world.md", "/posts/world");
596 b.add_file("guides/hello.md", "/guides/hello");
597 b.add_file("projects/index.md", "/projects");
598 b.add_file("notes/daily/daily.md", "/notes/daily");
599 b.add_headings(
600 "posts/hello.md",
601 vec![
602 ("Introduction".into(), "introduction".into()),
603 ("Getting Started".into(), "getting-started".into()),
604 ],
605 );
606 b.add_blocks(
607 "posts/hello.md",
608 vec!["abc123".into(), "def456".into()],
609 );
610 b.build()
611 }
612
613 #[test]
615 fn test_builder_adds_file() {
616 let mut b = ContentGraphBuilder::new();
617 b.add_file("notes/first.md", "/notes/first");
618 let g = b.build();
619
620 assert_eq!(g.all_files(), &["notes/first.md"]);
621 assert_eq!(
622 g.resolve_path("notes/first.md", ""),
623 Some("notes/first.md".into())
624 );
625 }
626
627 #[test]
629 fn test_filename_index_case_insensitive() {
630 let mut b = ContentGraphBuilder::new();
631 b.add_file("Notes/MyFile.md", "/notes/myfile");
632 let g = b.build();
633
634 assert_eq!(
636 g.resolve_path("myfile", ""),
637 Some("Notes/MyFile.md".into())
638 );
639 assert_eq!(
640 g.resolve_path("MYFILE", ""),
641 Some("Notes/MyFile.md".into())
642 );
643 assert_eq!(
644 g.resolve_path("MyFile", ""),
645 Some("Notes/MyFile.md".into())
646 );
647 }
648
649 #[test]
651 fn test_filename_index_without_extension() {
652 let g = sample_graph();
653
654 assert_eq!(
656 g.resolve_path("world", ""),
657 Some("posts/world.md".into())
658 );
659 }
660
661 #[test]
663 fn test_ambiguous_resolved_by_common_prefix() {
664 let g = sample_graph();
665
666 assert_eq!(
669 g.resolve_path("hello", "posts/other.md"),
670 Some("posts/hello.md".into())
671 );
672
673 assert_eq!(
675 g.resolve_path("hello", "guides/other.md"),
676 Some("guides/hello.md".into())
677 );
678 }
679
680 #[test]
682 fn test_headings_registered() {
683 let g = sample_graph();
684
685 assert!(g.has_heading("posts/hello.md", "introduction"));
686 assert!(g.has_heading("posts/hello.md", "getting-started"));
687 assert!(g.has_heading("posts/hello.md", "Introduction"));
689 assert!(!g.has_heading("posts/hello.md", "nonexistent"));
691 assert!(!g.has_heading("nope.md", "introduction"));
693 }
694
695 #[test]
697 fn test_blocks_registered() {
698 let g = sample_graph();
699
700 assert!(g.has_block("posts/hello.md", "abc123"));
701 assert!(g.has_block("posts/hello.md", "def456"));
702 assert!(g.has_block("posts/hello.md", "ABC123"));
704 assert!(!g.has_block("posts/hello.md", "zzz"));
706 assert!(!g.has_block("nope.md", "abc123"));
708 }
709
710 #[test]
712 fn test_folder_note_resolution() {
713 let g = sample_graph();
714
715 assert_eq!(
716 g.resolve_path("projects", ""),
717 Some("projects/index.md".into())
718 );
719 }
720
721 #[test]
723 fn test_self_named_folder_note_resolution() {
724 let g = sample_graph();
727
728 assert_eq!(
729 g.resolve_path("daily", ""),
730 Some("notes/daily/daily.md".into())
731 );
732 }
733
734 #[test]
736 fn test_self_named_folder_note_via_path() {
737 let mut b = ContentGraphBuilder::new();
738 b.add_file("archive/archive.md", "/archive");
740 let g = b.build();
741
742 assert_eq!(
744 g.resolve_path("archive", ""),
745 Some("archive/archive.md".into())
746 );
747 }
748
749 #[test]
751 fn test_unresolved_returns_none() {
752 let g = sample_graph();
753
754 assert_eq!(g.resolve_path("nonexistent", ""), None);
755 assert_eq!(g.resolve_path("posts/missing.md", ""), None);
756 }
757
758 #[test]
760 fn test_exact_path_match() {
761 let g = sample_graph();
762
763 assert_eq!(
765 g.resolve_path("guides/hello.md", "posts/other.md"),
766 Some("guides/hello.md".into())
767 );
768 }
769
770 #[test]
772 fn test_partial_path_match() {
773 let g = sample_graph();
774
775 assert_eq!(
776 g.resolve_path("posts/hello", ""),
777 Some("posts/hello.md".into())
778 );
779 assert_eq!(
780 g.resolve_path("posts/world", ""),
781 Some("posts/world.md".into())
782 );
783 }
784
785 #[test]
787 fn test_get_slug() {
788 let g = sample_graph();
789
790 assert_eq!(g.get_slug("posts/hello.md"), Some("/posts/hello"));
791 assert_eq!(g.get_slug("Posts/Hello.md"), Some("/posts/hello"));
792 assert_eq!(g.get_slug("nope.md"), None);
793 }
794
795 #[test]
797 fn test_all_files_order() {
798 let g = sample_graph();
799
800 assert_eq!(
801 g.all_files(),
802 &[
803 "posts/hello.md",
804 "posts/world.md",
805 "guides/hello.md",
806 "projects/index.md",
807 "notes/daily/daily.md",
808 ]
809 );
810 }
811
812 #[test]
814 fn test_unicode_normalization() {
815 let mut b = ContentGraphBuilder::new();
816 b.add_file("caf\u{0065}\u{0301}.md", "/cafe");
818 let g = b.build();
819
820 assert_eq!(
822 g.resolve_path("caf\u{00e9}.md", ""),
823 Some("caf\u{0065}\u{0301}.md".into())
824 );
825 assert_eq!(
827 g.resolve_path("caf\u{0065}\u{0301}.md", ""),
828 Some("caf\u{0065}\u{0301}.md".into())
829 );
830 }
831
832 #[test]
834 fn test_generate_slug_strips_extension() {
835 assert_eq!(generate_slug("posts/hello.md"), "posts/hello");
836 assert_eq!(generate_slug("image.png"), "image");
837 }
838
839 #[test]
840 fn test_generate_slug_lowercases() {
841 assert_eq!(generate_slug("Posts/Hello.md"), "posts/hello");
842 }
843
844 #[test]
845 fn test_generate_slug_replaces_spaces() {
846 assert_eq!(generate_slug("posts/Hello World.md"), "posts/hello-world");
847 }
848
849 #[test]
850 fn test_generate_slug_normalizes_backslashes() {
851 assert_eq!(generate_slug("posts\\hello.md"), "posts/hello");
852 }
853
854 #[test]
855 fn test_generate_slug_no_extension() {
856 assert_eq!(generate_slug("readme"), "readme");
857 }
858
859 #[test]
860 fn test_generate_slug_dotfile_keeps_leading_dot() {
861 assert_eq!(generate_slug(".gitignore"), "gitignore");
868 assert_eq!(generate_slug(".bashrc"), "bashrc");
869 assert_eq!(generate_slug("posts/.hidden"), "posts/hidden");
870 }
871
872 #[test]
873 fn test_generate_slug_deep_path() {
874 assert_eq!(
875 generate_slug("deep/path/to/file.txt"),
876 "deep/path/to/file"
877 );
878 }
879
880 #[test]
881 fn test_generate_slug_strips_ascii_punctuation() {
882 assert_eq!(
883 generate_slug("news/Farewell, and Erase on BroadwayWorld.md"),
884 "news/farewell-and-erase-on-broadwayworld"
885 );
886 assert_eq!(generate_slug("posts/Hello (World)!.md"), "posts/hello-world");
887 assert_eq!(generate_slug("posts/it's-mine.md"), "posts/its-mine");
888 assert_eq!(generate_slug("posts/foo:bar.md"), "posts/foobar");
889 }
890
891 #[test]
892 fn test_generate_slug_collapses_consecutive_hyphens() {
893 assert_eq!(generate_slug("posts/foo--bar.md"), "posts/foo-bar");
894 assert_eq!(generate_slug("posts/foo - bar.md"), "posts/foo-bar");
895 assert_eq!(generate_slug("posts/a---b.md"), "posts/a-b");
896 }
897
898 #[test]
899 fn test_generate_slug_trims_leading_trailing_hyphens_per_segment() {
900 assert_eq!(generate_slug("posts/-hello.md"), "posts/hello");
901 assert_eq!(generate_slug("posts/hello-.md"), "posts/hello");
902 }
903
904 #[test]
905 fn test_generate_slug_preserves_non_ascii() {
906 assert_eq!(generate_slug("视频/视频.md"), "视频/视频");
907 assert_eq!(
908 generate_slug("posts/AI 带来写作的黄金时代.md"),
909 "posts/ai-带来写作的黄金时代"
910 );
911 }
912
913 #[test]
914 fn test_generate_slug_preserves_path_separators() {
915 assert_eq!(generate_slug("a/b/c.md"), "a/b/c");
916 assert_eq!(generate_slug("a, b/c.md"), "a-b/c");
917 }
918
919 #[test]
923 fn test_resolve_self_named_via_filename_stem() {
924 let mut b = ContentGraphBuilder::new();
925 b.add_file("recipes/index.md", "/recipes");
926 b.add_file("recipes/recipes.md", "/recipes/recipes");
927 let g = b.build();
928
929 assert_eq!(
931 g.resolve_path("recipes", "other.md"),
932 Some("recipes/recipes.md".into())
933 );
934 }
935
936 #[test]
938 fn test_resolve_folder_note_fallback_to_index() {
939 let mut b = ContentGraphBuilder::new();
940 b.add_file("recipes/index.md", "/recipes");
941 b.add_file("recipes/pasta.md", "/recipes/pasta");
942 let g = b.build();
943
944 assert_eq!(
945 g.resolve_path("recipes", "other.md"),
946 Some("recipes/index.md".into())
947 );
948 }
949
950 #[test]
952 fn test_suffix_match_partial_path() {
953 let mut b = ContentGraphBuilder::new();
954 b.add_file("文字/游记/index.md", "/文字/游记");
955 b.add_file("index.md", "/");
956 let g = b.build();
957
958 assert_eq!(
960 g.resolve_path("游记/index.md", "index.md"),
961 Some("文字/游记/index.md".into())
962 );
963 }
964
965 #[test]
967 fn test_suffix_match_ambiguous_uses_tiebreaker() {
968 let mut b = ContentGraphBuilder::new();
969 b.add_file("a/游记/index.md", "/a/游记");
970 b.add_file("b/游记/index.md", "/b/游记");
971 let g = b.build();
972
973 assert_eq!(
975 g.resolve_path("游记/index.md", "a/other.md"),
976 Some("a/游记/index.md".into())
977 );
978 assert_eq!(
980 g.resolve_path("游记/index.md", "b/other.md"),
981 Some("b/游记/index.md".into())
982 );
983 }
984
985 #[test]
989 fn test_vault_root_prefix_resolves_correctly() {
990 let mut b = ContentGraphBuilder::new();
991 b.add_file("交互实验/index.md", "/交互实验");
992 b.add_file("文字/分布式信息网络/index.md", "/文字/分布式信息网络");
993 let g = b.build();
994
995 assert_eq!(
997 g.resolve_path("刘果/交互实验/index.md", ""),
998 Some("交互实验/index.md".into())
999 );
1000 }
1001
1002 #[test]
1004 fn test_vault_root_prefix_non_index() {
1005 let mut b = ContentGraphBuilder::new();
1006 b.add_file("posts/hello.md", "/posts/hello");
1007 b.add_file("guides/hello.md", "/guides/hello");
1008 let g = b.build();
1009
1010 assert_eq!(
1012 g.resolve_path("mysite/posts/hello.md", ""),
1013 Some("posts/hello.md".into())
1014 );
1015 }
1016
1017 #[test]
1019 fn test_vault_root_prefix_deep_nesting() {
1020 let mut b = ContentGraphBuilder::new();
1021 b.add_file("文字/游记/index.md", "/文字/游记");
1022 let g = b.build();
1023
1024 assert_eq!(
1026 g.resolve_path("vault/文字/游记/index.md", ""),
1027 Some("文字/游记/index.md".into())
1028 );
1029 }
1030
1031 #[test]
1033 fn test_resolve_path_preserves_original_case() {
1034 let mut b = ContentGraphBuilder::new();
1035 b.add_file("音乐/Winter-Song.mov", "音乐/winter-song");
1036 let g = b.build();
1037
1038 assert_eq!(
1040 g.resolve_path("winter-song.mov", ""),
1041 Some("音乐/Winter-Song.mov".into())
1042 );
1043 assert_eq!(
1044 g.resolve_path("Winter-Song.mov", ""),
1045 Some("音乐/Winter-Song.mov".into())
1046 );
1047 }
1048
1049 #[test]
1051 fn test_all_files_preserves_original_case() {
1052 let mut b = ContentGraphBuilder::new();
1053 b.add_file("Notes/MyFile.md", "/notes/myfile");
1054 b.add_file("Posts/Hello-World.md", "/posts/hello-world");
1055 let g = b.build();
1056
1057 assert_eq!(
1058 g.all_files(),
1059 &["Notes/MyFile.md", "Posts/Hello-World.md"]
1060 );
1061 }
1062
1063 #[test]
1073 fn stem_collision_prefers_matching_extension_png() {
1074 let mut b = ContentGraphBuilder::new();
1075 b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1076 b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1077 let g = b.build();
1078
1079 assert_eq!(
1080 g.resolve_path("scale-compare.png", "interactive/article.md"),
1081 Some("interactive/scale-compare.png".into())
1082 );
1083 }
1084
1085 #[test]
1086 fn stem_collision_prefers_matching_extension_html() {
1087 let mut b = ContentGraphBuilder::new();
1088 b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1089 b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1090 let g = b.build();
1091
1092 assert_eq!(
1093 g.resolve_path("scale-compare.html", "interactive/article.md"),
1094 Some("interactive/scale-compare.html".into())
1095 );
1096 }
1097
1098 #[test]
1099 fn stem_collision_independent_of_registration_order() {
1100 let mut b = ContentGraphBuilder::new();
1102 b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1103 b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1104 let g = b.build();
1105
1106 assert_eq!(
1107 g.resolve_path("scale-compare.png", "interactive/article.md"),
1108 Some("interactive/scale-compare.png".into())
1109 );
1110 assert_eq!(
1111 g.resolve_path("scale-compare.html", "interactive/article.md"),
1112 Some("interactive/scale-compare.html".into())
1113 );
1114 }
1115
1116 #[test]
1117 fn stem_collision_bare_ref_unchanged() {
1118 let mut b = ContentGraphBuilder::new();
1122 b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1123 b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1124 let g = b.build();
1125
1126 assert!(g.resolve_path("scale-compare", "interactive/article.md").is_some());
1129 }
1130
1131 #[test]
1132 fn stem_collision_md_wins_over_html_sibling() {
1133 let mut b = ContentGraphBuilder::new();
1137 b.add_file("notes/guide.md", "/notes/guide");
1138 b.add_file("notes/guide.html", "/notes/guide.html");
1139 let g = b.build();
1140
1141 assert_eq!(
1142 g.resolve_path("guide.md", "notes/index.md"),
1143 Some("notes/guide.md".into())
1144 );
1145 }
1146
1147 #[test]
1148 fn stem_collision_suffix_match_arm() {
1149 let mut b = ContentGraphBuilder::new();
1154 b.add_file("vault/a/scale.png", "/vault/a/scale.png");
1155 b.add_file("vault/a/scale.html", "/vault/a/scale.html");
1156 let g = b.build();
1157
1158 assert_eq!(
1159 g.resolve_path("a/scale.png", "vault/notes/article.md"),
1160 Some("vault/a/scale.png".into())
1161 );
1162 }
1163
1164 #[test]
1165 fn stem_collision_ext_match_overrides_lang_tree() {
1166 let mut b = ContentGraphBuilder::new();
1171 b.add_file("zh-hans/foo.html", "/zh-hans/foo.html");
1172 b.add_file("en/foo.png", "/en/foo.png");
1173 let g = b.build();
1174
1175 assert_eq!(
1176 g.resolve_path("foo.png", "zh-hans/note.md"),
1177 Some("en/foo.png".into())
1178 );
1179 }
1180
1181 #[test]
1182 fn stem_collision_alphabetical_final_tiebreaker() {
1183 let mut b1 = ContentGraphBuilder::new();
1187 b1.add_file("notes/photo.png", "/notes/photo.png");
1188 b1.add_file("notes/photo.html", "/notes/photo.html");
1189 let g1 = b1.build();
1190
1191 let mut b2 = ContentGraphBuilder::new();
1192 b2.add_file("notes/photo.html", "/notes/photo.html");
1193 b2.add_file("notes/photo.png", "/notes/photo.png");
1194 let g2 = b2.build();
1195
1196 let r1 = g1.resolve_path("photo", "notes/index.md");
1199 let r2 = g2.resolve_path("photo", "notes/index.md");
1200 assert_eq!(r1, r2, "result must not depend on registration order");
1201 assert_eq!(r1, Some("notes/photo.html".into()));
1202 }
1203
1204 #[test]
1205 fn stem_collision_case_insensitive_extension() {
1206 let mut b = ContentGraphBuilder::new();
1208 b.add_file("interactive/photo.PNG", "/interactive/photo.png");
1209 b.add_file("interactive/photo.html", "/interactive/photo.html");
1210 let g = b.build();
1211
1212 assert_eq!(
1213 g.resolve_path("photo.png", "interactive/article.md"),
1214 Some("interactive/photo.PNG".into())
1215 );
1216 }
1217
1218 #[test]
1219 fn exact_case_asset_index() {
1220 let g = ContentGraph::from_paths(&["assets/Hoon.JPG", "News/post.md"]);
1221 assert!(g.asset_contains("assets/Hoon.JPG"));
1222 assert!(!g.asset_contains("assets/hoon.jpg")); assert_eq!(
1224 g.asset_contains_ci("assets/hoon.jpg").as_deref(),
1225 Some("assets/Hoon.JPG")
1226 );
1227 assert_eq!(
1228 g.asset_find_by_suffix("Hoon.JPG"),
1229 vec!["assets/Hoon.JPG".to_string()]
1230 );
1231 }
1232}