Skip to main content

mbr_server/
link_grep.rs

1//! Grep-based inbound link discovery for server mode.
2//!
3//! This module provides fast, on-demand discovery of pages that link to a given page
4//! by searching through all markdown files in the repository.
5//!
6//! ## Algorithm
7//!
8//! The key challenge is that markdown links can be:
9//! - Absolute: `/a/b/c/1`
10//! - Relative to current folder: `c/1`, `./c/1`
11//! - Relative with parent traversal: `../b/c/1`, `../../a/b/c/1`
12//!
13//! To efficiently find all links to a target page, we:
14//! 1. Collect all unique folder paths in the repository
15//! 2. For each folder, compute which patterns could represent a link to the target
16//! 3. Build an Aho-Corasick automaton per folder for fast multi-pattern matching
17//! 4. Scan each file using the automaton for its folder
18//! 5. Only when a match is found, extract link details with regex
19//!
20//! Bare `[[Name]]` wiki links do not name a path at all: the renderer resolves
21//! them through [`mbr_core::wikilink_index::WikilinkIndex`] by title, alias, or
22//! filename stem. Those names are therefore added to the pattern set as well
23//! (gate + wiki regex only — never the inline/reference regexes, where a name
24//! is not a URL).
25
26use 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/// Result of scanning for inbound links to a page.
40#[derive(Clone)]
41struct InboundLinkCacheEntry {
42    /// Links pointing to this page from other pages
43    links: Vec<InboundLink>,
44    /// When this entry was computed
45    computed_at: std::time::Instant,
46    /// Estimated memory size
47    size_bytes: usize,
48}
49
50/// Cache for inbound link grep results.
51///
52/// Since grep operations can be slow for large repositories, we cache the results
53/// and invalidate on a time-based basis (results become stale after a period).
54pub struct InboundLinkCache {
55    /// Cached grep results (target_url_path -> inbound links)
56    cache: ConcurrentHashMap<String, InboundLinkCacheEntry>,
57    /// Current total size in bytes
58    current_size: AtomicUsize,
59    /// Maximum allowed size in bytes
60    max_size: usize,
61    /// How long entries stay valid (in seconds)
62    ttl_seconds: u64,
63}
64
65impl InboundLinkCache {
66    /// Creates a new cache with the specified maximum size and TTL.
67    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    /// Gets cached inbound links for a page, if still valid.
77    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            // Check TTL
85            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    /// Inserts inbound links into the cache.
96    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        // If an entry already existed for this key, subtract its accounted size
120        // first so `current_size` reflects the replacement rather than
121        // ratcheting upward on every overwrite.
122        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        // Evict if over limit
134        if new_size > self.max_size {
135            self.evict_oldest(new_size - self.max_size);
136        }
137    }
138
139    /// Evicts oldest entries until at least `target_bytes` have been freed.
140    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        // Sort by computation time (oldest first)
148        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    /// Invalidates all cached entries (e.g., after file changes).
174    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    /// Returns the current approximate size of the cache in bytes.
185    #[cfg(test)]
186    pub fn current_size(&self) -> usize {
187        self.current_size.load(Ordering::Relaxed)
188    }
189
190    /// Returns the number of entries in the cache.
191    #[cfg(test)]
192    pub fn len(&self) -> usize {
193        self.cache.pin().len()
194    }
195
196    /// Returns true if the cache is empty.
197    #[cfg(test)]
198    pub fn is_empty(&self) -> bool {
199        self.cache.pin().is_empty()
200    }
201}
202
203/// Computes the relative path from a source folder to a target URL path.
204///
205/// Given a source folder and a target URL path (both as URL-style paths starting with `/`),
206/// returns the relative path that would be used in a link.
207///
208/// # Examples
209/// - `/a/` -> `/a/1` = `1`
210/// - `/a/` -> `/a/b/1` = `b/1`
211/// - `/a/b/` -> `/a/1` = `../1`
212/// - `/a/` -> `/b/1` = `../b/1`
213/// - `/a/b/c/` -> `/d/e/f/1` = `../../../d/e/f/1`
214///
215/// # Arguments
216/// * `source_folder` - The folder containing the source file (e.g., `/a/b/`)
217/// * `target_path` - The target URL path (e.g., `/a/b/c/1`)
218///
219/// # Returns
220/// The relative path from source to target (e.g., `c/1` or `../1`)
221pub(crate) fn compute_relative_path(source_folder: &str, target_path: &str) -> String {
222    // Normalize: strip leading slash and any trailing slashes for comparison
223    let source = source_folder.trim_start_matches('/').trim_end_matches('/');
224    let target = target_path.trim_start_matches('/').trim_end_matches('/');
225
226    // Split into segments
227    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    // Find common prefix length
239    let common_len = source_parts
240        .iter()
241        .zip(target_parts.iter())
242        .take_while(|(a, b)| a == b)
243        .count();
244
245    // Number of ".." needed to go up from source to common ancestor
246    let ups_needed = source_parts.len() - common_len;
247
248    // Build the relative path
249    let mut result_parts: Vec<&str> = vec![".."; ups_needed];
250
251    // Add the remaining target parts after the common prefix
252    result_parts.extend(&target_parts[common_len..]);
253
254    if result_parts.is_empty() {
255        // Same directory - shouldn't happen for different files but handle it
256        ".".to_string()
257    } else {
258        result_parts.join("/")
259    }
260}
261
262/// Computes all possible link patterns that could reference the target from a given source folder.
263///
264/// This generates patterns for:
265/// - Absolute paths: `/a/b/c/1`
266/// - Relative paths without prefix: `b/c/1`
267/// - Relative paths with `./`: `./b/c/1`
268/// - Parent traversal: `../a/b/c/1`, `../../a/b/c/1`
269///
270/// For each base pattern, generates variants:
271/// - Without trailing slash: `b/c/1`
272/// - With trailing slash: `b/c/1/`
273/// - With .md extension: `b/c/1.md`
274/// - With anchor start: `b/c/1#` (to catch `b/c/1#anchor`)
275///
276/// # Arguments
277/// * `source_folder` - The URL path of the folder containing the source file (e.g., `/docs/`)
278/// * `target_url_path` - The full URL path of the target (e.g., `/a/b/c/1/`)
279///
280/// # Returns
281/// A vector of all patterns that could be valid links to the target from this folder
282pub(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    // Normalize target (strip leading/trailing slashes for the base)
289    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    // 1. Absolute paths (always valid from any folder)
298    let abs_path = format!("/{}", target_normalized);
299    add_pattern_variants(&mut patterns, &abs_path);
300
301    // 2. Relative path from this folder
302    let relative = compute_relative_path(source_folder, target_url_path);
303
304    // Skip if relative path is just "." (same location)
305    if relative != "." {
306        // Add the relative path
307        add_pattern_variants(&mut patterns, &relative);
308
309        // Add with explicit ./ prefix if it doesn't already have ../ prefix
310        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
318/// Adds pattern variants for a base path.
319///
320/// For base path `a/b/c`, adds:
321/// - `a/b/c`
322/// - `a/b/c/`
323/// - `a/b/c.md`
324/// - `a/b/c#` (for anchor detection)
325pub(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
333/// Builds a mapping from folder paths to their Aho-Corasick search patterns.
334///
335/// # Arguments
336/// * `target_url_path` - The URL path being searched for (e.g., "/docs/guide/")
337/// * `all_folders` - Set of all folder URL paths in the repository
338///
339/// # Returns
340/// HashMap from folder URL path to patterns valid for that folder
341fn 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
354/// Builds a regex pattern that matches any of the given patterns in markdown link syntax.
355///
356/// Creates a pattern like: `\[([^\]]*)\]\((pattern1|pattern2|...)(?:#([^)]*))?\)`
357fn build_extraction_regex(patterns: &[String]) -> Option<Regex> {
358    if patterns.is_empty() {
359        return None;
360    }
361
362    // Escape patterns for regex and join with |
363    let escaped_patterns: Vec<String> = patterns
364        .iter()
365        .map(|p| {
366            // Remove trailing slash, .md, # for the pattern base
367            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    // Deduplicate
376    let unique_patterns: HashSet<String> = escaped_patterns.into_iter().collect();
377    let pattern_alternation = unique_patterns.into_iter().collect::<Vec<_>>().join("|");
378
379    // Build regex for inline markdown links: [text](url) or [text](url#anchor)
380    let pattern = format!(
381        r#"\[([^\]]*)\]\((?:{})(?:\.md)?(?:/)?(?:#([^)]*))?\)"#,
382        pattern_alternation
383    );
384
385    Regex::new(&pattern).ok()
386}
387
388/// Builds a regex pattern for wiki-style links.
389///
390/// `names` holds the target's bare wiki names (title, aliases, filename stem).
391/// They are alternatives here — and in the Aho-Corasick gate — but deliberately
392/// nowhere else: `[text](Some Title)` is not a link to the target.
393fn build_wiki_extraction_regex(patterns: &[String], names: &[String]) -> Option<Regex> {
394    if patterns.is_empty() && names.is_empty() {
395        return None;
396    }
397
398    // Escape patterns for regex and join with |
399    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    // Build regex for wiki-style links: [[target]], [[target|text]], [[target#anchor]]
415    // Case insensitive
416    let pattern = format!(
417        r#"(?i)\[\[(?:{})(?:\.md)?(?:/)?(?:#([^\]|]*))?(?:\|([^\]]*))?\]\]"#,
418        pattern_alternation
419    );
420
421    Regex::new(&pattern).ok()
422}
423
424/// Builds a regex pattern for reference-style links.
425fn 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    // Build regex for reference-style link definitions: [ref]: url
445    let pattern = format!(
446        r#"\[([^\]]+)\]:\s*(?:{})(?:\.md)?(?:/)?(?:#\S*)?"#,
447        pattern_alternation
448    );
449
450    Regex::new(&pattern).ok()
451}
452
453/// Find all inbound links to a target page by grep-searching markdown files.
454///
455/// This scans all markdown files in the repository looking for links that point
456/// to the target URL path. It extracts link text and anchor information.
457///
458/// Bare `[[Name]]` links to the target (by title, alias, or filename stem) are
459/// found too: the target's own names are read from its frontmatter once and
460/// added to the wiki pattern set, so server-mode backlinks match what the
461/// renderer (and therefore build mode) resolves.
462///
463/// Links written inside fenced code blocks or backtick spans are ignored, as
464/// the parser-derived build-mode index ignores them.
465///
466/// # Arguments
467/// * `target_url_path` - The URL path being linked to (e.g., "/docs/guide/")
468/// * `vault` - The repository's storage ([`mbr_core::repo::Repo::vault`]),
469///   walked with [`link_scan_files`]
470/// * `markdown_extensions` - List of valid markdown file extensions
471/// * `ignore_dirs` - Directories to skip during scanning
472/// * `ignore_globs` - Glob patterns for files to ignore
473/// * `index_file` - Configured index file name (e.g., "index.md"), so the
474///   reported `from` URLs are the canonical ones (`docs/index.md` → `/docs/`)
475///
476/// # Returns
477/// A vector of `InboundLink` structs representing pages that link to the target.
478pub 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    // Normalize target for matching
490    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    // First pass: collect all unique folder paths and their files
498    let mut folder_files: HashMap<String, Vec<(VaultPath, String)>> = HashMap::new();
499    // The target's own file, located during the walk so its wiki names
500    // (title/aliases/stem) can be read without a second directory scan.
501    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        // Skip if this is the target page itself
511        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    // Collect all unique folders
523    let all_folders: HashSet<String> = folder_files.keys().cloned().collect();
524
525    // Build patterns for each folder
526    let folder_patterns = build_folder_patterns(target_url_path, &all_folders);
527
528    // The bare `[[Name]]` forms that resolve to this target. Folder-independent,
529    // so they are computed once and reused by every folder's gate/wiki regex.
530    let target_names = target_file
531        .as_ref()
532        .map(|path| wikilink_names_for_target(vault, path))
533        .unwrap_or_default();
534
535    // Build Aho-Corasick automatons for each folder (case-insensitive for wiki links)
536    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            // Build case-insensitive automaton to match wiki-style [[links]]
544            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    // Build folder-specific extraction regexes
561    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    // Scan files using folder-specific automatons
577    for (folder, files) in &folder_files {
578        let automaton = folder_automatons.get(folder).and_then(|a| a.as_ref());
579
580        // Skip if no automaton (no patterns for this folder)
581        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            // Read file content
593            let content = match vault.read_to_string(path) {
594                Ok(c) => c,
595                Err(_) => continue,
596            };
597
598            // Fast check with Aho-Corasick
599            if !ac.is_match(&content) {
600                continue;
601            }
602
603            // Only candidate files pay for code stripping. Links inside fences
604            // and backtick spans are documentation, not references: the
605            // parser-derived build-mode index never sees them.
606            let content = strip_code_regions(&content);
607
608            // Found a potential match - extract details with regex
609            let mut found_link = false;
610
611            // Search for inline links
612            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            // Search for wiki-style links
627            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            // Search for reference-style links
665            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                    // Find uses of this reference: [text][ref_name]
670                    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 before deduplicating, not after. The scan above iterated
691    // `folder_files`, a std `HashMap`, so the push order was randomly seeded
692    // per process — which made *both* the order of these links and, because
693    // the dedup keeps the first occurrence per source, *which* link survives
694    // for a page that links here twice depend on that hash order. Sorting
695    // first pins the surviving link to the lowest-sorting one and leaves the
696    // result in the same order the builder emits, so the two modes agree.
697    sort_inbound_links(&mut inbound_links);
698
699    // Deduplicate inbound links by source file - if a page links to the target
700    // multiple times, we only keep the first occurrence
701    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
718/// One markdown file of a repository-wide link pass.
719pub(crate) struct LinkScanFile {
720    /// Where the file is in the vault — under its canonical directory.
721    pub(crate) path: VaultPath,
722    /// Its index key ([`Vault::key`]): what the repository indexes it by and
723    /// the server's other writers lock it under.
724    pub(crate) key: PathBuf,
725    /// See [`page_and_folder_urls`].
726    pub(crate) page_url: String,
727    pub(crate) folder_url: String,
728}
729
730/// The markdown files a link pass reads (backlink grep, move rewrites).
731///
732/// Every file [`walk_files`] reaches — links followed, each directory once by
733/// its canonical path, nothing outside the vault — under directories not named
734/// in `ignore_dirs`, with a markdown extension (case-insensitive), and not
735/// excluded by [`should_ignore`]. URLs are computed against the vault root,
736/// which is what the keys are relative to.
737///
738/// No hidden-directory exemptions are threaded in here, and none are needed:
739/// the leading-dot rule only ever sees *file* basenames, because directories
740/// are pruned on `ignore_dirs` alone and never by the dot rule, so files under
741/// a hidden directory are reached either way.
742pub(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
775/// Gets the folder URL path from a file URL path.
776/// `/a/b/c/` -> `/a/b/`
777/// `/a/` -> `/`
778pub(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
787/// The two URL forms a markdown file needs during a link scan.
788///
789/// Returns `(page_url, folder_url)`:
790/// - `page_url` is the **canonical** site URL — the same value
791///   [`mbr_core::repo::build_markdown_url_path`] puts in `site.json`, so
792///   `docs/index.md` collapses to `/docs/`. This is what gets reported as
793///   `InboundLink::from`. Pass `index_file: None` when the caller has no index
794///   file configured, which keeps the positional form.
795/// - `folder_url` is the folder a *relative* link inside that file resolves
796///   against, and is deliberately derived from the **positional** URL: for
797///   `docs/index.md` that is `/docs/index/` → `/docs/`, whereas the canonical
798///   `/docs/` would wrongly yield `/`.
799pub(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
814/// Computes the **positional** URL path for a markdown file: every path
815/// component becomes a URL segment and the markdown extension becomes a
816/// trailing slash.
817///
818/// This is not the canonical page URL — it does not collapse the configured
819/// index file — and is only used to derive the folder that a file's relative
820/// links resolve against. Use [`page_and_folder_urls`] rather than calling this
821/// directly.
822fn 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    // Remove the file extension and add trailing slash. Extensions are compared
836    // case-insensitively so `NOTES.MD` and `notes.md` behave the same way (the
837    // walk that feeds this function already lowercases the extension it filters
838    // on). A matching tail is pure ASCII, so truncating there is always on a
839    // character boundary.
840    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
856/// The bare `[[Name]]` forms that resolve to `path`: its frontmatter `title`,
857/// its frontmatter `aliases`, and its filename stem.
858///
859/// Mirrors the note inputs [`mbr_core::wikilink_index::WikilinkIndex`] is built
860/// from (`Repo::collect_note_inputs`), so grep-based backlinks see the same
861/// names the renderer resolves. Names carrying wiki-link syntax (`/`, `[`, `]`,
862/// `|`, `#`) are dropped: path forms are already covered by the path patterns,
863/// and the rest could never appear inside a `[[…]]` target.
864fn 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
889/// Blanks out fenced code blocks and inline code spans so link extraction sees
890/// only prose, matching the parser-derived (build-mode) link index.
891///
892/// Single pass over the lines; blanked regions keep their line structure so the
893/// line-anchored reference-definition regex still behaves. Indented code blocks
894/// are deliberately *not* stripped: telling one from a nested list item needs
895/// full block parsing, and a wrong guess would silently drop real backlinks.
896fn 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    // The open fence's character and length, while inside a fenced block.
903    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        // A fence may be indented by at most 3 spaces (CommonMark).
908        let marker = (indent <= 3)
909            .then(|| fence_marker(&line[indent..]))
910            .flatten();
911        match fence {
912            Some((fence_char, fence_len)) => {
913                // A closing fence is the same character, at least as long, and
914                // carries no info string.
915                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
933/// The fence character and run length if `line` opens or closes a code fence.
934fn 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
943/// Removes inline code spans (`` `code` ``) from a single line.
944///
945/// A run of N backticks opens a span that ends at the next run of exactly N
946/// backticks on the same line; an unclosed run is literal text and is kept.
947fn 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                // Drop the span, leaving a space so neighbouring text can't fuse.
961                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
976/// Byte offset of the next run of exactly `run` backticks at or after `from`.
977fn 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    // ========== compute_relative_path tests ==========
1001
1002    #[test]
1003    fn test_compute_relative_path_same_directory() {
1004        // /a/ -> /a/1 = 1
1005        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        // /a/ -> /a/b/1 = b/1
1012        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        // /a/b/ -> /a/1 = ../1
1019        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        // /a/ -> /b/1 = ../b/1
1026        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        // /a/b/c/ -> /d/e/f/1 = ../../../d/e/f/1
1033        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        // / -> /a/b/1 = a/b/1
1042        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        // /a/b/ -> /1 = ../../1
1048        assert_eq!(compute_relative_path("/a/b/", "/1"), "../../1");
1049    }
1050
1051    // ========== compute_patterns_for_folder tests ==========
1052
1053    #[test]
1054    fn test_compute_patterns_for_folder_root() {
1055        let patterns = compute_patterns_for_folder("/", "/a/b/c/1/");
1056
1057        // Should include absolute path variants
1058        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        // Should include relative path variants
1064        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        // Should include absolute path
1075        assert!(patterns.contains(&"/a/b/c/1".to_string()));
1076
1077        // Should include relative path (c/1)
1078        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        // Should include absolute path
1087        assert!(patterns.contains(&"/a/b/c/1".to_string()));
1088
1089        // Should include parent traversal
1090        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        // Should include absolute path
1099        assert!(patterns.contains(&"/a/b/c/1".to_string()));
1100
1101        // Should include double parent traversal
1102        assert!(patterns.contains(&"../../a/b/c/1".to_string()));
1103    }
1104
1105    // ========== get_folder_url_path tests ==========
1106
1107    #[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    // ========== compute_url_path tests ==========
1115
1116    #[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        // The walk filters on a lowercased extension, so the URL builder must
1139        // treat `.MD` the same as `.md` instead of leaving it in the path.
1140        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    // ========== page_and_folder_urls tests ==========
1150
1151    #[test]
1152    fn page_and_folder_urls_collapses_index_but_keeps_link_base() {
1153        // The canonical page URL drops the index file; the folder that the
1154        // page's own relative links resolve against must not.
1155        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        // The reported `from` must be byte-identical to the `url_path` the repo
1191        // scanner puts in site.json, or backlink hrefs need a redirect and the
1192        // graph splits one page into two nodes.
1193        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    // ========== InboundLinkCache tests ==========
1212
1213    #[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        // Regression for #12: overwriting an existing key must not ratchet
1233        // `current_size` upward.
1234        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        // Overwriting with a larger payload adjusts size by the delta only.
1256        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    // ========== find_inbound_links integration tests ==========
1300
1301    #[test]
1302    fn test_find_inbound_links_basic() {
1303        let temp_dir = TempDir::new().unwrap();
1304
1305        // Create target file
1306        let target_path = temp_dir.path().join("target.md");
1307        fs::write(&target_path, "# Target Page\n\nThis is the target.").unwrap();
1308
1309        // Create source file with link to target
1310        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        // Create target file
1340        fs::write(temp_dir.path().join("target.md"), "# Target").unwrap();
1341
1342        // Create source with anchor link
1343        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        // Even though source.md links to target via both markdown and wiki syntax,
1453        // we deduplicate by source file - only one inbound link per source page
1454        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        // Two different source files linking to the same target = two inbound links
1477        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    // ========== NEW: Relative path tests (the bug fix) ==========
1489
1490    #[test]
1491    fn test_find_inbound_links_relative_path_from_subfolder() {
1492        let temp_dir = TempDir::new().unwrap();
1493
1494        // Create directory structure:
1495        // /coins/tricks/3-fly.md (target)
1496        // /coins/overview.md (source with relative link)
1497        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        // Create directory structure:
1526        // /coins/tricks/3-fly.md (target)
1527        // /cards/overview.md (source with ../coins/tricks/3-fly link)
1528        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        // Create directory structure:
1559        // /coins/tricks/3-fly.md (target)
1560        // /cards/overview.md (source with absolute link)
1561        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        // Create directory structure:
1591        // /a/b/c/target.md
1592        // /d/e/f/source.md with link ../../../a/b/c/target
1593        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        // Create directory structure with ./relative link
1623        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        // Canonical URL: `coins/index.md` is served at `/coins/`, not
1644        // `/coins/index/`. The relative `./tricks/…` link still resolves,
1645        // because the folder used for matching stays `/coins/`.
1646        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        // Create directory structure with .md extension in link
1654        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    // ========== canonical `from` URLs (index files) ==========
1678
1679    #[test]
1680    fn find_inbound_links_reports_index_source_with_canonical_url() {
1681        // A backlink from `docs/index.md` must be reported as `/docs/` — the URL
1682        // the page is actually served at — not the positional `/docs/index/`.
1683        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        // The target page is `docs/index.md`, served at `/docs/`. Its own
1706        // self-link must not be reported as an inbound link from itself.
1707        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        // `.MD` passes the (lowercased) extension filter, so its URL must be
1734        // built the same way `.md` is.
1735        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    // ========== bare wiki names (title / alias / stem) ==========
1753
1754    #[test]
1755    fn find_inbound_links_matches_title_and_alias_wikilinks() {
1756        // `[[Patrick Walsh]]` / `[[PW]]` resolve through the wikilink index by
1757        // title and alias; server-mode backlinks must see them too.
1758        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(&notes).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        // Two matches on one page dedupe to a single inbound link.
1785        assert_eq!(links.len(), 1);
1786        assert_eq!(links[0].from, "/notes/family/");
1787    }
1788
1789    /// A labeled alias (`maiden_name: …`) is a name like any other: grep-based
1790    /// backlinks must find `[[Mary Smith]]` exactly as the renderer resolves it.
1791    #[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(&notes).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(&notes).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        // A name is a `[[…]]` target, never a URL: `[x](Patrick Walsh)` must not
1846        // be reported as a backlink.
1847        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(&notes).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    // ========== code blocks / spans ==========
1869
1870    #[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        // Backtick fence.
1876        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        // Tilde fence (which may itself contain backticks).
1882        fs::write(
1883            temp_dir.path().join("tilde.md"),
1884            "Example:\n\n~~~\n[example](/target/) and `code`\n~~~\n",
1885        )
1886        .unwrap();
1887        // Inline code span.
1888        fs::write(
1889            temp_dir.path().join("span.md"),
1890            "Write `[example](target/)` to link.",
1891        )
1892        .unwrap();
1893        // Wiki link inside a fence.
1894        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        // Stripping code must not swallow the prose around it.
1915        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        // An unmatched backtick is literal text in CommonMark; dropping the rest
1951        // of the line would lose real links.
1952        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        // Fast path: files without fences or spans must not be reallocated.
1959        assert!(matches!(
1960            strip_code_regions("plain [x](y)\n"),
1961            Cow::Borrowed(_)
1962        ));
1963    }
1964
1965    // ========== cross-check against the build-mode (parser) link index ==========
1966
1967    /// Inverts parser-derived outbound links exactly the way
1968    /// `Builder::write_link_files` does, and returns the inbound set for
1969    /// `target_url`, sorted for comparison.
1970    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                // Only the outbound links are read; the HTML is discarded.
1997                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        // One nested fixture exercising every link shape at once: parent-relative,
2020        // `./`-relative, absolute, a wiki link, and a code-fenced decoy. The
2021        // grep-based (server) and parser-derived (build) inbound sets must match.
2022        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(&notes).unwrap();
2028
2029        fs::write(docs.join("guide.md"), "# Guide\n").unwrap();
2030        // ../guide/ from a nested folder
2031        fs::write(
2032            docs.join("sub").join("deep.md"),
2033            "Up to [Guide](../guide/).\n",
2034        )
2035        .unwrap();
2036        // ./guide/ from the same folder
2037        fs::write(docs.join("intro.md"), "See [Guide](./guide/).\n").unwrap();
2038        // absolute
2039        fs::write(notes.join("abs.md"), "See [Guide](/docs/guide/).\n").unwrap();
2040        // wiki link (same-folder stem)
2041        fs::write(docs.join("wiki.md"), "See [[guide]].\n").unwrap();
2042        // code-fenced decoy: must be reported by neither implementation
2043        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}