1use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
27use papaya::HashMap as ConcurrentHashMap;
28use regex::Regex;
29use std::borrow::Cow;
30use std::collections::{HashMap, HashSet};
31use std::path::{Path, PathBuf};
32use std::sync::atomic::{AtomicUsize, Ordering};
33use std::time::Instant;
34
35use mbr_core::link_index::{InboundLink, sort_inbound_links};
36use mbr_core::repo::{build_markdown_url_path, should_ignore};
37use mbr_core::vault::{Vault, VaultPath, walk_files};
38
39#[derive(Clone)]
41struct InboundLinkCacheEntry {
42 links: Vec<InboundLink>,
44 computed_at: std::time::Instant,
46 size_bytes: usize,
48}
49
50pub struct InboundLinkCache {
55 cache: ConcurrentHashMap<String, InboundLinkCacheEntry>,
57 current_size: AtomicUsize,
59 max_size: usize,
61 ttl_seconds: u64,
63}
64
65impl InboundLinkCache {
66 pub fn new(max_size_bytes: usize, ttl_seconds: u64) -> Self {
68 Self {
69 cache: ConcurrentHashMap::new(),
70 current_size: AtomicUsize::new(0),
71 max_size: max_size_bytes,
72 ttl_seconds,
73 }
74 }
75
76 pub fn get(&self, url_path: &str) -> Option<Vec<InboundLink>> {
78 if self.max_size == 0 {
79 return None;
80 }
81
82 let guard = self.cache.pin();
83 if let Some(entry) = guard.get(url_path) {
84 if entry.computed_at.elapsed().as_secs() < self.ttl_seconds {
86 tracing::debug!("inbound link cache hit: {}", url_path);
87 return Some(entry.links.clone());
88 } else {
89 tracing::debug!("inbound link cache expired: {}", url_path);
90 }
91 }
92 None
93 }
94
95 pub fn insert(&self, url_path: String, links: Vec<InboundLink>) {
97 if self.max_size == 0 {
98 return;
99 }
100
101 let size_bytes = url_path.len()
102 + links
103 .iter()
104 .map(|l| {
105 l.from.len()
106 + l.text.len()
107 + l.anchor.as_ref().map(|a| a.len()).unwrap_or(0)
108 + 32
109 })
110 .sum::<usize>()
111 + std::mem::size_of::<InboundLinkCacheEntry>();
112
113 let entry = InboundLinkCacheEntry {
114 links,
115 computed_at: Instant::now(),
116 size_bytes,
117 };
118
119 let replaced_size = self
123 .cache
124 .pin()
125 .insert(url_path.clone(), entry)
126 .map_or(0, |old| old.size_bytes);
127 self.current_size
128 .fetch_sub(replaced_size, Ordering::Relaxed);
129 let new_size = self.current_size.fetch_add(size_bytes, Ordering::Relaxed) + size_bytes;
130
131 tracing::debug!("inbound links cached: {} ({} bytes)", url_path, size_bytes);
132
133 if new_size > self.max_size {
135 self.evict_oldest(new_size - self.max_size);
136 }
137 }
138
139 fn evict_oldest(&self, target_bytes: usize) {
141 let guard = self.cache.pin();
142 let mut entries: Vec<(String, Instant, usize)> = guard
143 .iter()
144 .map(|(k, v)| (k.clone(), v.computed_at, v.size_bytes))
145 .collect();
146
147 entries.sort_by_key(|(_, computed_at, _)| *computed_at);
149
150 let mut freed = 0usize;
151 let mut evict_count = 0usize;
152
153 for (url, _, size) in entries {
154 if freed >= target_bytes {
155 break;
156 }
157 if guard.remove(&url).is_some() {
158 freed += size;
159 evict_count += 1;
160 self.current_size.fetch_sub(size, Ordering::Relaxed);
161 }
162 }
163
164 if evict_count > 0 {
165 tracing::debug!(
166 "inbound link cache evicted {} entries ({} bytes freed)",
167 evict_count,
168 freed
169 );
170 }
171 }
172
173 pub fn invalidate_all(&self) {
175 let guard = self.cache.pin();
176 let keys: Vec<String> = guard.iter().map(|(k, _)| k.clone()).collect();
177 for key in keys {
178 guard.remove(&key);
179 }
180 self.current_size.store(0, Ordering::Relaxed);
181 tracing::debug!("inbound link cache invalidated");
182 }
183
184 #[cfg(test)]
186 pub fn current_size(&self) -> usize {
187 self.current_size.load(Ordering::Relaxed)
188 }
189
190 #[cfg(test)]
192 pub fn len(&self) -> usize {
193 self.cache.pin().len()
194 }
195
196 #[cfg(test)]
198 pub fn is_empty(&self) -> bool {
199 self.cache.pin().is_empty()
200 }
201}
202
203pub(crate) fn compute_relative_path(source_folder: &str, target_path: &str) -> String {
222 let source = source_folder.trim_start_matches('/').trim_end_matches('/');
224 let target = target_path.trim_start_matches('/').trim_end_matches('/');
225
226 let source_parts: Vec<&str> = if source.is_empty() {
228 vec![]
229 } else {
230 source.split('/').collect()
231 };
232 let target_parts: Vec<&str> = if target.is_empty() {
233 vec![]
234 } else {
235 target.split('/').collect()
236 };
237
238 let common_len = source_parts
240 .iter()
241 .zip(target_parts.iter())
242 .take_while(|(a, b)| a == b)
243 .count();
244
245 let ups_needed = source_parts.len() - common_len;
247
248 let mut result_parts: Vec<&str> = vec![".."; ups_needed];
250
251 result_parts.extend(&target_parts[common_len..]);
253
254 if result_parts.is_empty() {
255 ".".to_string()
257 } else {
258 result_parts.join("/")
259 }
260}
261
262pub(crate) fn compute_patterns_for_folder(
283 source_folder: &str,
284 target_url_path: &str,
285) -> Vec<String> {
286 let mut patterns = HashSet::new();
287
288 let target_normalized = target_url_path
290 .trim_start_matches('/')
291 .trim_end_matches('/');
292
293 if target_normalized.is_empty() {
294 return vec![];
295 }
296
297 let abs_path = format!("/{}", target_normalized);
299 add_pattern_variants(&mut patterns, &abs_path);
300
301 let relative = compute_relative_path(source_folder, target_url_path);
303
304 if relative != "." {
306 add_pattern_variants(&mut patterns, &relative);
308
309 if !relative.starts_with("../") && !relative.starts_with("./") {
311 add_pattern_variants(&mut patterns, &format!("./{}", relative));
312 }
313 }
314
315 patterns.into_iter().collect()
316}
317
318pub(crate) fn add_pattern_variants(patterns: &mut HashSet<String>, base: &str) {
326 let normalized = base.trim_end_matches('/');
327 patterns.insert(normalized.to_string());
328 patterns.insert(format!("{}/", normalized));
329 patterns.insert(format!("{}.md", normalized));
330 patterns.insert(format!("{}#", normalized));
331}
332
333fn build_folder_patterns(
342 target_url_path: &str,
343 all_folders: &HashSet<String>,
344) -> HashMap<String, Vec<String>> {
345 all_folders
346 .iter()
347 .map(|folder| {
348 let patterns = compute_patterns_for_folder(folder, target_url_path);
349 (folder.clone(), patterns)
350 })
351 .collect()
352}
353
354fn build_extraction_regex(patterns: &[String]) -> Option<Regex> {
358 if patterns.is_empty() {
359 return None;
360 }
361
362 let escaped_patterns: Vec<String> = patterns
364 .iter()
365 .map(|p| {
366 let base = p
368 .trim_end_matches('/')
369 .trim_end_matches(".md")
370 .trim_end_matches('#');
371 regex::escape(base)
372 })
373 .collect();
374
375 let unique_patterns: HashSet<String> = escaped_patterns.into_iter().collect();
377 let pattern_alternation = unique_patterns.into_iter().collect::<Vec<_>>().join("|");
378
379 let pattern = format!(
381 r#"\[([^\]]*)\]\((?:{})(?:\.md)?(?:/)?(?:#([^)]*))?\)"#,
382 pattern_alternation
383 );
384
385 Regex::new(&pattern).ok()
386}
387
388fn build_wiki_extraction_regex(patterns: &[String], names: &[String]) -> Option<Regex> {
394 if patterns.is_empty() && names.is_empty() {
395 return None;
396 }
397
398 let escaped_patterns: Vec<String> = patterns
400 .iter()
401 .map(|p| {
402 let base = p
403 .trim_end_matches('/')
404 .trim_end_matches(".md")
405 .trim_end_matches('#');
406 regex::escape(base)
407 })
408 .chain(names.iter().map(|n| regex::escape(n)))
409 .collect();
410
411 let unique_patterns: HashSet<String> = escaped_patterns.into_iter().collect();
412 let pattern_alternation = unique_patterns.into_iter().collect::<Vec<_>>().join("|");
413
414 let pattern = format!(
417 r#"(?i)\[\[(?:{})(?:\.md)?(?:/)?(?:#([^\]|]*))?(?:\|([^\]]*))?\]\]"#,
418 pattern_alternation
419 );
420
421 Regex::new(&pattern).ok()
422}
423
424fn build_ref_extraction_regex(patterns: &[String]) -> Option<Regex> {
426 if patterns.is_empty() {
427 return None;
428 }
429
430 let escaped_patterns: Vec<String> = patterns
431 .iter()
432 .map(|p| {
433 let base = p
434 .trim_end_matches('/')
435 .trim_end_matches(".md")
436 .trim_end_matches('#');
437 regex::escape(base)
438 })
439 .collect();
440
441 let unique_patterns: HashSet<String> = escaped_patterns.into_iter().collect();
442 let pattern_alternation = unique_patterns.into_iter().collect::<Vec<_>>().join("|");
443
444 let pattern = format!(
446 r#"\[([^\]]+)\]:\s*(?:{})(?:\.md)?(?:/)?(?:#\S*)?"#,
447 pattern_alternation
448 );
449
450 Regex::new(&pattern).ok()
451}
452
453pub fn find_inbound_links(
479 target_url_path: &str,
480 vault: &dyn Vault,
481 markdown_extensions: &[String],
482 ignore_dirs: &[String],
483 ignore_globs: &[String],
484 index_file: &str,
485) -> Vec<InboundLink> {
486 let start = Instant::now();
487 let mut inbound_links = Vec::new();
488
489 let target_normalized = target_url_path.trim_end_matches('/');
491 let target_segments = target_normalized.trim_start_matches('/');
492
493 if target_segments.is_empty() {
494 return inbound_links;
495 }
496
497 let mut folder_files: HashMap<String, Vec<(VaultPath, String)>> = HashMap::new();
499 let mut target_file: Option<VaultPath> = None;
502
503 for file in link_scan_files(
504 vault,
505 markdown_extensions,
506 ignore_dirs,
507 ignore_globs,
508 Some(index_file),
509 ) {
510 if file.page_url.trim_end_matches('/') == target_normalized {
512 target_file = Some(file.path);
513 continue;
514 }
515
516 folder_files
517 .entry(file.folder_url)
518 .or_default()
519 .push((file.path, file.page_url));
520 }
521
522 let all_folders: HashSet<String> = folder_files.keys().cloned().collect();
524
525 let folder_patterns = build_folder_patterns(target_url_path, &all_folders);
527
528 let target_names = target_file
531 .as_ref()
532 .map(|path| wikilink_names_for_target(vault, path))
533 .unwrap_or_default();
534
535 let mut folder_automatons: HashMap<String, Option<AhoCorasick>> = HashMap::new();
537
538 for (folder, patterns) in &folder_patterns {
539 let gate_patterns: Vec<&String> = patterns.iter().chain(target_names.iter()).collect();
540 if gate_patterns.is_empty() {
541 folder_automatons.insert(folder.clone(), None);
542 } else {
543 match AhoCorasickBuilder::new()
545 .ascii_case_insensitive(true)
546 .match_kind(MatchKind::LeftmostFirst)
547 .build(&gate_patterns)
548 {
549 Ok(ac) => {
550 folder_automatons.insert(folder.clone(), Some(ac));
551 }
552 Err(e) => {
553 tracing::warn!("Failed to build Aho-Corasick for folder {}: {}", folder, e);
554 folder_automatons.insert(folder.clone(), None);
555 }
556 }
557 }
558 }
559
560 let mut folder_link_regexes: HashMap<String, Option<Regex>> = HashMap::new();
562 let mut folder_wiki_regexes: HashMap<String, Option<Regex>> = HashMap::new();
563 let mut folder_ref_regexes: HashMap<String, Option<Regex>> = HashMap::new();
564
565 for (folder, patterns) in &folder_patterns {
566 folder_link_regexes.insert(folder.clone(), build_extraction_regex(patterns));
567 folder_wiki_regexes.insert(
568 folder.clone(),
569 build_wiki_extraction_regex(patterns, &target_names),
570 );
571 folder_ref_regexes.insert(folder.clone(), build_ref_extraction_regex(patterns));
572 }
573
574 let mut files_scanned = 0;
575
576 for (folder, files) in &folder_files {
578 let automaton = folder_automatons.get(folder).and_then(|a| a.as_ref());
579
580 let Some(ac) = automaton else {
582 continue;
583 };
584
585 let link_regex = folder_link_regexes.get(folder).and_then(|r| r.as_ref());
586 let wiki_regex = folder_wiki_regexes.get(folder).and_then(|r| r.as_ref());
587 let ref_regex = folder_ref_regexes.get(folder).and_then(|r| r.as_ref());
588
589 for (path, source_url_path) in files {
590 files_scanned += 1;
591
592 let content = match vault.read_to_string(path) {
594 Ok(c) => c,
595 Err(_) => continue,
596 };
597
598 if !ac.is_match(&content) {
600 continue;
601 }
602
603 let content = strip_code_regions(&content);
607
608 let mut found_link = false;
610
611 if let Some(regex) = link_regex {
613 for cap in regex.captures_iter(&content) {
614 let text = cap.get(1).map(|m| m.as_str()).unwrap_or("");
615 let anchor = cap.get(2).map(|m| format!("#{}", m.as_str()));
616
617 inbound_links.push(InboundLink {
618 from: source_url_path.clone(),
619 text: text.to_string(),
620 anchor,
621 });
622 found_link = true;
623 }
624 }
625
626 if let Some(regex) = wiki_regex {
628 for cap in regex.captures_iter(&content) {
629 let anchor = cap.get(1).and_then(|m| {
630 let s = m.as_str();
631 if s.is_empty() {
632 None
633 } else {
634 Some(format!("#{}", s))
635 }
636 });
637
638 let text = cap
639 .get(2)
640 .map(|m| m.as_str().trim())
641 .filter(|s| !s.is_empty())
642 .map(|s| s.to_string())
643 .unwrap_or_else(|| {
644 target_segments
645 .split('/')
646 .next_back()
647 .unwrap_or(target_segments)
648 .to_string()
649 });
650
651 let link = InboundLink {
652 from: source_url_path.clone(),
653 text,
654 anchor,
655 };
656
657 if !inbound_links.contains(&link) {
658 inbound_links.push(link);
659 found_link = true;
660 }
661 }
662 }
663
664 if !found_link && let Some(regex) = ref_regex {
666 for cap in regex.captures_iter(&content) {
667 let ref_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
668
669 let use_pattern = format!(r#"\[([^\]]*)\]\[{}\]"#, regex::escape(ref_name));
671 if let Ok(use_regex) = Regex::new(&use_pattern) {
672 for use_cap in use_regex.captures_iter(&content) {
673 let text = use_cap.get(1).map(|m| m.as_str()).unwrap_or("");
674
675 let link = InboundLink {
676 from: source_url_path.clone(),
677 text: text.to_string(),
678 anchor: None,
679 };
680 if !inbound_links.contains(&link) {
681 inbound_links.push(link);
682 }
683 }
684 }
685 }
686 }
687 }
688 }
689
690 sort_inbound_links(&mut inbound_links);
698
699 let mut seen_sources: HashSet<String> = HashSet::new();
702 let deduplicated_links: Vec<InboundLink> = inbound_links
703 .into_iter()
704 .filter(|link| seen_sources.insert(link.from.clone()))
705 .collect();
706
707 tracing::debug!(
708 "Scanned {} files for inbound links to {} in {:?}, found {}",
709 files_scanned,
710 target_url_path,
711 start.elapsed(),
712 deduplicated_links.len()
713 );
714
715 deduplicated_links
716}
717
718pub(crate) struct LinkScanFile {
720 pub(crate) path: VaultPath,
722 pub(crate) key: PathBuf,
725 pub(crate) page_url: String,
727 pub(crate) folder_url: String,
728}
729
730pub(crate) fn link_scan_files(
743 vault: &dyn Vault,
744 markdown_extensions: &[String],
745 ignore_dirs: &[String],
746 ignore_globs: &[String],
747 index_file: Option<&str>,
748) -> Vec<LinkScanFile> {
749 let root = vault.root();
750 walk_files(vault, |dir| {
751 !ignore_dirs.iter().any(|name| name == dir.name())
752 })
753 .into_iter()
754 .filter(|entry| {
755 let ext = entry.path.extension().unwrap_or("").to_lowercase();
756 markdown_extensions.contains(&ext)
757 })
758 .filter_map(|entry| {
759 let key = vault.key(&entry.path);
760 if should_ignore(&key, ignore_dirs, ignore_globs, &[]) {
761 return None;
762 }
763 let (page_url, folder_url) =
764 page_and_folder_urls(&key, root, markdown_extensions, index_file);
765 Some(LinkScanFile {
766 path: entry.path,
767 key,
768 page_url,
769 folder_url,
770 })
771 })
772 .collect()
773}
774
775pub(crate) fn get_folder_url_path(file_url_path: &str) -> String {
779 let trimmed = file_url_path.trim_end_matches('/');
780 if let Some(pos) = trimmed.rfind('/') {
781 format!("{}/", &trimmed[..pos])
782 } else {
783 "/".to_string()
784 }
785}
786
787pub(crate) fn page_and_folder_urls(
800 file_path: &Path,
801 root_dir: &Path,
802 markdown_extensions: &[String],
803 index_file: Option<&str>,
804) -> (String, String) {
805 let positional = compute_url_path(file_path, root_dir, markdown_extensions);
806 let folder_url = get_folder_url_path(&positional);
807 let page_url = match index_file {
808 Some(index_file) => build_markdown_url_path(file_path, root_dir, index_file),
809 None => positional,
810 };
811 (page_url, folder_url)
812}
813
814fn compute_url_path(file_path: &Path, root_dir: &Path, markdown_extensions: &[String]) -> String {
823 let relative = file_path.strip_prefix(root_dir).unwrap_or(file_path);
824
825 let mut url_path = String::from("/");
826
827 for component in relative.components() {
828 if let std::path::Component::Normal(name) = component {
829 let name_str = name.to_string_lossy();
830 url_path.push_str(&name_str);
831 url_path.push('/');
832 }
833 }
834
835 for ext in markdown_extensions {
841 let suffix = format!(".{}/", ext);
842 let cut = match url_path.len().checked_sub(suffix.len()) {
843 Some(cut) => cut,
844 None => continue,
845 };
846 if url_path.as_bytes()[cut..].eq_ignore_ascii_case(suffix.as_bytes()) {
847 url_path.truncate(cut);
848 url_path.push('/');
849 break;
850 }
851 }
852
853 url_path
854}
855
856fn wikilink_names_for_target(vault: &dyn Vault, path: &VaultPath) -> Vec<String> {
865 let stem = Path::new(path.file_name().unwrap_or_default())
866 .file_stem()
867 .and_then(|s| s.to_str())
868 .unwrap_or_default()
869 .to_string();
870 let metadata = mbr_core::markdown::extract_metadata_from_vault(vault, path)
871 .map(|m| m.metadata)
872 .unwrap_or_default();
873 let title = metadata
874 .get("title")
875 .and_then(|v| v.as_str())
876 .map(str::to_string);
877 let aliases = mbr_core::contact::alias_names_in(&metadata);
878
879 let mut seen: HashSet<String> = HashSet::new();
880 std::iter::once(stem)
881 .chain(title)
882 .chain(aliases)
883 .map(|name| name.trim().to_string())
884 .filter(|name| !name.is_empty() && !name.contains(['/', '[', ']', '|', '#']))
885 .filter(|name| seen.insert(name.to_lowercase()))
886 .collect()
887}
888
889fn strip_code_regions(content: &str) -> Cow<'_, str> {
897 if !content.contains('`') && !content.contains('~') {
898 return Cow::Borrowed(content);
899 }
900
901 let mut out = String::with_capacity(content.len());
902 let mut fence: Option<(u8, usize)> = None;
904
905 for line in content.lines() {
906 let indent = line.len() - line.trim_start_matches(' ').len();
907 let marker = (indent <= 3)
909 .then(|| fence_marker(&line[indent..]))
910 .flatten();
911 match fence {
912 Some((fence_char, fence_len)) => {
913 let closes = marker.is_some_and(|(c, n)| {
916 c == fence_char && n >= fence_len && line[indent + n..].trim_end().is_empty()
917 });
918 if closes {
919 fence = None;
920 }
921 }
922 None => match marker {
923 Some((c, n)) => fence = Some((c, n)),
924 None => out.push_str(&strip_inline_code(line)),
925 },
926 }
927 out.push('\n');
928 }
929
930 Cow::Owned(out)
931}
932
933fn fence_marker(line: &str) -> Option<(u8, usize)> {
935 let first = *line.as_bytes().first()?;
936 if first != b'`' && first != b'~' {
937 return None;
938 }
939 let run = line.bytes().take_while(|b| *b == first).count();
940 (run >= 3).then_some((first, run))
941}
942
943fn strip_inline_code(line: &str) -> Cow<'_, str> {
948 if !line.contains('`') {
949 return Cow::Borrowed(line);
950 }
951 let bytes = line.as_bytes();
952 let mut out = String::with_capacity(line.len());
953 let mut i = 0;
954 while i < bytes.len() {
955 if bytes[i] == b'`' {
956 let open_start = i;
957 i += bytes[i..].iter().take_while(|b| **b == b'`').count();
958 let run = i - open_start;
959 match find_backtick_run(bytes, i, run) {
960 Some(close_start) => {
962 out.push(' ');
963 i = close_start + run;
964 }
965 None => out.push_str(&line[open_start..i]),
966 }
967 } else {
968 let start = i;
969 i += bytes[i..].iter().take_while(|b| **b != b'`').count();
970 out.push_str(&line[start..i]);
971 }
972 }
973 Cow::Owned(out)
974}
975
976fn find_backtick_run(bytes: &[u8], from: usize, run: usize) -> Option<usize> {
978 let mut i = from;
979 while i < bytes.len() {
980 if bytes[i] == b'`' {
981 let start = i;
982 i += bytes[i..].iter().take_while(|b| **b == b'`').count();
983 if i - start == run {
984 return Some(start);
985 }
986 } else {
987 i += 1;
988 }
989 }
990 None
991}
992
993#[cfg(test)]
994mod tests {
995 use super::*;
996 use mbr_core::vault::LocalVault;
997 use std::fs;
998 use tempfile::TempDir;
999
1000 #[test]
1003 fn test_compute_relative_path_same_directory() {
1004 assert_eq!(compute_relative_path("/a/", "/a/1"), "1");
1006 assert_eq!(compute_relative_path("/a/", "/a/1/"), "1");
1007 }
1008
1009 #[test]
1010 fn test_compute_relative_path_subdirectory() {
1011 assert_eq!(compute_relative_path("/a/", "/a/b/1"), "b/1");
1013 assert_eq!(compute_relative_path("/a/", "/a/b/c/1"), "b/c/1");
1014 }
1015
1016 #[test]
1017 fn test_compute_relative_path_parent_directory() {
1018 assert_eq!(compute_relative_path("/a/b/", "/a/1"), "../1");
1020 assert_eq!(compute_relative_path("/a/b/c/", "/a/1"), "../../1");
1021 }
1022
1023 #[test]
1024 fn test_compute_relative_path_sibling_directory() {
1025 assert_eq!(compute_relative_path("/a/", "/b/1"), "../b/1");
1027 assert_eq!(compute_relative_path("/a/", "/b/c/1"), "../b/c/1");
1028 }
1029
1030 #[test]
1031 fn test_compute_relative_path_deep_nesting() {
1032 assert_eq!(
1034 compute_relative_path("/a/b/c/", "/d/e/f/1"),
1035 "../../../d/e/f/1"
1036 );
1037 }
1038
1039 #[test]
1040 fn test_compute_relative_path_from_root() {
1041 assert_eq!(compute_relative_path("/", "/a/b/1"), "a/b/1");
1043 }
1044
1045 #[test]
1046 fn test_compute_relative_path_to_root_level() {
1047 assert_eq!(compute_relative_path("/a/b/", "/1"), "../../1");
1049 }
1050
1051 #[test]
1054 fn test_compute_patterns_for_folder_root() {
1055 let patterns = compute_patterns_for_folder("/", "/a/b/c/1/");
1056
1057 assert!(patterns.contains(&"/a/b/c/1".to_string()));
1059 assert!(patterns.contains(&"/a/b/c/1/".to_string()));
1060 assert!(patterns.contains(&"/a/b/c/1.md".to_string()));
1061 assert!(patterns.contains(&"/a/b/c/1#".to_string()));
1062
1063 assert!(patterns.contains(&"a/b/c/1".to_string()));
1065 assert!(patterns.contains(&"a/b/c/1/".to_string()));
1066 assert!(patterns.contains(&"./a/b/c/1".to_string()));
1067 assert!(patterns.contains(&"./a/b/c/1/".to_string()));
1068 }
1069
1070 #[test]
1071 fn test_compute_patterns_for_folder_same_directory() {
1072 let patterns = compute_patterns_for_folder("/a/b/", "/a/b/c/1/");
1073
1074 assert!(patterns.contains(&"/a/b/c/1".to_string()));
1076
1077 assert!(patterns.contains(&"c/1".to_string()));
1079 assert!(patterns.contains(&"./c/1".to_string()));
1080 }
1081
1082 #[test]
1083 fn test_compute_patterns_for_folder_sibling() {
1084 let patterns = compute_patterns_for_folder("/d/", "/a/b/c/1/");
1085
1086 assert!(patterns.contains(&"/a/b/c/1".to_string()));
1088
1089 assert!(patterns.contains(&"../a/b/c/1".to_string()));
1091 assert!(patterns.contains(&"../a/b/c/1/".to_string()));
1092 }
1093
1094 #[test]
1095 fn test_compute_patterns_for_folder_deeper_sibling() {
1096 let patterns = compute_patterns_for_folder("/d/e/", "/a/b/c/1/");
1097
1098 assert!(patterns.contains(&"/a/b/c/1".to_string()));
1100
1101 assert!(patterns.contains(&"../../a/b/c/1".to_string()));
1103 }
1104
1105 #[test]
1108 fn test_get_folder_url_path_basic() {
1109 assert_eq!(get_folder_url_path("/a/b/c/"), "/a/b/");
1110 assert_eq!(get_folder_url_path("/a/"), "/");
1111 assert_eq!(get_folder_url_path("/a/b/"), "/a/");
1112 }
1113
1114 #[test]
1117 fn test_compute_url_path_basic() {
1118 let root = Path::new("/home/user/notes");
1119 let file = Path::new("/home/user/notes/docs/guide.md");
1120 let extensions = vec!["md".to_string()];
1121
1122 let url = compute_url_path(file, root, &extensions);
1123 assert_eq!(url, "/docs/guide/");
1124 }
1125
1126 #[test]
1127 fn test_compute_url_path_nested() {
1128 let root = Path::new("/notes");
1129 let file = Path::new("/notes/a/b/c/page.md");
1130 let extensions = vec!["md".to_string()];
1131
1132 let url = compute_url_path(file, root, &extensions);
1133 assert_eq!(url, "/a/b/c/page/");
1134 }
1135
1136 #[test]
1137 fn compute_url_path_strips_uppercase_extension() {
1138 let root = Path::new("/notes");
1141 let extensions = vec!["md".to_string()];
1142
1143 assert_eq!(
1144 compute_url_path(Path::new("/notes/NOTES.MD"), root, &extensions),
1145 "/NOTES/"
1146 );
1147 }
1148
1149 #[test]
1152 fn page_and_folder_urls_collapses_index_but_keeps_link_base() {
1153 let root = Path::new("/notes");
1156 let extensions = vec!["md".to_string()];
1157
1158 let (page, folder) = page_and_folder_urls(
1159 Path::new("/notes/docs/index.md"),
1160 root,
1161 &extensions,
1162 Some("index.md"),
1163 );
1164 assert_eq!(page, "/docs/");
1165 assert_eq!(folder, "/docs/");
1166
1167 let (page, folder) = page_and_folder_urls(
1168 Path::new("/notes/docs/guide.md"),
1169 root,
1170 &extensions,
1171 Some("index.md"),
1172 );
1173 assert_eq!(page, "/docs/guide/");
1174 assert_eq!(folder, "/docs/");
1175 }
1176
1177 #[test]
1178 fn page_and_folder_urls_without_index_file_keeps_positional_url() {
1179 let root = Path::new("/notes");
1180 let extensions = vec!["md".to_string()];
1181
1182 let (page, folder) =
1183 page_and_folder_urls(Path::new("/notes/docs/index.md"), root, &extensions, None);
1184 assert_eq!(page, "/docs/index/");
1185 assert_eq!(folder, "/docs/");
1186 }
1187
1188 #[test]
1189 fn page_and_folder_urls_matches_repo_canonical_url() {
1190 let root = Path::new("/notes");
1194 let extensions = vec!["md".to_string()];
1195 for file in [
1196 "/notes/docs/index.md",
1197 "/notes/docs/guide.md",
1198 "/notes/index.md",
1199 "/notes/a/b/c/page.md",
1200 ] {
1201 let (page, _folder) =
1202 page_and_folder_urls(Path::new(file), root, &extensions, Some("index.md"));
1203 assert_eq!(
1204 page,
1205 mbr_core::repo::build_markdown_url_path(Path::new(file), root, "index.md"),
1206 "canonical URL mismatch for {file}"
1207 );
1208 }
1209 }
1210
1211 #[test]
1214 fn test_inbound_link_cache_basic() {
1215 let cache = InboundLinkCache::new(1024 * 1024, 60);
1216
1217 let links = vec![InboundLink {
1218 from: "/other/".to_string(),
1219 text: "Link text".to_string(),
1220 anchor: None,
1221 }];
1222
1223 cache.insert("/docs/".to_string(), links.clone());
1224
1225 let retrieved = cache.get("/docs/");
1226 assert!(retrieved.is_some());
1227 assert_eq!(retrieved.unwrap().len(), 1);
1228 }
1229
1230 #[test]
1231 fn test_inbound_link_cache_size_stable_on_overwrite() {
1232 let cache = InboundLinkCache::new(1024 * 1024, 60);
1235 let links = vec![InboundLink {
1236 from: "/other/".to_string(),
1237 text: "Link text".to_string(),
1238 anchor: None,
1239 }];
1240
1241 cache.insert("/docs/".to_string(), links.clone());
1242 let size_after_first = cache.current_size();
1243 assert_eq!(cache.len(), 1);
1244
1245 for _ in 0..10 {
1246 cache.insert("/docs/".to_string(), links.clone());
1247 }
1248
1249 assert_eq!(cache.len(), 1);
1250 assert_eq!(cache.current_size(), size_after_first);
1251 }
1252
1253 #[test]
1254 fn test_inbound_link_cache_size_tracks_replacement_delta() {
1255 let cache = InboundLinkCache::new(1024 * 1024, 60);
1257 let one = vec![InboundLink {
1258 from: "/a/".to_string(),
1259 text: "A".to_string(),
1260 anchor: None,
1261 }];
1262 let two = vec![
1263 InboundLink {
1264 from: "/a/".to_string(),
1265 text: "A".to_string(),
1266 anchor: None,
1267 },
1268 InboundLink {
1269 from: "/b/".to_string(),
1270 text: "B".to_string(),
1271 anchor: None,
1272 },
1273 ];
1274
1275 cache.insert("/docs/".to_string(), one.clone());
1276 let small = cache.current_size();
1277 cache.insert("/docs/".to_string(), two.clone());
1278 let large = cache.current_size();
1279
1280 assert_eq!(cache.len(), 1);
1281 let expected_delta = "/b/".len() + "B".len() + 32;
1282 assert_eq!(large - small, expected_delta);
1283 }
1284
1285 #[test]
1286 fn test_inbound_link_cache_disabled() {
1287 let cache = InboundLinkCache::new(0, 60);
1288
1289 let links = vec![InboundLink {
1290 from: "/other/".to_string(),
1291 text: "Link".to_string(),
1292 anchor: None,
1293 }];
1294
1295 cache.insert("/docs/".to_string(), links);
1296 assert!(cache.get("/docs/").is_none());
1297 }
1298
1299 #[test]
1302 fn test_find_inbound_links_basic() {
1303 let temp_dir = TempDir::new().unwrap();
1304
1305 let target_path = temp_dir.path().join("target.md");
1307 fs::write(&target_path, "# Target Page\n\nThis is the target.").unwrap();
1308
1309 let source_path = temp_dir.path().join("source.md");
1311 fs::write(
1312 &source_path,
1313 "# Source Page\n\nHere is a [link to target](target/).",
1314 )
1315 .unwrap();
1316
1317 let extensions = vec!["md".to_string()];
1318 let ignore_dirs: Vec<String> = vec![];
1319 let ignore_globs: Vec<String> = vec![];
1320
1321 let links = find_inbound_links(
1322 "/target/",
1323 &LocalVault::new(temp_dir.path()),
1324 &extensions,
1325 &ignore_dirs,
1326 &ignore_globs,
1327 "index.md",
1328 );
1329
1330 assert_eq!(links.len(), 1);
1331 assert_eq!(links[0].from, "/source/");
1332 assert_eq!(links[0].text, "link to target");
1333 }
1334
1335 #[test]
1336 fn test_find_inbound_links_with_anchor() {
1337 let temp_dir = TempDir::new().unwrap();
1338
1339 fs::write(temp_dir.path().join("target.md"), "# Target").unwrap();
1341
1342 fs::write(
1344 temp_dir.path().join("source.md"),
1345 "Link: [section link](target/#section)",
1346 )
1347 .unwrap();
1348
1349 let links = find_inbound_links(
1350 "/target/",
1351 &LocalVault::new(temp_dir.path()),
1352 &["md".to_string()],
1353 &[],
1354 &[],
1355 "index.md",
1356 );
1357
1358 assert_eq!(links.len(), 1);
1359 assert_eq!(links[0].anchor, Some("#section".to_string()));
1360 }
1361
1362 #[test]
1363 fn test_find_inbound_links_wiki_style_basic() {
1364 let temp_dir = TempDir::new().unwrap();
1365 fs::write(temp_dir.path().join("Japan.md"), "# Japan").unwrap();
1366 fs::write(temp_dir.path().join("source.md"), "See also: [[Japan]]").unwrap();
1367
1368 let links = find_inbound_links(
1369 "/Japan/",
1370 &LocalVault::new(temp_dir.path()),
1371 &["md".to_string()],
1372 &[],
1373 &[],
1374 "index.md",
1375 );
1376 assert_eq!(links.len(), 1);
1377 assert_eq!(links[0].from, "/source/");
1378 assert_eq!(links[0].text, "Japan");
1379 }
1380
1381 #[test]
1382 fn test_find_inbound_links_wiki_style_with_display_text() {
1383 let temp_dir = TempDir::new().unwrap();
1384 fs::write(temp_dir.path().join("Japan.md"), "# Japan").unwrap();
1385 fs::write(
1386 temp_dir.path().join("source.md"),
1387 "Visit [[Japan|the Land of the Rising Sun]].",
1388 )
1389 .unwrap();
1390
1391 let links = find_inbound_links(
1392 "/Japan/",
1393 &LocalVault::new(temp_dir.path()),
1394 &["md".to_string()],
1395 &[],
1396 &[],
1397 "index.md",
1398 );
1399 assert_eq!(links.len(), 1);
1400 assert_eq!(links[0].text, "the Land of the Rising Sun");
1401 }
1402
1403 #[test]
1404 fn test_find_inbound_links_wiki_style_with_anchor() {
1405 let temp_dir = TempDir::new().unwrap();
1406 fs::write(temp_dir.path().join("Japan.md"), "# Japan").unwrap();
1407 fs::write(temp_dir.path().join("source.md"), "See [[Japan#History]].").unwrap();
1408
1409 let links = find_inbound_links(
1410 "/Japan/",
1411 &LocalVault::new(temp_dir.path()),
1412 &["md".to_string()],
1413 &[],
1414 &[],
1415 "index.md",
1416 );
1417 assert_eq!(links.len(), 1);
1418 assert_eq!(links[0].anchor, Some("#History".to_string()));
1419 }
1420
1421 #[test]
1422 fn test_find_inbound_links_wiki_style_case_insensitive() {
1423 let temp_dir = TempDir::new().unwrap();
1424 fs::write(temp_dir.path().join("Japan.md"), "# Japan").unwrap();
1425 fs::write(
1426 temp_dir.path().join("source.md"),
1427 "See [[japan]] for details.",
1428 )
1429 .unwrap();
1430
1431 let links = find_inbound_links(
1432 "/Japan/",
1433 &LocalVault::new(temp_dir.path()),
1434 &["md".to_string()],
1435 &[],
1436 &[],
1437 "index.md",
1438 );
1439 assert_eq!(links.len(), 1);
1440 }
1441
1442 #[test]
1443 fn test_find_inbound_links_mixed_markdown_and_wiki() {
1444 let temp_dir = TempDir::new().unwrap();
1445 fs::write(temp_dir.path().join("target.md"), "# Target").unwrap();
1446 fs::write(
1447 temp_dir.path().join("source.md"),
1448 "See [standard](target/) and [[target]].",
1449 )
1450 .unwrap();
1451
1452 let links = find_inbound_links(
1455 "/target/",
1456 &LocalVault::new(temp_dir.path()),
1457 &["md".to_string()],
1458 &[],
1459 &[],
1460 "index.md",
1461 );
1462 assert_eq!(links.len(), 1);
1463 }
1464
1465 #[test]
1466 fn test_find_inbound_links_multiple_sources() {
1467 let temp_dir = TempDir::new().unwrap();
1468 fs::write(temp_dir.path().join("target.md"), "# Target").unwrap();
1469 fs::write(temp_dir.path().join("source1.md"), "See [link](target/).").unwrap();
1470 fs::write(
1471 temp_dir.path().join("source2.md"),
1472 "Also see [another link](target/).",
1473 )
1474 .unwrap();
1475
1476 let links = find_inbound_links(
1478 "/target/",
1479 &LocalVault::new(temp_dir.path()),
1480 &["md".to_string()],
1481 &[],
1482 &[],
1483 "index.md",
1484 );
1485 assert_eq!(links.len(), 2);
1486 }
1487
1488 #[test]
1491 fn test_find_inbound_links_relative_path_from_subfolder() {
1492 let temp_dir = TempDir::new().unwrap();
1493
1494 let tricks_dir = temp_dir.path().join("coins").join("tricks");
1498 fs::create_dir_all(&tricks_dir).unwrap();
1499
1500 fs::write(tricks_dir.join("3-fly.md"), "# 3 Fly Trick").unwrap();
1501 fs::write(
1502 temp_dir.path().join("coins").join("overview.md"),
1503 "Check out [3 Fly](tricks/3-fly/) for more.",
1504 )
1505 .unwrap();
1506
1507 let links = find_inbound_links(
1508 "/coins/tricks/3-fly/",
1509 &LocalVault::new(temp_dir.path()),
1510 &["md".to_string()],
1511 &[],
1512 &[],
1513 "index.md",
1514 );
1515
1516 assert_eq!(links.len(), 1);
1517 assert_eq!(links[0].from, "/coins/overview/");
1518 assert_eq!(links[0].text, "3 Fly");
1519 }
1520
1521 #[test]
1522 fn test_find_inbound_links_relative_path_with_parent_traversal() {
1523 let temp_dir = TempDir::new().unwrap();
1524
1525 let coins_tricks_dir = temp_dir.path().join("coins").join("tricks");
1529 let cards_dir = temp_dir.path().join("cards");
1530 fs::create_dir_all(&coins_tricks_dir).unwrap();
1531 fs::create_dir_all(&cards_dir).unwrap();
1532
1533 fs::write(coins_tricks_dir.join("3-fly.md"), "# 3 Fly Trick").unwrap();
1534 fs::write(
1535 cards_dir.join("overview.md"),
1536 "See also [3 Fly](../coins/tricks/3-fly/) coin trick.",
1537 )
1538 .unwrap();
1539
1540 let links = find_inbound_links(
1541 "/coins/tricks/3-fly/",
1542 &LocalVault::new(temp_dir.path()),
1543 &["md".to_string()],
1544 &[],
1545 &[],
1546 "index.md",
1547 );
1548
1549 assert_eq!(links.len(), 1);
1550 assert_eq!(links[0].from, "/cards/overview/");
1551 assert_eq!(links[0].text, "3 Fly");
1552 }
1553
1554 #[test]
1555 fn test_find_inbound_links_absolute_path() {
1556 let temp_dir = TempDir::new().unwrap();
1557
1558 let coins_tricks_dir = temp_dir.path().join("coins").join("tricks");
1562 let cards_dir = temp_dir.path().join("cards");
1563 fs::create_dir_all(&coins_tricks_dir).unwrap();
1564 fs::create_dir_all(&cards_dir).unwrap();
1565
1566 fs::write(coins_tricks_dir.join("3-fly.md"), "# 3 Fly Trick").unwrap();
1567 fs::write(
1568 cards_dir.join("overview.md"),
1569 "See also [3 Fly](/coins/tricks/3-fly/) coin trick.",
1570 )
1571 .unwrap();
1572
1573 let links = find_inbound_links(
1574 "/coins/tricks/3-fly/",
1575 &LocalVault::new(temp_dir.path()),
1576 &["md".to_string()],
1577 &[],
1578 &[],
1579 "index.md",
1580 );
1581
1582 assert_eq!(links.len(), 1);
1583 assert_eq!(links[0].from, "/cards/overview/");
1584 }
1585
1586 #[test]
1587 fn test_find_inbound_links_deep_relative_path() {
1588 let temp_dir = TempDir::new().unwrap();
1589
1590 let target_dir = temp_dir.path().join("a").join("b").join("c");
1594 let source_dir = temp_dir.path().join("d").join("e").join("f");
1595 fs::create_dir_all(&target_dir).unwrap();
1596 fs::create_dir_all(&source_dir).unwrap();
1597
1598 fs::write(target_dir.join("target.md"), "# Target").unwrap();
1599 fs::write(
1600 source_dir.join("source.md"),
1601 "Link: [target](../../../a/b/c/target/)",
1602 )
1603 .unwrap();
1604
1605 let links = find_inbound_links(
1606 "/a/b/c/target/",
1607 &LocalVault::new(temp_dir.path()),
1608 &["md".to_string()],
1609 &[],
1610 &[],
1611 "index.md",
1612 );
1613
1614 assert_eq!(links.len(), 1);
1615 assert_eq!(links[0].from, "/d/e/f/source/");
1616 }
1617
1618 #[test]
1619 fn test_find_inbound_links_with_dot_slash_prefix() {
1620 let temp_dir = TempDir::new().unwrap();
1621
1622 let tricks_dir = temp_dir.path().join("coins").join("tricks");
1624 fs::create_dir_all(&tricks_dir).unwrap();
1625
1626 fs::write(tricks_dir.join("3-fly.md"), "# 3 Fly").unwrap();
1627 fs::write(
1628 temp_dir.path().join("coins").join("index.md"),
1629 "See [3 Fly](./tricks/3-fly/) for more.",
1630 )
1631 .unwrap();
1632
1633 let links = find_inbound_links(
1634 "/coins/tricks/3-fly/",
1635 &LocalVault::new(temp_dir.path()),
1636 &["md".to_string()],
1637 &[],
1638 &[],
1639 "index.md",
1640 );
1641
1642 assert_eq!(links.len(), 1);
1643 assert_eq!(links[0].from, "/coins/");
1647 }
1648
1649 #[test]
1650 fn test_find_inbound_links_relative_with_md_extension() {
1651 let temp_dir = TempDir::new().unwrap();
1652
1653 let tricks_dir = temp_dir.path().join("coins").join("tricks");
1655 fs::create_dir_all(&tricks_dir).unwrap();
1656
1657 fs::write(tricks_dir.join("3-fly.md"), "# 3 Fly").unwrap();
1658 fs::write(
1659 temp_dir.path().join("coins").join("index.md"),
1660 "See [3 Fly](tricks/3-fly.md) for more.",
1661 )
1662 .unwrap();
1663
1664 let links = find_inbound_links(
1665 "/coins/tricks/3-fly/",
1666 &LocalVault::new(temp_dir.path()),
1667 &["md".to_string()],
1668 &[],
1669 &[],
1670 "index.md",
1671 );
1672
1673 assert_eq!(links.len(), 1);
1674 assert_eq!(links[0].from, "/coins/");
1675 }
1676
1677 #[test]
1680 fn find_inbound_links_reports_index_source_with_canonical_url() {
1681 let temp_dir = TempDir::new().unwrap();
1684 let docs = temp_dir.path().join("docs");
1685 fs::create_dir_all(&docs).unwrap();
1686
1687 fs::write(temp_dir.path().join("target.md"), "# Target").unwrap();
1688 fs::write(docs.join("index.md"), "See [Target](../target/).").unwrap();
1689
1690 let links = find_inbound_links(
1691 "/target/",
1692 &LocalVault::new(temp_dir.path()),
1693 &["md".to_string()],
1694 &[],
1695 &[],
1696 "index.md",
1697 );
1698
1699 assert_eq!(links.len(), 1);
1700 assert_eq!(links[0].from, "/docs/");
1701 }
1702
1703 #[test]
1704 fn find_inbound_links_skips_index_target_self_link() {
1705 let temp_dir = TempDir::new().unwrap();
1708 let docs = temp_dir.path().join("docs");
1709 fs::create_dir_all(&docs).unwrap();
1710
1711 fs::write(docs.join("index.md"), "Back to [me](/docs/).").unwrap();
1712 fs::write(
1713 temp_dir.path().join("other.md"),
1714 "See [Docs](/docs/) for more.",
1715 )
1716 .unwrap();
1717
1718 let links = find_inbound_links(
1719 "/docs/",
1720 &LocalVault::new(temp_dir.path()),
1721 &["md".to_string()],
1722 &[],
1723 &[],
1724 "index.md",
1725 );
1726
1727 assert_eq!(links.len(), 1);
1728 assert_eq!(links[0].from, "/other/");
1729 }
1730
1731 #[test]
1732 fn find_inbound_links_handles_uppercase_markdown_extension() {
1733 let temp_dir = TempDir::new().unwrap();
1736 fs::write(temp_dir.path().join("target.md"), "# Target").unwrap();
1737 fs::write(temp_dir.path().join("SOURCE.MD"), "See [t](target/).").unwrap();
1738
1739 let links = find_inbound_links(
1740 "/target/",
1741 &LocalVault::new(temp_dir.path()),
1742 &["md".to_string()],
1743 &[],
1744 &[],
1745 "index.md",
1746 );
1747
1748 assert_eq!(links.len(), 1);
1749 assert_eq!(links[0].from, "/SOURCE/");
1750 }
1751
1752 #[test]
1755 fn find_inbound_links_matches_title_and_alias_wikilinks() {
1756 let temp_dir = TempDir::new().unwrap();
1759 let people = temp_dir.path().join("people");
1760 let notes = temp_dir.path().join("notes");
1761 fs::create_dir_all(&people).unwrap();
1762 fs::create_dir_all(¬es).unwrap();
1763
1764 fs::write(
1765 people.join("pw.md"),
1766 "---\ntitle: Patrick Walsh\naliases: [PW]\n---\n\n# Patrick Walsh\n",
1767 )
1768 .unwrap();
1769 fs::write(
1770 notes.join("family.md"),
1771 "See [[Patrick Walsh]] and also [[PW]].",
1772 )
1773 .unwrap();
1774
1775 let links = find_inbound_links(
1776 "/people/pw/",
1777 &LocalVault::new(temp_dir.path()),
1778 &["md".to_string()],
1779 &[],
1780 &[],
1781 "index.md",
1782 );
1783
1784 assert_eq!(links.len(), 1);
1786 assert_eq!(links[0].from, "/notes/family/");
1787 }
1788
1789 #[test]
1792 fn find_inbound_links_matches_labeled_alias_wikilink() {
1793 let temp_dir = TempDir::new().unwrap();
1794 let people = temp_dir.path().join("people");
1795 let notes = temp_dir.path().join("notes");
1796 fs::create_dir_all(&people).unwrap();
1797 fs::create_dir_all(¬es).unwrap();
1798
1799 fs::write(
1800 people.join("mary.md"),
1801 "---\ntitle: Mary Doe\naliases:\n - Mare\n - maiden_name: Mary Smith\n---\n\nHi.\n",
1802 )
1803 .unwrap();
1804 fs::write(notes.join("a.md"), "Met [[Mary Smith]] today.").unwrap();
1805 fs::write(notes.join("b.md"), "And [[Mare]] too.").unwrap();
1806
1807 let links = find_inbound_links(
1808 "/people/mary/",
1809 &LocalVault::new(temp_dir.path()),
1810 &["md".to_string()],
1811 &[],
1812 &[],
1813 "index.md",
1814 );
1815 let froms: Vec<&str> = links.iter().map(|l| l.from.as_str()).collect();
1816 assert_eq!(froms, ["/notes/a/", "/notes/b/"]);
1817 }
1818
1819 #[test]
1820 fn find_inbound_links_matches_bare_stem_wikilink_from_another_folder() {
1821 let temp_dir = TempDir::new().unwrap();
1822 let people = temp_dir.path().join("people");
1823 let notes = temp_dir.path().join("notes");
1824 fs::create_dir_all(&people).unwrap();
1825 fs::create_dir_all(¬es).unwrap();
1826
1827 fs::write(people.join("pw.md"), "# Patrick Walsh\n").unwrap();
1828 fs::write(notes.join("family.md"), "See [[pw]] for details.").unwrap();
1829
1830 let links = find_inbound_links(
1831 "/people/pw/",
1832 &LocalVault::new(temp_dir.path()),
1833 &["md".to_string()],
1834 &[],
1835 &[],
1836 "index.md",
1837 );
1838
1839 assert_eq!(links.len(), 1);
1840 assert_eq!(links[0].from, "/notes/family/");
1841 }
1842
1843 #[test]
1844 fn find_inbound_links_wiki_names_do_not_match_inline_link_targets() {
1845 let temp_dir = TempDir::new().unwrap();
1848 let people = temp_dir.path().join("people");
1849 let notes = temp_dir.path().join("notes");
1850 fs::create_dir_all(&people).unwrap();
1851 fs::create_dir_all(¬es).unwrap();
1852
1853 fs::write(people.join("pw.md"), "---\ntitle: Patrick Walsh\n---\n").unwrap();
1854 fs::write(notes.join("family.md"), "See [him](Patrick Walsh).").unwrap();
1855
1856 let links = find_inbound_links(
1857 "/people/pw/",
1858 &LocalVault::new(temp_dir.path()),
1859 &["md".to_string()],
1860 &[],
1861 &[],
1862 "index.md",
1863 );
1864
1865 assert!(links.is_empty(), "unexpected links: {links:?}");
1866 }
1867
1868 #[test]
1871 fn find_inbound_links_ignores_links_inside_code_blocks() {
1872 let temp_dir = TempDir::new().unwrap();
1873 fs::write(temp_dir.path().join("target.md"), "# Target").unwrap();
1874
1875 fs::write(
1877 temp_dir.path().join("fenced.md"),
1878 "How to link:\n\n```markdown\n[example](target/)\n```\n",
1879 )
1880 .unwrap();
1881 fs::write(
1883 temp_dir.path().join("tilde.md"),
1884 "Example:\n\n~~~\n[example](/target/) and `code`\n~~~\n",
1885 )
1886 .unwrap();
1887 fs::write(
1889 temp_dir.path().join("span.md"),
1890 "Write `[example](target/)` to link.",
1891 )
1892 .unwrap();
1893 fs::write(
1895 temp_dir.path().join("wiki_fence.md"),
1896 "```\n[[target]]\n```\n",
1897 )
1898 .unwrap();
1899
1900 let links = find_inbound_links(
1901 "/target/",
1902 &LocalVault::new(temp_dir.path()),
1903 &["md".to_string()],
1904 &[],
1905 &[],
1906 "index.md",
1907 );
1908
1909 assert!(links.is_empty(), "unexpected links: {links:?}");
1910 }
1911
1912 #[test]
1913 fn find_inbound_links_still_finds_links_around_code_blocks() {
1914 let temp_dir = TempDir::new().unwrap();
1916 fs::write(temp_dir.path().join("target.md"), "# Target").unwrap();
1917 fs::write(
1918 temp_dir.path().join("source.md"),
1919 "Run `cargo test` first.\n\n```sh\necho hi\n```\n\nThen see [Target](target/).",
1920 )
1921 .unwrap();
1922
1923 let links = find_inbound_links(
1924 "/target/",
1925 &LocalVault::new(temp_dir.path()),
1926 &["md".to_string()],
1927 &[],
1928 &[],
1929 "index.md",
1930 );
1931
1932 assert_eq!(links.len(), 1);
1933 assert_eq!(links[0].from, "/source/");
1934 assert_eq!(links[0].text, "Target");
1935 }
1936
1937 #[test]
1938 fn strip_code_regions_blanks_fences_and_spans_only() {
1939 let input = "a [x](y) b\n```\n[fenced](y)\n```\nc `[span](y)` d\n~~~\n[tilde](y)\n~~~\ne\n";
1940 let out = strip_code_regions(input);
1941 assert!(out.contains("[x](y)"));
1942 assert!(!out.contains("[fenced](y)"));
1943 assert!(!out.contains("[span](y)"));
1944 assert!(!out.contains("[tilde](y)"));
1945 assert!(out.contains('c') && out.contains('d') && out.contains('e'));
1946 }
1947
1948 #[test]
1949 fn strip_code_regions_keeps_unclosed_backticks_literal() {
1950 let out = strip_code_regions("a ` b [x](y)\n");
1953 assert!(out.contains("[x](y)"));
1954 }
1955
1956 #[test]
1957 fn strip_code_regions_leaves_code_free_content_borrowed() {
1958 assert!(matches!(
1960 strip_code_regions("plain [x](y)\n"),
1961 Cow::Borrowed(_)
1962 ));
1963 }
1964
1965 fn parser_inbound_links(
1971 root: &Path,
1972 pages: &[(&str, &str, bool)],
1973 target_url: &str,
1974 ) -> Vec<(String, String)> {
1975 use mbr_core::link_index::resolve_relative_url;
1976 use mbr_core::link_transform::LinkTransformConfig;
1977
1978 let mut inbound: Vec<(String, String)> = Vec::new();
1979 for (rel_path, page_url, is_index) in pages {
1980 let rendered = mbr_core::markdown::render_sync(
1981 root.join(rel_path),
1982 root,
1983 0,
1984 LinkTransformConfig {
1985 markdown_extensions: vec!["md".to_string()],
1986 index_file: "index.md".to_string(),
1987 is_index_file: *is_index,
1988 url_depth: None,
1989 current_page_url: (*page_url).to_string(),
1990 markdown_page_probe: None,
1991 },
1992 None,
1993 true,
1994 false,
1995 HashSet::new(),
1996 mbr_core::markdown::ReviewLines::Omit,
1998 false,
1999 &[],
2000 None,
2001 )
2002 .expect("render");
2003
2004 for link in rendered.outbound_links {
2005 if !link.internal || link.to.is_empty() {
2006 continue;
2007 }
2008 if resolve_relative_url(page_url, &link.to, *is_index) == target_url {
2009 inbound.push(((*page_url).to_string(), link.text));
2010 }
2011 }
2012 }
2013 inbound.sort();
2014 inbound
2015 }
2016
2017 #[test]
2018 fn find_inbound_links_agrees_with_parser_derived_index() {
2019 let temp_dir = TempDir::new().unwrap();
2023 let root = temp_dir.path();
2024 let docs = root.join("docs");
2025 let notes = root.join("notes");
2026 fs::create_dir_all(docs.join("sub")).unwrap();
2027 fs::create_dir_all(¬es).unwrap();
2028
2029 fs::write(docs.join("guide.md"), "# Guide\n").unwrap();
2030 fs::write(
2032 docs.join("sub").join("deep.md"),
2033 "Up to [Guide](../guide/).\n",
2034 )
2035 .unwrap();
2036 fs::write(docs.join("intro.md"), "See [Guide](./guide/).\n").unwrap();
2038 fs::write(notes.join("abs.md"), "See [Guide](/docs/guide/).\n").unwrap();
2040 fs::write(docs.join("wiki.md"), "See [[guide]].\n").unwrap();
2042 fs::write(
2044 notes.join("decoy.md"),
2045 "Example syntax:\n\n```markdown\n[Guide](/docs/guide/)\n```\n",
2046 )
2047 .unwrap();
2048
2049 let grep: Vec<(String, String)> = {
2050 let mut v: Vec<(String, String)> = find_inbound_links(
2051 "/docs/guide/",
2052 &LocalVault::new(root),
2053 &["md".to_string()],
2054 &[],
2055 &[],
2056 "index.md",
2057 )
2058 .into_iter()
2059 .map(|l| (l.from, l.text))
2060 .collect();
2061 v.sort();
2062 v
2063 };
2064
2065 let parser = parser_inbound_links(
2066 root,
2067 &[
2068 ("docs/sub/deep.md", "/docs/sub/deep/", false),
2069 ("docs/intro.md", "/docs/intro/", false),
2070 ("notes/abs.md", "/notes/abs/", false),
2071 ("docs/wiki.md", "/docs/wiki/", false),
2072 ("notes/decoy.md", "/notes/decoy/", false),
2073 ],
2074 "/docs/guide/",
2075 );
2076
2077 assert_eq!(grep, parser);
2078 assert_eq!(grep.len(), 4, "expected four real backlinks: {grep:?}");
2079 assert!(
2080 !grep.iter().any(|(from, _)| from == "/notes/decoy/"),
2081 "code-fenced link must not be a backlink: {grep:?}"
2082 );
2083 }
2084}