Skip to main content

mbr_core/
link_index.rs

1//! Link tracking module for bidirectional link management.
2//!
3//! Provides data structures and caching for tracking inbound and outbound links
4//! between markdown pages, enabling wiki-style backlink features.
5
6use papaya::HashMap as ConcurrentHashMap;
7use serde::{Deserialize, Serialize};
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::time::Instant;
10
11/// An outbound link from a markdown page.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct OutboundLink {
14    /// The target URL path (e.g., "/docs/guide/")
15    pub to: String,
16    /// The link text displayed to users
17    pub text: String,
18    /// Optional anchor fragment (e.g., "#section")
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub anchor: Option<String>,
21    /// Whether this is an internal link (true) or external (false)
22    pub internal: bool,
23}
24
25/// An inbound link pointing to a markdown page.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct InboundLink {
28    /// The source page URL path that contains the link
29    pub from: String,
30    /// The link text used in the source page
31    pub text: String,
32    /// Optional anchor fragment targeted
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub anchor: Option<String>,
35}
36
37/// Sorts inbound links into a deterministic, mode-independent order.
38///
39/// Both producers of an inbound list build it by iterating a hash map whose
40/// order is randomly seeded per process: the builder inverts `build_link_index`
41/// (a `papaya::HashMap`, see `build::Builder::write_link_files`) and the
42/// server's grep walks `folder_files` (a std `HashMap`, see
43/// `link_grep::find_inbound_links`). Without a sort the same repository emits a
44/// different `links.json` on every build, and the two modes disagree on the
45/// ordering of the same page's backlinks.
46///
47/// The key is the struct's full contents rather than `from` alone. Today
48/// `from` happens to be unique within a list — the builder's source of truth
49/// dedups outbound links by target (`markdown::finalize_render`) and the
50/// server's grep dedups backlinks by source — so `from` alone would sort
51/// correctly. That is a property of two distant call sites, not of this type:
52/// if either dedup is relaxed, `from` stops being a total order, and because
53/// [`slice::sort_by`] is stable the leftover ties would silently preserve the
54/// hash order this function exists to remove. Sorting on the full contents
55/// costs nothing and does not depend on that invariant holding.
56pub fn sort_inbound_links(links: &mut [InboundLink]) {
57    links.sort_by(|a, b| (&a.from, &a.text, &a.anchor).cmp(&(&b.from, &b.text, &b.anchor)));
58}
59
60/// Links data for a single page (used in API responses).
61#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62pub struct PageLinks {
63    /// Links pointing to this page from other pages
64    pub inbound: Vec<InboundLink>,
65    /// Links from this page to other pages
66    pub outbound: Vec<OutboundLink>,
67    /// Typed relationships (declared + derived) for this page. Empty when
68    /// relationship tracking is disabled.
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub relationships: Vec<crate::relationships::ResolvedRelationship>,
71}
72
73/// Splits a URL into its path and anchor components.
74///
75/// # Examples
76/// ```ignore
77/// let (path, anchor) = split_url_anchor("/docs/guide/#section");
78/// assert_eq!(path, "/docs/guide/");
79/// assert_eq!(anchor, Some("#section".to_string()));
80/// ```
81pub fn split_url_anchor(url: &str) -> (String, Option<String>) {
82    if let Some(hash_pos) = url.find('#') {
83        let path = url[..hash_pos].to_string();
84        let anchor = Some(url[hash_pos..].to_string());
85        (path, anchor)
86    } else {
87        (url.to_string(), None)
88    }
89}
90
91/// Determines if a URL is an internal link.
92///
93/// Internal links are relative paths, absolute paths starting with `/`, and
94/// fragment-/query-only references. Anything carrying a URL scheme (or a
95/// protocol-relative `//host`) is external. The predicate itself lives in
96/// [`crate::url_path::is_external_url`] so link transformation, tracking and
97/// validation cannot disagree about a given href.
98pub fn is_internal_link(url: &str) -> bool {
99    !crate::url_path::is_external_url(url)
100}
101
102/// Normalizes a URL path for consistent comparison.
103///
104/// - Removes query strings
105/// - Ensures trailing slash for directory-style paths
106/// - Resolves relative path components (../, ./)
107pub fn normalize_url_path(url: &str) -> String {
108    // Remove query string
109    let url = url.split('?').next().unwrap_or(url);
110
111    // Remove anchor for normalization (we track it separately)
112    let (path, _anchor) = split_url_anchor(url);
113
114    // Ensure trailing slash for paths that look like directories
115    // (no file extension in the last component)
116    let path = path.trim_end_matches('/');
117    if path.is_empty() {
118        return "/".to_string();
119    }
120
121    // Check if the last component has a file extension
122    let last_component = path.rsplit('/').next().unwrap_or(path);
123    if last_component.contains('.') && !last_component.starts_with('.') {
124        // Looks like a file path, don't add trailing slash
125        path.to_string()
126    } else {
127        // Directory-style path, add trailing slash
128        format!("{}/", path)
129    }
130}
131
132/// Resolves a relative URL against a base URL path.
133///
134/// The `OutboundLink.to` values stored by the markdown renderer hold the
135/// **original** (pre-transform) link text from the markdown source, expressed
136/// relative to the source file in the markdown tree. This function converts
137/// that to an absolute site URL.
138///
139/// Markdown-level semantics: a link like `foo.md` in `X.md` means "sibling
140/// file of X.md". The base URL interpretation therefore depends on whether
141/// the source page is an index file:
142///
143/// - **Non-index** (`docs/guide.md` → `/docs/guide/`): the last URL segment
144///   is the file stem (`guide`), and siblings live in the parent directory
145///   (`/docs/`). Strip the last segment before applying the relative URL.
146/// - **Index** (`docs/modes/index.md` → `/modes/`): the URL already
147///   represents the directory; siblings live inside it. Keep all segments.
148///
149/// # Examples
150/// - `resolve_relative_url("/modes/", "gui/", true)` → `"/modes/gui/"`
151/// - `resolve_relative_url("/docs/guide/", "intro/", false)` → `"/docs/intro/"`
152/// - `resolve_relative_url("/docs/guide/", "../other/", false)` → `"/other/"`
153/// - `resolve_relative_url("/source/", "../", false)` → `"/"`
154///
155/// A `..` that would climb above the site root is **clamped** here, matching
156/// what a browser does with an over-long traversal. Callers that need to
157/// *report* such a link rather than silently repair it must use
158/// [`resolve_relative_url_checked`].
159pub fn resolve_relative_url(base_url: &str, relative_url: &str, is_index_file: bool) -> String {
160    resolve_relative_url_parts(base_url, relative_url, is_index_file).0
161}
162
163/// [`resolve_relative_url`], but `None` when the link climbs above the site
164/// root instead of clamping there.
165///
166/// The clamp exists because that is what browsers do (RFC 3986 §5.2.4 discards
167/// excess `..` segments), so it is the right behaviour for *resolution*. It is
168/// exactly the wrong behaviour for *validation*: a link that escapes the
169/// repository root is a genuine authoring defect, and clamping turns it into a
170/// link to some unrelated page that happens to exist, producing no diagnostic
171/// at all.
172pub fn resolve_relative_url_checked(
173    base_url: &str,
174    relative_url: &str,
175    is_index_file: bool,
176) -> Option<String> {
177    let (url, escaped) = resolve_relative_url_parts(base_url, relative_url, is_index_file);
178    (!escaped).then_some(url)
179}
180
181/// Shared body of [`resolve_relative_url`] and [`resolve_relative_url_checked`].
182///
183/// Returns the resolved URL plus whether any `..` segment was discarded because
184/// there was nothing left to pop — i.e. whether the link escaped the site root.
185fn resolve_relative_url_parts(
186    base_url: &str,
187    relative_url: &str,
188    is_index_file: bool,
189) -> (String, bool) {
190    // If the relative URL is already absolute, just normalize it
191    if relative_url.starts_with('/') {
192        let trimmed = relative_url.trim_end_matches('/');
193        return if trimmed.is_empty() {
194            ("/".to_string(), false)
195        } else {
196            (format!("{}/", trimmed), false)
197        };
198    }
199
200    // Anchor-only links stay as-is
201    if relative_url.starts_with('#') {
202        return (relative_url.to_string(), false);
203    }
204
205    let base_segments: Vec<&str> = base_url
206        .trim_matches('/')
207        .split('/')
208        .filter(|s| !s.is_empty())
209        .collect();
210
211    // For non-index pages, the last URL segment is the file stem — drop it
212    // so relative links resolve against the parent directory. For index
213    // pages, every segment is a real directory component.
214    let mut segments: Vec<&str> = if is_index_file || base_segments.is_empty() {
215        base_segments
216    } else {
217        base_segments[..base_segments.len() - 1].to_vec()
218    };
219
220    let mut escaped = false;
221    for part in relative_url.split('/') {
222        match part {
223            "" | "." => {} // Skip empty or current directory
224            ".." => {
225                // `Vec::pop` on an empty vec returns `None`; discarding that is
226                // precisely how an above-root traversal used to vanish without
227                // a trace.
228                if segments.pop().is_none() {
229                    escaped = true;
230                }
231            }
232            segment => {
233                segments.push(segment);
234            }
235        }
236    }
237
238    let url = if segments.is_empty() {
239        "/".to_string()
240    } else {
241        format!("/{}/", segments.join("/"))
242    };
243
244    (url, escaped)
245}
246
247/// Resolves all relative URLs in outbound links to absolute URLs.
248///
249/// This is used before caching outbound links so the frontend can use them
250/// directly. `is_index_file` indicates whether `base_url` corresponds to an
251/// index markdown file; see [`resolve_relative_url`] for why this matters.
252///
253/// Links with an empty target are dropped. The renderer splits `#anchor` off
254/// the destination before storing it, so an anchor-only link like
255/// `[Top](#top)` arrives as `to: ""` with `anchor: Some("#top")` — the
256/// `starts_with('#')` guard below never sees it. Such a link targets the
257/// current page, not another one; resolving `""` against `base_url` used to
258/// invent an outbound link to the parent directory (and, once inverted, a
259/// phantom backlink on a page the author never linked to).
260pub fn resolve_outbound_links(
261    base_url: &str,
262    links: Vec<OutboundLink>,
263    is_index_file: bool,
264) -> Vec<OutboundLink> {
265    links
266        .into_iter()
267        .filter(|link| !link.to.is_empty())
268        .map(|mut link| {
269            if link.internal && !link.to.starts_with('/') && !link.to.starts_with('#') {
270                link.to = resolve_relative_url(base_url, &link.to, is_index_file);
271            }
272            link
273        })
274        .collect()
275}
276
277/// A cached entry for outbound links.
278#[derive(Clone)]
279struct LinkCacheEntry {
280    /// The cached outbound links
281    links: Vec<OutboundLink>,
282    /// When this entry was inserted
283    inserted_at: Instant,
284    /// Estimated memory size in bytes
285    size_bytes: usize,
286}
287
288/// Thread-safe cache for outbound links per page.
289///
290/// Used in server mode to cache links extracted during page renders,
291/// avoiding re-parsing when the links.json endpoint is requested.
292pub struct LinkCache {
293    /// The underlying concurrent cache (url_path -> outbound links)
294    cache: ConcurrentHashMap<String, LinkCacheEntry>,
295    /// Current total size in bytes (approximate)
296    current_size: AtomicUsize,
297    /// Maximum allowed size in bytes
298    max_size: usize,
299}
300
301impl LinkCache {
302    /// Creates a new cache with the specified maximum size in bytes.
303    ///
304    /// Set `max_size_bytes` to 0 to disable caching.
305    pub fn new(max_size_bytes: usize) -> Self {
306        Self {
307            cache: ConcurrentHashMap::new(),
308            current_size: AtomicUsize::new(0),
309            max_size: max_size_bytes,
310        }
311    }
312
313    /// Retrieves cached outbound links for a page if present.
314    pub fn get(&self, url_path: &str) -> Option<Vec<OutboundLink>> {
315        if self.max_size == 0 {
316            return None;
317        }
318
319        let guard = self.cache.pin();
320        guard.get(url_path).map(|entry| {
321            tracing::debug!("link cache hit: {}", url_path);
322            entry.links.clone()
323        })
324    }
325
326    /// Inserts outbound links into the cache.
327    ///
328    /// If the cache exceeds its size limit, oldest entries are evicted.
329    pub fn insert(&self, url_path: String, links: Vec<OutboundLink>) {
330        if self.max_size == 0 {
331            return;
332        }
333
334        // Estimate size: URL + links (rough estimate based on string lengths)
335        let size_bytes = url_path.len()
336            + links
337                .iter()
338                .map(|l| {
339                    l.to.len() + l.text.len() + l.anchor.as_ref().map(|a| a.len()).unwrap_or(0) + 32
340                })
341                .sum::<usize>()
342            + std::mem::size_of::<LinkCacheEntry>();
343
344        let entry = LinkCacheEntry {
345            links,
346            inserted_at: Instant::now(),
347            size_bytes,
348        };
349
350        // If an entry already existed for this key, subtract its accounted size
351        // first so `current_size` reflects the replacement rather than
352        // ratcheting upward on every overwrite.
353        let replaced_size = self
354            .cache
355            .pin()
356            .insert(url_path.clone(), entry)
357            .map_or(0, |old| old.size_bytes);
358        self.current_size
359            .fetch_sub(replaced_size, Ordering::Relaxed);
360        let new_size = self.current_size.fetch_add(size_bytes, Ordering::Relaxed) + size_bytes;
361
362        tracing::debug!("link cached: {} ({} bytes)", url_path, size_bytes);
363
364        // Evict if over limit
365        if new_size > self.max_size {
366            self.evict_oldest(new_size - self.max_size);
367        }
368    }
369
370    /// Evicts oldest entries until at least `target_bytes` have been freed.
371    fn evict_oldest(&self, target_bytes: usize) {
372        let guard = self.cache.pin();
373        let mut entries: Vec<(String, Instant, usize)> = guard
374            .iter()
375            .map(|(k, v)| (k.clone(), v.inserted_at, v.size_bytes))
376            .collect();
377
378        // Sort by insertion time (oldest first)
379        entries.sort_by_key(|(_, inserted_at, _)| *inserted_at);
380
381        let mut freed = 0usize;
382        let mut evict_count = 0usize;
383
384        for (url, _, size) in entries {
385            if freed >= target_bytes {
386                break;
387            }
388            if guard.remove(&url).is_some() {
389                freed += size;
390                evict_count += 1;
391                self.current_size.fetch_sub(size, Ordering::Relaxed);
392            }
393        }
394
395        if evict_count > 0 {
396            tracing::debug!(
397                "link cache evicted {} entries ({} bytes freed)",
398                evict_count,
399                freed
400            );
401        }
402    }
403
404    /// Invalidates all cached entries (e.g., after a file move rewrites
405    /// outbound links across many pages). Clears the map and resets the tracked
406    /// size so the cache doesn't ratchet after a bulk change.
407    pub fn invalidate_all(&self) {
408        let guard = self.cache.pin();
409        let keys: Vec<String> = guard.iter().map(|(k, _)| k.clone()).collect();
410        for key in keys {
411            guard.remove(&key);
412        }
413        self.current_size.store(0, Ordering::Relaxed);
414        tracing::debug!("link cache invalidated");
415    }
416
417    /// Returns the current approximate size of the cache in bytes.
418    #[cfg(test)]
419    pub fn current_size(&self) -> usize {
420        self.current_size.load(Ordering::Relaxed)
421    }
422
423    /// Returns the number of entries in the cache.
424    #[cfg(test)]
425    pub fn len(&self) -> usize {
426        self.cache.pin().len()
427    }
428
429    /// Returns true if the cache is empty.
430    #[cfg(test)]
431    pub fn is_empty(&self) -> bool {
432        self.cache.pin().is_empty()
433    }
434}
435
436/// One target page's backlinks, keyed by the source page URL.
437///
438/// A `BTreeMap` rather than a `Vec` for two reasons: withdrawing one source's
439/// contribution when that file changes is O(log n) instead of a linear scan,
440/// and iteration is already in `from` order, which is the order
441/// [`sort_inbound_links`] produces. `from` is unique within a bucket because
442/// outbound links are deduplicated by target before they get here.
443type InboundBucket =
444    std::sync::Arc<std::sync::Mutex<std::collections::BTreeMap<String, InboundLink>>>;
445
446/// Repository-wide backlink index for server mode.
447///
448/// The server used to answer every `links.json` request by walking the entire
449/// repository and grepping each markdown file for references to that one page
450/// (`link_grep::find_inbound_links`). That is O(repository) per *page*, and the
451/// info panel's mini graph requests many pages at once, so a large repo paid
452/// for a full walk per distinct page on every cache miss.
453///
454/// This inverts the relationship once instead: each markdown file is parsed a
455/// single time, its internal outbound links are resolved to absolute URLs, and
456/// each one is recorded as a backlink on its target. Lookups are then a hash
457/// hit. It is the same inversion `build::Builder::write_link_files` performs
458/// for static output, which also closes the long-standing server/build
459/// disagreement about which links produce backlinks — the grep matched on path
460/// spelling, while both inverted indexes use the renderer's own resolution.
461///
462/// `contributions` is what makes incremental maintenance cheap: it remembers
463/// which targets a given source page currently contributes to, so a changed
464/// file's old backlinks can be withdrawn without touching any other bucket.
465#[derive(Default)]
466pub struct InboundIndex {
467    /// target page URL -> its backlinks, keyed by source page URL.
468    inbound: ConcurrentHashMap<String, InboundBucket>,
469    /// source page URL -> the target URLs it currently contributes to.
470    contributions: ConcurrentHashMap<String, Vec<String>>,
471    /// Whether the initial full-repository pass has finished. Until it has,
472    /// the index is incomplete and callers must not treat a miss as "no
473    /// backlinks".
474    ready: std::sync::atomic::AtomicBool,
475    /// Woken when `ready` first flips. Lets callers (and tests) await the
476    /// initial build instead of polling.
477    ready_notify: tokio::sync::Notify,
478}
479
480impl InboundIndex {
481    pub fn new() -> Self {
482        Self::default()
483    }
484
485    /// True once the initial full-repository pass has completed.
486    pub fn is_ready(&self) -> bool {
487        self.ready.load(Ordering::Acquire)
488    }
489
490    /// Mark the index queryable. Called after the initial pass.
491    pub fn mark_ready(&self) {
492        self.ready.store(true, Ordering::Release);
493        self.ready_notify.notify_waiters();
494    }
495
496    /// Wait until the initial full-repository pass has finished.
497    ///
498    /// `notify_waiters()` stores no permit, so interest is registered *before*
499    /// the flag is re-checked; otherwise a build that completes in that window
500    /// would leave the caller parked forever (same pattern as
501    /// `Repo::wait_for_scan`).
502    pub async fn wait_until_ready(&self) {
503        loop {
504            let notified = self.ready_notify.notified();
505            tokio::pin!(notified);
506            notified.as_mut().enable();
507
508            if self.is_ready() {
509                return;
510            }
511
512            notified.await;
513        }
514    }
515
516    /// Record `source_url`'s links, replacing whatever it contributed before.
517    ///
518    /// `links` must already be resolved to absolute URLs (see
519    /// [`resolve_outbound_links`]); external links are ignored, since only an
520    /// internal target can display a backlink.
521    pub fn set_page_links(&self, source_url: &str, links: &[OutboundLink]) {
522        self.withdraw(source_url);
523
524        let targets: Vec<String> = links
525            .iter()
526            .filter(|link| link.internal && !link.to.is_empty())
527            .map(|link| {
528                let bucket = {
529                    let guard = self.inbound.pin();
530                    guard
531                        .get_or_insert_with(link.to.clone(), InboundBucket::default)
532                        .clone()
533                };
534                bucket.lock().expect("inbound bucket poisoned").insert(
535                    source_url.to_string(),
536                    InboundLink {
537                        from: source_url.to_string(),
538                        text: link.text.clone(),
539                        anchor: link.anchor.clone(),
540                    },
541                );
542                link.to.clone()
543            })
544            .collect();
545
546        if targets.is_empty() {
547            self.contributions.pin().remove(source_url);
548        } else {
549            self.contributions
550                .pin()
551                .insert(source_url.to_string(), targets);
552        }
553    }
554
555    /// Remove every backlink contributed by `source_url` (the page was deleted).
556    pub fn remove_page(&self, source_url: &str) {
557        self.withdraw(source_url);
558        self.contributions.pin().remove(source_url);
559    }
560
561    /// Drop `source_url`'s existing contributions from every target it fed.
562    fn withdraw(&self, source_url: &str) {
563        let previous = self.contributions.pin().get(source_url).cloned();
564        let Some(previous) = previous else {
565            return;
566        };
567        let guard = self.inbound.pin();
568        for target in previous {
569            if let Some(bucket) = guard.get(&target) {
570                let mut entries = bucket.lock().expect("inbound bucket poisoned");
571                entries.remove(source_url);
572                // Leave the empty bucket in place: a page that lost its only
573                // backlink usually gains another on the next edit, and the key
574                // count is bounded by the number of link targets.
575            }
576        }
577    }
578
579    /// Backlinks for `target_url`, in `from` order.
580    pub fn get(&self, target_url: &str) -> Vec<InboundLink> {
581        let bucket = self.inbound.pin().get(target_url).cloned();
582        match bucket {
583            Some(bucket) => bucket
584                .lock()
585                .expect("inbound bucket poisoned")
586                .values()
587                .cloned()
588                .collect(),
589            None => Vec::new(),
590        }
591    }
592
593    /// How many distinct pages link to `target_url` — a hash hit and a length,
594    /// no clone. Only meaningful once [`Self::is_ready`]; a miss before then is
595    /// "unknown", not zero.
596    pub fn source_count(&self, target_url: &str) -> usize {
597        self.inbound
598            .pin()
599            .get(target_url)
600            .map(|bucket| bucket.lock().expect("inbound bucket poisoned").len())
601            .unwrap_or(0)
602    }
603
604    /// Drop everything, including readiness (a full rescan rebuilds it).
605    pub fn clear(&self) {
606        self.inbound.pin().clear();
607        self.contributions.pin().clear();
608        self.ready.store(false, Ordering::Release);
609    }
610
611    /// Number of pages that currently have at least one backlink recorded.
612    pub fn target_count(&self) -> usize {
613        self.inbound
614            .pin()
615            .iter()
616            .filter(|(_, bucket)| !bucket.lock().expect("inbound bucket poisoned").is_empty())
617            .count()
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    // ==================== InboundIndex ====================
626
627    fn link_to(to: &str, text: &str) -> OutboundLink {
628        OutboundLink {
629            to: to.to_string(),
630            text: text.to_string(),
631            anchor: None,
632            internal: true,
633        }
634    }
635
636    #[test]
637    fn inbound_index_inverts_internal_links() {
638        let index = InboundIndex::new();
639        index.set_page_links("/a/", &[link_to("/b/", "see B"), link_to("/c/", "see C")]);
640
641        let b = index.get("/b/");
642        assert_eq!(b.len(), 1);
643        assert_eq!(b[0].from, "/a/");
644        assert_eq!(b[0].text, "see B");
645        assert_eq!(index.get("/c/").len(), 1);
646        assert!(index.get("/nowhere/").is_empty());
647    }
648
649    #[test]
650    fn inbound_index_source_count_counts_distinct_pages() {
651        let index = InboundIndex::new();
652        index.set_page_links("/a/", &[link_to("/b/", "one")]);
653        index.set_page_links("/c/", &[link_to("/b/", "two")]);
654        assert_eq!(index.source_count("/b/"), 2);
655        assert_eq!(index.source_count("/b/"), index.get("/b/").len());
656        assert_eq!(index.source_count("/nowhere/"), 0);
657        index.set_page_links("/a/", &[]);
658        assert_eq!(index.source_count("/b/"), 1);
659    }
660
661    #[test]
662    fn inbound_index_ignores_external_links() {
663        let index = InboundIndex::new();
664        let external = OutboundLink {
665            to: "https://example.com/".to_string(),
666            text: "out".to_string(),
667            anchor: None,
668            internal: false,
669        };
670        index.set_page_links("/a/", &[external]);
671
672        assert!(index.get("https://example.com/").is_empty());
673        assert_eq!(index.target_count(), 0);
674    }
675
676    /// Editing a page must withdraw the backlinks it no longer contains.
677    /// Without `contributions` this would need a scan of every bucket, and
678    /// skipping it leaves a backlink pointing at a link the author deleted.
679    #[test]
680    fn inbound_index_withdraws_links_removed_by_an_edit() {
681        let index = InboundIndex::new();
682        index.set_page_links("/a/", &[link_to("/b/", "see B"), link_to("/c/", "see C")]);
683        assert_eq!(index.get("/b/").len(), 1);
684
685        // The author deleted the link to B, kept C, and added D.
686        index.set_page_links("/a/", &[link_to("/c/", "see C"), link_to("/d/", "see D")]);
687
688        assert!(
689            index.get("/b/").is_empty(),
690            "a link removed by an edit must stop producing a backlink"
691        );
692        assert_eq!(index.get("/c/").len(), 1);
693        assert_eq!(index.get("/d/").len(), 1);
694    }
695
696    #[test]
697    fn inbound_index_removing_a_page_drops_only_its_own_backlinks() {
698        let index = InboundIndex::new();
699        index.set_page_links("/a/", &[link_to("/target/", "from A")]);
700        index.set_page_links("/b/", &[link_to("/target/", "from B")]);
701        assert_eq!(index.get("/target/").len(), 2);
702
703        index.remove_page("/a/");
704
705        let remaining = index.get("/target/");
706        assert_eq!(remaining.len(), 1);
707        assert_eq!(remaining[0].from, "/b/");
708    }
709
710    /// Backlinks are returned in `from` order regardless of insertion order,
711    /// matching what [`sort_inbound_links`] produces for the build path.
712    #[test]
713    fn inbound_index_returns_backlinks_in_from_order() {
714        let index = InboundIndex::new();
715        index.set_page_links("/zeta/", &[link_to("/t/", "z")]);
716        index.set_page_links("/alpha/", &[link_to("/t/", "a")]);
717        index.set_page_links("/mid/", &[link_to("/t/", "m")]);
718
719        let froms: Vec<String> = index.get("/t/").into_iter().map(|l| l.from).collect();
720        assert_eq!(froms, vec!["/alpha/", "/mid/", "/zeta/"]);
721
722        let mut expected = index.get("/t/");
723        sort_inbound_links(&mut expected);
724        assert_eq!(index.get("/t/"), expected, "must already be sorted");
725    }
726
727    #[test]
728    fn inbound_index_clear_resets_readiness() {
729        let index = InboundIndex::new();
730        index.set_page_links("/a/", &[link_to("/b/", "see B")]);
731        index.mark_ready();
732        assert!(index.is_ready());
733
734        index.clear();
735
736        assert!(!index.is_ready());
737        assert!(index.get("/b/").is_empty());
738    }
739
740    #[tokio::test]
741    async fn inbound_index_wait_until_ready_returns_after_late_build() {
742        let index = std::sync::Arc::new(InboundIndex::new());
743
744        let waiting = std::sync::Arc::clone(&index);
745        let waiter = tokio::spawn(async move { waiting.wait_until_ready().await });
746        tokio::task::yield_now().await;
747
748        index.mark_ready();
749
750        tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
751            .await
752            .expect("waiter must be woken by mark_ready")
753            .expect("waiter task panicked");
754    }
755
756    /// A waiter created after the build already finished must not block:
757    /// `notify_waiters()` leaves no permit behind.
758    #[tokio::test]
759    async fn inbound_index_wait_until_ready_returns_immediately_when_already_ready() {
760        let index = InboundIndex::new();
761        index.mark_ready();
762
763        tokio::time::timeout(std::time::Duration::from_secs(5), index.wait_until_ready())
764            .await
765            .expect("a waiter started after readiness must not block");
766    }
767
768    #[test]
769    fn test_split_url_anchor_with_anchor() {
770        let (path, anchor) = split_url_anchor("/docs/guide/#section");
771        assert_eq!(path, "/docs/guide/");
772        assert_eq!(anchor, Some("#section".to_string()));
773    }
774
775    #[test]
776    fn test_split_url_anchor_without_anchor() {
777        let (path, anchor) = split_url_anchor("/docs/guide/");
778        assert_eq!(path, "/docs/guide/");
779        assert_eq!(anchor, None);
780    }
781
782    #[test]
783    fn test_split_url_anchor_only_anchor() {
784        let (path, anchor) = split_url_anchor("#section");
785        assert_eq!(path, "");
786        assert_eq!(anchor, Some("#section".to_string()));
787    }
788
789    #[test]
790    fn test_is_internal_link_external_https() {
791        assert!(!is_internal_link("https://example.com"));
792        assert!(!is_internal_link("http://example.com"));
793    }
794
795    #[test]
796    fn test_is_internal_link_external_protocols() {
797        assert!(!is_internal_link("mailto:test@example.com"));
798        assert!(!is_internal_link("tel:+1234567890"));
799        assert!(!is_internal_link("javascript:void(0)"));
800        assert!(!is_internal_link("data:text/html,<h1>Hi</h1>"));
801        assert!(!is_internal_link("//cdn.example.com/script.js"));
802    }
803
804    #[test]
805    fn test_is_internal_link_internal() {
806        assert!(is_internal_link("/docs/guide/"));
807        assert!(is_internal_link("../other-page/"));
808        assert!(is_internal_link("./sibling/"));
809        assert!(is_internal_link("relative-path/"));
810        assert!(is_internal_link("#anchor"));
811    }
812
813    #[test]
814    fn test_is_internal_link_schemes_missing_from_old_allowlist() {
815        // Regression: these schemes were absent from the hand-rolled list, so
816        // the links were marked internal, resolved against the page URL and
817        // then reported as broken internal links.
818        assert!(!is_internal_link("ftp://ftp.example.com/pub/file.zip"));
819        assert!(!is_internal_link("ftps://ftp.example.com/pub/file.zip"));
820        assert!(!is_internal_link("magnet:?xt=urn:btih:c12fe1c06bba"));
821        assert!(!is_internal_link("sms:+15555550123"));
822        assert!(!is_internal_link("callto:+15555550123"));
823        assert!(!is_internal_link("blob:http://localhost:5220/550e8400"));
824    }
825
826    #[test]
827    fn test_is_internal_link_windows_drive_and_colon_in_path() {
828        // A drive letter and a colon in a later segment must not read as
829        // schemes, or real files would be treated as off-site targets.
830        assert!(is_internal_link("C:/win/path"));
831        assert!(is_internal_link("docs/a:b.md"));
832    }
833
834    #[test]
835    fn test_normalize_url_path_trailing_slash() {
836        assert_eq!(normalize_url_path("/docs/guide"), "/docs/guide/");
837        assert_eq!(normalize_url_path("/docs/guide/"), "/docs/guide/");
838    }
839
840    #[test]
841    fn test_normalize_url_path_file() {
842        assert_eq!(normalize_url_path("/images/photo.jpg"), "/images/photo.jpg");
843        assert_eq!(normalize_url_path("/docs/file.pdf"), "/docs/file.pdf");
844    }
845
846    #[test]
847    fn test_normalize_url_path_with_query() {
848        assert_eq!(normalize_url_path("/docs/guide/?foo=bar"), "/docs/guide/");
849    }
850
851    #[test]
852    fn test_normalize_url_path_root() {
853        assert_eq!(normalize_url_path("/"), "/");
854        assert_eq!(normalize_url_path(""), "/");
855    }
856
857    #[test]
858    fn test_link_cache_insert_and_get() {
859        let cache = LinkCache::new(1024 * 1024);
860        let links = vec![OutboundLink {
861            to: "/other/".to_string(),
862            text: "Other Page".to_string(),
863            anchor: None,
864            internal: true,
865        }];
866
867        cache.insert("/docs/".to_string(), links.clone());
868
869        let retrieved = cache.get("/docs/");
870        assert!(retrieved.is_some());
871        assert_eq!(retrieved.unwrap(), links);
872    }
873
874    #[test]
875    fn test_link_cache_miss() {
876        let cache = LinkCache::new(1024 * 1024);
877        assert!(cache.get("/nonexistent/").is_none());
878    }
879
880    #[test]
881    fn test_link_cache_size_stable_on_overwrite() {
882        // Regression for #12: overwriting an existing key must not ratchet
883        // `current_size` upward.
884        let cache = LinkCache::new(1024 * 1024);
885        let links = vec![OutboundLink {
886            to: "/other/".to_string(),
887            text: "Other Page".to_string(),
888            anchor: None,
889            internal: true,
890        }];
891
892        cache.insert("/docs/".to_string(), links.clone());
893        let size_after_first = cache.current_size();
894        assert_eq!(cache.len(), 1);
895
896        for _ in 0..10 {
897            cache.insert("/docs/".to_string(), links.clone());
898        }
899
900        assert_eq!(cache.len(), 1);
901        assert_eq!(cache.current_size(), size_after_first);
902    }
903
904    #[test]
905    fn test_link_cache_size_tracks_replacement_delta() {
906        // Overwriting with a larger payload adjusts size by the delta only.
907        let cache = LinkCache::new(1024 * 1024);
908        let one = vec![OutboundLink {
909            to: "/a/".to_string(),
910            text: "A".to_string(),
911            anchor: None,
912            internal: true,
913        }];
914        let two = vec![
915            OutboundLink {
916                to: "/a/".to_string(),
917                text: "A".to_string(),
918                anchor: None,
919                internal: true,
920            },
921            OutboundLink {
922                to: "/b/".to_string(),
923                text: "B".to_string(),
924                anchor: None,
925                internal: true,
926            },
927        ];
928
929        cache.insert("/docs/".to_string(), one.clone());
930        let small = cache.current_size();
931        cache.insert("/docs/".to_string(), two.clone());
932        let large = cache.current_size();
933
934        assert_eq!(cache.len(), 1);
935        // Growth equals a single extra link's accounted size, not the full
936        // two-link entry stacked on top of the one-link entry.
937        let expected_delta = "/b/".len() + "B".len() + 32;
938        assert_eq!(large - small, expected_delta);
939    }
940
941    #[test]
942    fn test_link_cache_invalidate_all_clears_and_resets_size() {
943        let cache = LinkCache::new(1024 * 1024);
944        let links = vec![OutboundLink {
945            to: "/other/".to_string(),
946            text: "Other".to_string(),
947            anchor: None,
948            internal: true,
949        }];
950        cache.insert("/a/".to_string(), links.clone());
951        cache.insert("/b/".to_string(), links);
952        assert_eq!(cache.len(), 2);
953        assert!(cache.current_size() > 0);
954
955        cache.invalidate_all();
956
957        assert!(cache.is_empty());
958        assert_eq!(cache.current_size(), 0);
959    }
960
961    #[test]
962    fn test_link_cache_disabled() {
963        let cache = LinkCache::new(0);
964        let links = vec![OutboundLink {
965            to: "/other/".to_string(),
966            text: "Other".to_string(),
967            anchor: None,
968            internal: true,
969        }];
970
971        cache.insert("/docs/".to_string(), links);
972        assert!(cache.get("/docs/").is_none());
973    }
974
975    #[test]
976    fn test_outbound_link_serialize() {
977        let link = OutboundLink {
978            to: "/docs/guide/".to_string(),
979            text: "Guide".to_string(),
980            anchor: Some("#intro".to_string()),
981            internal: true,
982        };
983
984        let json = serde_json::to_string(&link).unwrap();
985        assert!(json.contains("\"to\":\"/docs/guide/\""));
986        assert!(json.contains("\"text\":\"Guide\""));
987        assert!(json.contains("\"anchor\":\"#intro\""));
988        assert!(json.contains("\"internal\":true"));
989    }
990
991    #[test]
992    fn test_outbound_link_serialize_no_anchor() {
993        let link = OutboundLink {
994            to: "/docs/".to_string(),
995            text: "Docs".to_string(),
996            anchor: None,
997            internal: true,
998        };
999
1000        let json = serde_json::to_string(&link).unwrap();
1001        // anchor should be skipped when None
1002        assert!(!json.contains("anchor"));
1003    }
1004
1005    #[test]
1006    fn test_page_links_default() {
1007        let links = PageLinks::default();
1008        assert!(links.inbound.is_empty());
1009        assert!(links.outbound.is_empty());
1010    }
1011
1012    fn inbound(from: &str, text: &str, anchor: Option<&str>) -> InboundLink {
1013        InboundLink {
1014            from: from.to_string(),
1015            text: text.to_string(),
1016            anchor: anchor.map(str::to_string),
1017        }
1018    }
1019
1020    #[test]
1021    fn test_sort_inbound_links_orders_by_source() {
1022        let mut links = vec![
1023            inbound("/zeta/", "Zeta", None),
1024            inbound("/docs/alpha/", "Alpha", None),
1025            inbound("/docs/beta/", "Beta", None),
1026        ];
1027        sort_inbound_links(&mut links);
1028
1029        let sources: Vec<&str> = links.iter().map(|l| l.from.as_str()).collect();
1030        assert_eq!(sources, ["/docs/alpha/", "/docs/beta/", "/zeta/"]);
1031    }
1032
1033    /// The whole point of the function: two lists holding the same backlinks in
1034    /// different (hash-seeded) orders must converge on identical output, or
1035    /// `links.json` churns between builds.
1036    #[test]
1037    fn test_sort_inbound_links_is_order_independent() {
1038        let mut forward = vec![
1039            inbound("/a/", "First", None),
1040            inbound("/b/", "Second", None),
1041            inbound("/c/", "Third", None),
1042        ];
1043        let mut reversed: Vec<InboundLink> = forward.iter().rev().cloned().collect();
1044
1045        sort_inbound_links(&mut forward);
1046        sort_inbound_links(&mut reversed);
1047
1048        assert_eq!(forward, reversed);
1049    }
1050
1051    /// `from` alone is not assumed to be unique: entries sharing a source fall
1052    /// back to `text`, then `anchor`, so no pair is left tied and reordered by
1053    /// the stable sort.
1054    #[test]
1055    fn test_sort_inbound_links_breaks_ties_on_text_then_anchor() {
1056        let mut links = vec![
1057            inbound("/src/", "Beta", None),
1058            inbound("/src/", "Alpha", Some("#z")),
1059            inbound("/src/", "Alpha", Some("#a")),
1060            inbound("/src/", "Alpha", None),
1061        ];
1062        sort_inbound_links(&mut links);
1063
1064        let keys: Vec<(&str, Option<&str>)> = links
1065            .iter()
1066            .map(|l| (l.text.as_str(), l.anchor.as_deref()))
1067            .collect();
1068        // `None` sorts before `Some` for Option, so the anchorless link leads.
1069        assert_eq!(
1070            keys,
1071            [
1072                ("Alpha", None),
1073                ("Alpha", Some("#a")),
1074                ("Alpha", Some("#z")),
1075                ("Beta", None),
1076            ]
1077        );
1078    }
1079
1080    #[test]
1081    fn test_sort_inbound_links_handles_empty() {
1082        let mut links: Vec<InboundLink> = Vec::new();
1083        sort_inbound_links(&mut links);
1084        assert!(links.is_empty());
1085    }
1086
1087    #[test]
1088    fn test_resolve_relative_url_index_page_sibling() {
1089        // docs/modes/index.md → URL /modes/ → [GUI Mode](gui/) lands at /modes/gui/
1090        assert_eq!(resolve_relative_url("/modes/", "gui/", true), "/modes/gui/");
1091        assert_eq!(
1092            resolve_relative_url("/reference/", "cli/", true),
1093            "/reference/cli/"
1094        );
1095        assert_eq!(
1096            resolve_relative_url("/getting-started/", "quickstart/", true),
1097            "/getting-started/quickstart/"
1098        );
1099    }
1100
1101    #[test]
1102    fn test_resolve_relative_url_nonindex_sibling() {
1103        // docs/guide.md → URL /docs/guide/ with link [intro](intro.md) means
1104        // sibling file docs/intro.md, which lives at URL /docs/intro/.
1105        assert_eq!(
1106            resolve_relative_url("/docs/guide/", "intro/", false),
1107            "/docs/intro/"
1108        );
1109    }
1110
1111    #[test]
1112    fn test_resolve_relative_url_nonindex_parent_traversal() {
1113        // docs/sub/guide.md → URL /docs/sub/guide/ with link ../other/ means
1114        // go up from docs/sub/ to docs/, then into other/.
1115        assert_eq!(
1116            resolve_relative_url("/docs/sub/guide/", "../other/", false),
1117            "/docs/other/"
1118        );
1119    }
1120
1121    #[test]
1122    fn test_resolve_relative_url_index_parent_traversal() {
1123        // docs/modes/index.md with link ../other/ goes up from /modes/ to /,
1124        // then into other/.
1125        assert_eq!(
1126            resolve_relative_url("/modes/", "../other/", true),
1127            "/other/"
1128        );
1129    }
1130
1131    #[test]
1132    fn test_resolve_relative_url_absolute_unchanged() {
1133        // is_index_file value doesn't matter for absolute URLs
1134        assert_eq!(resolve_relative_url("/modes/", "/other/", true), "/other/");
1135        assert_eq!(
1136            resolve_relative_url("/docs/guide/", "/other/", false),
1137            "/other/"
1138        );
1139        assert_eq!(resolve_relative_url("/modes/", "/", true), "/");
1140    }
1141
1142    #[test]
1143    fn test_resolve_relative_url_anchor_only() {
1144        assert_eq!(
1145            resolve_relative_url("/modes/", "#section", true),
1146            "#section"
1147        );
1148    }
1149
1150    #[test]
1151    fn test_resolve_relative_url_to_root() {
1152        // Migrated from build.rs when its duplicate resolver was removed.
1153        assert_eq!(resolve_relative_url("/source/", "../", false), "/");
1154        assert_eq!(resolve_relative_url("/docs/guide/", "../../", false), "/");
1155        assert_eq!(resolve_relative_url("/modes/", "../", true), "/");
1156    }
1157
1158    #[test]
1159    fn test_resolve_outbound_links_drops_anchor_only_link() {
1160        // Regression: `[Top](#top)` reaches this function already split into
1161        // `to: ""` + `anchor: Some("#top")`, so the `starts_with('#')` guard
1162        // never fired and the link resolved to the parent page (`/docs/`),
1163        // which then showed up as a phantom backlink there.
1164        let links = vec![OutboundLink {
1165            to: String::new(),
1166            text: "Top".to_string(),
1167            anchor: Some("#top".to_string()),
1168            internal: true,
1169        }];
1170
1171        let resolved = resolve_outbound_links("/docs/guide/", links, false);
1172
1173        assert!(
1174            resolved.is_empty(),
1175            "anchor-only link must not become an outbound link: {resolved:?}"
1176        );
1177    }
1178
1179    #[test]
1180    fn test_resolve_outbound_links_keeps_real_links_alongside_anchor_only() {
1181        let links = vec![
1182            OutboundLink {
1183                to: String::new(),
1184                text: "Top".to_string(),
1185                anchor: Some("#top".to_string()),
1186                internal: true,
1187            },
1188            OutboundLink {
1189                to: "intro/".to_string(),
1190                text: "Intro".to_string(),
1191                anchor: None,
1192                internal: true,
1193            },
1194        ];
1195
1196        let resolved = resolve_outbound_links("/docs/guide/", links, false);
1197
1198        assert_eq!(resolved.len(), 1);
1199        assert_eq!(resolved[0].to, "/docs/intro/");
1200    }
1201
1202    #[test]
1203    fn test_resolve_relative_url_checked_accepts_in_root_links() {
1204        // Every shape that stays inside the site must resolve identically to
1205        // the clamping version.
1206        let cases = [
1207            ("/modes/", "gui/", true),
1208            ("/docs/guide/", "intro/", false),
1209            ("/docs/sub/guide/", "../other/", false),
1210            ("/docs/sub/guide/", "../../other/", false),
1211            ("/modes/", "../", true),
1212            ("/docs/guide/", "/other/", false),
1213            ("/modes/", "#section", true),
1214        ];
1215
1216        for (base, relative, is_index) in cases {
1217            assert_eq!(
1218                resolve_relative_url_checked(base, relative, is_index),
1219                Some(resolve_relative_url(base, relative, is_index)),
1220                "{relative} from {base} must resolve without an escape"
1221            );
1222        }
1223    }
1224
1225    /// Regression: `Vec::pop()` on an empty vec returns `None`, and discarding
1226    /// that made a link escaping the repository root indistinguishable from a
1227    /// link to a real page. The clamping resolver still repairs it (browsers
1228    /// do the same), but validation must be able to see it.
1229    #[test]
1230    fn test_resolve_relative_url_checked_rejects_above_root_traversal() {
1231        let cases = [
1232            ("/docs/guide/", "../../../escape/", false),
1233            ("/docs/", "../../escape/", true),
1234            ("/", "../escape/", true),
1235            ("/note/", "../../", false),
1236            // A root-level page's `../` really does name the repository's
1237            // parent directory, so the emitted `../../` escapes.
1238            ("/source/", "../../", true),
1239        ];
1240
1241        for (base, relative, is_index) in cases {
1242            assert_eq!(
1243                resolve_relative_url_checked(base, relative, is_index),
1244                None,
1245                "{relative} from {base} escapes the site root and must be reported"
1246            );
1247            // The clamping variant still returns something; that is the whole
1248            // reason the escape was invisible.
1249            assert!(resolve_relative_url(base, relative, is_index).starts_with('/'));
1250        }
1251    }
1252
1253    #[test]
1254    fn test_resolve_relative_url_nonindex_nested_parent_traversal() {
1255        // Migrated from build.rs: strip guide → /docs/, then apply ../../.
1256        assert_eq!(
1257            resolve_relative_url("/docs/guide/", "../../other/", false),
1258            "/other/"
1259        );
1260    }
1261}