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
23pub(crate) fn normalize_path(path: &str) -> String {
31 path.replace('\\', "/")
32 .split('/')
33 .filter(|c| !c.is_empty())
34 .map(normalize_component)
35 .collect::<Vec<_>>()
36 .join("/")
37}
38
39fn filename_stem(normalized: &str) -> &str {
41 let filename = normalized.rsplit('/').next().unwrap_or(normalized);
42 match filename.rsplit_once('.') {
43 Some((stem, _)) if !stem.is_empty() => stem,
46 _ => filename,
47 }
48}
49
50fn filename_with_ext(path: &str) -> &str {
52 path.rsplit('/').next().unwrap_or(path)
53}
54
55
56pub(crate) fn dir_components(path: &str) -> Vec<&str> {
59 let parts: Vec<&str> = path.split('/').collect();
60 if parts.len() <= 1 {
61 vec![]
62 } else {
63 parts[..parts.len() - 1].to_vec()
64 }
65}
66
67pub(crate) fn common_prefix_len(a: &[&str], b: &[&str]) -> usize {
70 a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
71}
72
73fn ext_match_score(ref_ext: Option<&str>, candidate: &str) -> u8 {
79 let Some(want) = ref_ext else { return 0 };
80 match path_extension(candidate) {
81 Some(have) if have == want => 1,
82 _ => 0,
83 }
84}
85
86fn lang_tree_match(candidate: &str, from_lang: Option<&str>) -> u8 {
97 let cand_lang = crate::home::lang_tree_prefix(candidate);
98 match (from_lang, cand_lang) {
99 (Some(f), Some(c)) if f.eq_ignore_ascii_case(c) => 1,
100 (None, None) => 1,
101 _ => 0,
102 }
103}
104
105pub fn generate_slug(relative_path: &str) -> String {
127 let normalized = relative_path.replace('\\', "/");
129
130 let last_segment = normalized.rsplit('/').next().unwrap_or(&normalized);
136 let stem_in_segment = match last_segment.rsplit_once('.') {
137 Some((stem, _ext)) if !stem.is_empty() => Some(stem),
138 _ => None,
139 };
140 let prefix = match normalized.rsplit_once('/') {
141 Some((p, _)) => Some(p),
142 None => None,
143 };
144 let without_ext: String = match (prefix, stem_in_segment) {
145 (Some(p), Some(stem)) => format!("{p}/{stem}"),
146 (None, Some(stem)) => stem.to_string(),
147 _ => normalized.clone(),
148 };
149
150 without_ext
153 .split('/')
154 .map(sanitize_slug_segment)
155 .collect::<Vec<_>>()
156 .join("/")
157}
158
159fn sanitize_slug_segment(segment: &str) -> String {
162 let lowered = segment.to_lowercase();
163
164 let mut buf = String::with_capacity(lowered.len());
165 for c in lowered.chars() {
166 if c.is_alphanumeric() {
167 buf.push(c);
168 } else if c == ' ' || c == '-' || c == '_' {
169 buf.push('-');
170 }
171 }
174
175 let mut collapsed = String::with_capacity(buf.len());
177 let mut prev_hyphen = false;
178 for c in buf.chars() {
179 if c == '-' {
180 if !prev_hyphen {
181 collapsed.push('-');
182 }
183 prev_hyphen = true;
184 } else {
185 collapsed.push(c);
186 prev_hyphen = false;
187 }
188 }
189 collapsed.trim_matches('-').to_string()
190}
191
192#[derive(Debug, Clone)]
201pub struct ContentGraph {
202 files: Vec<String>,
204
205 filename_index: HashMap<String, Vec<usize>>,
207
208 path_index: HashMap<String, usize>,
210
211 slug_map: HashMap<String, String>,
213
214 headings: HashMap<String, Vec<(String, String)>>,
216
217 blocks: HashMap<String, Vec<String>>,
219
220 asset_exact: HashSet<String>,
222
223 asset_ci: HashMap<String, Vec<String>>,
225}
226
227impl ContentGraph {
228 pub fn resolve_path(&self, reference: &str, from_path: &str) -> Option<String> {
258 let norm_ref = normalize_path(reference);
259 let norm_from = normalize_path(from_path);
260 let ref_ext = path_extension(&norm_ref);
261
262 let from_lang = crate::home::lang_tree_prefix(&norm_from);
266
267 if self.path_index.contains_key(&norm_ref) {
269 return Some(self.files[self.path_index[&norm_ref]].clone());
270 }
271
272 if !norm_ref.contains('/') {
277 if let Some(lang) = from_lang {
278 let scoped = format!("{}/{}", lang, norm_ref);
279 if let Some(&idx) = self.path_index.get(&scoped) {
280 return Some(self.files[idx].clone());
281 }
282 let scoped_md = format!("{}/{}.md", lang, norm_ref);
283 if let Some(&idx) = self.path_index.get(&scoped_md) {
284 return Some(self.files[idx].clone());
285 }
286 }
287 }
288
289 let with_md = format!("{}.md", norm_ref);
291 if self.path_index.contains_key(&with_md) {
292 return Some(self.files[self.path_index[&with_md]].clone());
293 }
294
295 if norm_ref.contains('/') {
300 let parts: Vec<&str> = norm_ref.split('/').collect();
301 for start in 0..parts.len().saturating_sub(1) {
303 let subpath = parts[start..].join("/");
304 if !subpath.contains('/') {
305 break; }
307
308 if self.path_index.contains_key(&subpath) {
310 return Some(self.files[self.path_index[&subpath]].clone());
311 }
312 let with_md = format!("{}.md", subpath);
314 if self.path_index.contains_key(&with_md) {
315 return Some(self.files[self.path_index[&with_md]].clone());
316 }
317
318 let suffix = format!("/{}", subpath);
320 let candidates: Vec<usize> = self.files.iter().enumerate()
321 .filter(|(_, f)| normalize_path(f).ends_with(&suffix))
322 .map(|(i, _)| i)
323 .collect();
324 if candidates.len() == 1 {
325 return Some(self.files[candidates[0]].clone());
326 }
327 if candidates.len() > 1 {
328 let from_dirs = dir_components(&norm_from);
329 let best = candidates.iter().copied().max_by_key(|&idx| {
330 let normalized = normalize_path(&self.files[idx]);
334 let candidate_dirs = dir_components(&normalized);
335 let tree_match = lang_tree_match(&normalized, from_lang);
336 let ext_match = ext_match_score(ref_ext.as_deref(), &normalized);
337 (
342 ext_match,
343 tree_match,
344 common_prefix_len(&candidate_dirs, &from_dirs),
345 std::cmp::Reverse(normalized.clone()),
346 )
347 });
348 if let Some(idx) = best {
349 return Some(self.files[idx].clone());
350 }
351 }
352 }
353 }
354
355 let ref_stem = normalize_component(
360 filename_stem(filename_with_ext(&norm_ref)),
361 );
362 let skip_stem = norm_ref.contains('/') && crate::home::is_index_stem(&ref_stem);
363 if !skip_stem {
364 if let Some(candidates) = self.filename_index.get(&ref_stem) {
365 if candidates.len() == 1 {
366 return Some(self.files[candidates[0]].clone());
367 }
368 let from_dirs = dir_components(&norm_from);
374 let best = candidates
375 .iter()
376 .copied()
377 .max_by_key(|&idx| {
378 let normalized = normalize_path(&self.files[idx]);
382 let candidate_dirs = dir_components(&normalized);
383 let tree_match = lang_tree_match(&normalized, from_lang);
384 let ext_match = ext_match_score(ref_ext.as_deref(), &normalized);
385 (
390 ext_match,
391 tree_match,
392 common_prefix_len(&candidate_dirs, &from_dirs),
393 std::cmp::Reverse(normalized.clone()),
394 )
395 });
396 if let Some(idx) = best {
397 return Some(self.files[idx].clone());
398 }
399 }
400 }
401
402 let folder_note = |base: &str| -> Option<String> {
406 for stem in crate::home::INDEX_STEMS {
407 let folder_index = format!("{}/{}.md", base, stem);
408 if let Some(&idx) = self.path_index.get(&folder_index) {
409 return Some(self.files[idx].clone());
410 }
411 }
412 let leaf = base.rsplit('/').next().unwrap_or(base);
413 let self_named = format!("{}/{}.md", base, leaf);
414 self.path_index
415 .get(&self_named)
416 .map(|&idx| self.files[idx].clone())
417 };
418
419 if let Some(lang) = from_lang {
425 if crate::home::lang_tree_prefix(&norm_ref).is_none() {
426 let scoped = format!("{}/{}", lang, norm_ref);
427 if let Some(found) = folder_note(&scoped) {
428 return Some(found);
429 }
430 }
431 }
432
433 if let Some(found) = folder_note(&norm_ref) {
435 return Some(found);
436 }
437
438 None
439 }
440
441 pub fn has_heading(&self, path: &str, anchor: &str) -> bool {
443 let norm = normalize_path(path);
444 let anchor_lower = normalize_component(anchor);
445 self.headings
446 .get(&norm)
447 .map_or(false, |hs| hs.iter().any(|(_, a)| *a == anchor_lower))
448 }
449
450 pub fn has_block(&self, path: &str, block_id: &str) -> bool {
452 let norm = normalize_path(path);
453 let id_lower = normalize_component(block_id);
454 self.blocks
455 .get(&norm)
456 .map_or(false, |bs| bs.iter().any(|b| *b == id_lower))
457 }
458
459 pub fn get_slug(&self, path: &str) -> Option<&str> {
461 let norm = normalize_path(path);
462 self.slug_map.get(&norm).map(|s| s.as_str())
463 }
464
465 pub fn all_files(&self) -> &[String] {
467 &self.files
468 }
469
470 pub fn asset_contains(&self, p: &str) -> bool {
477 self.asset_exact.contains(p)
478 }
479
480 pub fn asset_contains_ci(&self, p: &str) -> Option<String> {
483 self.asset_ci.get(&p.to_lowercase()).and_then(|v| v.first().cloned())
484 }
485
486 pub fn asset_find_by_suffix(&self, suffix: &str) -> Vec<String> {
489 let ls = suffix.to_lowercase();
490 let mut v: Vec<String> = self.asset_exact.iter().filter(|p| {
491 let lp = p.to_lowercase();
492 lp.ends_with(&ls)
493 && (lp.len() == ls.len()
494 || lp.as_bytes()[lp.len() - ls.len() - 1] == b'/')
495 }).cloned().collect();
496 v.sort();
497 v
498 }
499
500 pub fn from_paths(paths: &[&str]) -> ContentGraph {
506 let mut b = ContentGraphBuilder::new();
507 for &p in paths {
508 b.add_file(p, "");
509 }
510 b.build()
511 }
512}
513
514#[derive(Debug, Default)]
523pub struct ContentGraphBuilder {
524 files: Vec<String>,
525 filename_index: HashMap<String, Vec<usize>>,
526 path_index: HashMap<String, usize>,
527 slug_map: HashMap<String, String>,
528 headings: HashMap<String, Vec<(String, String)>>,
529 blocks: HashMap<String, Vec<String>>,
530 asset_exact: HashSet<String>,
531 asset_ci: HashMap<String, Vec<String>>,
532}
533
534impl ContentGraphBuilder {
535 pub fn new() -> Self {
537 Self::default()
538 }
539
540 pub fn add_file(&mut self, relative_path: &str, slug: &str) {
545 let norm = normalize_path(relative_path);
546
547 if self.path_index.contains_key(&norm) {
550 return;
551 }
552
553 let idx = self.files.len();
554
555 let stem = filename_stem(&norm).to_owned();
557 self.filename_index.entry(stem).or_default().push(idx);
558
559 self.path_index.insert(norm.clone(), idx);
561
562 self.slug_map.insert(norm.clone(), slug.to_owned());
564
565 self.files.push(relative_path.to_string());
567
568 self.asset_exact.insert(relative_path.to_string());
570 self.asset_ci
571 .entry(relative_path.to_lowercase())
572 .or_default()
573 .push(relative_path.to_string());
574 }
575
576 pub fn add_headings(&mut self, relative_path: &str, entries: Vec<(String, String)>) {
578 let norm = normalize_path(relative_path);
579 let normalized_entries = entries
580 .into_iter()
581 .map(|(text, anchor)| (text, normalize_component(&anchor)))
582 .collect();
583 self.headings.insert(norm, normalized_entries);
584 }
585
586 pub fn add_blocks(&mut self, relative_path: &str, ids: Vec<String>) {
588 let norm = normalize_path(relative_path);
589 let normalized_ids = ids.into_iter().map(|id| normalize_component(&id)).collect();
590 self.blocks.insert(norm, normalized_ids);
591 }
592
593 pub fn build(self) -> ContentGraph {
595 ContentGraph {
596 files: self.files,
597 filename_index: self.filename_index,
598 path_index: self.path_index,
599 slug_map: self.slug_map,
600 headings: self.headings,
601 blocks: self.blocks,
602 asset_exact: self.asset_exact,
603 asset_ci: self.asset_ci,
604 }
605 }
606}
607
608#[cfg(test)]
613mod tests {
614 use super::*;
615
616 fn sample_graph() -> ContentGraph {
618 let mut b = ContentGraphBuilder::new();
619 b.add_file("posts/hello.md", "/posts/hello");
620 b.add_file("posts/world.md", "/posts/world");
621 b.add_file("guides/hello.md", "/guides/hello");
622 b.add_file("projects/index.md", "/projects");
623 b.add_file("notes/daily/daily.md", "/notes/daily");
624 b.add_headings(
625 "posts/hello.md",
626 vec![
627 ("Introduction".into(), "introduction".into()),
628 ("Getting Started".into(), "getting-started".into()),
629 ],
630 );
631 b.add_blocks(
632 "posts/hello.md",
633 vec!["abc123".into(), "def456".into()],
634 );
635 b.build()
636 }
637
638 #[test]
640 fn test_builder_adds_file() {
641 let mut b = ContentGraphBuilder::new();
642 b.add_file("notes/first.md", "/notes/first");
643 let g = b.build();
644
645 assert_eq!(g.all_files(), &["notes/first.md"]);
646 assert_eq!(
647 g.resolve_path("notes/first.md", ""),
648 Some("notes/first.md".into())
649 );
650 }
651
652 #[test]
654 fn test_filename_index_case_insensitive() {
655 let mut b = ContentGraphBuilder::new();
656 b.add_file("Notes/MyFile.md", "/notes/myfile");
657 let g = b.build();
658
659 assert_eq!(
661 g.resolve_path("myfile", ""),
662 Some("Notes/MyFile.md".into())
663 );
664 assert_eq!(
665 g.resolve_path("MYFILE", ""),
666 Some("Notes/MyFile.md".into())
667 );
668 assert_eq!(
669 g.resolve_path("MyFile", ""),
670 Some("Notes/MyFile.md".into())
671 );
672 }
673
674 #[test]
676 fn test_filename_index_without_extension() {
677 let g = sample_graph();
678
679 assert_eq!(
681 g.resolve_path("world", ""),
682 Some("posts/world.md".into())
683 );
684 }
685
686 #[test]
688 fn test_ambiguous_resolved_by_common_prefix() {
689 let g = sample_graph();
690
691 assert_eq!(
694 g.resolve_path("hello", "posts/other.md"),
695 Some("posts/hello.md".into())
696 );
697
698 assert_eq!(
700 g.resolve_path("hello", "guides/other.md"),
701 Some("guides/hello.md".into())
702 );
703 }
704
705 #[test]
707 fn test_headings_registered() {
708 let g = sample_graph();
709
710 assert!(g.has_heading("posts/hello.md", "introduction"));
711 assert!(g.has_heading("posts/hello.md", "getting-started"));
712 assert!(g.has_heading("posts/hello.md", "Introduction"));
714 assert!(!g.has_heading("posts/hello.md", "nonexistent"));
716 assert!(!g.has_heading("nope.md", "introduction"));
718 }
719
720 #[test]
722 fn test_blocks_registered() {
723 let g = sample_graph();
724
725 assert!(g.has_block("posts/hello.md", "abc123"));
726 assert!(g.has_block("posts/hello.md", "def456"));
727 assert!(g.has_block("posts/hello.md", "ABC123"));
729 assert!(!g.has_block("posts/hello.md", "zzz"));
731 assert!(!g.has_block("nope.md", "abc123"));
733 }
734
735 #[test]
737 fn test_folder_note_resolution() {
738 let g = sample_graph();
739
740 assert_eq!(
741 g.resolve_path("projects", ""),
742 Some("projects/index.md".into())
743 );
744 }
745
746 #[test]
752 fn test_folder_note_prefers_same_language_tree() {
753 let g = ContentGraph::from_paths(&[
754 "docs/index.md",
755 "zh-hans/docs/index.md",
756 "zh-hans/index.md",
757 ]);
758
759 assert_eq!(
761 g.resolve_path("docs/", "zh-hans/index.md"),
762 Some("zh-hans/docs/index.md".into())
763 );
764
765 assert_eq!(
767 g.resolve_path("docs/", "index.md"),
768 Some("docs/index.md".into())
769 );
770 }
771
772 #[test]
775 fn test_folder_note_falls_back_to_root_when_no_language_sibling() {
776 let g = ContentGraph::from_paths(&["docs/index.md", "zh-hans/index.md"]);
777
778 assert_eq!(
779 g.resolve_path("docs/", "zh-hans/index.md"),
780 Some("docs/index.md".into())
781 );
782 }
783
784 #[test]
786 fn test_self_named_folder_note_resolution() {
787 let g = sample_graph();
790
791 assert_eq!(
792 g.resolve_path("daily", ""),
793 Some("notes/daily/daily.md".into())
794 );
795 }
796
797 #[test]
799 fn test_self_named_folder_note_via_path() {
800 let mut b = ContentGraphBuilder::new();
801 b.add_file("archive/archive.md", "/archive");
803 let g = b.build();
804
805 assert_eq!(
807 g.resolve_path("archive", ""),
808 Some("archive/archive.md".into())
809 );
810 }
811
812 #[test]
814 fn test_unresolved_returns_none() {
815 let g = sample_graph();
816
817 assert_eq!(g.resolve_path("nonexistent", ""), None);
818 assert_eq!(g.resolve_path("posts/missing.md", ""), None);
819 }
820
821 #[test]
823 fn test_exact_path_match() {
824 let g = sample_graph();
825
826 assert_eq!(
828 g.resolve_path("guides/hello.md", "posts/other.md"),
829 Some("guides/hello.md".into())
830 );
831 }
832
833 #[test]
835 fn test_partial_path_match() {
836 let g = sample_graph();
837
838 assert_eq!(
839 g.resolve_path("posts/hello", ""),
840 Some("posts/hello.md".into())
841 );
842 assert_eq!(
843 g.resolve_path("posts/world", ""),
844 Some("posts/world.md".into())
845 );
846 }
847
848 #[test]
850 fn test_get_slug() {
851 let g = sample_graph();
852
853 assert_eq!(g.get_slug("posts/hello.md"), Some("/posts/hello"));
854 assert_eq!(g.get_slug("Posts/Hello.md"), Some("/posts/hello"));
855 assert_eq!(g.get_slug("nope.md"), None);
856 }
857
858 #[test]
860 fn test_all_files_order() {
861 let g = sample_graph();
862
863 assert_eq!(
864 g.all_files(),
865 &[
866 "posts/hello.md",
867 "posts/world.md",
868 "guides/hello.md",
869 "projects/index.md",
870 "notes/daily/daily.md",
871 ]
872 );
873 }
874
875 #[test]
877 fn test_unicode_normalization() {
878 let mut b = ContentGraphBuilder::new();
879 b.add_file("caf\u{0065}\u{0301}.md", "/cafe");
881 let g = b.build();
882
883 assert_eq!(
885 g.resolve_path("caf\u{00e9}.md", ""),
886 Some("caf\u{0065}\u{0301}.md".into())
887 );
888 assert_eq!(
890 g.resolve_path("caf\u{0065}\u{0301}.md", ""),
891 Some("caf\u{0065}\u{0301}.md".into())
892 );
893 }
894
895 #[test]
897 fn test_generate_slug_strips_extension() {
898 assert_eq!(generate_slug("posts/hello.md"), "posts/hello");
899 assert_eq!(generate_slug("image.png"), "image");
900 }
901
902 #[test]
903 fn test_generate_slug_lowercases() {
904 assert_eq!(generate_slug("Posts/Hello.md"), "posts/hello");
905 }
906
907 #[test]
908 fn test_generate_slug_replaces_spaces() {
909 assert_eq!(generate_slug("posts/Hello World.md"), "posts/hello-world");
910 }
911
912 #[test]
913 fn test_generate_slug_normalizes_backslashes() {
914 assert_eq!(generate_slug("posts\\hello.md"), "posts/hello");
915 }
916
917 #[test]
918 fn test_generate_slug_no_extension() {
919 assert_eq!(generate_slug("readme"), "readme");
920 }
921
922 #[test]
923 fn test_generate_slug_dotfile_keeps_leading_dot() {
924 assert_eq!(generate_slug(".gitignore"), "gitignore");
931 assert_eq!(generate_slug(".bashrc"), "bashrc");
932 assert_eq!(generate_slug("posts/.hidden"), "posts/hidden");
933 }
934
935 #[test]
936 fn test_generate_slug_deep_path() {
937 assert_eq!(
938 generate_slug("deep/path/to/file.txt"),
939 "deep/path/to/file"
940 );
941 }
942
943 #[test]
944 fn test_generate_slug_strips_ascii_punctuation() {
945 assert_eq!(
946 generate_slug("news/Farewell, and Erase on BroadwayWorld.md"),
947 "news/farewell-and-erase-on-broadwayworld"
948 );
949 assert_eq!(generate_slug("posts/Hello (World)!.md"), "posts/hello-world");
950 assert_eq!(generate_slug("posts/it's-mine.md"), "posts/its-mine");
951 assert_eq!(generate_slug("posts/foo:bar.md"), "posts/foobar");
952 }
953
954 #[test]
955 fn test_generate_slug_collapses_consecutive_hyphens() {
956 assert_eq!(generate_slug("posts/foo--bar.md"), "posts/foo-bar");
957 assert_eq!(generate_slug("posts/foo - bar.md"), "posts/foo-bar");
958 assert_eq!(generate_slug("posts/a---b.md"), "posts/a-b");
959 }
960
961 #[test]
962 fn test_generate_slug_trims_leading_trailing_hyphens_per_segment() {
963 assert_eq!(generate_slug("posts/-hello.md"), "posts/hello");
964 assert_eq!(generate_slug("posts/hello-.md"), "posts/hello");
965 }
966
967 #[test]
968 fn test_generate_slug_preserves_non_ascii() {
969 assert_eq!(generate_slug("视频/视频.md"), "视频/视频");
970 assert_eq!(
971 generate_slug("posts/AI 带来写作的黄金时代.md"),
972 "posts/ai-带来写作的黄金时代"
973 );
974 }
975
976 #[test]
977 fn test_generate_slug_preserves_path_separators() {
978 assert_eq!(generate_slug("a/b/c.md"), "a/b/c");
979 assert_eq!(generate_slug("a, b/c.md"), "a-b/c");
980 }
981
982 #[test]
986 fn test_resolve_self_named_via_filename_stem() {
987 let mut b = ContentGraphBuilder::new();
988 b.add_file("recipes/index.md", "/recipes");
989 b.add_file("recipes/recipes.md", "/recipes/recipes");
990 let g = b.build();
991
992 assert_eq!(
994 g.resolve_path("recipes", "other.md"),
995 Some("recipes/recipes.md".into())
996 );
997 }
998
999 #[test]
1001 fn test_resolve_folder_note_fallback_to_index() {
1002 let mut b = ContentGraphBuilder::new();
1003 b.add_file("recipes/index.md", "/recipes");
1004 b.add_file("recipes/pasta.md", "/recipes/pasta");
1005 let g = b.build();
1006
1007 assert_eq!(
1008 g.resolve_path("recipes", "other.md"),
1009 Some("recipes/index.md".into())
1010 );
1011 }
1012
1013 #[test]
1015 fn test_suffix_match_partial_path() {
1016 let mut b = ContentGraphBuilder::new();
1017 b.add_file("文字/游记/index.md", "/文字/游记");
1018 b.add_file("index.md", "/");
1019 let g = b.build();
1020
1021 assert_eq!(
1023 g.resolve_path("游记/index.md", "index.md"),
1024 Some("文字/游记/index.md".into())
1025 );
1026 }
1027
1028 #[test]
1030 fn test_suffix_match_ambiguous_uses_tiebreaker() {
1031 let mut b = ContentGraphBuilder::new();
1032 b.add_file("a/游记/index.md", "/a/游记");
1033 b.add_file("b/游记/index.md", "/b/游记");
1034 let g = b.build();
1035
1036 assert_eq!(
1038 g.resolve_path("游记/index.md", "a/other.md"),
1039 Some("a/游记/index.md".into())
1040 );
1041 assert_eq!(
1043 g.resolve_path("游记/index.md", "b/other.md"),
1044 Some("b/游记/index.md".into())
1045 );
1046 }
1047
1048 #[test]
1052 fn test_vault_root_prefix_resolves_correctly() {
1053 let mut b = ContentGraphBuilder::new();
1054 b.add_file("交互实验/index.md", "/交互实验");
1055 b.add_file("文字/分布式信息网络/index.md", "/文字/分布式信息网络");
1056 let g = b.build();
1057
1058 assert_eq!(
1060 g.resolve_path("刘果/交互实验/index.md", ""),
1061 Some("交互实验/index.md".into())
1062 );
1063 }
1064
1065 #[test]
1067 fn test_vault_root_prefix_non_index() {
1068 let mut b = ContentGraphBuilder::new();
1069 b.add_file("posts/hello.md", "/posts/hello");
1070 b.add_file("guides/hello.md", "/guides/hello");
1071 let g = b.build();
1072
1073 assert_eq!(
1075 g.resolve_path("mysite/posts/hello.md", ""),
1076 Some("posts/hello.md".into())
1077 );
1078 }
1079
1080 #[test]
1082 fn test_vault_root_prefix_deep_nesting() {
1083 let mut b = ContentGraphBuilder::new();
1084 b.add_file("文字/游记/index.md", "/文字/游记");
1085 let g = b.build();
1086
1087 assert_eq!(
1089 g.resolve_path("vault/文字/游记/index.md", ""),
1090 Some("文字/游记/index.md".into())
1091 );
1092 }
1093
1094 #[test]
1096 fn test_resolve_path_preserves_original_case() {
1097 let mut b = ContentGraphBuilder::new();
1098 b.add_file("音乐/Winter-Song.mov", "音乐/winter-song");
1099 let g = b.build();
1100
1101 assert_eq!(
1103 g.resolve_path("winter-song.mov", ""),
1104 Some("音乐/Winter-Song.mov".into())
1105 );
1106 assert_eq!(
1107 g.resolve_path("Winter-Song.mov", ""),
1108 Some("音乐/Winter-Song.mov".into())
1109 );
1110 }
1111
1112 #[test]
1114 fn test_all_files_preserves_original_case() {
1115 let mut b = ContentGraphBuilder::new();
1116 b.add_file("Notes/MyFile.md", "/notes/myfile");
1117 b.add_file("Posts/Hello-World.md", "/posts/hello-world");
1118 let g = b.build();
1119
1120 assert_eq!(
1121 g.all_files(),
1122 &["Notes/MyFile.md", "Posts/Hello-World.md"]
1123 );
1124 }
1125
1126 #[test]
1136 fn stem_collision_prefers_matching_extension_png() {
1137 let mut b = ContentGraphBuilder::new();
1138 b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1139 b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1140 let g = b.build();
1141
1142 assert_eq!(
1143 g.resolve_path("scale-compare.png", "interactive/article.md"),
1144 Some("interactive/scale-compare.png".into())
1145 );
1146 }
1147
1148 #[test]
1149 fn stem_collision_prefers_matching_extension_html() {
1150 let mut b = ContentGraphBuilder::new();
1151 b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1152 b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1153 let g = b.build();
1154
1155 assert_eq!(
1156 g.resolve_path("scale-compare.html", "interactive/article.md"),
1157 Some("interactive/scale-compare.html".into())
1158 );
1159 }
1160
1161 #[test]
1162 fn stem_collision_independent_of_registration_order() {
1163 let mut b = ContentGraphBuilder::new();
1165 b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1166 b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1167 let g = b.build();
1168
1169 assert_eq!(
1170 g.resolve_path("scale-compare.png", "interactive/article.md"),
1171 Some("interactive/scale-compare.png".into())
1172 );
1173 assert_eq!(
1174 g.resolve_path("scale-compare.html", "interactive/article.md"),
1175 Some("interactive/scale-compare.html".into())
1176 );
1177 }
1178
1179 #[test]
1180 fn stem_collision_bare_ref_unchanged() {
1181 let mut b = ContentGraphBuilder::new();
1185 b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1186 b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1187 let g = b.build();
1188
1189 assert!(g.resolve_path("scale-compare", "interactive/article.md").is_some());
1192 }
1193
1194 #[test]
1195 fn stem_collision_md_wins_over_html_sibling() {
1196 let mut b = ContentGraphBuilder::new();
1200 b.add_file("notes/guide.md", "/notes/guide");
1201 b.add_file("notes/guide.html", "/notes/guide.html");
1202 let g = b.build();
1203
1204 assert_eq!(
1205 g.resolve_path("guide.md", "notes/index.md"),
1206 Some("notes/guide.md".into())
1207 );
1208 }
1209
1210 #[test]
1211 fn stem_collision_suffix_match_arm() {
1212 let mut b = ContentGraphBuilder::new();
1217 b.add_file("vault/a/scale.png", "/vault/a/scale.png");
1218 b.add_file("vault/a/scale.html", "/vault/a/scale.html");
1219 let g = b.build();
1220
1221 assert_eq!(
1222 g.resolve_path("a/scale.png", "vault/notes/article.md"),
1223 Some("vault/a/scale.png".into())
1224 );
1225 }
1226
1227 #[test]
1228 fn stem_collision_ext_match_overrides_lang_tree() {
1229 let mut b = ContentGraphBuilder::new();
1234 b.add_file("zh-hans/foo.html", "/zh-hans/foo.html");
1235 b.add_file("en/foo.png", "/en/foo.png");
1236 let g = b.build();
1237
1238 assert_eq!(
1239 g.resolve_path("foo.png", "zh-hans/note.md"),
1240 Some("en/foo.png".into())
1241 );
1242 }
1243
1244 #[test]
1245 fn stem_collision_alphabetical_final_tiebreaker() {
1246 let mut b1 = ContentGraphBuilder::new();
1250 b1.add_file("notes/photo.png", "/notes/photo.png");
1251 b1.add_file("notes/photo.html", "/notes/photo.html");
1252 let g1 = b1.build();
1253
1254 let mut b2 = ContentGraphBuilder::new();
1255 b2.add_file("notes/photo.html", "/notes/photo.html");
1256 b2.add_file("notes/photo.png", "/notes/photo.png");
1257 let g2 = b2.build();
1258
1259 let r1 = g1.resolve_path("photo", "notes/index.md");
1262 let r2 = g2.resolve_path("photo", "notes/index.md");
1263 assert_eq!(r1, r2, "result must not depend on registration order");
1264 assert_eq!(r1, Some("notes/photo.html".into()));
1265 }
1266
1267 #[test]
1268 fn stem_collision_case_insensitive_extension() {
1269 let mut b = ContentGraphBuilder::new();
1271 b.add_file("interactive/photo.PNG", "/interactive/photo.png");
1272 b.add_file("interactive/photo.html", "/interactive/photo.html");
1273 let g = b.build();
1274
1275 assert_eq!(
1276 g.resolve_path("photo.png", "interactive/article.md"),
1277 Some("interactive/photo.PNG".into())
1278 );
1279 }
1280
1281 #[test]
1282 fn exact_case_asset_index() {
1283 let g = ContentGraph::from_paths(&["assets/Hoon.JPG", "News/post.md"]);
1284 assert!(g.asset_contains("assets/Hoon.JPG"));
1285 assert!(!g.asset_contains("assets/hoon.jpg")); assert_eq!(
1287 g.asset_contains_ci("assets/hoon.jpg").as_deref(),
1288 Some("assets/Hoon.JPG")
1289 );
1290 assert_eq!(
1291 g.asset_find_by_suffix("Hoon.JPG"),
1292 vec!["assets/Hoon.JPG".to_string()]
1293 );
1294 }
1295}