Skip to main content

mbr_core/
page_errors.rs

1//! Per-page error detection for server/GUI mode.
2//!
3//! Validates the problems a reader might care about for a single rendered page:
4//!
5//! 1. Broken internal links — reuse `OutboundLink` data from `LinkCache` and
6//!    resolve each through `path_resolver::resolve_request_path`.
7//! 2. Broken media references — parse rendered HTML for `<img>`, `<video>`,
8//!    `<audio>`, and `<source>` tags (plus Reveal.js `data-background-*`
9//!    URLs on `<section>`) and confirm internal `src` attributes resolve when interpreted the way a browser would: relative to the
10//!    page's canonical URL, then through the same request pipeline the
11//!    server uses. Checking never duplicates resolution logic, so it cannot
12//!    disagree with what a live request actually serves.
13//! 3. Unresolved wikilinks — literal `[[...]]` substrings that escaped
14//!    `transform_wikilinks` (see `src/wikilink.rs`). Skipped inside `<code>`
15//!    and `<pre>` blocks.
16//! 4. Frontmatter parse errors — a YAML error discards the *whole* block, so
17//!    otherwise-valid fields vanish; the captured message is surfaced verbatim.
18//! 5. Relationship data problems — hierarchical (parent/child) cycles and
19//!    endpoints that resolved through a name shared by several notes. Both are
20//!    detected once per index rebuild (see `src/relationships.rs`) and only
21//!    looked up here.
22//! 6. Ambiguous body wikilinks — a `[[Name]]` several notes answer to, detected
23//!    during the render (see `src/wikilink_index.rs`).
24//!
25//! Designed to be cheap: each validator is a pure function and is expected to
26//! run on-demand for a single page render. The module is never invoked from
27//! `src/build.rs`, keeping static-site output untouched.
28
29use regex::Regex;
30use scraper::{Html, Selector};
31use serde::{Deserialize, Serialize};
32use std::sync::LazyLock;
33
34use crate::link_index::{OutboundLink, resolve_relative_url, resolve_relative_url_checked};
35use crate::path_resolver::{
36    PathResolverConfig, ResolvedPath, normalize_link_target, resolve_request_path,
37};
38use crate::relationships::{AmbiguousEndpoint, RelationshipCycle};
39use crate::url_path::is_external_url;
40use crate::wikilink_index::AmbiguousWikilink;
41
42/// Type of media element whose `src` attribute is broken.
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum MediaKind {
46    Image,
47    Video,
48    Audio,
49    Source,
50}
51
52/// A single problem detected on a page.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(tag = "type", rename_all = "snake_case")]
55pub enum PageError {
56    /// A `<a href>` whose internal target does not resolve.
57    BrokenInternalLink {
58        target: String,
59        text: String,
60        #[serde(skip_serializing_if = "Option::is_none")]
61        anchor: Option<String>,
62    },
63    /// A media element (`<img>`, `<video>`, `<audio>`, `<source>`) whose
64    /// internal `src` does not resolve.
65    BrokenMediaReference { src: String, kind: MediaKind },
66    /// A literal `[[...]]` that was not transformed into a link.
67    UnresolvedWikilink { raw: String },
68    /// The YAML frontmatter block failed to parse, so the entire frontmatter
69    /// (including otherwise-valid fields) was discarded.
70    FrontmatterParseError { message: String },
71    /// The repository's `.mbr/config.toml` could not be loaded, so every page
72    /// is rendered with the default configuration. Repository-wide, so it is
73    /// reported on every page. Only an embedder that falls back rather than
74    /// failing (the iOS app) produces it; the desktop refuses to start.
75    ConfigParseError { message: String },
76    /// Two or more notes form a parent/child cycle, which is impossible in a real
77    /// family tree and makes the genealogy chart unrenderable.
78    RelationshipCycle {
79        /// Note URLs in the cycle, in traversal order.
80        members: Vec<String>,
81        /// Canonical relation type whose edges close the cycle (e.g. "child").
82        rel_type: String,
83    },
84    /// A relationship endpoint named a title/alias shared by several notes; mbr
85    /// silently resolved it to one of them.
86    AmbiguousRelationshipEndpoint {
87        /// The endpoint as authored, e.g. "[[John Doe]]".
88        raw: String,
89        /// The note URL it resolved to.
90        resolved_to: String,
91        /// The other notes sharing that name.
92        candidates: Vec<String>,
93    },
94    /// A contact field (`aliases`, `phones`, `dates`, …) on a `type: person` /
95    /// `type: organization` note had an entry mbr could not read, so the card
96    /// skipped it. See `contact::ContactProblem`.
97    ContactDataProblem {
98        /// The frontmatter key at fault.
99        field: String,
100        /// What was wrong and what was done about it.
101        message: String,
102    },
103    /// A `[[Wikilink]]` in the body named a title/stem shared by several notes;
104    /// mbr silently resolved it to one of them.
105    AmbiguousWikilink {
106        /// The wikilink as authored, e.g. "[[John Doe]]".
107        raw: String,
108        /// The note URL it resolved to.
109        resolved_to: String,
110        /// The other notes sharing that name.
111        candidates: Vec<String>,
112    },
113    /// A media file that resolves and is served correctly, but whose track
114    /// layout matches a combination implicated in browser decode failures.
115    /// Detected by probing the container (see
116    /// `video_metadata::probe_playback_compatibility`), so it is server/GUI
117    /// only and requires the `media-metadata` feature.
118    ///
119    /// This is a *heuristic hint*, not a verdict — the combination it looks for
120    /// is necessary but not sufficient, with a known false positive (see
121    /// `video_metadata::WEBKIT_RISKY_DATA_TAGS`). The browser's own
122    /// `MediaError` is the only ground truth for "this did not play", so the
123    /// frontend surfaces these entries solely to explain a failure it has
124    /// already observed, and never as a standalone warning.
125    ///
126    /// Unlike [`Self::BrokenMediaReference`], `kind` describes the *media
127    /// type* (`video`) rather than the HTML element, because the diagnosis is
128    /// about the file, not about which tag referenced it.
129    UnplayableMedia {
130        /// Matches the `src` attribute the frontend sees on the corresponding
131        /// `<source>` / `<mbr-video-extras>` element, with any `#fragment` or
132        /// `?query` removed so both spellings compare equal.
133        src: String,
134        kind: MediaKind,
135        /// Human-readable explanation of the most likely cause.
136        reason: String,
137        /// Copy-pasteable command that resolves the conflict, when one exists.
138        #[serde(skip_serializing_if = "Option::is_none")]
139        remedy: Option<String>,
140        /// Always `true`: marks this entry as a heuristic hint so the frontend
141        /// keeps it out of the error badge until a real playback failure is
142        /// observed for the same `src`.
143        advisory: bool,
144    },
145}
146
147/// A media reference in the rendered HTML that resolves to a real file on disk.
148///
149/// Produced by [`collect_media_references`] so callers can inspect the actual
150/// bytes (e.g. probe a container for undecodable tracks) while still reporting
151/// the `src` string the browser sees.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct MediaReference {
154    /// The element's `src` with `#fragment` / `?query` stripped. This is the
155    /// join key the frontend uses to match an element to its diagnosis.
156    pub src: String,
157    /// Which element carried the reference.
158    pub kind: MediaKind,
159    /// The file the server would actually serve for this `src`.
160    pub path: std::path::PathBuf,
161}
162
163/// Response payload for `GET /{page}/errors.json`.
164#[derive(Debug, Clone, Serialize, Deserialize, Default)]
165pub struct PageErrors {
166    /// Canonical URL of the page (e.g., `/docs/guide/`).
167    pub page_url: String,
168    /// Ordered list of detected problems. Always present; the client uses the
169    /// length to decide visibility.
170    pub errors: Vec<PageError>,
171    /// The note's bytes are not on this device yet (a cloud provider's
172    /// dataless file, mobile only), so `errors` holds only the problems that
173    /// do not depend on its body. Omitted when false, which keeps every
174    /// ordinary payload exactly as it was.
175    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
176    pub not_downloaded: bool,
177}
178
179/// What is wrong with one link, as judged from the href a browser would
180/// actually follow.
181///
182/// All three surface as [`PageError::BrokenInternalLink`]: the wire format is a
183/// pinned contract with `components/src/mbr-page-errors.ts`, and a variant that
184/// file does not know about would be counted by nothing and rendered by
185/// nothing — an error that "exists" but is invisible is worse than no error.
186/// Every one of these does break a reader's navigation, so the label is honest.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188enum LinkDefect {
189    /// The target does not resolve to anything the server would serve.
190    Missing,
191    /// A `..` climbed above the repository root. Browsers clamp this (RFC 3986
192    /// §5.2.4), so the reader silently lands on an unrelated page instead of
193    /// the one the author meant.
194    EscapesRoot,
195    /// The target is a *markdown page*, but the href does not end in `/`.
196    /// Markdown pages live at directory-style URLs; without the slash the
197    /// browser resolves the target page's own relative links one directory too
198    /// high, so the damage lands on the *next* click, not this one. In server
199    /// mode a redirect repairs it; in a static build nothing does.
200    ///
201    /// Deliberately **markdown-only**. Every other thing the resolver serves at
202    /// a directory-style URL — a directory listing, a tag page, a tag source
203    /// index — is rendered from a template whose links are site-absolute
204    /// (server mode) or root-anchored `../`-chains (build mode), and neither
205    /// form depends on the base's trailing slash. Only a markdown *body*
206    /// carries links authored relative to the page's own location, so only a
207    /// markdown target can suffer the next-click damage this variant predicts.
208    /// See [`classify_emitted_href`] for why widening it also breaks the
209    /// checker's central invariant.
210    NonCanonical,
211}
212
213/// URL prefix mbr reserves for its own endpoints and assets.
214const MBR_URL_PREFIX: &str = "/.mbr/";
215
216/// Whether a resolved site URL addresses mbr's own namespace rather than the
217/// markdown repository.
218///
219/// Nothing under `/.mbr/` goes through [`resolve_request_path`]. In server mode
220/// axum answers it from explicit routes registered *before* the catch-all: the
221/// four media viewers (`/.mbr/videos/?path=…`, `pdfs`, `audio`, `images`), the
222/// JSON endpoints (`site.json`, `search`, `tasks`, …), and the asset route that
223/// serves the repository's `.mbr/` folder *falling back to compiled-in
224/// defaults*. In a static build `src/build.rs` writes that whole tree itself.
225///
226/// So the resolver has no opinion worth having here, and asking it produces
227/// `NotFound` for URLs that serve 200 — the checker contradicting its own
228/// server, which is the one failure this module is built to make impossible.
229/// Two independent ways it happens today:
230///
231/// - `/.mbr/videos/?path=%2Fvideos%2Fdemo.mp4` — the viewer is a route, and
232///   `base_dir/.mbr/videos` is not a file or directory anywhere.
233/// - `/.mbr/theme.css` on a repo with no `.mbr/` folder at all — served from the
234///   binary, absent from disk.
235///
236/// The trade is deliberate: a link to a genuinely missing `.mbr/` asset is no
237/// longer reported. Silence on a namespace whose truth lives in the router and
238/// the binary beats a 404 claim about a URL that works.
239fn is_mbr_namespace_url(absolute_url: &str) -> bool {
240    // `resolve_relative_url_checked` normalizes every non-root result to a
241    // trailing slash, so bare `/.mbr` arrives here as `/.mbr/`.
242    absolute_url.starts_with(MBR_URL_PREFIX)
243}
244
245/// Judges one **already-emitted** href exactly as a browser would.
246///
247/// The distinction from validating the *authored* markdown destination is the
248/// whole point: a link transform bug — the wrong number of `../`, a missing
249/// trailing slash — is invisible to a checker that re-derives the target from
250/// the source text, because it re-applies the same (buggy) rules. This reads
251/// what actually went into the HTML.
252///
253/// `page_url` is the page's canonical directory-style URL, so relative
254/// resolution uses `is_index_file = true`: every segment of a URL ending in `/`
255/// is a real directory component. That is the same rationale
256/// [`media_reference_resolves`] documents for media srcs.
257///
258/// # The trailing-slash check must use the *renderer's* notion of "page"
259///
260/// Only one thing puts a trailing slash on an emitted href:
261/// `link_transform::transform_link`, via
262/// [`crate::link_transform::LinkTransformConfig::markdown_page_probe`], which
263/// answers yes for [`ResolvedPath::MarkdownFile`] and nothing else. So
264/// [`LinkDefect::NonCanonical`] is checked for exactly that one variant. Asking
265/// for a slash on any other kind reports a defect the pipeline is structurally
266/// incapable of producing — the checker flagging its own renderer's output,
267/// which is the failure this module's header claims cannot happen.
268///
269/// It would also be wrong on the merits. `/tags/rust`, `/tags` and a directory
270/// listing are all served **200 in place**, with no redirect
271/// (`path_resolver::canonical_page_redirect` fires only for markdown pages), and
272/// the templates behind them emit site-absolute links in server mode and
273/// root-anchored `../`-chains in build mode. Both forms resolve identically
274/// whether or not the base URL ends in `/`, so there is no next-click damage to
275/// warn about. [`ResolvedPath::Redirect`] is likewise safe: the browser is sent
276/// to the canonical URL before it resolves anything.
277///
278/// # `/.mbr/` is not the resolver's to judge
279///
280/// See [`is_mbr_namespace_url`]. Those URLs are answered by explicit routes, not
281/// by path resolution, so the resolver's verdict on them means nothing.
282fn classify_emitted_href(
283    href: &str,
284    resolver_config: &PathResolverConfig,
285    page_url: &str,
286) -> Option<LinkDefect> {
287    // Fragment-only links address the current page; validating them would need
288    // target-page parsing, so they are skipped to avoid false positives.
289    if href.is_empty() || href.starts_with('#') {
290        return None;
291    }
292
293    // Off-site targets are not ours to resolve. Anything with a scheme
294    // (`ftp://…`, `magnet:…`) would otherwise be joined onto the base directory
295    // and reported as a false broken link.
296    if is_external_url(href) {
297        return None;
298    }
299
300    // Fragment / query must come off before relative resolution so their
301    // payloads never participate in `.` / `..` segment handling.
302    let path_part = href.split(['#', '?']).next().unwrap_or_default();
303    if path_part.is_empty() {
304        return None;
305    }
306
307    // `_checked`, not the clamping resolver: an above-root `..` is a real
308    // authoring defect and clamping it turns the link into one that points at
309    // whatever page happens to sit at the clamped location.
310    let Some(absolute_url) = resolve_relative_url_checked(page_url, path_part, true) else {
311        return Some(LinkDefect::EscapesRoot);
312    };
313
314    // Judged *after* relative resolution, so `../../.mbr/videos/` is recognized
315    // as readily as the site-absolute spelling authors actually write.
316    if is_mbr_namespace_url(&absolute_url) {
317        return None;
318    }
319
320    // Normalize (percent-decode, trim slashes) and resolve through the same
321    // pipeline a live HTTP request hits, so this can never disagree with what
322    // the server serves. See `normalize_link_target` for why the decoding has
323    // to match axum's.
324    let request_path = normalize_link_target(&absolute_url);
325    match resolve_request_path(resolver_config, &request_path) {
326        ResolvedPath::NotFound => Some(LinkDefect::Missing),
327        // The one target whose body links are authored relative to its own
328        // location, and the one target the renderer can spell canonically.
329        // `normalize_link_target` deliberately trims slashes, which makes the
330        // two spellings indistinguishable at that layer — so the check has to
331        // be made here, on the raw href.
332        ResolvedPath::MarkdownFile(_) => {
333            (!path_part.ends_with('/')).then_some(LinkDefect::NonCanonical)
334        }
335        // A static file is addressed by its exact name and must NOT gain a
336        // trailing slash. Directory listings, tag pages, tag source indexes and
337        // `/x/index`-style redirects are all canonical however they were
338        // written — see the doc comment above.
339        _ => None,
340    }
341}
342
343/// Validates every `<a href>` in the page's **rendered** HTML.
344///
345/// This is the layer that can see link-transform defects, because it reads the
346/// href that was emitted rather than re-deriving one from the markdown source.
347/// Prefer it over [`validate_internal_links`] wherever the rendered body is
348/// available.
349pub fn validate_rendered_links(
350    html: &str,
351    resolver_config: &PathResolverConfig,
352    page_url: &str,
353) -> Vec<PageError> {
354    let Ok(selector) = Selector::parse("a[href]") else {
355        return Vec::new();
356    };
357    let doc = Html::parse_document(html);
358    let mut seen = std::collections::HashSet::new();
359
360    doc.select(&selector)
361        .filter_map(|element| {
362            let href = element.value().attr("href")?;
363            if !seen.insert(href.to_string()) {
364                return None;
365            }
366            classify_emitted_href(href, resolver_config, page_url)?;
367            let text = element.text().collect::<String>().trim().to_string();
368            let (_, anchor) = crate::link_index::split_url_anchor(href);
369            Some(PageError::BrokenInternalLink {
370                target: href.to_string(),
371                text,
372                anchor,
373            })
374        })
375        .collect()
376}
377
378/// Validates the internal outbound links for a single page.
379///
380/// Used where there is no rendered body to read hrefs from — tag pages and tag
381/// indexes, whose outbound links are synthesized as absolute site URLs rather
382/// than authored. For markdown pages use [`validate_rendered_links`], which
383/// sees what the renderer actually emitted.
384pub fn validate_internal_links(
385    outbound: &[OutboundLink],
386    resolver_config: &PathResolverConfig,
387) -> Vec<PageError> {
388    outbound
389        .iter()
390        .filter(|link| link.internal)
391        .filter(|link| {
392            // These targets are already absolute site URLs, so the site root is
393            // the correct base to resolve them against.
394            classify_emitted_href(&link.to, resolver_config, "/").is_some_and(|defect| {
395                // Only genuine 404s are reported here. `OutboundLink.to` for a
396                // markdown page holds the *authored* destination resolved with
397                // markdown semantics (`beta.md` -> `/docs/beta.md/`), which is
398                // not the href a browser follows — judging its canonicality
399                // would be judging a string nobody navigates to.
400                matches!(defect, LinkDefect::Missing | LinkDefect::EscapesRoot)
401            })
402        })
403        .map(|link| PageError::BrokenInternalLink {
404            target: link.to.clone(),
405            text: link.text.clone(),
406            anchor: link.anchor.clone(),
407        })
408        .collect()
409}
410
411/// Validates `<img>`, `<video>`, `<audio>` and `<source>` `src` attributes in
412/// the rendered HTML.
413///
414/// `page_url` is the page's canonical directory-style URL (e.g.
415/// `/docs/guide/`; `/` for the root page). The rendered HTML contains srcs
416/// that the link transform has already rewritten relative to that URL, so
417/// each internal `src` is resolved against it exactly the way a browser
418/// would, then checked through the same pipeline a live HTTP request hits. An
419/// error is recorded only when the server would actually 404 the request.
420pub fn validate_media_references(
421    html: &str,
422    resolver_config: &PathResolverConfig,
423    page_url: &str,
424) -> Vec<PageError> {
425    scan_media_elements(html, SectionBackgrounds::Include)
426        .into_iter()
427        .filter(|(src, _)| !media_reference_resolves(src, resolver_config, page_url))
428        .map(|(src, kind)| PageError::BrokenMediaReference { src, kind })
429        .collect()
430}
431
432/// Scans rendered HTML for every internal media reference, yielding
433/// `(src, kind)` pairs with the `src` verbatim as authored.
434///
435/// External (`https:`, `data:`, …) and empty `src` values are dropped because
436/// nothing local can be said about them. Parsing the document is
437/// ~microseconds for typical page sizes; the selectors compile once per call,
438/// which keeps the API ergonomic and the endpoint off any hot path.
439fn scan_media_elements(html: &str, backgrounds: SectionBackgrounds) -> Vec<(String, MediaKind)> {
440    let doc = Html::parse_document(html);
441    let section_backgrounds = match backgrounds {
442        SectionBackgrounds::Include => scan_section_backgrounds(&doc),
443        SectionBackgrounds::Skip => Vec::new(),
444    };
445
446    let specs: [(&str, MediaKind); 4] = [
447        ("img[src]", MediaKind::Image),
448        ("video[src]", MediaKind::Video),
449        ("audio[src]", MediaKind::Audio),
450        ("source[src]", MediaKind::Source),
451    ];
452
453    specs
454        .into_iter()
455        .filter_map(|(selector_str, kind)| {
456            Selector::parse(selector_str).ok().map(|sel| (sel, kind))
457        })
458        .flat_map(|(selector, kind)| {
459            doc.select(&selector)
460                .filter_map(|el| el.value().attr("src"))
461                .filter(|src| !src.is_empty() && !is_external_url(src))
462                .map(|src| (src.to_string(), kind.clone()))
463                .collect::<Vec<_>>()
464        })
465        .chain(section_backgrounds)
466        .collect()
467}
468
469/// Whether [`scan_media_elements`] also reports Reveal.js section backgrounds.
470///
471/// Only the broken-reference check wants them: [`collect_media_references`]
472/// feeds the video playback probe, which is about `<video>` elements a reader
473/// plays, not a slide's muted background loop.
474#[derive(Clone, Copy)]
475enum SectionBackgrounds {
476    Include,
477    Skip,
478}
479
480/// Internal Reveal.js background URLs on `<section>` elements — the
481/// `--- {data-background-image=…}` attributes `markdown.rs` rewrites with the
482/// image transform, so they resolve against the page URL the same way an
483/// `<img src>` does.
484///
485/// `data-background` counts only when Reveal itself would read it as an image
486/// (a colour is not a reference), and `data-background-video` is a
487/// comma-separated list. `data-background-iframe` is not checked: it names a
488/// page, which the link checks, not the media checks, are about.
489fn scan_section_backgrounds(doc: &Html) -> Vec<(String, MediaKind)> {
490    let Ok(selector) = Selector::parse(
491        "section[data-background-image], section[data-background], \
492         section[data-background-video]",
493    ) else {
494        return Vec::new();
495    };
496    let internal = |url: &str| !url.is_empty() && !is_external_url(url);
497
498    doc.select(&selector)
499        .flat_map(|el| {
500            let attr = |name| el.value().attr(name);
501            let images = attr("data-background-image")
502                .into_iter()
503                .chain(
504                    attr("data-background")
505                        .filter(|v| crate::markdown::is_reveal_background_url(v)),
506                )
507                .map(str::trim)
508                .filter(|url| internal(url))
509                .map(|url| (url.to_string(), MediaKind::Image));
510            let videos = attr("data-background-video")
511                .filter(|v| internal(v.trim()))
512                .into_iter()
513                .flat_map(|v| v.split(','))
514                .map(str::trim)
515                .filter(|url| internal(url))
516                .map(|url| (url.to_string(), MediaKind::Video));
517            images.chain(videos).collect::<Vec<_>>()
518        })
519        .collect()
520}
521
522/// Resolves every internal media reference in `html` to the file the server
523/// would actually serve, dropping the ones that do not resolve to a static
524/// file (those are already reported by [`validate_media_references`]).
525///
526/// The returned `src` has its `#fragment` / `?query` stripped, so a
527/// `<source src='…mp4#t=30,200'>` and the sibling
528/// `<mbr-video-extras src='…mp4'>` produce the same join key for the frontend.
529///
530/// References are deduplicated by `src`, so a file embedded twice on one page
531/// yields one entry and callers never probe the same bytes twice.
532pub fn collect_media_references(
533    html: &str,
534    resolver_config: &PathResolverConfig,
535    page_url: &str,
536) -> Vec<MediaReference> {
537    let mut seen = std::collections::HashSet::new();
538
539    scan_media_elements(html, SectionBackgrounds::Skip)
540        .into_iter()
541        .filter_map(|(src, kind)| {
542            let path_part = src.split(['#', '?']).next().unwrap_or_default();
543            if path_part.is_empty() || !seen.insert(path_part.to_string()) {
544                return None;
545            }
546            let absolute_url = resolve_relative_url(page_url, path_part, true);
547            let request_path = normalize_link_target(&absolute_url);
548            match resolve_request_path(resolver_config, &request_path) {
549                ResolvedPath::StaticFile(path) => Some(MediaReference {
550                    src: path_part.to_string(),
551                    kind,
552                    path,
553                }),
554                _ => None,
555            }
556        })
557        .collect()
558}
559
560/// Resolves a media `src` against the page's canonical URL and checks whether
561/// the server would serve it, mimicking exactly what a browser + live request
562/// does:
563///
564/// 1. Strip the fragment / query from the (still percent-encoded) src.
565/// 2. Resolve the remaining path against `page_url` with browser semantics.
566///    The page URL is directory-style (ends in `/`), so all of its segments
567///    are kept while `.` / `..` segments are applied — which is
568///    [`resolve_relative_url`] with `is_index_file = true`. Absolute srcs
569///    (`/foo.png`) pass through unchanged.
570/// 3. Normalize (percent-decode, trim slashes — this also drops the trailing
571///    slash step 2 appends) via [`normalize_link_target`] and resolve via
572///    [`resolve_request_path`] — the identical pipeline a live HTTP request
573///    hits. If the resolver reports `NotFound` the browser would 404 too, so
574///    flagging the reference is always correct.
575fn media_reference_resolves(
576    src: &str,
577    resolver_config: &PathResolverConfig,
578    page_url: &str,
579) -> bool {
580    // Fragment / query stripping must happen before relative resolution so
581    // `#` / `?` payloads never participate in `.` / `..` segment handling.
582    let path_part = src.split(['#', '?']).next().unwrap_or_default();
583
584    // A fragment- or query-only src refers to the page itself.
585    if path_part.is_empty() {
586        return true;
587    }
588
589    let absolute_url = resolve_relative_url(page_url, path_part, true);
590    let request_path = normalize_link_target(&absolute_url);
591
592    // "" means the site root; the resolver handles it like any live request.
593    !matches!(
594        resolve_request_path(resolver_config, &request_path),
595        ResolvedPath::NotFound
596    )
597}
598
599/// Matches a literal `[[...]]` that survived `transform_wikilinks`. We exclude
600/// `]` inside the match so we correctly stop at the first `]]` and do not
601/// greedily consume nested brackets.
602static WIKILINK_RE: LazyLock<Regex> =
603    LazyLock::new(|| Regex::new(r"\[\[([^\[\]\n]+)\]\]").expect("static wikilink regex is valid"));
604
605/// Masks out content inside `<code>` and `<pre>` blocks so the wikilink scan
606/// does not report examples that readers intentionally wrote in markdown
607/// code samples. The `regex` crate does not support backreferences, so we
608/// handle the two tags independently.
609fn mask_code_blocks(html: &str) -> String {
610    static CODE_BLOCK_RE: LazyLock<Regex> = LazyLock::new(|| {
611        Regex::new(r"(?is)<code\b[^>]*>.*?</code>|<pre\b[^>]*>.*?</pre>")
612            .expect("static code-block regex is valid")
613    });
614
615    CODE_BLOCK_RE
616        .replace_all(html, |caps: &regex::Captures| {
617            // Preserve length so downstream match offsets stay sensible; the
618            // only thing that matters is that bracket characters are gone.
619            " ".repeat(caps[0].len())
620        })
621        .into_owned()
622}
623
624/// Detects literal `[[...]]` strings left in the rendered HTML by failed
625/// wikilink transformation.
626pub fn detect_unresolved_wikilinks(html: &str) -> Vec<PageError> {
627    let masked = mask_code_blocks(html);
628    let mut errors = Vec::new();
629    let mut seen = std::collections::HashSet::new();
630
631    for cap in WIKILINK_RE.captures_iter(&masked) {
632        let raw = cap
633            .get(0)
634            .map(|m| m.as_str().to_string())
635            .unwrap_or_default();
636        if raw.is_empty() {
637            continue;
638        }
639        // De-dup identical literals (common when the same bad wikilink appears
640        // more than once on a page).
641        if seen.insert(raw.clone()) {
642            errors.push(PageError::UnresolvedWikilink { raw });
643        }
644    }
645
646    errors
647}
648
649/// Wraps a captured YAML frontmatter parse error (from
650/// [`crate::markdown::MarkdownRenderResult::frontmatter_error`]) into the
651/// page-error list. Returns an empty vec when there was no error.
652pub fn frontmatter_parse_errors(err: &Option<String>) -> Vec<PageError> {
653    err.iter()
654        .map(|message| PageError::FrontmatterParseError {
655            message: message.clone(),
656        })
657        .collect()
658}
659
660/// Wraps the hierarchical relationship cycles a page is a member of (from
661/// [`crate::relationships::RelationshipIndex::cycles_for`]) into the page-error
662/// list.
663///
664/// Attached to every note *in* the cycle rather than to whichever note declared
665/// the closing edge: the data is wrong across the whole loop, and any member is
666/// a valid place to break it.
667pub fn relationship_cycle_errors(cycles: &[RelationshipCycle]) -> Vec<PageError> {
668    cycles
669        .iter()
670        .map(|cycle| PageError::RelationshipCycle {
671            members: cycle.members.clone(),
672            rel_type: cycle.rel_type.clone(),
673        })
674        .collect()
675}
676
677/// Wraps the ambiguous relationship endpoints a page *declared* (from
678/// [`crate::relationships::RelationshipIndex::ambiguous_endpoints_for`]) into
679/// the page-error list.
680pub fn ambiguous_relationship_endpoint_errors(endpoints: &[AmbiguousEndpoint]) -> Vec<PageError> {
681    endpoints
682        .iter()
683        .map(|endpoint| PageError::AmbiguousRelationshipEndpoint {
684            raw: endpoint.raw.clone(),
685            resolved_to: endpoint.resolved_to.clone(),
686            candidates: endpoint.candidates.clone(),
687        })
688        .collect()
689}
690
691/// Wraps the unreadable contact-field entries found while parsing a contact
692/// note (from [`crate::markdown::MarkdownRenderResult::contact`]) into the
693/// page-error list.
694pub fn contact_problem_errors(problems: &[crate::contact::ContactProblem]) -> Vec<PageError> {
695    problems
696        .iter()
697        .map(|p| PageError::ContactDataProblem {
698            field: p.field.clone(),
699            message: p.message.clone(),
700        })
701        .collect()
702}
703
704/// Wraps the ambiguous body wikilinks found while rendering a page (from
705/// [`crate::markdown::MarkdownRenderResult::ambiguous_wikilinks`]) into the
706/// page-error list.
707pub fn ambiguous_wikilink_errors(wikilinks: &[AmbiguousWikilink]) -> Vec<PageError> {
708    wikilinks
709        .iter()
710        .map(|wikilink| PageError::AmbiguousWikilink {
711            raw: wikilink.raw.clone(),
712            resolved_to: wikilink.resolved_to.clone(),
713            candidates: wikilink.candidates.clone(),
714        })
715        .collect()
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use std::path::{Path, PathBuf};
722    use tempfile::TempDir;
723
724    fn make_config<'a>(
725        base_dir: &'a Path,
726        exts: &'a [String],
727        index_file: &'a str,
728        tag_sources: &'a [String],
729    ) -> PathResolverConfig<'a> {
730        // Leaked so the borrowed config can name it for the test's lifetime;
731        // a few bytes per test.
732        let vault: &'a crate::vault::LocalVault =
733            Box::leak(Box::new(crate::vault::LocalVault::new(base_dir)));
734        PathResolverConfig {
735            vault,
736            static_vault: None,
737            static_folder: "static",
738            markdown_extensions: exts,
739            index_file,
740            tag_sources,
741            exempt_hidden_dirs: &[],
742        }
743    }
744
745    // --- validate_internal_links -------------------------------------------
746
747    #[test]
748    fn broken_internal_link_is_reported() {
749        let dir = TempDir::new().unwrap();
750        let base = dir.path().canonicalize().unwrap();
751        let exts = vec!["md".to_string()];
752        let tags: Vec<String> = vec![];
753        let cfg = make_config(&base, &exts, "index.md", &tags);
754
755        let outbound = vec![OutboundLink {
756            to: "/nonexistent/".to_string(),
757            text: "bad".to_string(),
758            anchor: None,
759            internal: true,
760        }];
761
762        let errs = validate_internal_links(&outbound, &cfg);
763        assert_eq!(errs.len(), 1);
764        assert!(matches!(
765            &errs[0],
766            PageError::BrokenInternalLink { target, .. } if target == "/nonexistent/"
767        ));
768    }
769
770    #[test]
771    fn valid_internal_link_is_ignored() {
772        let dir = TempDir::new().unwrap();
773        std::fs::write(dir.path().join("page.md"), "# x").unwrap();
774        let base = dir.path().canonicalize().unwrap();
775        let exts = vec!["md".to_string()];
776        let tags: Vec<String> = vec![];
777        let cfg = make_config(&base, &exts, "index.md", &tags);
778
779        let outbound = vec![OutboundLink {
780            to: "/page/".to_string(),
781            text: "ok".to_string(),
782            anchor: None,
783            internal: true,
784        }];
785
786        let errs = validate_internal_links(&outbound, &cfg);
787        assert!(errs.is_empty());
788    }
789
790    #[test]
791    fn external_link_is_ignored() {
792        let dir = TempDir::new().unwrap();
793        let base = dir.path().canonicalize().unwrap();
794        let exts = vec!["md".to_string()];
795        let tags: Vec<String> = vec![];
796        let cfg = make_config(&base, &exts, "index.md", &tags);
797
798        let outbound = vec![OutboundLink {
799            to: "https://example.com/anything".to_string(),
800            text: "ext".to_string(),
801            anchor: None,
802            internal: false,
803        }];
804
805        let errs = validate_internal_links(&outbound, &cfg);
806        assert!(errs.is_empty());
807    }
808
809    #[test]
810    fn external_scheme_link_marked_internal_is_ignored() {
811        // Regression: `is_internal_link` did not know these schemes, so the
812        // renderer stored them with `internal: true`; this validator then ran
813        // them through the path resolver, got `NotFound`, and showed a false
814        // "broken internal link" in the page-errors panel.
815        let dir = TempDir::new().unwrap();
816        let base = dir.path().canonicalize().unwrap();
817        let exts = vec!["md".to_string()];
818        let tags: Vec<String> = vec![];
819        let cfg = make_config(&base, &exts, "index.md", &tags);
820
821        let outbound = [
822            "ftp://example.com/data.zip",
823            "magnet:?xt=urn:btih:abc",
824            "sms:+15555550123",
825        ]
826        .iter()
827        .map(|to| OutboundLink {
828            to: (*to).to_string(),
829            text: "ext".to_string(),
830            anchor: None,
831            // Deliberately mislabelled, as the old renderer did.
832            internal: true,
833        })
834        .collect::<Vec<_>>();
835
836        let errs = validate_internal_links(&outbound, &cfg);
837        assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
838    }
839
840    #[test]
841    fn fragment_only_link_is_ignored() {
842        let dir = TempDir::new().unwrap();
843        let base = dir.path().canonicalize().unwrap();
844        let exts = vec!["md".to_string()];
845        let tags: Vec<String> = vec![];
846        let cfg = make_config(&base, &exts, "index.md", &tags);
847
848        let outbound = vec![OutboundLink {
849            to: "#section".to_string(),
850            text: "anchor".to_string(),
851            anchor: Some("#section".to_string()),
852            internal: true,
853        }];
854
855        let errs = validate_internal_links(&outbound, &cfg);
856        assert!(errs.is_empty());
857    }
858
859    #[test]
860    fn percent_encoded_link_to_existing_file_is_not_reported() {
861        // Regression: axum percent-decodes live request paths, so an authored
862        // href like /IronCore%20Swag%20T-shirts%20Gifts must be decoded before
863        // resolution or the checker reports a bogus 404.
864        let dir = TempDir::new().unwrap();
865        std::fs::write(dir.path().join("IronCore Swag T-shirts Gifts.md"), "# x").unwrap();
866        let base = dir.path().canonicalize().unwrap();
867        let exts = vec!["md".to_string()];
868        let tags: Vec<String> = vec![];
869        let cfg = make_config(&base, &exts, "index.md", &tags);
870
871        let outbound = vec![
872            OutboundLink {
873                to: "/IronCore%20Swag%20T-shirts%20Gifts".to_string(),
874                text: "no trailing slash".to_string(),
875                anchor: None,
876                internal: true,
877            },
878            OutboundLink {
879                to: "/IronCore%20Swag%20T-shirts%20Gifts/".to_string(),
880                text: "trailing slash".to_string(),
881                anchor: None,
882                internal: true,
883            },
884        ];
885
886        let errs = validate_internal_links(&outbound, &cfg);
887        assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
888    }
889
890    #[test]
891    fn percent_encoded_apostrophe_link_is_not_reported() {
892        let dir = TempDir::new().unwrap();
893        std::fs::write(dir.path().join("World's Best.md"), "# x").unwrap();
894        let base = dir.path().canonicalize().unwrap();
895        let exts = vec!["md".to_string()];
896        let tags: Vec<String> = vec![];
897        let cfg = make_config(&base, &exts, "index.md", &tags);
898
899        let outbound = vec![OutboundLink {
900            to: "/World%27s%20Best/".to_string(),
901            text: "apostrophe".to_string(),
902            anchor: None,
903            internal: true,
904        }];
905
906        let errs = validate_internal_links(&outbound, &cfg);
907        assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
908    }
909
910    #[test]
911    fn percent_encoded_unicode_link_is_not_reported() {
912        let dir = TempDir::new().unwrap();
913        std::fs::write(dir.path().join("café.md"), "# x").unwrap();
914        let base = dir.path().canonicalize().unwrap();
915        let exts = vec!["md".to_string()];
916        let tags: Vec<String> = vec![];
917        let cfg = make_config(&base, &exts, "index.md", &tags);
918
919        let outbound = vec![OutboundLink {
920            to: "/caf%C3%A9/".to_string(),
921            text: "unicode".to_string(),
922            anchor: None,
923            internal: true,
924        }];
925
926        let errs = validate_internal_links(&outbound, &cfg);
927        assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
928    }
929
930    #[test]
931    fn percent_encoded_link_with_anchor_and_query_is_not_reported() {
932        let dir = TempDir::new().unwrap();
933        std::fs::write(dir.path().join("IronCore Swag T-shirts Gifts.md"), "# x").unwrap();
934        let base = dir.path().canonicalize().unwrap();
935        let exts = vec!["md".to_string()];
936        let tags: Vec<String> = vec![];
937        let cfg = make_config(&base, &exts, "index.md", &tags);
938
939        let outbound = vec![OutboundLink {
940            to: "/IronCore%20Swag%20T-shirts%20Gifts/?x=1#top".to_string(),
941            text: "anchor and query".to_string(),
942            anchor: Some("#top".to_string()),
943            internal: true,
944        }];
945
946        let errs = validate_internal_links(&outbound, &cfg);
947        assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
948    }
949
950    #[test]
951    fn missing_percent_encoded_target_is_still_reported() {
952        let dir = TempDir::new().unwrap();
953        let base = dir.path().canonicalize().unwrap();
954        let exts = vec!["md".to_string()];
955        let tags: Vec<String> = vec![];
956        let cfg = make_config(&base, &exts, "index.md", &tags);
957
958        let outbound = vec![OutboundLink {
959            to: "/Nope%20Missing/".to_string(),
960            text: "gone".to_string(),
961            anchor: None,
962            internal: true,
963        }];
964
965        let errs = validate_internal_links(&outbound, &cfg);
966        assert_eq!(errs.len(), 1);
967        // The error payload preserves the authored (still-encoded) target.
968        assert!(matches!(
969            &errs[0],
970            PageError::BrokenInternalLink { target, .. } if target == "/Nope%20Missing/"
971        ));
972    }
973
974    // --- validate_rendered_links -------------------------------------------
975
976    /// The layout from the reported bug: `docs/guide.md` links across to
977    /// `folder/file.md`, which has a sibling.
978    fn rendered_link_setup() -> (TempDir, PathBuf) {
979        let dir = TempDir::new().unwrap();
980        let base = dir.path().canonicalize().unwrap();
981        std::fs::create_dir_all(base.join("docs")).unwrap();
982        std::fs::create_dir_all(base.join("folder")).unwrap();
983        std::fs::write(base.join("docs/guide.md"), "# guide").unwrap();
984        std::fs::write(base.join("folder/file.md"), "# file").unwrap();
985        std::fs::write(base.join("folder/sibling.md"), "# sibling").unwrap();
986        std::fs::write(base.join("LICENSE"), "MIT").unwrap();
987        (dir, base)
988    }
989
990    fn rendered_link_errors(html: &str, base: &Path, page_url: &str) -> Vec<PageError> {
991        let exts = vec!["md".to_string()];
992        let tags: Vec<String> = vec![];
993        let cfg = make_config(base, &exts, "index.md", &tags);
994        validate_rendered_links(html, &cfg, page_url)
995    }
996
997    /// The reported defect. `/folder/file` serves 200, so nothing downstream
998    /// looked wrong — but the browser then resolves that page's own relative
999    /// links against `/folder/` and every one of them 404s. The href, not the
1000    /// authored destination, is the only place this is visible.
1001    #[test]
1002    fn page_link_without_trailing_slash_is_reported() {
1003        let (_guard, base) = rendered_link_setup();
1004        let html = r#"<a href="../../folder/file">file</a>"#;
1005        let errs = rendered_link_errors(html, &base, "/docs/guide/");
1006
1007        assert_eq!(errs.len(), 1, "{errs:?}");
1008        assert!(matches!(
1009            &errs[0],
1010            PageError::BrokenInternalLink { target, text, .. }
1011                if target == "../../folder/file" && text == "file"
1012        ));
1013    }
1014
1015    #[test]
1016    fn page_link_with_trailing_slash_is_ignored() {
1017        let (_guard, base) = rendered_link_setup();
1018        let html = r#"<a href="../../folder/file/">file</a>"#;
1019        assert!(
1020            rendered_link_errors(html, &base, "/docs/guide/").is_empty(),
1021            "the canonical spelling must not be flagged"
1022        );
1023    }
1024
1025    /// A static file is addressed by its exact name; demanding a trailing slash
1026    /// would be the mirror-image bug.
1027    #[test]
1028    fn extensionless_static_file_link_is_ignored() {
1029        let (_guard, base) = rendered_link_setup();
1030        let html = r#"<a href="../../LICENSE">License</a>"#;
1031        assert!(
1032            rendered_link_errors(html, &base, "/docs/guide/").is_empty(),
1033            "an extension-less static file must not be asked for a trailing slash"
1034        );
1035    }
1036
1037    /// Regression: `normalize_link_target` trims slashes, so `/folder/file` and
1038    /// `/folder/file/` collapse to the same string and the defect was
1039    /// structurally invisible to anything checking only "does it resolve".
1040    #[test]
1041    fn resolving_alone_cannot_tell_the_two_spellings_apart() {
1042        assert_eq!(
1043            normalize_link_target("/folder/file"),
1044            normalize_link_target("/folder/file/"),
1045            "if this ever differs, the trailing-slash check can move earlier"
1046        );
1047    }
1048
1049    /// A `..` that climbs out of the repository produced no diagnostic at all:
1050    /// `resolve_relative_url` popped an empty segment stack and discarded the
1051    /// `None`, so the link silently became one to a real page.
1052    #[test]
1053    fn above_root_link_is_reported() {
1054        let (_guard, base) = rendered_link_setup();
1055        let html = r#"<a href="../../../../escape/target/">escape</a>"#;
1056        let errs = rendered_link_errors(html, &base, "/docs/guide/");
1057
1058        assert_eq!(errs.len(), 1, "{errs:?}");
1059        assert!(matches!(
1060            &errs[0],
1061            PageError::BrokenInternalLink { target, .. } if target == "../../../../escape/target/"
1062        ));
1063    }
1064
1065    /// An above-root link whose *clamped* destination happens to exist is the
1066    /// nastiest shape: the reader lands on a real, wrong page and nothing
1067    /// complains.
1068    #[test]
1069    fn above_root_link_to_an_existing_clamped_target_is_still_reported() {
1070        let (_guard, base) = rendered_link_setup();
1071        // From /docs/guide/, `../../../folder/file/` climbs one level too far;
1072        // a browser clamps it and serves /folder/file/, which exists.
1073        let html = r#"<a href="../../../folder/file/">file</a>"#;
1074        let errs = rendered_link_errors(html, &base, "/docs/guide/");
1075        assert_eq!(
1076            errs.len(),
1077            1,
1078            "a clamped above-root link must still be reported: {errs:?}"
1079        );
1080    }
1081
1082    #[test]
1083    fn missing_target_is_reported() {
1084        let (_guard, base) = rendered_link_setup();
1085        let html = r#"<a href="../../folder/gone/">gone</a>"#;
1086        assert_eq!(rendered_link_errors(html, &base, "/docs/guide/").len(), 1);
1087    }
1088
1089    #[test]
1090    fn external_fragment_and_empty_hrefs_are_ignored() {
1091        let (_guard, base) = rendered_link_setup();
1092        let html = r##"
1093            <a href="https://example.com/x">ext</a>
1094            <a href="mailto:a@b.c">mail</a>
1095            <a href="#section">anchor</a>
1096            <a href="">empty</a>
1097            <a href="?q=1">query only</a>
1098        "##;
1099        let errs = rendered_link_errors(html, &base, "/docs/guide/");
1100        assert!(errs.is_empty(), "{errs:?}");
1101    }
1102
1103    #[test]
1104    fn anchor_and_query_do_not_hide_a_page_link_defect() {
1105        let (_guard, base) = rendered_link_setup();
1106        // The path part is what decides canonicality; the suffix must neither
1107        // mask a defect nor create one.
1108        let bad = r#"<a href="../../folder/file#top">f</a>"#;
1109        assert_eq!(rendered_link_errors(bad, &base, "/docs/guide/").len(), 1);
1110
1111        let good = r#"<a href="../../folder/file/?x=1#top">f</a>"#;
1112        assert!(rendered_link_errors(good, &base, "/docs/guide/").is_empty());
1113    }
1114
1115    #[test]
1116    fn percent_encoded_href_resolves_before_it_is_judged() {
1117        let dir = TempDir::new().unwrap();
1118        let base = dir.path().canonicalize().unwrap();
1119        std::fs::create_dir_all(base.join("docs")).unwrap();
1120        std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1121        std::fs::write(base.join("My Page.md"), "# m").unwrap();
1122
1123        let html = r#"<a href="../../My%20Page/">My Page</a>"#;
1124        assert!(rendered_link_errors(html, &base, "/docs/guide/").is_empty());
1125    }
1126
1127    #[test]
1128    fn repeated_identical_hrefs_are_deduped() {
1129        let (_guard, base) = rendered_link_setup();
1130        let html = r#"
1131            <a href="../../folder/gone/">a</a>
1132            <a href="../../folder/gone/">b</a>
1133        "#;
1134        assert_eq!(rendered_link_errors(html, &base, "/docs/guide/").len(), 1);
1135    }
1136
1137    /// Regression (false positive introduced with `validate_rendered_links`):
1138    /// a link to a *directory* without a trailing slash was reported broken.
1139    ///
1140    /// Nothing in the pipeline can emit the "canonical" spelling —
1141    /// `markdown_page_probe` answers yes only for a markdown file, so
1142    /// `transform_link` leaves an extension-less directory target exactly as
1143    /// authored — and the server answers `/folder` with 200 in place, no
1144    /// redirect. The listing it renders links site-absolutely, so the
1145    /// next-click damage `NonCanonical` exists to predict cannot occur.
1146    #[test]
1147    fn directory_listing_link_without_trailing_slash_is_ignored() {
1148        let (_guard, base) = rendered_link_setup();
1149        // `folder/` holds file.md and sibling.md but no index, so it resolves
1150        // to a DirectoryListing.
1151        let html = r#"<a href="../../folder">folder</a>"#;
1152        assert!(
1153            rendered_link_errors(html, &base, "/docs/guide/").is_empty(),
1154            "a directory listing is served in place and must not be flagged"
1155        );
1156    }
1157
1158    /// Same defect via an absolute href, which is how the browse UI and hand-
1159    /// written navigation spell directory links.
1160    #[test]
1161    fn absolute_directory_link_without_trailing_slash_is_ignored() {
1162        let (_guard, base) = rendered_link_setup();
1163        let html = r#"<a href="/folder">folder</a>"#;
1164        assert!(
1165            rendered_link_errors(html, &base, "/docs/guide/").is_empty(),
1166            "an absolute directory link must not be flagged"
1167        );
1168    }
1169
1170    /// Tag pages and tag source indexes have no filesystem existence at all and
1171    /// are served 200 at either spelling. Their templates emit absolute URLs, so
1172    /// the trailing slash cannot change what a subsequent click resolves to.
1173    #[test]
1174    fn tag_urls_without_trailing_slash_are_ignored() {
1175        let dir = TempDir::new().unwrap();
1176        let base = dir.path().canonicalize().unwrap();
1177        std::fs::create_dir_all(base.join("docs")).unwrap();
1178        std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1179        let exts = vec!["md".to_string()];
1180        let tags = vec!["tags".to_string(), "people".to_string()];
1181        let cfg = make_config(&base, &exts, "index.md", &tags);
1182
1183        let html = r#"
1184            <a href="/tags/rust">tag page</a>
1185            <a href="/tags">tag index</a>
1186            <a href="/people/jane_doe">person</a>
1187        "#;
1188        let errs = validate_rendered_links(html, &cfg, "/docs/guide/");
1189        assert!(errs.is_empty(), "expected no errors, got: {errs:?}");
1190    }
1191
1192    /// The canonical spellings must stay clean too — a fix that silenced the
1193    /// slashless form by silencing the whole variant would pass the tests above
1194    /// and this one, so the markdown case below is what pins the behaviour.
1195    #[test]
1196    fn tag_and_directory_urls_with_trailing_slash_are_ignored() {
1197        let dir = TempDir::new().unwrap();
1198        let base = dir.path().canonicalize().unwrap();
1199        std::fs::create_dir_all(base.join("docs")).unwrap();
1200        std::fs::create_dir_all(base.join("folder")).unwrap();
1201        std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1202        std::fs::write(base.join("folder/file.md"), "# f").unwrap();
1203        let exts = vec!["md".to_string()];
1204        let tags = vec!["tags".to_string()];
1205        let cfg = make_config(&base, &exts, "index.md", &tags);
1206
1207        let html = r#"
1208            <a href="/tags/rust/">tag page</a>
1209            <a href="/tags/">tag index</a>
1210            <a href="/folder/">folder</a>
1211        "#;
1212        let errs = validate_rendered_links(html, &cfg, "/docs/guide/");
1213        assert!(errs.is_empty(), "expected no errors, got: {errs:?}");
1214    }
1215
1216    /// Regression: a page listing its media linked every file through the
1217    /// viewer endpoint (`/.mbr/videos/?path=…`), and every one of those working
1218    /// links was reported broken. The viewer is an axum route; nothing named
1219    /// `.mbr/videos` exists on disk, so the resolver answered `NotFound` for a
1220    /// URL that serves 200.
1221    #[test]
1222    fn media_viewer_links_are_ignored() {
1223        let (_guard, base) = rendered_link_setup();
1224        let html = r#"
1225            <a href="/.mbr/videos/?path=%2Fvideos%2Fdemo.mp4">demo.mp4</a>
1226            <a href="/.mbr/pdfs/?path=%2Fdocs%2Fpaper.pdf">paper.pdf</a>
1227            <a href="/.mbr/audio/?path=%2Fmusic%2Fsong.mp3">song.mp3</a>
1228            <a href="/.mbr/images/?path=%2Fimages%2Fphoto.jpg">photo.jpg</a>
1229        "#;
1230        let errs = rendered_link_errors(html, &base, "/docs/guide/");
1231        assert!(
1232            errs.is_empty(),
1233            "media viewer endpoints are routes, not files: {errs:?}"
1234        );
1235    }
1236
1237    /// The rest of the namespace, including the relative spelling and the
1238    /// compiled-in assets that exist in the binary rather than on disk.
1239    #[test]
1240    fn mbr_namespace_links_are_ignored() {
1241        let (_guard, base) = rendered_link_setup();
1242        // `rendered_link_setup` creates no `.mbr/` folder, which is the common
1243        // case: these are served from compiled-in defaults.
1244        let html = r#"
1245            <a href="/.mbr/site.json">site index</a>
1246            <a href="/.mbr/theme.css">theme</a>
1247            <a href="../../.mbr/videos/?path=%2Fvideos%2Fdemo.mp4">relative viewer</a>
1248        "#;
1249        let errs = rendered_link_errors(html, &base, "/docs/guide/");
1250        assert!(errs.is_empty(), "expected no errors, got: {errs:?}");
1251    }
1252
1253    /// The exclusion is the `/.mbr/` namespace, not "anything starting with a
1254    /// dot" and not a substring match — a repository directory that merely
1255    /// looks similar is still checked.
1256    #[test]
1257    fn lookalike_paths_are_still_checked() {
1258        let (_guard, base) = rendered_link_setup();
1259        let html = r#"
1260            <a href="/.mbrx/gone/">not the namespace</a>
1261            <a href="/docs/.mbr/gone/">not at the root</a>
1262        "#;
1263        assert_eq!(
1264            rendered_link_errors(html, &base, "/docs/guide/").len(),
1265            2,
1266            "only the site-root `/.mbr/` prefix is mbr's"
1267        );
1268    }
1269
1270    /// The narrowing must not reach the defect the variant was added for: a
1271    /// *markdown* page link with no trailing slash is still reported. Paired
1272    /// with the tests above, this pins `NonCanonical` to exactly the set
1273    /// `markdown_page_probe` can canonicalize.
1274    #[test]
1275    fn narrowing_keeps_reporting_markdown_page_links() {
1276        let dir = TempDir::new().unwrap();
1277        let base = dir.path().canonicalize().unwrap();
1278        std::fs::create_dir_all(base.join("docs")).unwrap();
1279        std::fs::create_dir_all(base.join("folder")).unwrap();
1280        std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1281        std::fs::write(base.join("folder/file.md"), "# f").unwrap();
1282        let exts = vec!["md".to_string()];
1283        let tags = vec!["tags".to_string()];
1284        let cfg = make_config(&base, &exts, "index.md", &tags);
1285
1286        let html = r#"<a href="/folder/file">file</a>"#;
1287        assert_eq!(
1288            validate_rendered_links(html, &cfg, "/docs/guide/").len(),
1289            1,
1290            "a markdown page link without its trailing slash is still a defect"
1291        );
1292    }
1293
1294    /// An href that still carries `.md` is a defect, in every spelling.
1295    ///
1296    /// `link_transform::strip_markdown_extension` normally removes the
1297    /// extension and adds the slash, so a surviving `.md` means the transform
1298    /// did not fire — and the result is exactly what [`LinkDefect::NonCanonical`]
1299    /// exists for: the server 301s `/docs/guide.md` to `/docs/guide/`, but a
1300    /// static build emits no such redirect, so the link dies there.
1301    ///
1302    /// Pinned separately from the extension-less case because the narrowing to
1303    /// [`ResolvedPath::MarkdownFile`] is what keeps these reported, and an
1304    /// over-eager future widening of the `_ => None` arm would silence them.
1305    ///
1306    /// Asserts the exact [`LinkDefect`] rather than "something was reported".
1307    /// The distinction matters: an href that merely fails to *resolve* also
1308    /// produces an error, so a count-based assertion goes green even when the
1309    /// canonicality rule has stopped working. (Written the sloppy way first,
1310    /// this test passed while proving nothing — `../folder/file.md` from
1311    /// `/docs/guide/` is `/docs/folder/file.md`, which is simply `Missing`.)
1312    #[test]
1313    fn a_markdown_href_keeping_its_md_extension_is_not_canonical() {
1314        let dir = TempDir::new().unwrap();
1315        let base = dir.path().canonicalize().unwrap();
1316        std::fs::create_dir_all(base.join("docs")).unwrap();
1317        std::fs::create_dir_all(base.join("folder")).unwrap();
1318        std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1319        std::fs::write(base.join("folder/file.md"), "# f").unwrap();
1320        let exts = vec!["md".to_string()];
1321        let tags = vec!["tags".to_string()];
1322        let cfg = make_config(&base, &exts, "index.md", &tags);
1323
1324        // `../../` because the page itself sits one directory down: from
1325        // `/docs/guide/`, `../` is `/docs/` and `../../` is the root.
1326        for href in [
1327            "/folder/file.md",      // site-absolute
1328            "../../folder/file.md", // page-relative
1329            "/folder/file.md#top",  // a fragment must not excuse it
1330            "/folder/file.md?v=2",  // nor a query
1331        ] {
1332            assert_eq!(
1333                classify_emitted_href(href, &cfg, "/docs/guide/"),
1334                Some(LinkDefect::NonCanonical),
1335                "`{href}` still names the file, so it needs the trailing slash"
1336            );
1337        }
1338
1339        // Same rule for an extension-less page link, which is the shape the
1340        // transform actually emits when its probe fails.
1341        assert_eq!(
1342            classify_emitted_href("/folder/file", &cfg, "/docs/guide/"),
1343            Some(LinkDefect::NonCanonical)
1344        );
1345    }
1346
1347    /// The trailing slash is the whole difference: the same targets, spelled
1348    /// canonically, are silent.
1349    ///
1350    /// Guards the other direction of the narrowing — repairing the false
1351    /// positives must not start reporting correct links.
1352    #[test]
1353    fn a_markdown_href_with_its_trailing_slash_is_silent() {
1354        let dir = TempDir::new().unwrap();
1355        let base = dir.path().canonicalize().unwrap();
1356        std::fs::create_dir_all(base.join("docs")).unwrap();
1357        std::fs::create_dir_all(base.join("folder")).unwrap();
1358        std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1359        std::fs::write(base.join("folder/file.md"), "# f").unwrap();
1360        let exts = vec!["md".to_string()];
1361        let tags = vec!["tags".to_string()];
1362        let cfg = make_config(&base, &exts, "index.md", &tags);
1363
1364        for href in [
1365            "/folder/file/",
1366            "../../folder/file/",
1367            "/folder/file/#top",
1368            "/folder/file/?v=2",
1369        ] {
1370            assert_eq!(
1371                classify_emitted_href(href, &cfg, "/docs/guide/"),
1372                None,
1373                "`{href}` is exactly what the renderer emits and must be silent"
1374            );
1375        }
1376    }
1377
1378    /// A missing target and a non-canonical one are different defects, and the
1379    /// relative arithmetic decides which. Pins that they cannot be confused —
1380    /// the trap the two tests above were written to avoid.
1381    #[test]
1382    fn a_relative_md_href_that_resolves_nowhere_is_missing_not_non_canonical() {
1383        let dir = TempDir::new().unwrap();
1384        let base = dir.path().canonicalize().unwrap();
1385        std::fs::create_dir_all(base.join("docs")).unwrap();
1386        std::fs::create_dir_all(base.join("folder")).unwrap();
1387        std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1388        std::fs::write(base.join("folder/file.md"), "# f").unwrap();
1389        let exts = vec!["md".to_string()];
1390        let tags = vec!["tags".to_string()];
1391        let cfg = make_config(&base, &exts, "index.md", &tags);
1392
1393        // One `../` short: this addresses /docs/folder/file.md, which is not there.
1394        assert_eq!(
1395            classify_emitted_href("../folder/file.md", &cfg, "/docs/guide/"),
1396            Some(LinkDefect::Missing)
1397        );
1398    }
1399
1400    /// A genuinely missing target keeps being reported no matter how it is
1401    /// spelled — the narrowing touches canonicality only, never existence.
1402    #[test]
1403    fn narrowing_does_not_hide_missing_targets() {
1404        let dir = TempDir::new().unwrap();
1405        let base = dir.path().canonicalize().unwrap();
1406        std::fs::create_dir_all(base.join("docs")).unwrap();
1407        std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1408        let exts = vec!["md".to_string()];
1409        let tags = vec!["tags".to_string()];
1410        let cfg = make_config(&base, &exts, "index.md", &tags);
1411
1412        // `/nope` is neither a file, a directory, nor a configured tag source.
1413        let html = r#"<a href="/nope">gone</a><a href="/nope/">gone too</a>"#;
1414        assert_eq!(validate_rendered_links(html, &cfg, "/docs/guide/").len(), 2);
1415    }
1416
1417    /// The index-page variant: `/docs/` keeps every segment, so a sibling link
1418    /// carries no `../`.
1419    #[test]
1420    fn index_page_sibling_link_is_ignored() {
1421        let dir = TempDir::new().unwrap();
1422        let base = dir.path().canonicalize().unwrap();
1423        std::fs::create_dir_all(base.join("docs")).unwrap();
1424        std::fs::write(base.join("docs/index.md"), "# i").unwrap();
1425        std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1426
1427        let html = r#"<a href="guide/">Guide</a>"#;
1428        assert!(rendered_link_errors(html, &base, "/docs/").is_empty());
1429    }
1430
1431    // --- validate_media_references -----------------------------------------
1432
1433    fn media_setup() -> (TempDir, PathBuf) {
1434        let dir = TempDir::new().unwrap();
1435        let base = dir.path().canonicalize().unwrap();
1436        // Pre-create a known image so valid media resolves.
1437        std::fs::write(base.join("photo.png"), b"\x89PNG").unwrap();
1438        std::fs::create_dir_all(base.join("static/images")).unwrap();
1439        std::fs::write(base.join("static/images/ok.png"), b"\x89PNG").unwrap();
1440        (dir, base)
1441    }
1442
1443    #[test]
1444    fn broken_img_is_reported() {
1445        let (_guard, base) = media_setup();
1446        let exts = vec!["md".to_string()];
1447        let tags: Vec<String> = vec![];
1448        let cfg = make_config(&base, &exts, "index.md", &tags);
1449
1450        // Root-level page: srcs resolve against the site root URL "/".
1451        let html = r#"<p><img src="./missing.png" alt="x"></p>"#;
1452        let errs = validate_media_references(html, &cfg, "/");
1453        assert_eq!(errs.len(), 1);
1454        assert!(matches!(
1455            &errs[0],
1456            PageError::BrokenMediaReference { kind: MediaKind::Image, src } if src == "./missing.png"
1457        ));
1458    }
1459
1460    #[test]
1461    fn broken_section_backgrounds_are_reported() {
1462        let (_guard, base) = media_setup();
1463        let exts = vec!["md".to_string()];
1464        let tags: Vec<String> = vec![];
1465        let cfg = make_config(&base, &exts, "index.md", &tags);
1466
1467        // As `markdown.rs` emits them for a page at `/deck/`: already rewritten.
1468        let html = r##"
1469            <section data-background-image="../photo.png"></section>
1470            <section data-background-image="../gone.png"></section>
1471            <section data-background="#336"></section>
1472            <section data-background="../also-gone.jpg"></section>
1473            <section data-background-video="../photo.png, ../missing.mp4"></section>
1474            <section data-background-image="https://example.com/x.png"></section>
1475            <section data-background-video="data:video/mp4;base64,AAAA"></section>
1476            <section data-background-iframe="../nowhere.html"></section>
1477        "##;
1478        let errs = validate_media_references(html, &cfg, "/deck/");
1479        let mut found: Vec<(String, MediaKind)> = errs
1480            .into_iter()
1481            .map(|e| match e {
1482                PageError::BrokenMediaReference { src, kind } => (src, kind),
1483                other => panic!("unexpected {other:?}"),
1484            })
1485            .collect();
1486        found.sort_by(|a, b| a.0.cmp(&b.0));
1487        assert_eq!(
1488            found,
1489            vec![
1490                ("../also-gone.jpg".to_string(), MediaKind::Image),
1491                ("../gone.png".to_string(), MediaKind::Image),
1492                ("../missing.mp4".to_string(), MediaKind::Video),
1493            ]
1494        );
1495    }
1496
1497    #[test]
1498    fn section_backgrounds_stay_out_of_collected_media() {
1499        let (_guard, base) = media_setup();
1500        let exts = vec!["md".to_string()];
1501        let tags: Vec<String> = vec![];
1502        let cfg = make_config(&base, &exts, "index.md", &tags);
1503
1504        let html = r#"<section data-background-image="../photo.png"></section>"#;
1505        assert!(collect_media_references(html, &cfg, "/deck/").is_empty());
1506    }
1507
1508    #[test]
1509    fn valid_img_is_ignored() {
1510        let (_guard, base) = media_setup();
1511        let exts = vec!["md".to_string()];
1512        let tags: Vec<String> = vec![];
1513        let cfg = make_config(&base, &exts, "index.md", &tags);
1514
1515        let html = r#"<p><img src="photo.png" alt="x"></p>"#;
1516        let errs = validate_media_references(html, &cfg, "/");
1517        assert!(errs.is_empty());
1518    }
1519
1520    #[test]
1521    fn absolute_path_image_under_static_is_ignored() {
1522        let (_guard, base) = media_setup();
1523        let exts = vec!["md".to_string()];
1524        let tags: Vec<String> = vec![];
1525        let cfg = make_config(&base, &exts, "index.md", &tags);
1526
1527        // "/images/ok.png" should resolve via the static folder overlay,
1528        // regardless of which page references it.
1529        let html = r#"<p><img src="/images/ok.png" alt="x"></p>"#;
1530        let errs = validate_media_references(html, &cfg, "/docs/guide/");
1531        assert!(errs.is_empty());
1532    }
1533
1534    #[test]
1535    fn external_image_is_ignored() {
1536        let (_guard, base) = media_setup();
1537        let exts = vec!["md".to_string()];
1538        let tags: Vec<String> = vec![];
1539        let cfg = make_config(&base, &exts, "index.md", &tags);
1540
1541        let html = r#"<p><img src="https://example.com/a.png"></p>"#;
1542        let errs = validate_media_references(html, &cfg, "/");
1543        assert!(errs.is_empty());
1544    }
1545
1546    #[test]
1547    fn broken_video_audio_and_source_are_reported() {
1548        let (_guard, base) = media_setup();
1549        let exts = vec!["md".to_string()];
1550        let tags: Vec<String> = vec![];
1551        let cfg = make_config(&base, &exts, "index.md", &tags);
1552
1553        let html = r#"
1554            <video src="./gone.mp4"></video>
1555            <audio src="./gone.mp3"></audio>
1556            <video><source src="./gone.webm"></video>
1557        "#;
1558        let errs = validate_media_references(html, &cfg, "/");
1559
1560        assert!(
1561            errs.iter().any(|e| matches!(
1562                e,
1563                PageError::BrokenMediaReference {
1564                    kind: MediaKind::Video,
1565                    ..
1566                }
1567            )),
1568            "{:?}",
1569            errs
1570        );
1571        assert!(errs.iter().any(|e| matches!(
1572            e,
1573            PageError::BrokenMediaReference {
1574                kind: MediaKind::Audio,
1575                ..
1576            }
1577        )));
1578        assert!(errs.iter().any(|e| matches!(
1579            e,
1580            PageError::BrokenMediaReference {
1581                kind: MediaKind::Source,
1582                ..
1583            }
1584        )));
1585    }
1586
1587    #[test]
1588    fn percent_encoded_relative_img_next_to_markdown_is_ignored() {
1589        // Encoded relative srcs must resolve against the page URL first and
1590        // percent-decode afterwards, matching axum's live-request decoding.
1591        let dir = TempDir::new().unwrap();
1592        let base = dir.path().canonicalize().unwrap();
1593        std::fs::write(base.join("my photo.png"), b"\x89PNG").unwrap();
1594        let exts = vec!["md".to_string()];
1595        let tags: Vec<String> = vec![];
1596        let cfg = make_config(&base, &exts, "index.md", &tags);
1597
1598        let html = r#"<p><img src="./my%20photo.png" alt="x"></p>"#;
1599        let errs = validate_media_references(html, &cfg, "/");
1600        assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
1601    }
1602
1603    /// Layout matching the real-world false-positive report: an attachments
1604    /// folder sitting next to the markdown file, referenced from a page
1605    /// served at a directory-style URL. The server-mode link transform
1606    /// rewrites the src to `../<attachments>/...` relative to the page URL.
1607    fn attachments_setup() -> (TempDir, PathBuf) {
1608        let dir = TempDir::new().unwrap();
1609        let base = dir.path().canonicalize().unwrap();
1610        let attachments = base.join("Projects/Ideas/hledger-web-gui_attachments");
1611        std::fs::create_dir_all(&attachments).unwrap();
1612        std::fs::write(base.join("Projects/Ideas/hledger-web-gui.md"), "# x").unwrap();
1613        std::fs::write(attachments.join("img.png"), b"\x89PNG").unwrap();
1614        std::fs::write(attachments.join("my photo.png"), b"\x89PNG").unwrap();
1615        (dir, base)
1616    }
1617
1618    #[test]
1619    fn parent_relative_img_next_to_markdown_is_ignored() {
1620        // Regression: the checker used to resolve `../` srcs against the
1621        // markdown file's directory (one level too high) and falsely flag
1622        // images that the browser loads fine via the page URL.
1623        let (_guard, base) = attachments_setup();
1624        let exts = vec!["md".to_string()];
1625        let tags: Vec<String> = vec![];
1626        let cfg = make_config(&base, &exts, "index.md", &tags);
1627
1628        let html = r#"<p><img src="../hledger-web-gui_attachments/img.png" alt="x"></p>"#;
1629        let errs = validate_media_references(html, &cfg, "/Projects/Ideas/hledger-web-gui/");
1630        assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
1631    }
1632
1633    #[test]
1634    fn parent_relative_percent_encoded_img_is_ignored() {
1635        let (_guard, base) = attachments_setup();
1636        let exts = vec!["md".to_string()];
1637        let tags: Vec<String> = vec![];
1638        let cfg = make_config(&base, &exts, "index.md", &tags);
1639
1640        let html = r#"<p><img src="../hledger-web-gui_attachments/my%20photo.png" alt="x"></p>"#;
1641        let errs = validate_media_references(html, &cfg, "/Projects/Ideas/hledger-web-gui/");
1642        assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
1643    }
1644
1645    #[test]
1646    fn index_page_sibling_img_is_ignored() {
1647        // docs/index.md is served at /docs/, so a plain "img.png" src loads
1648        // docs/img.png in the browser.
1649        let dir = TempDir::new().unwrap();
1650        let base = dir.path().canonicalize().unwrap();
1651        std::fs::create_dir_all(base.join("docs")).unwrap();
1652        std::fs::write(base.join("docs/index.md"), "# x").unwrap();
1653        std::fs::write(base.join("docs/img.png"), b"\x89PNG").unwrap();
1654        let exts = vec!["md".to_string()];
1655        let tags: Vec<String> = vec![];
1656        let cfg = make_config(&base, &exts, "index.md", &tags);
1657
1658        let html = r#"<p><img src="img.png" alt="x"></p>"#;
1659        let errs = validate_media_references(html, &cfg, "/docs/");
1660        assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
1661    }
1662
1663    #[test]
1664    fn parent_relative_missing_img_is_reported() {
1665        // A genuinely missing file reached via `../` must still be flagged.
1666        let (_guard, base) = attachments_setup();
1667        let exts = vec!["md".to_string()];
1668        let tags: Vec<String> = vec![];
1669        let cfg = make_config(&base, &exts, "index.md", &tags);
1670
1671        let html = r#"<p><img src="../hledger-web-gui_attachments/nope.png" alt="x"></p>"#;
1672        let errs = validate_media_references(html, &cfg, "/Projects/Ideas/hledger-web-gui/");
1673        assert_eq!(errs.len(), 1);
1674        assert!(matches!(
1675            &errs[0],
1676            PageError::BrokenMediaReference { kind: MediaKind::Image, src }
1677                if src == "../hledger-web-gui_attachments/nope.png"
1678        ));
1679    }
1680
1681    // --- collect_media_references -----------------------------------------
1682
1683    fn video_setup() -> (TempDir, PathBuf) {
1684        let dir = TempDir::new().unwrap();
1685        let base = dir.path().canonicalize().unwrap();
1686        std::fs::create_dir_all(base.join("Projects")).unwrap();
1687        std::fs::write(base.join("Projects/note.md"), "# x").unwrap();
1688        std::fs::write(base.join("Projects/Foo Bar.mp4"), b"\x00\x00\x00\x18ftyp").unwrap();
1689        (dir, base)
1690    }
1691
1692    #[test]
1693    fn collect_media_references_resolves_source_to_disk_path() {
1694        let (_guard, base) = video_setup();
1695        let exts = vec!["md".to_string()];
1696        let tags: Vec<String> = vec![];
1697        let cfg = make_config(&base, &exts, "index.md", &tags);
1698
1699        let html = r#"<video><source src="../Foo%20Bar.mp4" type="video/mp4"></video>"#;
1700        let refs = collect_media_references(html, &cfg, "/Projects/note/");
1701
1702        assert_eq!(refs.len(), 1, "{:?}", refs);
1703        assert_eq!(refs[0].src, "../Foo%20Bar.mp4");
1704        assert_eq!(refs[0].kind, MediaKind::Source);
1705        assert_eq!(refs[0].path, base.join("Projects/Foo Bar.mp4"));
1706    }
1707
1708    #[test]
1709    fn collect_media_references_strips_time_fragment() {
1710        // A `{{ vid(start=…) }}` embed puts `#t=30,200` on the <source> but not
1711        // on the sibling <mbr-video-extras>. Stripping makes both spellings
1712        // produce the same join key for the frontend.
1713        let (_guard, base) = video_setup();
1714        let exts = vec!["md".to_string()];
1715        let tags: Vec<String> = vec![];
1716        let cfg = make_config(&base, &exts, "index.md", &tags);
1717
1718        let html = r#"<video><source src="../Foo%20Bar.mp4#t=30,200"></video>"#;
1719        let refs = collect_media_references(html, &cfg, "/Projects/note/");
1720
1721        assert_eq!(refs.len(), 1);
1722        assert_eq!(refs[0].src, "../Foo%20Bar.mp4");
1723    }
1724
1725    #[test]
1726    fn collect_media_references_dedupes_repeated_embeds() {
1727        // reid-video-issue.md embeds the same clip twice (percent-encoded and
1728        // angle-bracketed); the probe must not run twice.
1729        let (_guard, base) = video_setup();
1730        let exts = vec!["md".to_string()];
1731        let tags: Vec<String> = vec![];
1732        let cfg = make_config(&base, &exts, "index.md", &tags);
1733
1734        let html = r#"
1735            <video><source src="../Foo%20Bar.mp4"></video>
1736            <video><source src="../Foo%20Bar.mp4#t=5"></video>
1737        "#;
1738        let refs = collect_media_references(html, &cfg, "/Projects/note/");
1739        assert_eq!(refs.len(), 1, "{:?}", refs);
1740    }
1741
1742    #[test]
1743    fn collect_media_references_skips_missing_and_external() {
1744        let (_guard, base) = video_setup();
1745        let exts = vec!["md".to_string()];
1746        let tags: Vec<String> = vec![];
1747        let cfg = make_config(&base, &exts, "index.md", &tags);
1748
1749        let html = r#"
1750            <video><source src="../Gone.mp4"></video>
1751            <video><source src="https://example.com/x.mp4"></video>
1752        "#;
1753        let refs = collect_media_references(html, &cfg, "/Projects/note/");
1754        assert!(refs.is_empty(), "{:?}", refs);
1755    }
1756
1757    #[test]
1758    fn collect_media_references_finds_images_and_audio_too() {
1759        // The collector is media-type agnostic; callers filter by extension.
1760        let (_guard, base) = media_setup();
1761        let exts = vec!["md".to_string()];
1762        let tags: Vec<String> = vec![];
1763        let cfg = make_config(&base, &exts, "index.md", &tags);
1764
1765        let html = r#"<img src="photo.png"><audio src="photo.png"></audio>"#;
1766        let refs = collect_media_references(html, &cfg, "/");
1767        // Deduped by src, so the second reference to photo.png is dropped.
1768        assert_eq!(refs.len(), 1);
1769        assert_eq!(refs[0].kind, MediaKind::Image);
1770    }
1771
1772    // --- detect_unresolved_wikilinks --------------------------------------
1773
1774    #[test]
1775    fn literal_wikilink_in_body_is_reported() {
1776        let html = "<p>See [[never-a-real-page]] for more.</p>";
1777        let errs = detect_unresolved_wikilinks(html);
1778        assert_eq!(errs.len(), 1);
1779        assert!(matches!(
1780            &errs[0],
1781            PageError::UnresolvedWikilink { raw } if raw == "[[never-a-real-page]]"
1782        ));
1783    }
1784
1785    #[test]
1786    fn transformed_wikilink_yields_no_match() {
1787        // Once `transform_wikilinks` has resolved it, only a normal anchor
1788        // remains and the regex must not match.
1789        let html = r#"<p><a href="/tags/rust/">rust</a></p>"#;
1790        let errs = detect_unresolved_wikilinks(html);
1791        assert!(errs.is_empty());
1792    }
1793
1794    #[test]
1795    fn wikilink_inside_code_block_is_ignored() {
1796        let html =
1797            "<p>Regular</p><pre><code>This is a literal [[bracket]] inside code</code></pre>";
1798        let errs = detect_unresolved_wikilinks(html);
1799        assert!(
1800            errs.is_empty(),
1801            "expected no wikilink errors inside code/pre, got: {:?}",
1802            errs
1803        );
1804    }
1805
1806    #[test]
1807    fn wikilink_inside_inline_code_is_ignored() {
1808        let html = "<p>See <code>[[foo]]</code> for the literal syntax.</p>";
1809        let errs = detect_unresolved_wikilinks(html);
1810        assert!(errs.is_empty());
1811    }
1812
1813    #[test]
1814    fn repeated_wikilink_is_deduped() {
1815        let html = "<p>[[bad]] again [[bad]] and [[bad]]</p>";
1816        let errs = detect_unresolved_wikilinks(html);
1817        assert_eq!(errs.len(), 1);
1818    }
1819
1820    #[test]
1821    fn wikilink_with_display_text_is_reported_verbatim() {
1822        let html = "<p>[[Target|Display Text]]</p>";
1823        let errs = detect_unresolved_wikilinks(html);
1824        assert_eq!(errs.len(), 1);
1825        assert!(matches!(
1826            &errs[0],
1827            PageError::UnresolvedWikilink { raw } if raw == "[[Target|Display Text]]"
1828        ));
1829    }
1830
1831    // --- Serialization ----------------------------------------------------
1832
1833    #[test]
1834    fn page_error_serializes_with_snake_case_type_tag() {
1835        let err = PageError::BrokenInternalLink {
1836            target: "/x/".to_string(),
1837            text: "x".to_string(),
1838            anchor: None,
1839        };
1840        let json = serde_json::to_string(&err).unwrap();
1841        assert!(
1842            json.contains("\"type\":\"broken_internal_link\""),
1843            "{}",
1844            json
1845        );
1846        // anchor is None, so it should be skipped
1847        assert!(!json.contains("\"anchor\""), "{}", json);
1848    }
1849
1850    #[test]
1851    fn media_kind_serializes_as_snake_case() {
1852        let err = PageError::BrokenMediaReference {
1853            src: "./x.png".to_string(),
1854            kind: MediaKind::Image,
1855        };
1856        let json = serde_json::to_string(&err).unwrap();
1857        assert!(json.contains("\"kind\":\"image\""), "{}", json);
1858        assert!(
1859            json.contains("\"type\":\"broken_media_reference\""),
1860            "{}",
1861            json
1862        );
1863    }
1864
1865    #[test]
1866    fn unresolved_wikilink_serializes() {
1867        let err = PageError::UnresolvedWikilink {
1868            raw: "[[foo]]".to_string(),
1869        };
1870        let json = serde_json::to_string(&err).unwrap();
1871        assert!(json.contains("\"type\":\"unresolved_wikilink\""));
1872        assert!(json.contains("\"raw\":\"[[foo]]\""));
1873    }
1874
1875    #[test]
1876    fn frontmatter_parse_error_serializes() {
1877        let err = PageError::FrontmatterParseError {
1878            message: "mapping values are not allowed".to_string(),
1879        };
1880        let json = serde_json::to_string(&err).unwrap();
1881        assert!(
1882            json.contains("\"type\":\"frontmatter_parse_error\""),
1883            "{}",
1884            json
1885        );
1886        assert!(json.contains("mapping values are not allowed"), "{}", json);
1887    }
1888
1889    #[test]
1890    fn frontmatter_parse_errors_none_is_empty() {
1891        assert!(frontmatter_parse_errors(&None).is_empty());
1892    }
1893
1894    #[test]
1895    fn frontmatter_parse_errors_some_yields_one() {
1896        let errs = frontmatter_parse_errors(&Some("bad yaml".to_string()));
1897        assert_eq!(errs.len(), 1);
1898        assert!(matches!(
1899            &errs[0],
1900            PageError::FrontmatterParseError { message } if message == "bad yaml"
1901        ));
1902    }
1903
1904    // The three variants below are a pinned wire contract shared with
1905    // `mbr-page-errors.ts`. The assertions spell out the exact JSON on purpose:
1906    // renaming a field here silently breaks the frontend, and a full-string
1907    // comparison is what makes that impossible to miss.
1908
1909    #[test]
1910    fn config_parse_error_serializes_to_the_pinned_shape() {
1911        let err = PageError::ConfigParseError {
1912            message: "bad toml".to_string(),
1913        };
1914        assert_eq!(
1915            serde_json::to_string(&err).unwrap(),
1916            r#"{"type":"config_parse_error","message":"bad toml"}"#
1917        );
1918    }
1919
1920    #[test]
1921    fn relationship_cycle_serializes_to_the_pinned_shape() {
1922        let err = PageError::RelationshipCycle {
1923            members: vec!["/a/".to_string(), "/b/".to_string()],
1924            rel_type: "child".to_string(),
1925        };
1926        assert_eq!(
1927            serde_json::to_string(&err).unwrap(),
1928            r#"{"type":"relationship_cycle","members":["/a/","/b/"],"rel_type":"child"}"#
1929        );
1930    }
1931
1932    #[test]
1933    fn ambiguous_relationship_endpoint_serializes_to_the_pinned_shape() {
1934        let err = PageError::AmbiguousRelationshipEndpoint {
1935            raw: "[[John Doe]]".to_string(),
1936            resolved_to: "/people/john-jr/".to_string(),
1937            candidates: vec!["/people/john-sr/".to_string()],
1938        };
1939        assert_eq!(
1940            serde_json::to_string(&err).unwrap(),
1941            r#"{"type":"ambiguous_relationship_endpoint","raw":"[[John Doe]]","resolved_to":"/people/john-jr/","candidates":["/people/john-sr/"]}"#
1942        );
1943    }
1944
1945    #[test]
1946    fn ambiguous_wikilink_serializes_to_the_pinned_shape() {
1947        let err = PageError::AmbiguousWikilink {
1948            raw: "[[John Doe]]".to_string(),
1949            resolved_to: "/people/john-jr/".to_string(),
1950            candidates: vec!["/people/john-sr/".to_string()],
1951        };
1952        assert_eq!(
1953            serde_json::to_string(&err).unwrap(),
1954            r#"{"type":"ambiguous_wikilink","raw":"[[John Doe]]","resolved_to":"/people/john-jr/","candidates":["/people/john-sr/"]}"#
1955        );
1956    }
1957
1958    #[test]
1959    fn relationship_cycle_errors_maps_every_cycle() {
1960        let cycles = vec![
1961            RelationshipCycle {
1962                members: vec!["/a/".to_string(), "/b/".to_string()],
1963                rel_type: "child".to_string(),
1964            },
1965            RelationshipCycle {
1966                members: vec!["/x/".to_string(), "/y/".to_string()],
1967                rel_type: "employee".to_string(),
1968            },
1969        ];
1970        let errs = relationship_cycle_errors(&cycles);
1971        assert_eq!(errs.len(), 2);
1972        assert!(matches!(
1973            &errs[0],
1974            PageError::RelationshipCycle { rel_type, members }
1975                if rel_type == "child" && members.len() == 2
1976        ));
1977        assert!(relationship_cycle_errors(&[]).is_empty());
1978    }
1979
1980    #[test]
1981    fn ambiguous_relationship_endpoint_errors_preserves_all_candidates() {
1982        let endpoints = vec![AmbiguousEndpoint {
1983            raw: "[[Sam]]".to_string(),
1984            resolved_to: "/a/sam/".to_string(),
1985            candidates: vec!["/m/sam/".to_string(), "/z/sam/".to_string()],
1986        }];
1987        let errs = ambiguous_relationship_endpoint_errors(&endpoints);
1988        assert_eq!(errs.len(), 1);
1989        assert!(matches!(
1990            &errs[0],
1991            PageError::AmbiguousRelationshipEndpoint { raw, resolved_to, candidates }
1992                if raw == "[[Sam]]" && resolved_to == "/a/sam/" && candidates.len() == 2
1993        ));
1994        assert!(ambiguous_relationship_endpoint_errors(&[]).is_empty());
1995    }
1996
1997    #[test]
1998    fn ambiguous_wikilink_errors_preserves_all_candidates() {
1999        let wikilinks = vec![AmbiguousWikilink {
2000            raw: "[[Sam]]".to_string(),
2001            resolved_to: "/a/sam/".to_string(),
2002            candidates: vec!["/z/sam/".to_string()],
2003        }];
2004        let errs = ambiguous_wikilink_errors(&wikilinks);
2005        assert_eq!(errs.len(), 1);
2006        assert!(matches!(
2007            &errs[0],
2008            PageError::AmbiguousWikilink { raw, resolved_to, candidates }
2009                if raw == "[[Sam]]" && resolved_to == "/a/sam/" && candidates == &["/z/sam/".to_string()]
2010        ));
2011        assert!(ambiguous_wikilink_errors(&[]).is_empty());
2012    }
2013
2014    #[test]
2015    fn unplayable_media_serializes_to_the_frontend_contract() {
2016        // This exact shape is a contract with `components/src/`. Changing any
2017        // key or tag value here is a breaking frontend change.
2018        let err = PageError::UnplayableMedia {
2019            src: "../Foo%20Bar.mp4".to_string(),
2020            kind: MediaKind::Video,
2021            reason: "likely cause".to_string(),
2022            remedy: Some(
2023                "ffmpeg -i in.mp4 -map 0 -c copy -dn -movflags +faststart out.mp4".to_string(),
2024            ),
2025            advisory: true,
2026        };
2027
2028        let json = serde_json::to_value(&err).unwrap();
2029        assert_eq!(
2030            json,
2031            serde_json::json!({
2032                "type": "unplayable_media",
2033                "src": "../Foo%20Bar.mp4",
2034                "kind": "video",
2035                "reason": "likely cause",
2036                "remedy": "ffmpeg -i in.mp4 -map 0 -c copy -dn -movflags +faststart out.mp4",
2037                "advisory": true
2038            })
2039        );
2040    }
2041
2042    #[test]
2043    fn unplayable_media_omits_absent_remedy() {
2044        let err = PageError::UnplayableMedia {
2045            src: "../x.mp4".to_string(),
2046            kind: MediaKind::Video,
2047            reason: "nope".to_string(),
2048            remedy: None,
2049            advisory: true,
2050        };
2051        let json = serde_json::to_string(&err).unwrap();
2052        assert!(!json.contains("remedy"), "{}", json);
2053    }
2054
2055    #[test]
2056    fn unplayable_media_round_trips() {
2057        let err = PageError::UnplayableMedia {
2058            src: "../Foo%20Bar.mp4".to_string(),
2059            kind: MediaKind::Video,
2060            reason: "r".to_string(),
2061            remedy: Some("cmd".to_string()),
2062            advisory: true,
2063        };
2064        let json = serde_json::to_string(&err).unwrap();
2065        let back: PageError = serde_json::from_str(&json).unwrap();
2066        assert_eq!(back, err);
2067    }
2068
2069    #[test]
2070    fn page_errors_empty_default_serializes() {
2071        let pe = PageErrors {
2072            page_url: "/x/".to_string(),
2073            errors: vec![],
2074            not_downloaded: false,
2075        };
2076        let json = serde_json::to_string(&pe).unwrap();
2077        assert!(json.contains("\"page_url\":\"/x/\""));
2078        assert!(json.contains("\"errors\":[]"));
2079        assert!(
2080            !json.contains("not_downloaded"),
2081            "an ordinary payload is unchanged: {json}"
2082        );
2083    }
2084
2085    #[test]
2086    fn page_errors_mark_a_not_downloaded_note() {
2087        let pe = PageErrors {
2088            page_url: "/cloud/".to_string(),
2089            errors: vec![],
2090            not_downloaded: true,
2091        };
2092        let json = serde_json::to_string(&pe).unwrap();
2093        assert_eq!(
2094            json,
2095            r#"{"page_url":"/cloud/","errors":[],"not_downloaded":true}"#
2096        );
2097        let back: PageErrors = serde_json::from_str(r#"{"page_url":"/x/","errors":[]}"#).unwrap();
2098        assert!(!back.not_downloaded, "absent means downloaded");
2099    }
2100}