Skip to main content

rumdl_lib/rules/
md057_existing_relative_links.rs

1//!
2//! Rule MD057: Existing relative links
3//!
4//! See [docs/md057.md](../../docs/md057.md) for full documentation, configuration, and examples.
5
6use crate::rule::{
7    CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
8};
9use crate::utils::frontmatter_values;
10use crate::utils::range_utils::byte_to_char_count;
11use crate::workspace_index::{
12    FileIndex, LinkOrigin, Md057LinkTarget, extract_cross_file_links, link_path_part, normalize_relative_path,
13    url_decode,
14};
15use pulldown_cmark::LinkType;
16use regex::Regex;
17use std::cell::{LazyCell, RefCell};
18use std::collections::{HashMap, HashSet};
19use std::env;
20use std::ffi::{OsStr, OsString};
21use std::path::{Component, Path, PathBuf};
22use std::sync::LazyLock;
23use std::sync::{Arc, Mutex};
24use std::time::SystemTime;
25use unicode_normalization::UnicodeNormalization;
26
27mod link_resolver;
28mod md057_config;
29use crate::utils::mkdocs_config::resolve_docs_dir;
30use crate::utils::obsidian_config::resolve_attachment_folder;
31use crate::utils::project_root::project_root;
32#[cfg(test)]
33use link_resolver::is_external_url;
34pub use link_resolver::{DocumentLinks, LinkResolution, LinkResolver};
35pub use md057_config::{AbsoluteLinksOption, MD057Config};
36
37thread_local! {
38    /// Which paths exist, as learned during the check running on this thread.
39    /// A check runs start to finish on one thread and clears the map when it
40    /// starts, so documents checked in parallel neither contend for one lock
41    /// nor clear each other's answers.
42    static FILE_EXISTENCE_CACHE: RefCell<HashMap<PathBuf, bool>> = RefCell::new(HashMap::new());
43    // A single check observes each directory once, just as it observes each
44    // target once above. No snapshot survives into another document check.
45    static CHECK_DIRECTORY_LISTINGS: RefCell<Option<HashMap<PathBuf, Arc<DirectoryListing>>>> = const { RefCell::new(None) };
46}
47
48struct DirectoryCheck {
49    previous: Option<HashMap<PathBuf, Arc<DirectoryListing>>>,
50}
51
52impl DirectoryCheck {
53    fn begin() -> Self {
54        Self {
55            previous: CHECK_DIRECTORY_LISTINGS.with_borrow_mut(|cache| cache.replace(HashMap::new())),
56        }
57    }
58}
59
60impl Drop for DirectoryCheck {
61    fn drop(&mut self) {
62        CHECK_DIRECTORY_LISTINGS.with_borrow_mut(|cache| *cache = self.previous.take());
63    }
64}
65
66/// The shared map behind the directory cache. Each listing is an `Arc` so a
67/// lookup clones the handle and releases the lock before reading the names.
68type DirectoryListingCache = Arc<Mutex<HashMap<PathBuf, Arc<DirectoryListing>>>>;
69
70/// Directory listings, keyed by directory. One listing answers for every link
71/// naming an entry in that directory, and stands for as long as the
72/// directory's modification time does.
73static DIRECTORY_LISTING_CACHE: LazyLock<DirectoryListingCache> =
74    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
75
76/// Drops what one check learned about which paths exist, so the next check
77/// asks the filesystem again.
78///
79/// The directory listings are not dropped. Each carries the modification time
80/// it was read at and is read again once that time moves, which is a stat per
81/// lookup rather than a read of the whole directory per file checked.
82fn reset_file_existence_cache() {
83    FILE_EXISTENCE_CACHE.with_borrow_mut(HashMap::clear);
84}
85
86/// The entry names of one directory, in the spelling the filesystem stores,
87/// together with the moment they were read at.
88struct DirectoryListing {
89    /// The directory's modification time when the read began, or `None` where
90    /// the directory's metadata could not be read. A lookup keeps this listing
91    /// only while the directory still reports this time.
92    modified: Option<SystemTime>,
93    /// Whether the directory could be read in full. One that could not says
94    /// nothing about how its entries are spelled.
95    listed: bool,
96    /// Every entry name, compared byte for byte.
97    names: HashSet<OsString>,
98    /// The composed form of each entry name holding a character outside ASCII.
99    /// A macOS volume can store such a name decomposed while the link is typed
100    /// composed, and the two spell the same name.
101    composed: HashSet<String>,
102}
103
104impl DirectoryListing {
105    /// A listing that says nothing about how the directory's entries are
106    /// spelled, so every name under it is accepted.
107    fn unlisted(modified: Option<SystemTime>) -> Self {
108        Self {
109            modified,
110            listed: false,
111            names: HashSet::new(),
112            composed: HashSet::new(),
113        }
114    }
115
116    fn read(directory: &Path) -> Self {
117        // The time is taken before the entries are, so a change landing during
118        // the read leaves this listing behind the directory's own time and the
119        // next lookup reads again.
120        let modified = std::fs::metadata(directory)
121            .and_then(|metadata| metadata.modified())
122            .ok();
123        let mut names = HashSet::new();
124        let mut composed = HashSet::new();
125        let Ok(entries) = std::fs::read_dir(directory) else {
126            return Self::unlisted(modified);
127        };
128        for entry in entries {
129            let Ok(entry) = entry else {
130                // A name the kernel could not return is missing from these
131                // names, and a listing missing a name must not answer that the
132                // name is absent.
133                return Self::unlisted(modified);
134            };
135            let name = entry.file_name();
136            if let Some(text) = name.to_str()
137                && !text.is_ascii()
138            {
139                composed.insert(text.nfc().collect());
140            }
141            names.insert(name);
142        }
143        Self {
144            modified,
145            listed: true,
146            names,
147            composed,
148        }
149    }
150
151    /// Whether the directory holds an entry that spells `name`: either the
152    /// spelling as written, or the same name in composed form on one side or
153    /// the other. A name outside ASCII can compose to one inside it (the
154    /// Kelvin sign composes to the letter K), so an ASCII name is looked up
155    /// among the composed forms as well, and a composed form is looked up
156    /// among the entries as written as well as among their composed forms.
157    fn holds(&self, name: &OsStr) -> bool {
158        if !self.listed {
159            return true;
160        }
161        if self.names.contains(name) {
162            return true;
163        }
164        let Some(text) = name.to_str() else {
165            return false;
166        };
167        if text.is_ascii() {
168            return self.composed.contains(text);
169        }
170        let composed: String = text.nfc().collect();
171        self.composed.contains(composed.as_str()) || self.names.contains(OsStr::new(composed.as_str()))
172    }
173}
174
175/// The listing of `directory`, reused for every link naming an entry in it
176/// until the directory's modification time moves.
177///
178/// The first lookup in a check stats the directory and keeps a stored listing
179/// only while its recorded time is the one the directory reports, so a directory of many
180/// documents linking to their siblings is read once rather than once per
181/// document. Creating, deleting or renaming an entry moves that time on APFS,
182/// ext4 and NTFS, so a process that lints the same tree repeatedly sees the
183/// change at its next lookup. Where the filesystem keeps the time coarsely,
184/// one second on HFS+ and two on FAT, a change inside the same tick as the
185/// read is seen once the directory next changes. A long-lived process holds
186/// one listing per directory its links reach.
187///
188/// The read happens with no lock held, so one slow directory does not stop
189/// other threads answering from the cache. Two threads reaching the same
190/// directory both read it and store the same names.
191fn directory_listing(directory: &Path) -> Arc<DirectoryListing> {
192    if let Some(listing) =
193        CHECK_DIRECTORY_LISTINGS.with_borrow(|cache| cache.as_ref().and_then(|cache| cache.get(directory).cloned()))
194    {
195        return listing;
196    }
197    let listing = fresh_directory_listing(directory);
198    CHECK_DIRECTORY_LISTINGS.with_borrow_mut(|cache| {
199        if let Some(cache) = cache {
200            cache.insert(directory.to_path_buf(), Arc::clone(&listing));
201        }
202    });
203    listing
204}
205
206fn fresh_directory_listing(directory: &Path) -> Arc<DirectoryListing> {
207    let Ok(modified) = std::fs::metadata(directory).and_then(|metadata| metadata.modified()) else {
208        // With no time to compare against, a stored listing cannot be told
209        // from one the directory has outgrown.
210        return Arc::new(DirectoryListing::read(directory));
211    };
212    match DIRECTORY_LISTING_CACHE.lock() {
213        Ok(cache) => {
214            if let Some(listing) = cache.get(directory)
215                && listing.modified == Some(modified)
216            {
217                return Arc::clone(listing);
218            }
219        }
220        Err(_) => return Arc::new(DirectoryListing::read(directory)), // Fallback to an uncached listing on mutex poison
221    }
222    let listing = Arc::new(DirectoryListing::read(directory));
223    match DIRECTORY_LISTING_CACHE.lock() {
224        Ok(mut cache) => {
225            // Two threads that raced on an unchanged directory store the same
226            // names. If the directory changed between their reads, the slower
227            // thread can store the older listing over the newer one; the next
228            // lookup's stat then finds a time that no longer matches and reads
229            // again, so a stale insert costs one extra read and never a wrong
230            // answer.
231            cache.insert(directory.to_path_buf(), Arc::clone(&listing));
232            listing
233        }
234        Err(_) => listing, // Fallback to an uncached listing on mutex poison
235    }
236}
237
238// Check if a file exists with caching
239fn file_exists_with_cache(path: &Path) -> bool {
240    if let Some(exists) = FILE_EXISTENCE_CACHE.with_borrow(|cache| cache.get(path).copied()) {
241        return exists;
242    }
243    let exists = path.exists();
244    FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(path.to_path_buf(), exists));
245    exists
246}
247
248/// Whether every component of `path` below `anchor` is spelled the way the
249/// filesystem stores it.
250///
251/// `anchor` is the directory the link resolves against, so everything above it
252/// is the path to the project rather than anything the link author wrote and is
253/// left alone. A `..` moves up without a listing check, and a directory that
254/// cannot be listed is accepted, so the walk only ever rejects a spelling a
255/// directory listing contradicts.
256///
257/// A `..` component moves the anchor up lexically, the way a URL resolver does,
258/// so a link through a symlinked directory is judged by the path as written
259/// rather than by the directory the symlink reaches.
260fn has_exact_case_components(anchor: &Path, path: &Path) -> bool {
261    let Ok(relative) = path.strip_prefix(anchor) else {
262        return true;
263    };
264    let mut directory = if anchor.as_os_str().is_empty() {
265        PathBuf::from(".")
266    } else {
267        anchor.to_path_buf()
268    };
269    for component in relative.components() {
270        match component {
271            Component::CurDir => {}
272            Component::ParentDir => {
273                // Only a named directory can be stepped out of by dropping it.
274                // Above a root, or past a `..` already on the path, the step
275                // has to be written out instead.
276                if matches!(directory.components().next_back(), Some(Component::Normal(_))) {
277                    directory.pop();
278                } else {
279                    directory.push("..");
280                }
281                // Dropping the only component of a relative path lands on the
282                // directory the process runs in.
283                if directory.as_os_str().is_empty() {
284                    directory.push(".");
285                }
286            }
287            Component::Normal(name) => {
288                if !directory_listing(&directory).holds(name) {
289                    return false;
290                }
291                directory.push(name);
292            }
293            // A relative path holds neither, and a path that does is not one
294            // this rule resolved against the anchor.
295            Component::RootDir | Component::Prefix(_) => return true,
296        }
297    }
298    true
299}
300
301/// Whether `path` exists with exactly the spelling it is written in.
302///
303/// A case-insensitive volume answers `exists` for a spelling it does not store,
304/// so macOS and Windows accept a link that 404s on every web server and breaks
305/// the moment the project is checked out on Linux. The filesystem's own answer
306/// is the cheap negative: a path that is not there is not there whatever its
307/// case. A path that is there is confirmed against the directory listings below
308/// `anchor`, which compare the stored name, as a server does.
309fn exists_exact_case(anchor: &Path, path: &Path) -> bool {
310    file_exists_with_cache(path) && has_exact_case_components(anchor, path)
311}
312
313/// The file a link path resolves to, or `None` when nothing is there.
314///
315/// An extensionless link is tried against the markdown extensions in turn, so
316/// `[Link](page)` resolves to `page.md`.
317fn resolve_existing_target(anchor: &Path, path: &Path) -> Option<PathBuf> {
318    // First, check exact path
319    if exists_exact_case(anchor, path) {
320        return Some(path.to_path_buf());
321    }
322
323    // If the path has no extension, try adding markdown extensions
324    if path.extension().is_none() {
325        for ext in MARKDOWN_EXTENSIONS {
326            // MARKDOWN_EXTENSIONS includes the dot, e.g., ".md"
327            let path_with_ext = path.with_extension(&ext[1..]);
328            if exists_exact_case(anchor, &path_with_ext) {
329                return Some(path_with_ext);
330            }
331        }
332    }
333
334    None
335}
336
337/// The markdown sources an `.html`/`.htm` link may be generated from, in
338/// `MARKDOWN_EXTENSIONS` order; empty for any other link.
339fn html_markdown_sources(path: &Path) -> impl Iterator<Item = PathBuf> + '_ {
340    let source = path
341        .extension()
342        .and_then(|ext| ext.to_str())
343        .filter(|ext| ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
344        .and(path.file_stem().and_then(|stem| stem.to_str()))
345        .zip(path.parent());
346    source.into_iter().flat_map(|(stem, parent)| {
347        MARKDOWN_EXTENSIONS
348            .iter()
349            .map(move |md_ext| parent.join(format!("{stem}{md_ext}")))
350    })
351}
352
353/// Regex to detect URLs with explicit schemes (should not be checked as relative links)
354/// Matches: scheme:// or scheme: (per RFC 3986)
355/// This covers http, https, ftp, file, smb, mailto, tel, data, macappstores, etc.
356static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
357    LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
358
359// Current working directory
360static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
361
362/// Supported markdown file extensions
363const MARKDOWN_EXTENSIONS: &[&str] = &[
364    ".md",
365    ".markdown",
366    ".mdx",
367    ".mkd",
368    ".mkdn",
369    ".mdown",
370    ".mdwn",
371    ".qmd",
372    ".rmd",
373];
374
375/// Where one document's relative destinations resolve from.
376struct DocumentAnchor {
377    /// The document as the filesystem sees it, symlinks resolved. Links are
378    /// compared against it to find the ones that point back at their own
379    /// document.
380    self_path: Option<PathBuf>,
381    /// The directory relative destinations are joined to: the explicit base
382    /// when one is set, otherwise the directory holding `self_path`, so a
383    /// symlinked document resolves from its target's directory.
384    base_path: PathBuf,
385    /// The directories a destination is also looked up in after `base_path`.
386    search_paths: Vec<PathBuf>,
387}
388
389/// A document's anchor, worked out the first time a destination needs it.
390/// Finding it canonicalizes the document's path, a filesystem call per
391/// document, while most destinations never read it: an external URL, a
392/// fragment and an ignored absolute path are all settled from the text.
393type LazyAnchor<'a> = LazyCell<DocumentAnchor, Box<dyn FnOnce() -> DocumentAnchor + 'a>>;
394
395#[cfg(test)]
396thread_local! {
397    /// How many anchors this thread has worked out, so a test can tell a
398    /// check that never needed one from a check that resolved one.
399    static ANCHOR_RESOLUTIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
400}
401
402/// A relative link that resolves to the file it is written in.
403#[derive(Debug, PartialEq, Eq)]
404enum SelfReferentialLink {
405    /// The link addresses the whole file, so there is no shorter way to write
406    /// the same destination.
407    WholeFile,
408    /// The link carries a fragment, which on its own reaches the same heading
409    /// without leaving the page.
410    Fragment(String),
411}
412
413#[cfg(feature = "blake3")]
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415enum DependencyPathState {
416    Missing,
417    File,
418    Directory,
419    Other,
420}
421
422/// Rule MD057: Existing relative links should point to valid files or directories.
423#[derive(Debug, Clone)]
424pub struct MD057ExistingRelativeLinks {
425    /// Base directory for resolving relative links. Behind an `Arc<Mutex<..>>`
426    /// so it is shared across clones (the rule is cloned per config group and
427    /// for inline-config overrides); the base is resolved from the file under
428    /// check and consulted while validating that file's links.
429    base_path: Arc<Mutex<Option<PathBuf>>>,
430    /// Configuration for the rule
431    config: MD057Config,
432}
433
434impl Default for MD057ExistingRelativeLinks {
435    fn default() -> Self {
436        Self {
437            base_path: Arc::new(Mutex::new(None)),
438            config: MD057Config::default(),
439        }
440    }
441}
442
443impl MD057ExistingRelativeLinks {
444    /// Create a new instance with default settings
445    pub fn new() -> Self {
446        Self::default()
447    }
448
449    /// Set the base path for resolving relative links
450    pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
451        let path = path.as_ref();
452        let dir_path = if path.is_file() {
453            path.parent().map(std::path::Path::to_path_buf)
454        } else {
455            Some(path.to_path_buf())
456        };
457
458        if let Ok(mut guard) = self.base_path.lock() {
459            *guard = dir_path;
460        }
461        self
462    }
463
464    pub fn from_config_struct(config: MD057Config) -> Self {
465        Self {
466            base_path: Arc::new(Mutex::new(None)),
467            config,
468        }
469    }
470
471    /// Resolve a config-supplied path string (from `roots` or `search-paths`)
472    /// against the project root: absolute strings are taken verbatim, relative
473    /// strings are joined onto `project_root`.
474    fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
475        if Path::new(path_str).is_absolute() {
476            PathBuf::from(path_str)
477        } else {
478            project_root.join(path_str)
479        }
480    }
481
482    /// External destinations and gh-aw output placeholders do not name files
483    /// relative to the Markdown source.
484    #[inline]
485    fn is_non_file_destination(&self, url: &str, flavor: crate::config::MarkdownFlavor) -> bool {
486        link_resolver::is_non_file_destination(url, flavor)
487    }
488
489    /// Check if the URL is a fragment-only link (internal document link)
490    #[inline]
491    fn is_fragment_only_link(&self, url: &str) -> bool {
492        url.starts_with('#')
493    }
494
495    /// Check if the URL is an absolute path (starts with /)
496    /// These are typically routes for published documentation sites.
497    #[inline]
498    fn is_absolute_path(url: &str) -> bool {
499        url.starts_with('/')
500    }
501
502    /// Strip query parameters and fragments from a URL for file existence checking.
503    /// URLs like `path/to/image.png?raw=true` or `file.md#section` should check
504    /// for `path/to/image.png` or `file.md` respectively.
505    ///
506    /// Note: In standard URLs, query parameters (`?`) come before fragments (`#`),
507    /// so we check for `?` first. If a URL has both, only the query is stripped here
508    /// (fragments are handled separately by the regex in `contribute_to_index`).
509    fn strip_query_and_fragment(url: &str) -> &str {
510        // Find the first occurrence of '?' or '#', whichever comes first
511        // This handles both standard URLs (? before #) and edge cases (# before ?)
512        let query_pos = url.find('?');
513        let fragment_pos = url.find('#');
514
515        match (query_pos, fragment_pos) {
516            (Some(q), Some(f)) => {
517                // Both exist - strip at whichever comes first
518                &url[..q.min(f)]
519            }
520            (Some(q), None) => &url[..q],
521            (None, Some(f)) => &url[..f],
522            (None, None) => url,
523        }
524    }
525
526    /// Resolve a relative link against a provided base path
527    fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
528        base_path.join(link)
529    }
530
531    /// Compute additional search paths for fallback link resolution.
532    ///
533    /// Combines Obsidian attachment folder auto-detection (when flavor is Obsidian)
534    /// with explicitly configured `search-paths`.
535    fn compute_search_paths(
536        &self,
537        flavor: crate::config::MarkdownFlavor,
538        source_file: Option<&Path>,
539        base_path: &Path,
540        project_root: &Path,
541    ) -> Vec<PathBuf> {
542        Self::search_paths_for(&self.config.search_paths, flavor, source_file, base_path, project_root)
543    }
544
545    /// The directories a relative destination is also looked up in, after the
546    /// linking document's own directory: the Obsidian attachment folder, then
547    /// each configured search path.
548    fn search_paths_for(
549        configured: &[String],
550        flavor: crate::config::MarkdownFlavor,
551        source_file: Option<&Path>,
552        base_path: &Path,
553        project_root: &Path,
554    ) -> Vec<PathBuf> {
555        let mut paths = Vec::new();
556
557        // Auto-detect Obsidian attachment folder
558        if flavor == crate::config::MarkdownFlavor::Obsidian
559            && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
560            && attachment_dir != *base_path
561        {
562            paths.push(attachment_dir);
563        }
564
565        // Add explicitly configured search paths. Resolved relative to the
566        // discovered project root so paths are stable regardless of which
567        // subdirectory rumdl is invoked from.
568        for search_path in configured {
569            let resolved = Self::resolve_against_project_root(search_path, project_root);
570            if resolved != *base_path && !paths.contains(&resolved) {
571                paths.push(resolved);
572            }
573        }
574
575        paths
576    }
577
578    /// Record every filesystem destination this rule may validate.
579    ///
580    /// The index keeps frontmatter inputs regardless of the current rule config
581    /// because a content-matched entry can be reused by another config group.
582    fn contribute_dependency_targets(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
583        for destination in ctx.body_link_destinations() {
584            let url = destination.url;
585            if url.is_empty()
586                || (url.starts_with('`') && url.ends_with('`'))
587                || self.is_non_file_destination(url, ctx.flavor)
588                || self.is_fragment_only_link(url)
589            {
590                continue;
591            }
592            index.add_md057_link_target(Md057LinkTarget {
593                target: url.to_string(),
594                origin: LinkOrigin::Body,
595            });
596        }
597
598        for image in ctx.images() {
599            if ctx
600                .line_info(image.line)
601                .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
602                || matches!(image.link_type, LinkType::WikiLink { .. })
603                || ctx.is_in_shortcode(image.byte_offset)
604            {
605                continue;
606            }
607            let url = image.url.as_ref();
608            if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
609                continue;
610            }
611            index.add_md057_link_target(Md057LinkTarget {
612                target: url.to_string(),
613                origin: LinkOrigin::Body,
614            });
615        }
616
617        for reference in ctx.reference_definitions() {
618            if ctx.line_info(reference.line).is_some_and(|info| info.in_front_matter) {
619                continue;
620            }
621            let url = reference.url.as_str();
622            if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
623                continue;
624            }
625            index.add_md057_link_target(Md057LinkTarget {
626                target: url.to_string(),
627                origin: LinkOrigin::Body,
628            });
629        }
630
631        for link in frontmatter_values::link_destinations(ctx) {
632            let line = ctx.lines[link.line - 1].content(ctx.content);
633            let url = &line[link.range];
634            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
635                continue;
636            }
637            index.add_md057_link_target(Md057LinkTarget {
638                target: url.to_string(),
639                origin: LinkOrigin::FrontMatter { field: link.field },
640            });
641        }
642    }
643
644    /// The first additional search path holding a link target, as the file it
645    /// resolves to there.
646    fn resolve_in_search_paths(
647        decoded_path: &str,
648        search_paths: &[PathBuf],
649        policy: Option<&crate::lint_context::LinkTargetPolicy>,
650    ) -> Option<PathBuf> {
651        search_paths
652            .iter()
653            .find_map(|dir| Self::resolve_target(dir, &dir.join(decoded_path), policy))
654    }
655
656    /// The file a link target names, resolved against the directory the link is
657    /// written relative to. That directory is the anchor for the spelling
658    /// check: the components below it are the link author's, the ones above it
659    /// belong to wherever the project happens to sit.
660    fn resolve_target(
661        anchor: &Path,
662        path: &Path,
663        policy: Option<&crate::lint_context::LinkTargetPolicy>,
664    ) -> Option<PathBuf> {
665        if let Some(supplied) = policy.and_then(|policy| policy.resolve_supplied(path)) {
666            return Some(supplied);
667        }
668        if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
669            return None;
670        }
671        resolve_existing_target(anchor, path)
672    }
673
674    fn missing_relative_message(url: &str, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> String {
675        if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
676            format!("Relative link '{url}' target not in the supplied document set")
677        } else {
678            format!("Relative link '{url}' does not exist")
679        }
680    }
681
682    /// Check if a relative link can be compacted and return the simplified form.
683    ///
684    /// Returns `None` if compact-paths is disabled, the link has no traversal,
685    /// or the link is already the shortest form.
686    /// Returns `Some(suggestion)` with the full compacted URL (including fragment/query suffix).
687    fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
688        if !self.config.compact_paths {
689            return None;
690        }
691
692        // Split URL into path and suffix (fragment/query)
693        let path_end = url
694            .find('?')
695            .unwrap_or(url.len())
696            .min(url.find('#').unwrap_or(url.len()));
697        let path_part = &url[..path_end];
698        let suffix = &url[path_end..];
699
700        // URL-decode the path portion for filesystem resolution
701        let decoded_path = url_decode(path_part);
702
703        compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
704    }
705
706    /// Classify a relative link that points back at the file it is written in.
707    ///
708    /// Returns `None` when the option is off, when the file under check is
709    /// unknown, or when the link addresses anything else. Fragment-only links
710    /// never reach here: they are already the form this reports towards.
711    ///
712    /// The link is resolved against the file's own directory first and then
713    /// against each search path, matching how the existence check resolves it:
714    /// a link that MD057 accepts through a search path is recognized here as
715    /// well, and one that already resolves next to the document is judged
716    /// against that target alone.
717    fn self_referential_link(
718        &self,
719        url: &str,
720        base_path: &Path,
721        search_paths: &[PathBuf],
722        source_file: Option<&Path>,
723        policy: Option<&crate::lint_context::LinkTargetPolicy>,
724    ) -> Option<SelfReferentialLink> {
725        if !self.config.self_referential_links {
726            return None;
727        }
728        let source_file = source_file?;
729
730        let path_part = Self::strip_query_and_fragment(url);
731        if path_part.is_empty() {
732            return None;
733        }
734        let suffix = &url[path_part.len()..];
735
736        let decoded_path = url_decode(path_part);
737        // First hit wins, as it does for the existence check: a target next to
738        // the document is the one the link addresses, and a search path only
739        // answers for a link that resolves nowhere else.
740        let resolved = std::iter::once(base_path)
741            .chain(search_paths.iter().map(PathBuf::as_path))
742            .find_map(|dir| {
743                Self::resolve_target(dir, &Self::resolve_link_path_with_base(&decoded_path, dir), policy)
744            })?;
745        if !Self::is_same_file(&resolved, source_file) {
746            return None;
747        }
748
749        // A bare fragment reaches the same place. A query string does not
750        // survive being detached from its path, and neither does an empty
751        // fragment, so those are reported without a suggestion.
752        match suffix.strip_prefix('#') {
753            Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
754            _ => Some(SelfReferentialLink::WholeFile),
755        }
756    }
757
758    /// Byte range of a reference definition's destination in the document.
759    ///
760    /// Both the label and the title can repeat the destination text, so the
761    /// search is bounded to what sits between the label's closing bracket and
762    /// the title. Anchoring anywhere else risks a fix rewriting the label,
763    /// which would leave every usage of the reference dangling.
764    fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
765        let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
766        let label_end = Self::label_end(def)?;
767        let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
768            title.saturating_sub(ref_def.byte_offset).min(def.len())
769        });
770        let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
771        let start = ref_def.byte_offset + offset;
772        Some(start..start + ref_def.url.len())
773    }
774
775    /// Offset just past the `]:` that closes a reference definition's label.
776    ///
777    /// A label may itself contain a bracket when the bracket is escaped, so
778    /// escapes are skipped rather than matched.
779    fn label_end(def: &str) -> Option<usize> {
780        let bytes = def.as_bytes();
781        let mut i = 0;
782        while i < bytes.len() {
783            match bytes[i] {
784                b'\\' => i += 2,
785                b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
786                _ => i += 1,
787            }
788        }
789        None
790    }
791
792    /// Whether this document has frontmatter the rule is configured to check.
793    /// Gates the body-link early exits, which would otherwise skip a document
794    /// whose only destinations sit in its frontmatter.
795    fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
796        self.config.check_frontmatter && ctx.front_matter_end_line() > 0
797    }
798
799    /// The warning text for an absolute destination, or `None` when the
800    /// configured handling accepts it.
801    fn absolute_link_message(
802        &self,
803        url: &str,
804        anchor: &LazyAnchor<'_>,
805        project_root: &Path,
806        policy: Option<&crate::lint_context::LinkTargetPolicy>,
807    ) -> Option<String> {
808        match self.config.absolute_links {
809            AbsoluteLinksOption::Ignore => None,
810            AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
811            AbsoluteLinksOption::RelativeToDocs => {
812                Self::validate_absolute_link_via_docs_dir(url, &anchor.base_path, policy)
813            }
814            AbsoluteLinksOption::RelativeToRoots => {
815                Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root, policy)
816            }
817        }
818    }
819
820    /// Report frontmatter values that read as relative destinations but point
821    /// at nothing.
822    ///
823    /// Only existence is checked. The compact-path and self-referential
824    /// suggestions stay out of frontmatter: both rewrite a destination, and a
825    /// frontmatter value is only ever a guess at being one.
826    fn check_front_matter(
827        &self,
828        ctx: &crate::lint_context::LintContext,
829        anchor: &LazyAnchor<'_>,
830        project_root: &Path,
831        warnings: &mut Vec<LintWarning>,
832    ) {
833        if !self.config.check_frontmatter {
834            return;
835        }
836
837        let ignored: HashSet<String> = self
838            .config
839            .ignore_frontmatter_fields
840            .iter()
841            .map(|field| field.to_lowercase())
842            .collect();
843
844        for link in frontmatter_values::link_destinations(ctx) {
845            if link.field_is_in(&ignored) {
846                continue;
847            }
848
849            let line = ctx.lines[link.line - 1].content(ctx.content);
850            let url = &line[link.range.clone()];
851
852            // A fragment belongs to MD051, which validates it against the
853            // document's own headings.
854            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
855                continue;
856            }
857
858            let column = byte_to_char_count(line, link.range.start);
859            let end_column = column + url.chars().count();
860
861            if Self::is_absolute_path(url) {
862                if let Some(message) = self.absolute_link_message(url, anchor, project_root, ctx.link_target_policy()) {
863                    warnings.push(LintWarning {
864                        rule_name: Some(self.name().to_string()),
865                        line: link.line,
866                        column,
867                        end_line: link.line,
868                        end_column,
869                        message,
870                        severity: Severity::Warning,
871                        fix: None,
872                    });
873                }
874                continue;
875            }
876
877            if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
878                continue;
879            }
880
881            warnings.push(LintWarning {
882                rule_name: Some(self.name().to_string()),
883                line: link.line,
884                column,
885                end_line: link.line,
886                end_column,
887                message: Self::missing_relative_message(url, ctx.link_target_policy()),
888                severity: Severity::Error,
889                fix: None,
890            });
891        }
892    }
893
894    /// Whether a relative destination resolves to something on disk.
895    fn relative_target_exists(
896        url: &str,
897        base_path: &Path,
898        search_paths: &[PathBuf],
899        policy: Option<&crate::lint_context::LinkTargetPolicy>,
900    ) -> bool {
901        Self::resolve_relative(url, base_path, search_paths, policy).is_some()
902    }
903
904    /// The file or directory a relative destination resolves to.
905    ///
906    /// The destination is stripped of its query and fragment, percent-decoded,
907    /// then resolved against `base_path`, with two fallbacks: an `.html`/`.htm`
908    /// target resolves to the markdown source it is generated from, and any
909    /// target resolves through the first of `search_paths` that holds it.
910    fn resolve_relative(
911        url: &str,
912        base_path: &Path,
913        search_paths: &[PathBuf],
914        policy: Option<&crate::lint_context::LinkTargetPolicy>,
915    ) -> Option<PathBuf> {
916        let decoded_path = link_path_part(url);
917        let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
918
919        // An extensionless link is also tried with each markdown extension.
920        if let Some(target) = Self::resolve_target(base_path, &resolved_path, policy) {
921            return Some(target);
922        }
923
924        if let Some(source) =
925            html_markdown_sources(&resolved_path).find_map(|source| Self::resolve_target(base_path, &source, policy))
926        {
927            return Some(source);
928        }
929
930        Self::resolve_in_search_paths(&decoded_path, search_paths, policy)
931    }
932
933    /// The anchor of the document at `source_file`. The caller guarantees a
934    /// source file or an explicit base, so a base path always exists.
935    fn document_anchor(
936        &self,
937        flavor: crate::config::MarkdownFlavor,
938        source_file: Option<&Path>,
939        explicit_base: Option<PathBuf>,
940        project_root: &Path,
941    ) -> DocumentAnchor {
942        #[cfg(test)]
943        ANCHOR_RESOLUTIONS.with(|count| count.set(count.get() + 1));
944        let self_path = source_file.map(|source_file| {
945            crate::source_paths::canonicalize_source(source_file).unwrap_or_else(|_| source_file.to_path_buf())
946        });
947        let base_path = explicit_base.unwrap_or_else(|| {
948            self_path
949                .as_deref()
950                .and_then(Path::parent)
951                .map_or_else(|| CURRENT_DIR.clone(), Path::to_path_buf)
952        });
953        let search_paths = self.compute_search_paths(flavor, source_file, &base_path, project_root);
954        DocumentAnchor {
955            self_path,
956            base_path,
957            search_paths,
958        }
959    }
960
961    /// Whether any enabled check can offer a fix. Broken links and absolute
962    /// links are reported without one, so the answer rests on the two options
963    /// that rewrite a destination.
964    fn produces_fixes(&self) -> bool {
965        self.config.compact_paths || self.config.self_referential_links
966    }
967
968    /// The warning text for a link that points at its own file.
969    fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
970        match self_link {
971            SelfReferentialLink::Fragment(fragment) => {
972                format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
973            }
974            SelfReferentialLink::WholeFile => {
975                format!("Relative link '{url}' points to the file it is in")
976            }
977        }
978    }
979
980    /// Whether two existing paths are the same file.
981    ///
982    /// Both sides are canonicalized, which settles symlinks and the platform's
983    /// path representation; the lexical form is the fallback for a path the
984    /// filesystem cannot answer for.
985    fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
986        // Cheap reject before the syscall: a different name is a different file.
987        if resolved.file_name() != source_file.file_name() {
988            return false;
989        }
990        match (
991            resolved.canonicalize(),
992            crate::source_paths::canonicalize_source(source_file),
993        ) {
994            (Ok(link), Ok(source)) => link == source,
995            _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
996        }
997    }
998
999    /// Validate an absolute link against the MkDocs `docs_dir`.
1000    ///
1001    /// Returns `Some(warning_message)` if the link is broken, `None` if valid.
1002    /// Falls back to a generic warning if no mkdocs.yml is found.
1003    fn validate_absolute_link_via_docs_dir(
1004        url: &str,
1005        source_path: &Path,
1006        policy: Option<&crate::lint_context::LinkTargetPolicy>,
1007    ) -> Option<String> {
1008        let Some(resolution) = Self::resolve_absolute_via_docs_dir(url, source_path, policy) else {
1009            return Some(format!(
1010                "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
1011            ));
1012        };
1013
1014        match resolution {
1015            Resolution::Found(_) => None,
1016            Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
1017                "Absolute link '{url}' resolves to directory '{}' which has no index.md",
1018                resolved.display()
1019            )),
1020            Resolution::NotFound { resolved } => Some(format!(
1021                "Absolute link '{url}' resolves to '{}' which does not exist",
1022                resolved.display()
1023            )),
1024        }
1025    }
1026
1027    /// Validate an absolute link by resolving it against each configured root and the project root.
1028    ///
1029    /// Configured `roots` are tried first (first match wins), then the project
1030    /// root is tried as an implicit fallback. The fallback supports links
1031    /// written as literal absolute paths from the project root (e.g.
1032    /// `/content/en/foo.md`) alongside links written relative to a configured
1033    /// root (e.g. `/foo.md` with `roots = ["content/en"]`). A warning is
1034    /// emitted only when no root — configured or implicit — contains the target.
1035    fn validate_absolute_link_via_roots(
1036        url: &str,
1037        roots: &[String],
1038        project_root: &Path,
1039        policy: Option<&crate::lint_context::LinkTargetPolicy>,
1040    ) -> Option<String> {
1041        if Self::resolve_absolute_via_roots(url, roots, project_root, policy).is_some() {
1042            return None;
1043        }
1044
1045        let msg = if roots.is_empty() {
1046            format!("Absolute link '{url}' was not found under the project root")
1047        } else {
1048            format!("Absolute link '{url}' was not found under any configured root or the project root")
1049        };
1050        Some(msg)
1051    }
1052
1053    /// Resolve an absolute link under the MkDocs `docs_dir` of the document at
1054    /// `source_path`, or `None` when no mkdocs.yml governs it.
1055    ///
1056    /// MkDocs mode: an extensionless directory link must have index.md, so
1057    /// `require_index_for_dirs = true` applies to every directory hit.
1058    fn resolve_absolute_via_docs_dir(
1059        url: &str,
1060        source_path: &Path,
1061        policy: Option<&crate::lint_context::LinkTargetPolicy>,
1062    ) -> Option<Resolution> {
1063        let docs_dir = resolve_docs_dir(source_path)?;
1064        let decoded = Self::prepare_absolute_url(url);
1065        Some(Self::resolve_under_root_with_opts(&docs_dir, &decoded, true, policy))
1066    }
1067
1068    /// The target of an absolute link under the first configured root that
1069    /// holds it, then under the project root.
1070    ///
1071    /// Filesystem mode: an existing directory is a valid target, so
1072    /// `require_index_for_dirs = false` aligns with relative-link behavior. (#632)
1073    fn resolve_absolute_via_roots(
1074        url: &str,
1075        roots: &[String],
1076        project_root: &Path,
1077        policy: Option<&crate::lint_context::LinkTargetPolicy>,
1078    ) -> Option<PathBuf> {
1079        let decoded = Self::prepare_absolute_url(url);
1080        roots
1081            .iter()
1082            .map(|root| Self::resolve_against_project_root(root, project_root))
1083            .chain(std::iter::once(project_root.to_path_buf()))
1084            .find_map(
1085                |root| match Self::resolve_under_root_with_opts(&root, &decoded, false, policy) {
1086                    Resolution::Found(target) => Some(target),
1087                    _ => None,
1088                },
1089            )
1090    }
1091
1092    /// Decode an absolute-link URL into a filesystem-relative path. Strips the
1093    /// leading `/`, query/fragment suffix, and percent-encoding.
1094    fn prepare_absolute_url(url: &str) -> String {
1095        let relative_url = url.trim_start_matches('/');
1096        let file_path = Self::strip_query_and_fragment(relative_url);
1097        url_decode(file_path)
1098    }
1099
1100    /// Try to resolve a decoded absolute-link path under a single root directory.
1101    ///
1102    /// `require_index_for_dirs` says what a link that lands on a directory means,
1103    /// and the two answers belong to two different worlds:
1104    ///
1105    /// - `true` (MkDocs / docs-dir mode): a URL is a route, and MkDocs serves
1106    ///   `/section` from `section/index.md`. A directory with no `index.md` is a
1107    ///   route that 404s, so it is reported.
1108    ///
1109    /// - `false` (roots / filesystem mode): a link names a path on disk, so an
1110    ///   existing directory is a valid target. That is what relative links already
1111    ///   do (they ask only whether the target is there), and the punctuation of
1112    ///   the link does not change it: `/adir`, `/adir/` and `/adir/#section` all
1113    ///   name the same directory, and no router is going to turn one of them into
1114    ///   `index.md`.
1115    ///
1116    /// Every path is resolved under `root_path`, which is therefore the anchor
1117    /// for the spelling check: a target is found only under the case the link
1118    /// writes it in.
1119    ///
1120    /// A run with a supplied document set consults it first, the way relative
1121    /// links do; a closed-world run never looks at disk.
1122    ///
1123    /// On disk, strategies apply in order:
1124    /// 1. A directory hit, answered by the mode as described above. Must be checked
1125    ///    before `resolve_existing_target`, because a directory is one of
1126    ///    the things a target can be.
1127    /// 2. Direct existence (with markdown-extension fallback for extensionless links).
1128    /// 3. `.html`/`.htm` links: look for a markdown source with the same stem.
1129    ///
1130    /// `Found` carries the file the link lands on: the section's `index.md` for
1131    /// a docs-dir route, the directory itself in filesystem mode.
1132    fn resolve_under_root_with_opts(
1133        root_path: &Path,
1134        decoded: &str,
1135        require_index_for_dirs: bool,
1136        policy: Option<&crate::lint_context::LinkTargetPolicy>,
1137    ) -> Resolution {
1138        let resolved = root_path.join(decoded);
1139
1140        if let Some(policy) = policy {
1141            if let Some(resolution) = Self::resolve_supplied_under_root(&resolved, require_index_for_dirs, policy) {
1142                return resolution;
1143            }
1144            if !policy.allow_disk_fallback() {
1145                return Resolution::NotFound { resolved };
1146            }
1147        }
1148
1149        if resolved.is_dir() && exists_exact_case(root_path, &resolved) {
1150            if !require_index_for_dirs {
1151                return Resolution::Found(resolved);
1152            }
1153            let index = resolved.join("index.md");
1154            return if exists_exact_case(root_path, &index) {
1155                Resolution::Found(index)
1156            } else {
1157                Resolution::DirectoryWithoutIndex { resolved }
1158            };
1159        }
1160
1161        if let Some(target) = resolve_existing_target(root_path, &resolved) {
1162            return Resolution::Found(target);
1163        }
1164
1165        // For .html/.htm links, accept a matching markdown source in the same
1166        // directory: supports doc sites that compile .md to .html.
1167        if let Some(source) = html_markdown_sources(&resolved).find(|source| exists_exact_case(root_path, source)) {
1168            return Resolution::Found(source);
1169        }
1170
1171        Resolution::NotFound { resolved }
1172    }
1173
1174    /// `resolve_under_root_with_opts` against the supplied document set.
1175    ///
1176    /// `None` leaves the answer to disk in an open-world run. A supplied
1177    /// directory without a supplied `index.md` is only reported in a closed
1178    /// world, where disk cannot supply one.
1179    fn resolve_supplied_under_root(
1180        resolved: &Path,
1181        require_index_for_dirs: bool,
1182        policy: &crate::lint_context::LinkTargetPolicy,
1183    ) -> Option<Resolution> {
1184        let Some(supplied) = policy.resolve_supplied(resolved) else {
1185            return html_markdown_sources(resolved)
1186                .find(|source| policy.contains(source))
1187                .map(Resolution::Found);
1188        };
1189        if policy.contains(&supplied) || !require_index_for_dirs {
1190            return Some(Resolution::Found(supplied));
1191        }
1192        let index = supplied.join("index.md");
1193        if policy.contains(&index) {
1194            Some(Resolution::Found(index))
1195        } else if policy.allow_disk_fallback() {
1196            None
1197        } else {
1198            Some(Resolution::DirectoryWithoutIndex {
1199                resolved: resolved.to_path_buf(),
1200            })
1201        }
1202    }
1203}
1204
1205/// Cache invalidation for the on-disk lint cache, which only exists in builds
1206/// with a filesystem and the `blake3` hasher (native and WASI). The browser build
1207/// has neither, so nothing here can be reached from it.
1208#[cfg(feature = "blake3")]
1209impl MD057ExistingRelativeLinks {
1210    /// Fingerprint the filesystem facts that can change this file's MD057 verdict.
1211    ///
1212    /// The caller supplies path inputs from a content-matched workspace-index
1213    /// entry. Resolution stays here so cache validation follows the same config,
1214    /// project-root, search-path, and flavor rules as `check`.
1215    pub fn cache_dependency_fingerprint(
1216        &self,
1217        source_file: &Path,
1218        flavor: crate::config::MarkdownFlavor,
1219        file_index: &FileIndex,
1220    ) -> String {
1221        let mut hasher = blake3::Hasher::new();
1222        hasher.update(b"rumdl-md057-dependencies-v1");
1223        if file_index.md057_link_targets.is_empty() {
1224            return hasher.finalize().to_hex().to_string();
1225        }
1226
1227        let explicit_base = self.base_path.lock().ok().and_then(|guard| guard.clone());
1228        let project_root = explicit_base.clone().unwrap_or_else(|| project_root().to_path_buf());
1229        // Only a relative destination needs the document's own location, so a
1230        // document whose targets are all absolute never touches the filesystem
1231        // to resolve it.
1232        let anchor = LazyCell::new(|| self.document_anchor(flavor, Some(source_file), explicit_base, &project_root));
1233        let ignored_frontmatter_fields: HashSet<String> = self
1234            .config
1235            .ignore_frontmatter_fields
1236            .iter()
1237            .map(|field| field.to_lowercase())
1238            .collect();
1239
1240        for dependency in &file_index.md057_link_targets {
1241            if let LinkOrigin::FrontMatter { field } = &dependency.origin
1242                && (!self.config.check_frontmatter
1243                    || field
1244                        .as_ref()
1245                        .is_some_and(|field| ignored_frontmatter_fields.contains(field)))
1246            {
1247                continue;
1248            }
1249
1250            let url = dependency.target.as_str();
1251            if self.is_non_file_destination(url, flavor) || self.is_fragment_only_link(url) {
1252                continue;
1253            }
1254
1255            Self::hash_bytes(&mut hasher, url.as_bytes());
1256            if Self::is_absolute_path(url) {
1257                match self.config.absolute_links {
1258                    AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => {}
1259                    AbsoluteLinksOption::RelativeToDocs => {
1260                        hasher.update(b"docs");
1261                        if let Some(docs_dir) = resolve_docs_dir(source_file) {
1262                            Self::observe_absolute_resolution(&mut hasher, &docs_dir, url, true);
1263                        } else {
1264                            hasher.update(b"no-docs-dir");
1265                        }
1266                    }
1267                    AbsoluteLinksOption::RelativeToRoots => {
1268                        hasher.update(b"roots");
1269                        let decoded = Self::prepare_absolute_url(url);
1270                        let mut found = false;
1271                        for root in &self.config.roots {
1272                            let root_path = Self::resolve_against_project_root(root, &project_root);
1273                            if Self::observe_under_root(&mut hasher, &root_path, &decoded, false) {
1274                                found = true;
1275                                break;
1276                            }
1277                        }
1278                        if !found {
1279                            Self::observe_under_root(&mut hasher, &project_root, &decoded, false);
1280                        }
1281                    }
1282                }
1283            } else {
1284                hasher.update(b"relative");
1285                if self.config.self_referential_links
1286                    && Self::observe_self_referential_resolution(
1287                        &mut hasher,
1288                        url,
1289                        &anchor.base_path,
1290                        &anchor.search_paths,
1291                        anchor.self_path.as_deref().unwrap_or(source_file),
1292                    )
1293                {
1294                    continue;
1295                }
1296                Self::observe_relative_resolution(&mut hasher, url, &anchor.base_path, &anchor.search_paths);
1297            }
1298        }
1299
1300        hasher.finalize().to_hex().to_string()
1301    }
1302
1303    fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
1304        hasher.update(&(bytes.len() as u64).to_le_bytes());
1305        hasher.update(bytes);
1306    }
1307
1308    fn hash_path(hasher: &mut blake3::Hasher, path: &Path) {
1309        #[cfg(unix)]
1310        {
1311            use std::os::unix::ffi::OsStrExt;
1312            Self::hash_bytes(hasher, path.as_os_str().as_bytes());
1313        }
1314        #[cfg(windows)]
1315        {
1316            use std::os::windows::ffi::OsStrExt;
1317            let encoded: Vec<u8> = path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect();
1318            Self::hash_bytes(hasher, &encoded);
1319        }
1320        #[cfg(not(any(unix, windows)))]
1321        Self::hash_bytes(hasher, path.to_string_lossy().as_bytes());
1322    }
1323
1324    /// Record one path and what is there, judged the way the check judges it: a
1325    /// target the filesystem answers for under a spelling it does not store
1326    /// counts as missing, so renaming a file to the case its links use changes
1327    /// the fingerprint and the cached verdict is thrown away.
1328    fn observe_path(hasher: &mut blake3::Hasher, anchor: &Path, path: &Path) -> DependencyPathState {
1329        Self::hash_path(hasher, path);
1330        let state = if exists_exact_case(anchor, path) {
1331            match std::fs::metadata(path) {
1332                Ok(metadata) if metadata.is_file() => DependencyPathState::File,
1333                Ok(metadata) if metadata.is_dir() => DependencyPathState::Directory,
1334                Ok(_) => DependencyPathState::Other,
1335                Err(_) => DependencyPathState::Missing,
1336            }
1337        } else {
1338            DependencyPathState::Missing
1339        };
1340        hasher.update(&[match state {
1341            DependencyPathState::Missing => 0,
1342            DependencyPathState::File => 1,
1343            DependencyPathState::Directory => 2,
1344            DependencyPathState::Other => 3,
1345        }]);
1346        state
1347    }
1348
1349    fn observe_existing_target(hasher: &mut blake3::Hasher, anchor: &Path, path: &Path) -> Option<PathBuf> {
1350        if Self::observe_path(hasher, anchor, path) != DependencyPathState::Missing {
1351            return Some(path.to_path_buf());
1352        }
1353        if path.extension().is_none() {
1354            for extension in MARKDOWN_EXTENSIONS {
1355                let candidate = path.with_extension(&extension[1..]);
1356                if Self::observe_path(hasher, anchor, &candidate) != DependencyPathState::Missing {
1357                    return Some(candidate);
1358                }
1359            }
1360        }
1361        None
1362    }
1363
1364    fn observe_self_referential_resolution(
1365        hasher: &mut blake3::Hasher,
1366        url: &str,
1367        base_path: &Path,
1368        search_paths: &[PathBuf],
1369        source_file: &Path,
1370    ) -> bool {
1371        let decoded = url_decode(Self::strip_query_and_fragment(url));
1372        for directory in std::iter::once(base_path).chain(search_paths.iter().map(PathBuf::as_path)) {
1373            let candidate = Self::resolve_link_path_with_base(&decoded, directory);
1374            if let Some(resolved) = Self::observe_existing_target(hasher, directory, &candidate) {
1375                let canonical = resolved.canonicalize().unwrap_or(resolved);
1376                hasher.update(b"resolved-identity");
1377                Self::hash_path(hasher, &canonical);
1378                return Self::is_same_file(&canonical, source_file);
1379            }
1380        }
1381        false
1382    }
1383
1384    fn observe_relative_resolution(hasher: &mut blake3::Hasher, url: &str, base_path: &Path, search_paths: &[PathBuf]) {
1385        let decoded = url_decode(Self::strip_query_and_fragment(url));
1386        let resolved = Self::resolve_link_path_with_base(&decoded, base_path);
1387        if Self::observe_existing_target(hasher, base_path, &resolved).is_some() {
1388            return;
1389        }
1390
1391        if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1392            && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1393            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1394            && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1395                Self::observe_path(hasher, base_path, &parent.join(format!("{stem}{extension}")))
1396                    != DependencyPathState::Missing
1397            })
1398        {
1399            return;
1400        }
1401
1402        for search_path in search_paths {
1403            if Self::observe_existing_target(hasher, search_path, &search_path.join(&decoded)).is_some() {
1404                return;
1405            }
1406        }
1407    }
1408
1409    fn observe_absolute_resolution(
1410        hasher: &mut blake3::Hasher,
1411        root: &Path,
1412        url: &str,
1413        require_index_for_dirs: bool,
1414    ) -> bool {
1415        let decoded = Self::prepare_absolute_url(url);
1416        Self::observe_under_root(hasher, root, &decoded, require_index_for_dirs)
1417    }
1418
1419    /// Mirror of `resolve_under_root_with_opts` that records the filesystem facts
1420    /// the verdict rests on. Any change to the resolution order there has to land
1421    /// here too, or a cached verdict outlives the state that produced it.
1422    fn observe_under_root(
1423        hasher: &mut blake3::Hasher,
1424        root: &Path,
1425        decoded: &str,
1426        require_index_for_dirs: bool,
1427    ) -> bool {
1428        let resolved = root.join(decoded);
1429        let resolved_state = Self::observe_path(hasher, root, &resolved);
1430
1431        if resolved_state == DependencyPathState::Directory {
1432            if !require_index_for_dirs {
1433                return true;
1434            }
1435            return Self::observe_path(hasher, root, &resolved.join("index.md")) != DependencyPathState::Missing;
1436        }
1437
1438        if resolved_state != DependencyPathState::Missing {
1439            return true;
1440        }
1441        if resolved.extension().is_none()
1442            && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1443                Self::observe_path(hasher, root, &resolved.with_extension(&extension[1..]))
1444                    != DependencyPathState::Missing
1445            })
1446        {
1447            return true;
1448        }
1449
1450        if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1451            && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1452            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1453        {
1454            return MARKDOWN_EXTENSIONS.iter().any(|extension| {
1455                Self::observe_path(hasher, root, &parent.join(format!("{stem}{extension}")))
1456                    != DependencyPathState::Missing
1457            });
1458        }
1459
1460        false
1461    }
1462}
1463
1464/// Outcome of trying to resolve an absolute link under a single root directory.
1465/// Carries the resolved path on the failure variants so callers can build
1466/// specific error messages without recomputing it.
1467enum Resolution {
1468    Found(PathBuf),
1469    DirectoryWithoutIndex { resolved: PathBuf },
1470    NotFound { resolved: PathBuf },
1471}
1472
1473impl Rule for MD057ExistingRelativeLinks {
1474    fn name(&self) -> &'static str {
1475        "MD057"
1476    }
1477
1478    fn description(&self) -> &'static str {
1479        "Relative links should point to existing files"
1480    }
1481
1482    fn category(&self) -> RuleCategory {
1483        RuleCategory::Link
1484    }
1485
1486    fn skippable_by_category(&self) -> bool {
1487        // A frontmatter path is a link this rule resolves, and the document
1488        // holding it needs no link syntax anywhere else.
1489        !self.config.check_frontmatter
1490    }
1491
1492    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1493        ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
1494    }
1495
1496    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1497        let content = ctx.content;
1498
1499        if content.is_empty() {
1500            return Ok(Vec::new());
1501        }
1502
1503        // Early returns for performance. A document whose only destinations
1504        // sit in its frontmatter has no link syntax to find, so the body-link
1505        // shortcuts only apply when frontmatter is not being checked.
1506        let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
1507        if !has_body_links && !self.checks_front_matter_of(ctx) {
1508            return Ok(Vec::new());
1509        }
1510
1511        // Reset the filesystem caches for a fresh run
1512        reset_file_existence_cache();
1513        let _directory_check = DirectoryCheck::begin();
1514
1515        let mut warnings = Vec::new();
1516
1517        // Read the explicit base path (set via `with_path()` in tests) once; it
1518        // doubles as both the per-file base path and the project root override
1519        // for absolute-link resolution.
1520        let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
1521
1522        // Project root used for absolute-link resolution against configured
1523        // `roots` and as the implicit fallback root. The explicit base wins
1524        // when set; otherwise the discovered project root is used.
1525        let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| project_root().to_path_buf());
1526
1527        // Without a source file or an explicit base there is no directory to
1528        // resolve relative links from, so nothing can be validated.
1529        let source_file = ctx.source_file();
1530        if explicit_base.is_none() && source_file.is_none() {
1531            return Ok(warnings);
1532        }
1533        // Each file resolves from its own directory, never from a base path
1534        // cached by an earlier check.
1535        let anchor: LazyAnchor<'_> = LazyCell::new(Box::new(|| {
1536            self.document_anchor(ctx.flavor, source_file, explicit_base, &project_root)
1537        }));
1538
1539        // Destinations come from the parse, so a link whose text wraps onto
1540        // another line is read the same as one written on a single line. Every
1541        // report on a link points at its destination, which sits on the line
1542        // the link ends on rather than the line it starts on.
1543        for destination in ctx.body_link_destinations() {
1544            let url = destination.url;
1545
1546            // Skip empty URLs
1547            if url.is_empty() {
1548                continue;
1549            }
1550
1551            // Skip rustdoc intra-doc links (backtick-wrapped URLs)
1552            // These are Rust API references, not file paths
1553            // Example: [`f32::is_subnormal`], [`Vec::push`]
1554            if url.starts_with('`') && url.ends_with('`') {
1555                continue;
1556            }
1557
1558            // Skip external URLs and fragment-only links
1559            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1560                continue;
1561            }
1562
1563            // Handle absolute paths based on config
1564            if Self::is_absolute_path(url) {
1565                if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1566                {
1567                    let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1568                    warnings.push(LintWarning {
1569                        rule_name: Some(self.name().to_string()),
1570                        line,
1571                        column,
1572                        end_line: line,
1573                        end_column: ctx.offset_to_line_col(destination.url_range.end).1,
1574                        message,
1575                        severity: Severity::Warning,
1576                        fix: None,
1577                    });
1578                }
1579                continue;
1580            }
1581
1582            // The compaction and the self-link check both read the destination
1583            // together with its fragment, because both rewrite the whole of it.
1584            let full_url = destination.full_url();
1585
1586            // A link back into the current file. Reported instead of
1587            // the compaction below, whose shorter path would still
1588            // be a link the reader should not follow, and instead
1589            // of the existence check, which this target passes.
1590            if let Some(self_link) = self.self_referential_link(
1591                &full_url,
1592                &anchor.base_path,
1593                &anchor.search_paths,
1594                anchor.self_path.as_deref(),
1595                ctx.link_target_policy(),
1596            ) {
1597                let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1598                warnings.push(LintWarning {
1599                    rule_name: Some(self.name().to_string()),
1600                    line,
1601                    column,
1602                    end_line: line,
1603                    end_column: ctx.offset_to_line_col(destination.fix_range.end).1,
1604                    message: Self::self_referential_message(&full_url, &self_link),
1605                    severity: Severity::Warning,
1606                    fix: match &self_link {
1607                        SelfReferentialLink::Fragment(fragment) => {
1608                            Some(Fix::new(destination.fix_range.clone(), fragment.clone()))
1609                        }
1610                        SelfReferentialLink::WholeFile => None,
1611                    },
1612                });
1613                continue;
1614            }
1615
1616            if let Some(suggestion) = self.compact_path_suggestion(&full_url, &anchor.base_path) {
1617                let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1618                warnings.push(LintWarning {
1619                    rule_name: Some(self.name().to_string()),
1620                    line,
1621                    column,
1622                    end_line: line,
1623                    end_column: ctx.offset_to_line_col(destination.fix_range.end).1,
1624                    message: format!("Relative link '{full_url}' can be simplified to '{suggestion}'"),
1625                    severity: Severity::Warning,
1626                    fix: Some(Fix::new(destination.fix_range.clone(), suggestion)),
1627                });
1628            }
1629
1630            if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1631                continue;
1632            }
1633
1634            // File doesn't exist and no source file found
1635            let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1636            warnings.push(LintWarning {
1637                rule_name: Some(self.name().to_string()),
1638                line,
1639                column,
1640                end_line: line,
1641                end_column: ctx.offset_to_line_col(destination.url_range.end).1,
1642                message: Self::missing_relative_message(url, ctx.link_target_policy()),
1643                severity: Severity::Error,
1644                fix: None,
1645            });
1646        }
1647
1648        // Also process images - they have URLs already parsed
1649        for image in ctx.images() {
1650            // Skip images inside PyMdown blocks (MkDocs flavor)
1651            if ctx
1652                .line_info(image.line)
1653                .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1654            {
1655                continue;
1656            }
1657
1658            // A wiki embed names a vault entry, not a path relative to this
1659            // file: `![[diagram.png]]` resolves wherever the attachment lives.
1660            // The links loop already leaves `[[diagram.png]]` alone.
1661            if matches!(image.link_type, LinkType::WikiLink { .. }) {
1662                continue;
1663            }
1664
1665            // Image syntax inside a template shortcode tag is a parameter the
1666            // template resolves, not a path relative to this file.
1667            if ctx.is_in_shortcode(image.byte_offset) {
1668                continue;
1669            }
1670
1671            let url = image.url.as_ref();
1672
1673            // Skip empty URLs
1674            if url.is_empty() {
1675                continue;
1676            }
1677
1678            // Skip external URLs and fragment-only links
1679            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1680                continue;
1681            }
1682
1683            // Handle absolute paths based on config
1684            if Self::is_absolute_path(url) {
1685                if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1686                {
1687                    warnings.push(LintWarning {
1688                        rule_name: Some(self.name().to_string()),
1689                        line: image.line,
1690                        column: image.start_col + 1,
1691                        end_line: image.line,
1692                        end_column: image.start_col + 1 + url.chars().count(),
1693                        message,
1694                        severity: Severity::Warning,
1695                        fix: None,
1696                    });
1697                }
1698                continue;
1699            }
1700
1701            // Check for unnecessary path traversal (compact-paths)
1702            if let Some(suggestion) = self.compact_path_suggestion(url, &anchor.base_path) {
1703                // Find the URL position within the image syntax using document byte offsets.
1704                // Search from image.byte_offset (the `!` character) to locate the URL string.
1705                let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1706                    let fix_byte_start = image.byte_offset + url_offset;
1707                    let fix_byte_end = fix_byte_start + url.len();
1708                    Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1709                });
1710
1711                let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1712                let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
1713                // The fix range is a document byte offset; the displayed column is
1714                // the corresponding character offset within the line.
1715                let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1716                    byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1717                });
1718                warnings.push(LintWarning {
1719                    rule_name: Some(self.name().to_string()),
1720                    line: image.line,
1721                    column: url_col,
1722                    end_line: image.line,
1723                    end_column: url_col + url.chars().count(),
1724                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1725                    severity: Severity::Warning,
1726                    fix,
1727                });
1728            }
1729
1730            if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1731                continue;
1732            }
1733
1734            // File doesn't exist and no source file found
1735            // Images already have correct position from parser
1736            warnings.push(LintWarning {
1737                rule_name: Some(self.name().to_string()),
1738                line: image.line,
1739                column: image.start_col + 1,
1740                end_line: image.line,
1741                end_column: image.start_col + 1 + url.chars().count(),
1742                message: Self::missing_relative_message(url, ctx.link_target_policy()),
1743                severity: Severity::Error,
1744                fix: None,
1745            });
1746        }
1747
1748        // Also process reference definitions: [ref]: ./path.md
1749        for ref_def in ctx.reference_definitions() {
1750            if ctx.line_info(ref_def.line).is_some_and(|info| info.in_front_matter) {
1751                continue;
1752            }
1753            let url = &ref_def.url;
1754
1755            // Skip empty URLs
1756            if url.is_empty() {
1757                continue;
1758            }
1759
1760            // Skip external URLs and fragment-only links
1761            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1762                continue;
1763            }
1764
1765            // Where this definition's destination sits, shared by every report
1766            // on it. Without a located destination a warning falls back to the
1767            // start of the definition's line and offers no fix.
1768            let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1769            let (line, col) = url_range
1770                .as_ref()
1771                .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1772            let end_col = col + url.chars().count();
1773
1774            // Handle absolute paths based on config
1775            if Self::is_absolute_path(url) {
1776                if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1777                {
1778                    warnings.push(LintWarning {
1779                        rule_name: Some(self.name().to_string()),
1780                        line,
1781                        column: col,
1782                        end_line: line,
1783                        end_column: end_col,
1784                        message,
1785                        severity: Severity::Warning,
1786                        fix: None,
1787                    });
1788                }
1789                continue;
1790            }
1791
1792            // A definition whose destination is the file holding it.
1793            if let Some(self_link) = self.self_referential_link(
1794                url,
1795                &anchor.base_path,
1796                &anchor.search_paths,
1797                anchor.self_path.as_deref(),
1798                ctx.link_target_policy(),
1799            ) {
1800                warnings.push(LintWarning {
1801                    rule_name: Some(self.name().to_string()),
1802                    line,
1803                    column: col,
1804                    end_line: line,
1805                    end_column: end_col,
1806                    message: Self::self_referential_message(url, &self_link),
1807                    severity: Severity::Warning,
1808                    fix: match (&self_link, &url_range) {
1809                        (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1810                            Some(Fix::new(range.clone(), fragment.clone()))
1811                        }
1812                        _ => None,
1813                    },
1814                });
1815                continue;
1816            }
1817
1818            // Check for unnecessary path traversal (compact-paths)
1819            if let Some(suggestion) = self.compact_path_suggestion(url, &anchor.base_path) {
1820                warnings.push(LintWarning {
1821                    rule_name: Some(self.name().to_string()),
1822                    line,
1823                    column: col,
1824                    end_line: line,
1825                    end_column: end_col,
1826                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1827                    severity: Severity::Warning,
1828                    fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1829                });
1830            }
1831
1832            if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1833                continue;
1834            }
1835
1836            // File doesn't exist and no source file found
1837            warnings.push(LintWarning {
1838                rule_name: Some(self.name().to_string()),
1839                line,
1840                column: col,
1841                end_line: line,
1842                end_column: end_col,
1843                message: Self::missing_relative_message(url, ctx.link_target_policy()),
1844                severity: Severity::Error,
1845                fix: None,
1846            });
1847        }
1848
1849        self.check_front_matter(ctx, &anchor, &project_root, &mut warnings);
1850
1851        Ok(warnings)
1852    }
1853
1854    fn fix_capability(&self) -> FixCapability {
1855        if self.produces_fixes() {
1856            FixCapability::ConditionallyFixable
1857        } else {
1858            FixCapability::Unfixable
1859        }
1860    }
1861
1862    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1863        if !self.produces_fixes() {
1864            return Ok(ctx.content.to_string());
1865        }
1866
1867        let warnings = self.check(ctx)?;
1868        let warnings =
1869            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1870        let mut content = ctx.content.to_string();
1871
1872        // Collect fixable warnings (compact-paths) sorted by byte offset descending
1873        let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1874        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1875
1876        // Applying fixes right-to-left lets each range stay valid against the
1877        // still-unshifted content to its left. A duplicate or overlapping fix
1878        // would otherwise be applied a second time against content already
1879        // rewritten by an earlier fix, corrupting it; skip any fix whose range
1880        // overlaps the one most recently applied.
1881        let mut last_applied_start: Option<usize> = None;
1882        for fix in fixes {
1883            if let Some(prev_start) = last_applied_start
1884                && fix.range.end > prev_start
1885            {
1886                continue;
1887            }
1888            if fix.range.end <= content.len() {
1889                content.replace_range(fix.range.clone(), &fix.replacement);
1890                last_applied_start = Some(fix.range.start);
1891            }
1892        }
1893
1894        Ok(content)
1895    }
1896
1897    fn as_any(&self) -> &dyn std::any::Any {
1898        self
1899    }
1900
1901    crate::impl_rule_config_sections!(MD057Config);
1902
1903    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1904    where
1905        Self: Sized,
1906    {
1907        let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1908        // The flavor is deliberately not captured here: Obsidian attachment-folder
1909        // detection reads `ctx.flavor`, which resolves per file, so a rule built
1910        // once for a workspace still honors a per-file flavor override.
1911        Box::new(Self::from_config_struct(rule_config))
1912    }
1913
1914    fn cross_file_scope(&self) -> CrossFileScope {
1915        CrossFileScope::Workspace
1916    }
1917
1918    fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1919        self.contribute_dependency_targets(ctx, index);
1920
1921        // Use the shared utility for cross-file link extraction
1922        // This ensures consistent position tracking between CLI and LSP
1923        let links = extract_cross_file_links(ctx);
1924        for link in links.relative {
1925            index.add_cross_file_link(link);
1926        }
1927        // Root-relative links are not linted, but indexing them keeps the cached
1928        // index complete so the LSP can resolve them for find-references.
1929        for link in links.root_relative {
1930            index.add_root_relative_link(link);
1931        }
1932    }
1933
1934    fn cross_file_check(
1935        &self,
1936        _file_path: &Path,
1937        _file_index: &FileIndex,
1938        _workspace_index: &crate::workspace_index::WorkspaceIndex,
1939    ) -> LintResult {
1940        // All link targets are already validated by check() on each per-file pass.
1941        // check() resolves relative links against the file's own directory, handles
1942        // configured search paths, and applies the absolute_links config.
1943        // Validating them here too would produce identical duplicate warnings for
1944        // every broken link. (#631)
1945        //
1946        // The cross_file_scope / contribute_to_index / workspace-index infrastructure
1947        // remains in place to support future cross-file analyses (e.g. heading-anchor
1948        // validation across files).
1949        Ok(Vec::new())
1950    }
1951}
1952
1953/// Compute the shortest relative path from `from_dir` to `to_path`.
1954///
1955/// Both paths must be normalized (no `.` or `..` components).
1956/// Returns a relative `PathBuf` that navigates from `from_dir` to `to_path`.
1957fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1958    let from_components: Vec<_> = from_dir.components().collect();
1959    let to_components: Vec<_> = to_path.components().collect();
1960
1961    // Find common prefix length
1962    let common_len = from_components
1963        .iter()
1964        .zip(to_components.iter())
1965        .take_while(|(a, b)| a == b)
1966        .count();
1967
1968    let mut result = PathBuf::new();
1969
1970    // Go up for each remaining component in from_dir
1971    for _ in common_len..from_components.len() {
1972        result.push("..");
1973    }
1974
1975    // Append remaining components from to_path
1976    for component in &to_components[common_len..] {
1977        result.push(component);
1978    }
1979
1980    result
1981}
1982
1983/// Check if a relative link path can be shortened.
1984///
1985/// Given the source directory and the raw link path, computes whether there's
1986/// a shorter equivalent path. Returns `Some(compact_path)` if the link can
1987/// be simplified, `None` if it's already optimal.
1988fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1989    let link_path = Path::new(raw_link_path);
1990
1991    // Only check paths that contain traversal (../ or ./)
1992    let has_traversal = link_path
1993        .components()
1994        .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1995
1996    if !has_traversal {
1997        return None;
1998    }
1999
2000    // Resolve: source_dir + raw_link_path, then normalize
2001    let combined = source_dir.join(link_path);
2002    let normalized_target = normalize_relative_path(&combined);
2003
2004    // Compute shortest path from source_dir back to the normalized target
2005    let normalized_source = normalize_relative_path(source_dir);
2006    let shortest = shortest_relative_path(&normalized_source, &normalized_target);
2007
2008    // Compare against the raw link path — if it differs, the path can be compacted
2009    if shortest != link_path {
2010        let compact = shortest.to_string_lossy().to_string();
2011        // Avoid suggesting empty path
2012        if compact.is_empty() {
2013            return None;
2014        }
2015        // Markdown links always use forward slashes regardless of platform
2016        Some(compact.replace('\\', "/"))
2017    } else {
2018        None
2019    }
2020}
2021
2022#[cfg(test)]
2023mod tests {
2024    use super::*;
2025    use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
2026    use std::fs::File;
2027    use std::io::Write;
2028    use tempfile::tempdir;
2029
2030    #[test]
2031    fn test_strip_query_and_fragment() {
2032        // Test query parameter stripping
2033        assert_eq!(
2034            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
2035            "file.png"
2036        );
2037        assert_eq!(
2038            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
2039            "file.png"
2040        );
2041        assert_eq!(
2042            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
2043            "file.png"
2044        );
2045
2046        // Test fragment stripping
2047        assert_eq!(
2048            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
2049            "file.md"
2050        );
2051        assert_eq!(
2052            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
2053            "file.md"
2054        );
2055
2056        // Test both query and fragment (query comes first, per RFC 3986)
2057        assert_eq!(
2058            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
2059            "file.md"
2060        );
2061
2062        // Test no query or fragment
2063        assert_eq!(
2064            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
2065            "file.png"
2066        );
2067
2068        // Test with path
2069        assert_eq!(
2070            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
2071            "path/to/image.png"
2072        );
2073        assert_eq!(
2074            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
2075            "path/to/image.png"
2076        );
2077
2078        // Edge case: fragment before query (non-standard but possible)
2079        assert_eq!(
2080            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
2081            "file.md"
2082        );
2083    }
2084
2085    #[test]
2086    fn test_url_decode() {
2087        // Simple space encoding
2088        assert_eq!(url_decode("penguin%20with%20space.jpg"), "penguin with space.jpg");
2089
2090        // Path with encoded spaces
2091        assert_eq!(url_decode("assets/my%20file%20name.png"), "assets/my file name.png");
2092
2093        // Multiple encoded characters
2094        assert_eq!(url_decode("hello%20world%21.md"), "hello world!.md");
2095
2096        // Lowercase hex
2097        assert_eq!(url_decode("%2f%2e%2e"), "/..");
2098
2099        // Uppercase hex
2100        assert_eq!(url_decode("%2F%2E%2E"), "/..");
2101
2102        // Mixed case hex
2103        assert_eq!(url_decode("%2f%2E%2e"), "/..");
2104
2105        // No encoding - return as-is
2106        assert_eq!(url_decode("normal-file.md"), "normal-file.md");
2107
2108        // Incomplete percent encoding - leave as-is
2109        assert_eq!(url_decode("file%2.txt"), "file%2.txt");
2110
2111        // Percent at end - leave as-is
2112        assert_eq!(url_decode("file%"), "file%");
2113
2114        // Invalid hex digits - leave as-is
2115        assert_eq!(url_decode("file%GG.txt"), "file%GG.txt");
2116
2117        // Plus sign (should NOT be decoded - that's form encoding, not URL encoding)
2118        assert_eq!(url_decode("file+name.txt"), "file+name.txt");
2119
2120        // Empty string
2121        assert_eq!(url_decode(""), "");
2122
2123        // UTF-8 multi-byte characters (é = C3 A9 in UTF-8)
2124        assert_eq!(url_decode("caf%C3%A9.md"), "café.md");
2125
2126        // Multiple consecutive encoded characters
2127        assert_eq!(url_decode("%20%20%20"), "   ");
2128
2129        // Encoded path separators
2130        assert_eq!(url_decode("path%2Fto%2Ffile.md"), "path/to/file.md");
2131
2132        // Mixed encoded and non-encoded
2133        assert_eq!(url_decode("hello%20world/foo%20bar.md"), "hello world/foo bar.md");
2134
2135        // Special characters that are commonly encoded
2136        assert_eq!(url_decode("file%5B1%5D.md"), "file[1].md");
2137
2138        // Percent at position that looks like encoding but isn't valid
2139        assert_eq!(url_decode("100%pure.md"), "100%pure.md");
2140    }
2141
2142    #[test]
2143    fn test_url_encoded_filenames() {
2144        // Create a temporary directory for test files
2145        let temp_dir = tempdir().unwrap();
2146        let base_path = temp_dir.path();
2147
2148        // Create a file with spaces in the name
2149        let file_with_spaces = base_path.join("penguin with space.jpg");
2150        File::create(&file_with_spaces)
2151            .unwrap()
2152            .write_all(b"image data")
2153            .unwrap();
2154
2155        // Create a subdirectory with spaces
2156        let subdir = base_path.join("my images");
2157        std::fs::create_dir(&subdir).unwrap();
2158        let nested_file = subdir.join("photo 1.png");
2159        File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
2160
2161        // Test content with URL-encoded links
2162        let content = r#"
2163# Test Document with URL-Encoded Links
2164
2165![Penguin](penguin%20with%20space.jpg)
2166![Photo](my%20images/photo%201.png)
2167![Missing](missing%20file.jpg)
2168"#;
2169
2170        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2171
2172        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2173        let result = rule.check(&ctx).unwrap();
2174
2175        // Should only have one warning for the missing file
2176        assert_eq!(
2177            result.len(),
2178            1,
2179            "Should only warn about missing%20file.jpg. Got: {result:?}"
2180        );
2181        assert!(
2182            result[0].message.contains("missing%20file.jpg"),
2183            "Warning should mention the URL-encoded filename"
2184        );
2185    }
2186
2187    #[test]
2188    fn test_external_urls() {
2189        // Common web protocols
2190        assert!(is_external_url("https://example.com"));
2191        assert!(is_external_url("http://example.com"));
2192        assert!(is_external_url("ftp://example.com"));
2193        assert!(is_external_url("www.example.com"));
2194        assert!(is_external_url("example.com"));
2195
2196        // Special URI schemes
2197        assert!(is_external_url("file:///path/to/file"));
2198        assert!(is_external_url("smb://server/share"));
2199        assert!(is_external_url("macappstores://apps.apple.com/"));
2200        assert!(is_external_url("mailto:user@example.com"));
2201        assert!(is_external_url("tel:+1234567890"));
2202        assert!(is_external_url("data:text/plain;base64,SGVsbG8="));
2203        assert!(is_external_url("javascript:void(0)"));
2204        assert!(is_external_url("ssh://git@github.com/repo"));
2205        assert!(is_external_url("git://github.com/repo.git"));
2206
2207        // Email addresses without mailto: protocol
2208        // These are clearly not file links and should be skipped
2209        assert!(is_external_url("user@example.com"));
2210        assert!(is_external_url("steering@kubernetes.io"));
2211        assert!(is_external_url("john.doe+filter@company.co.uk"));
2212        assert!(is_external_url("user_name@sub.domain.com"));
2213        assert!(is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
2214
2215        // Template variables should be skipped (not checked as relative links)
2216        assert!(is_external_url("{{URL}}")); // Handlebars/Mustache
2217        assert!(is_external_url("{{#URL}}")); // Handlebars block helper
2218        assert!(is_external_url("{{> partial}}")); // Handlebars partial
2219        assert!(is_external_url("{{ variable }}")); // Mustache with spaces
2220        assert!(is_external_url("{{% include %}}")); // Jinja2/Hugo shortcode
2221        assert!(is_external_url("{{")); // Even partial matches (regex edge case)
2222
2223        // Absolute paths are NOT external (handled separately via is_absolute_path)
2224        // By default they are ignored, but can be configured to warn
2225        assert!(!is_external_url("/api/v1/users"));
2226        assert!(!is_external_url("/blog/2024/release.html"));
2227        assert!(!is_external_url("/react/hooks/use-state.html"));
2228        assert!(!is_external_url("/pkg/runtime"));
2229        assert!(!is_external_url("/doc/go1compat"));
2230        assert!(!is_external_url("/index.html"));
2231        assert!(!is_external_url("/assets/logo.png"));
2232
2233        // But is_absolute_path should detect them
2234        assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
2235        assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
2236        assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
2237        assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
2238        assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
2239
2240        // Framework path aliases should be skipped (resolved by build tools)
2241        // Tilde prefix (common in Vite, Nuxt, Astro for project root)
2242        assert!(is_external_url("~/assets/image.png"));
2243        assert!(is_external_url("~/components/Button.vue"));
2244        assert!(is_external_url("~assets/logo.svg")); // Nuxt style without /
2245
2246        // @ prefix (common in Vue, webpack, Vite aliases)
2247        assert!(is_external_url("@/components/Header.vue"));
2248        assert!(is_external_url("@images/photo.jpg"));
2249        assert!(is_external_url("@assets/styles.css"));
2250
2251        // Relative paths should NOT be external (should be validated)
2252        assert!(!is_external_url("./relative/path.md"));
2253        assert!(!is_external_url("relative/path.md"));
2254        assert!(!is_external_url("../parent/path.md"));
2255    }
2256
2257    #[test]
2258    fn test_dot_com_only_skips_bare_domains() {
2259        // Bare domains ending in .com are treated as external (skipped).
2260        assert!(is_external_url("example.com"));
2261        assert!(is_external_url("sub.example.com"));
2262
2263        // A relative path that merely ends in ".com" must NOT be skipped:
2264        // it contains a path separator, so it is a relative file reference
2265        // that should be validated, not assumed external.
2266        assert!(!is_external_url("../../vendor.com"));
2267        assert!(!is_external_url("./vendor.com"));
2268        assert!(!is_external_url("docs/vendor.com"));
2269    }
2270
2271    #[test]
2272    fn test_framework_path_aliases() {
2273        // Create a temporary directory for test files
2274        let temp_dir = tempdir().unwrap();
2275        let base_path = temp_dir.path();
2276
2277        // Test content with framework path aliases (should all be skipped)
2278        let content = r#"
2279# Framework Path Aliases
2280
2281![Image 1](~/assets/penguin.jpg)
2282![Image 2](~assets/logo.svg)
2283![Image 3](@images/photo.jpg)
2284![Image 4](@/components/icon.svg)
2285[Link](@/pages/about.md)
2286
2287This is a [real missing link](missing.md) that should be flagged.
2288"#;
2289
2290        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2291
2292        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2293        let result = rule.check(&ctx).unwrap();
2294
2295        // Should only have one warning for the real missing link
2296        assert_eq!(
2297            result.len(),
2298            1,
2299            "Should only warn about missing.md, not framework aliases. Got: {result:?}"
2300        );
2301        assert!(
2302            result[0].message.contains("missing.md"),
2303            "Warning should be for missing.md"
2304        );
2305    }
2306
2307    #[test]
2308    fn test_url_decode_security_path_traversal() {
2309        // Ensure URL decoding doesn't enable path traversal attacks
2310        // The decoded path is still validated against the base path
2311        let temp_dir = tempdir().unwrap();
2312        let base_path = temp_dir.path();
2313
2314        // Create a file in the temp directory
2315        let file_in_base = base_path.join("safe.md");
2316        File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
2317
2318        // Test with encoded path traversal attempt
2319        // Use a path that definitely won't exist on any platform (not /etc/passwd which exists on Linux)
2320        // %2F = /, so ..%2F..%2Fnonexistent%2Ffile = ../../nonexistent/file
2321        // %252F = %2F (double encoded), so ..%252F..%252F = ..%2F..%2F (literal, won't decode to ..)
2322        let content = r#"
2323[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
2324[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
2325[Safe link](safe.md)
2326"#;
2327
2328        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2329
2330        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2331        let result = rule.check(&ctx).unwrap();
2332
2333        // The traversal attempts should still be flagged as missing
2334        // (they don't exist relative to base_path after decoding)
2335        assert_eq!(
2336            result.len(),
2337            2,
2338            "Should have warnings for traversal attempts. Got: {result:?}"
2339        );
2340    }
2341
2342    #[test]
2343    fn test_url_encoded_utf8_filenames() {
2344        // Test with actual UTF-8 encoded filenames
2345        let temp_dir = tempdir().unwrap();
2346        let base_path = temp_dir.path();
2347
2348        // Create files with unicode names
2349        let cafe_file = base_path.join("café.md");
2350        File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
2351
2352        let content = r#"
2353[Café link](caf%C3%A9.md)
2354[Missing unicode](r%C3%A9sum%C3%A9.md)
2355"#;
2356
2357        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2358
2359        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2360        let result = rule.check(&ctx).unwrap();
2361
2362        // Should only warn about the missing file
2363        assert_eq!(
2364            result.len(),
2365            1,
2366            "Should only warn about missing résumé.md. Got: {result:?}"
2367        );
2368        assert!(
2369            result[0].message.contains("r%C3%A9sum%C3%A9.md"),
2370            "Warning should mention the URL-encoded filename"
2371        );
2372    }
2373
2374    #[test]
2375    fn test_url_encoded_emoji_filenames() {
2376        // URL-encoded emoji paths should be correctly resolved
2377        // 👤 = U+1F464 = F0 9F 91 A4 in UTF-8
2378        let temp_dir = tempdir().unwrap();
2379        let base_path = temp_dir.path();
2380
2381        // Create directory with emoji in name: 👤 Personal
2382        let emoji_dir = base_path.join("👤 Personal");
2383        std::fs::create_dir(&emoji_dir).unwrap();
2384
2385        // Create file in that directory: TV Shows.md
2386        let file_path = emoji_dir.join("TV Shows.md");
2387        File::create(&file_path)
2388            .unwrap()
2389            .write_all(b"# TV Shows\n\nContent here.")
2390            .unwrap();
2391
2392        // Test content with URL-encoded emoji link
2393        // %F0%9F%91%A4 = 👤, %20 = space
2394        let content = r#"
2395# Test Document
2396
2397[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
2398[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
2399"#;
2400
2401        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2402
2403        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2404        let result = rule.check(&ctx).unwrap();
2405
2406        // Should only warn about the missing file, not the valid emoji path
2407        assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
2408        assert!(
2409            result[0].message.contains("Missing.md"),
2410            "Warning should be for Missing.md, got: {}",
2411            result[0].message
2412        );
2413    }
2414
2415    #[test]
2416    fn test_no_warnings_without_base_path() {
2417        let rule = MD057ExistingRelativeLinks::new();
2418        let content = "[Link](missing.md)";
2419
2420        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2421        let result = rule.check(&ctx).unwrap();
2422        assert!(result.is_empty(), "Should have no warnings without base path");
2423    }
2424
2425    #[test]
2426    fn test_existing_and_missing_links() {
2427        // Create a temporary directory for test files
2428        let temp_dir = tempdir().unwrap();
2429        let base_path = temp_dir.path();
2430
2431        // Create an existing file
2432        let exists_path = base_path.join("exists.md");
2433        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2434
2435        // Verify the file exists
2436        assert!(exists_path.exists(), "exists.md should exist for this test");
2437
2438        // Create test content with both existing and missing links
2439        let content = r#"
2440# Test Document
2441
2442[Valid Link](exists.md)
2443[Invalid Link](missing.md)
2444[External Link](https://example.com)
2445[Media Link](image.jpg)
2446        "#;
2447
2448        // Initialize rule with the base path (default: check all files including media)
2449        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2450
2451        // Test the rule
2452        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2453        let result = rule.check(&ctx).unwrap();
2454
2455        // Should have two warnings: missing.md and image.jpg (both don't exist)
2456        assert_eq!(result.len(), 2);
2457        let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
2458        assert!(messages.iter().any(|m| m.contains("missing.md")));
2459        assert!(messages.iter().any(|m| m.contains("image.jpg")));
2460    }
2461
2462    #[test]
2463    fn test_angle_bracket_links() {
2464        // Create a temporary directory for test files
2465        let temp_dir = tempdir().unwrap();
2466        let base_path = temp_dir.path();
2467
2468        // Create an existing file
2469        let exists_path = base_path.join("exists.md");
2470        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2471
2472        // Create test content with angle bracket links
2473        let content = r#"
2474# Test Document
2475
2476[Valid Link](<exists.md>)
2477[Invalid Link](<missing.md>)
2478[External Link](<https://example.com>)
2479    "#;
2480
2481        // Test with default settings
2482        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2483
2484        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2485        let result = rule.check(&ctx).unwrap();
2486
2487        // Should have one warning for missing.md
2488        assert_eq!(result.len(), 1, "Should have exactly one warning");
2489        assert!(
2490            result[0].message.contains("missing.md"),
2491            "Warning should mention missing.md"
2492        );
2493    }
2494
2495    #[test]
2496    fn test_angle_bracket_links_with_parens() {
2497        // Create a temporary directory for test files
2498        let temp_dir = tempdir().unwrap();
2499        let base_path = temp_dir.path();
2500
2501        // Create directory structure with parentheses in path
2502        let app_dir = base_path.join("app");
2503        std::fs::create_dir(&app_dir).unwrap();
2504        let upload_dir = app_dir.join("(upload)");
2505        std::fs::create_dir(&upload_dir).unwrap();
2506        let page_file = upload_dir.join("page.tsx");
2507        File::create(&page_file)
2508            .unwrap()
2509            .write_all(b"export default function Page() {}")
2510            .unwrap();
2511
2512        // Create test content with angle bracket links containing parentheses
2513        let content = r#"
2514# Test Document with Paths Containing Parens
2515
2516[Upload Page](<app/(upload)/page.tsx>)
2517[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2518[Missing](<app/(missing)/file.md>)
2519"#;
2520
2521        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2522
2523        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2524        let result = rule.check(&ctx).unwrap();
2525
2526        // Should only have one warning for the missing file
2527        assert_eq!(
2528            result.len(),
2529            1,
2530            "Should have exactly one warning for missing file. Got: {result:?}"
2531        );
2532        assert!(
2533            result[0].message.contains("app/(missing)/file.md"),
2534            "Warning should mention app/(missing)/file.md"
2535        );
2536    }
2537
2538    #[test]
2539    fn test_balanced_parentheses_in_link_paths() {
2540        // Reproduces https://github.com/rvben/rumdl/issues/830:
2541        // links whose file path contains balanced parentheses used to be
2542        // truncated at the first ')' and reported as missing even when the
2543        // target exists on disk.
2544        let temp_dir = tempdir().unwrap();
2545        let base_path = temp_dir.path();
2546
2547        // Existing file with parens in its name
2548        let paren_file = base_path.join("file(inner).md");
2549        File::create(&paren_file)
2550            .unwrap()
2551            .write_all(b"# file(inner).md\n")
2552            .unwrap();
2553
2554        // Existing file inside a folder with parens in its name
2555        let folder = base_path.join("folder(inner)");
2556        std::fs::create_dir(&folder).unwrap();
2557        File::create(folder.join("file.md"))
2558            .unwrap()
2559            .write_all(b"# folder(inner)/file.md\n")
2560            .unwrap();
2561
2562        let content = r#"
2563# Test cases
2564
2565[File with parenthesis exists](file(inner).md)
2566[Folder with parenthesis exists](folder(inner)/file.md)
2567[Missing with parenthesis](missing(inner).md)
2568"#;
2569
2570        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2571
2572        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2573        let result = rule.check(&ctx).unwrap();
2574
2575        // The two existing targets must not warn, and the missing one must be
2576        // reported with its full, untruncated path.
2577        assert_eq!(
2578            result.len(),
2579            1,
2580            "Expected exactly one warning (the missing file). Got: {result:?}"
2581        );
2582        assert!(
2583            result[0].message.contains("missing(inner).md"),
2584            "Warning should name the full path `missing(inner).md`, got: {}",
2585            result[0].message
2586        );
2587    }
2588
2589    #[test]
2590    fn test_all_file_types_checked() {
2591        // Create a temporary directory for test files
2592        let temp_dir = tempdir().unwrap();
2593        let base_path = temp_dir.path();
2594
2595        // Create a test with various file types - all should be checked
2596        let content = r#"
2597[Image Link](image.jpg)
2598[Video Link](video.mp4)
2599[Markdown Link](document.md)
2600[PDF Link](file.pdf)
2601"#;
2602
2603        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2604
2605        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2606        let result = rule.check(&ctx).unwrap();
2607
2608        // Should warn about all missing files regardless of extension
2609        assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2610    }
2611
2612    #[test]
2613    fn test_code_span_detection() {
2614        let rule = MD057ExistingRelativeLinks::new();
2615
2616        // Create a temporary directory for test files
2617        let temp_dir = tempdir().unwrap();
2618        let base_path = temp_dir.path();
2619
2620        let rule = rule.with_path(base_path);
2621
2622        // Test with document structure
2623        let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2624
2625        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2626        let result = rule.check(&ctx).unwrap();
2627
2628        // Should only find the real link, not the one in code
2629        assert_eq!(result.len(), 1, "Should only flag the real link");
2630        assert!(result[0].message.contains("nonexistent.md"));
2631    }
2632
2633    #[test]
2634    fn test_inline_code_spans() {
2635        // Create a temporary directory for test files
2636        let temp_dir = tempdir().unwrap();
2637        let base_path = temp_dir.path();
2638
2639        // Create test content with links in inline code spans
2640        let content = r#"
2641# Test Document
2642
2643This is a normal link: [Link](missing.md)
2644
2645This is a code span with a link: `[Link](another-missing.md)`
2646
2647Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2648
2649    "#;
2650
2651        // Initialize rule with the base path
2652        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2653
2654        // Test the rule
2655        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2656        let result = rule.check(&ctx).unwrap();
2657
2658        // Should only have warning for the normal link, not for links in code spans
2659        assert_eq!(result.len(), 1, "Should have exactly one warning");
2660        assert!(
2661            result[0].message.contains("missing.md"),
2662            "Warning should be for missing.md"
2663        );
2664        assert!(
2665            !result.iter().any(|w| w.message.contains("another-missing.md")),
2666            "Should not warn about link in code span"
2667        );
2668        assert!(
2669            !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2670            "Should not warn about link in inline code"
2671        );
2672    }
2673
2674    #[test]
2675    fn test_extensionless_link_resolution() {
2676        // Create a temporary directory for test files
2677        let temp_dir = tempdir().unwrap();
2678        let base_path = temp_dir.path();
2679
2680        // Create a markdown file WITHOUT specifying .md extension in the link
2681        let page_path = base_path.join("page.md");
2682        File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2683
2684        // Test content with extensionless link that should resolve to page.md
2685        let content = r#"
2686# Test Document
2687
2688[Link without extension](page)
2689[Link with extension](page.md)
2690[Missing link](nonexistent)
2691"#;
2692
2693        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2694
2695        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2696        let result = rule.check(&ctx).unwrap();
2697
2698        // Should only have warning for nonexistent link
2699        // Both "page" and "page.md" should resolve to the same file
2700        assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2701        assert!(
2702            result[0].message.contains("nonexistent"),
2703            "Warning should be for 'nonexistent' not 'page'"
2704        );
2705    }
2706
2707    // Cross-file validation tests
2708    #[test]
2709    fn test_cross_file_scope() {
2710        let rule = MD057ExistingRelativeLinks::new();
2711        assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2712    }
2713
2714    #[test]
2715    fn test_contribute_to_index_extracts_markdown_links() {
2716        let rule = MD057ExistingRelativeLinks::new();
2717        let content = r#"
2718# Document
2719
2720[Link to docs](./docs/guide.md)
2721[Link with fragment](./other.md#section)
2722[External link](https://example.com)
2723[Image link](image.png)
2724[Media file](video.mp4)
2725"#;
2726
2727        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2728        let mut index = FileIndex::new();
2729        rule.contribute_to_index(&ctx, &mut index);
2730
2731        // Should only index markdown file links
2732        assert_eq!(index.cross_file_links.len(), 2);
2733
2734        // Check first link
2735        assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2736        assert_eq!(index.cross_file_links[0].fragment, "");
2737
2738        // Check second link (with fragment)
2739        assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2740        assert_eq!(index.cross_file_links[1].fragment, "section");
2741    }
2742
2743    #[test]
2744    fn test_contribute_to_index_skips_external_and_anchors() {
2745        let rule = MD057ExistingRelativeLinks::new();
2746        let content = r#"
2747# Document
2748
2749[External](https://example.com)
2750[Another external](http://example.org)
2751[Fragment only](#section)
2752[FTP link](ftp://files.example.com)
2753[Mail link](mailto:test@example.com)
2754[WWW link](www.example.com)
2755"#;
2756
2757        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2758        let mut index = FileIndex::new();
2759        rule.contribute_to_index(&ctx, &mut index);
2760
2761        // Should not index any of these
2762        assert_eq!(index.cross_file_links.len(), 0);
2763    }
2764
2765    #[test]
2766    fn test_cross_file_check_valid_link() {
2767        use crate::workspace_index::WorkspaceIndex;
2768
2769        let rule = MD057ExistingRelativeLinks::new();
2770
2771        // Create a workspace index with the target file
2772        let mut workspace_index = WorkspaceIndex::new();
2773        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2774
2775        // Create file index with a link to an existing file
2776        let mut file_index = FileIndex::new();
2777        file_index.add_cross_file_link(CrossFileLinkIndex {
2778            target_path: "guide.md".to_string(),
2779            fragment: "".to_string(),
2780            line: 5,
2781            column: 1,
2782            origin: LinkOrigin::Body,
2783        });
2784
2785        // Run cross-file check from docs/index.md
2786        let warnings = rule
2787            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2788            .unwrap();
2789
2790        // Should have no warnings - file exists
2791        assert!(warnings.is_empty());
2792    }
2793
2794    #[test]
2795    fn test_cross_file_check_missing_link() {
2796        // cross_file_check delegates all validation to check() to avoid duplicates.
2797        // It always returns empty — the per-file check() path is authoritative.
2798        use crate::workspace_index::WorkspaceIndex;
2799
2800        let rule = MD057ExistingRelativeLinks::new();
2801        let workspace_index = WorkspaceIndex::new();
2802
2803        let mut file_index = FileIndex::new();
2804        file_index.add_cross_file_link(CrossFileLinkIndex {
2805            target_path: "missing.md".to_string(),
2806            fragment: "".to_string(),
2807            line: 5,
2808            column: 1,
2809            origin: LinkOrigin::Body,
2810        });
2811
2812        let warnings = rule
2813            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2814            .unwrap();
2815
2816        // cross_file_check defers to check(); it produces no warnings of its own.
2817        assert!(
2818            warnings.is_empty(),
2819            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2820        );
2821    }
2822
2823    #[test]
2824    fn test_cross_file_check_parent_path() {
2825        use crate::workspace_index::WorkspaceIndex;
2826
2827        let rule = MD057ExistingRelativeLinks::new();
2828
2829        // Create a workspace index with the target file at the root
2830        let mut workspace_index = WorkspaceIndex::new();
2831        workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2832
2833        // Create file index with a parent path link
2834        let mut file_index = FileIndex::new();
2835        file_index.add_cross_file_link(CrossFileLinkIndex {
2836            target_path: "../readme.md".to_string(),
2837            fragment: "".to_string(),
2838            line: 5,
2839            column: 1,
2840            origin: LinkOrigin::Body,
2841        });
2842
2843        // Run cross-file check from docs/guide.md
2844        let warnings = rule
2845            .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2846            .unwrap();
2847
2848        // Should have no warnings - file exists at normalized path
2849        assert!(warnings.is_empty());
2850    }
2851
2852    #[test]
2853    fn test_cross_file_check_html_link_with_md_source() {
2854        // Test that .html links are accepted when corresponding .md source exists
2855        // This supports mdBook and similar doc generators that compile .md to .html
2856        use crate::workspace_index::WorkspaceIndex;
2857
2858        let rule = MD057ExistingRelativeLinks::new();
2859
2860        // Create a workspace index with the .md source file
2861        let mut workspace_index = WorkspaceIndex::new();
2862        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2863
2864        // Create file index with an .html link (from another rule like MD051)
2865        let mut file_index = FileIndex::new();
2866        file_index.add_cross_file_link(CrossFileLinkIndex {
2867            target_path: "guide.html".to_string(),
2868            fragment: "section".to_string(),
2869            line: 10,
2870            column: 5,
2871            origin: LinkOrigin::Body,
2872        });
2873
2874        // Run cross-file check from docs/index.md
2875        let warnings = rule
2876            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2877            .unwrap();
2878
2879        // Should have no warnings - .md source exists for the .html link
2880        assert!(
2881            warnings.is_empty(),
2882            "Expected no warnings for .html link with .md source, got: {warnings:?}"
2883        );
2884    }
2885
2886    #[test]
2887    fn test_cross_file_check_html_link_without_source() {
2888        // cross_file_check delegates all validation to check() to avoid duplicates.
2889        // Verifying that .html links without a matching .md source are caught is
2890        // already covered by test_html_link_with_md_source (check() path).
2891        use crate::workspace_index::WorkspaceIndex;
2892
2893        let rule = MD057ExistingRelativeLinks::new();
2894        let workspace_index = WorkspaceIndex::new();
2895
2896        let mut file_index = FileIndex::new();
2897        file_index.add_cross_file_link(CrossFileLinkIndex {
2898            target_path: "missing.html".to_string(),
2899            fragment: "".to_string(),
2900            line: 10,
2901            column: 5,
2902            origin: LinkOrigin::Body,
2903        });
2904
2905        let warnings = rule
2906            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2907            .unwrap();
2908
2909        // cross_file_check defers to check(); it produces no warnings of its own.
2910        assert!(
2911            warnings.is_empty(),
2912            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2913        );
2914    }
2915
2916    #[test]
2917    fn test_normalize_path_function() {
2918        // Test simple cases
2919        assert_eq!(
2920            normalize_relative_path(Path::new("docs/guide.md")),
2921            PathBuf::from("docs/guide.md")
2922        );
2923
2924        // Test current directory removal
2925        assert_eq!(
2926            normalize_relative_path(Path::new("./docs/guide.md")),
2927            PathBuf::from("docs/guide.md")
2928        );
2929
2930        // Test parent directory resolution
2931        assert_eq!(
2932            normalize_relative_path(Path::new("docs/sub/../guide.md")),
2933            PathBuf::from("docs/guide.md")
2934        );
2935
2936        // Test multiple parent directories
2937        assert_eq!(
2938            normalize_relative_path(Path::new("a/b/c/../../d.md")),
2939            PathBuf::from("a/d.md")
2940        );
2941    }
2942
2943    #[test]
2944    fn test_html_link_with_md_source() {
2945        // Links to .html files should pass if corresponding .md source exists
2946        let temp_dir = tempdir().unwrap();
2947        let base_path = temp_dir.path();
2948
2949        // Create guide.md (source file)
2950        let md_file = base_path.join("guide.md");
2951        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2952
2953        let content = r#"
2954[Read the guide](guide.html)
2955[Also here](getting-started.html)
2956"#;
2957
2958        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2959        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2960        let result = rule.check(&ctx).unwrap();
2961
2962        // guide.html passes (guide.md exists), getting-started.html fails
2963        assert_eq!(
2964            result.len(),
2965            1,
2966            "Should only warn about missing source. Got: {result:?}"
2967        );
2968        assert!(result[0].message.contains("getting-started.html"));
2969    }
2970
2971    #[test]
2972    fn test_htm_link_with_md_source() {
2973        // .htm extension should also check for markdown source
2974        let temp_dir = tempdir().unwrap();
2975        let base_path = temp_dir.path();
2976
2977        let md_file = base_path.join("page.md");
2978        File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2979
2980        let content = "[Page](page.htm)";
2981
2982        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2983        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2984        let result = rule.check(&ctx).unwrap();
2985
2986        assert!(
2987            result.is_empty(),
2988            "Should not warn when .md source exists for .htm link"
2989        );
2990    }
2991
2992    #[test]
2993    fn test_html_link_finds_various_markdown_extensions() {
2994        // Should find .mdx, .markdown, etc. as source files
2995        let temp_dir = tempdir().unwrap();
2996        let base_path = temp_dir.path();
2997
2998        File::create(base_path.join("doc.md")).unwrap();
2999        File::create(base_path.join("tutorial.mdx")).unwrap();
3000        File::create(base_path.join("guide.markdown")).unwrap();
3001
3002        let content = r#"
3003[Doc](doc.html)
3004[Tutorial](tutorial.html)
3005[Guide](guide.html)
3006"#;
3007
3008        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3009        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3010        let result = rule.check(&ctx).unwrap();
3011
3012        assert!(
3013            result.is_empty(),
3014            "Should find all markdown variants as source files. Got: {result:?}"
3015        );
3016    }
3017
3018    #[test]
3019    fn test_html_link_in_subdirectory() {
3020        // Should find markdown source in subdirectories
3021        let temp_dir = tempdir().unwrap();
3022        let base_path = temp_dir.path();
3023
3024        let docs_dir = base_path.join("docs");
3025        std::fs::create_dir(&docs_dir).unwrap();
3026        File::create(docs_dir.join("guide.md"))
3027            .unwrap()
3028            .write_all(b"# Guide")
3029            .unwrap();
3030
3031        let content = "[Guide](docs/guide.html)";
3032
3033        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3034        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3035        let result = rule.check(&ctx).unwrap();
3036
3037        assert!(result.is_empty(), "Should find markdown source in subdirectory");
3038    }
3039
3040    #[test]
3041    fn test_absolute_path_skipped_in_check() {
3042        // Test that absolute paths are skipped during link validation
3043        // This fixes the bug where /pkg/runtime was being flagged
3044        let temp_dir = tempdir().unwrap();
3045        let base_path = temp_dir.path();
3046
3047        let content = r#"
3048# Test Document
3049
3050[Go Runtime](/pkg/runtime)
3051[Go Runtime with Fragment](/pkg/runtime#section)
3052[API Docs](/api/v1/users)
3053[Blog Post](/blog/2024/release.html)
3054[React Hook](/react/hooks/use-state.html)
3055"#;
3056
3057        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3058        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3059        let result = rule.check(&ctx).unwrap();
3060
3061        // Should have NO warnings - all absolute paths should be skipped
3062        assert!(
3063            result.is_empty(),
3064            "Absolute paths should be skipped. Got warnings: {result:?}"
3065        );
3066    }
3067
3068    #[test]
3069    fn test_absolute_path_skipped_in_cross_file_check() {
3070        // Test that absolute paths are skipped in cross_file_check()
3071        use crate::workspace_index::WorkspaceIndex;
3072
3073        let rule = MD057ExistingRelativeLinks::new();
3074
3075        // Create an empty workspace index (no files exist)
3076        let workspace_index = WorkspaceIndex::new();
3077
3078        // Create file index with absolute path links (should be skipped)
3079        let mut file_index = FileIndex::new();
3080        file_index.add_cross_file_link(CrossFileLinkIndex {
3081            target_path: "/pkg/runtime.md".to_string(),
3082            fragment: "".to_string(),
3083            line: 5,
3084            column: 1,
3085            origin: LinkOrigin::Body,
3086        });
3087        file_index.add_cross_file_link(CrossFileLinkIndex {
3088            target_path: "/api/v1/users.md".to_string(),
3089            fragment: "section".to_string(),
3090            line: 10,
3091            column: 1,
3092            origin: LinkOrigin::Body,
3093        });
3094
3095        // Run cross-file check
3096        let warnings = rule
3097            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3098            .unwrap();
3099
3100        // Should have NO warnings - absolute paths should be skipped
3101        assert!(
3102            warnings.is_empty(),
3103            "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
3104        );
3105    }
3106
3107    #[test]
3108    fn test_protocol_relative_url_not_skipped() {
3109        // Test that protocol-relative URLs (//example.com) are NOT skipped as absolute paths
3110        // They should still be caught by is_external_url() though
3111        let temp_dir = tempdir().unwrap();
3112        let base_path = temp_dir.path();
3113
3114        let content = r#"
3115# Test Document
3116
3117[External](//example.com/page)
3118[Another](//cdn.example.com/asset.js)
3119"#;
3120
3121        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3122        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3123        let result = rule.check(&ctx).unwrap();
3124
3125        // Should have NO warnings - protocol-relative URLs are external and should be skipped
3126        assert!(
3127            result.is_empty(),
3128            "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
3129        );
3130    }
3131
3132    #[test]
3133    fn test_email_addresses_skipped() {
3134        // Test that email addresses without mailto: are skipped
3135        // These are clearly not file links (the @ symbol is definitive)
3136        let temp_dir = tempdir().unwrap();
3137        let base_path = temp_dir.path();
3138
3139        let content = r#"
3140# Test Document
3141
3142[Contact](user@example.com)
3143[Steering](steering@kubernetes.io)
3144[Support](john.doe+filter@company.co.uk)
3145[User](user_name@sub.domain.com)
3146"#;
3147
3148        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3149        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3150        let result = rule.check(&ctx).unwrap();
3151
3152        // Should have NO warnings - email addresses are clearly not file links and should be skipped
3153        assert!(
3154            result.is_empty(),
3155            "Email addresses should be skipped. Got warnings: {result:?}"
3156        );
3157    }
3158
3159    #[test]
3160    fn test_email_addresses_vs_file_paths() {
3161        // Test that email addresses (anything with @) are skipped
3162        // Note: File paths with @ are extremely rare, so we treat anything with @ as an email
3163        let temp_dir = tempdir().unwrap();
3164        let base_path = temp_dir.path();
3165
3166        let content = r#"
3167# Test Document
3168
3169[Email](user@example.com)  <!-- Should be skipped (email) -->
3170[Email2](steering@kubernetes.io)  <!-- Should be skipped (email) -->
3171[Email3](user@file.md)  <!-- Should be skipped (has @, treated as email) -->
3172"#;
3173
3174        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3175        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3176        let result = rule.check(&ctx).unwrap();
3177
3178        // All should be skipped - anything with @ is treated as an email
3179        assert!(
3180            result.is_empty(),
3181            "All email addresses should be skipped. Got: {result:?}"
3182        );
3183    }
3184
3185    #[test]
3186    fn test_diagnostic_position_accuracy() {
3187        // Test that diagnostics point to the URL, not the link text
3188        let temp_dir = tempdir().unwrap();
3189        let base_path = temp_dir.path();
3190
3191        // Position markers:     0         1         2         3
3192        //                       0123456789012345678901234567890123456789
3193        let content = "prefix [text](missing.md) suffix";
3194        //             The URL "missing.md" starts at 0-indexed position 14
3195        //             which is 1-indexed column 15, and ends at 0-indexed 24 (1-indexed column 25)
3196
3197        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3198        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3199        let result = rule.check(&ctx).unwrap();
3200
3201        assert_eq!(result.len(), 1, "Should have exactly one warning");
3202        assert_eq!(result[0].line, 1, "Should be on line 1");
3203        assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
3204        assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
3205    }
3206
3207    #[test]
3208    fn test_diagnostic_position_non_ascii_link() {
3209        // Issue #670: columns are character offsets, not byte offsets. The CJK
3210        // prefix is multi-byte in UTF-8, so a byte offset over-counts the column.
3211        let temp_dir = tempdir().unwrap();
3212        let base_path = temp_dir.path();
3213
3214        // Character columns: 1:你 2:好 3:你 4:好 5:[ 6:你 7:好 8:] 9:( 10:n ...
3215        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 10
3216        // and ends past character column 21, i.e. end_column 22.
3217        let content = "你好你好[你好](not-exist.md) bar";
3218
3219        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3220        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3221        let result = rule.check(&ctx).unwrap();
3222
3223        assert_eq!(result.len(), 1, "Should have exactly one warning");
3224        assert_eq!(result[0].line, 1, "Should be on line 1");
3225        assert_eq!(
3226            result[0].column, 10,
3227            "Column must be a character offset, not a byte offset"
3228        );
3229        assert_eq!(result[0].end_column, 22, "End column must be character-based");
3230    }
3231
3232    #[test]
3233    fn test_diagnostic_position_angle_brackets() {
3234        // Test position accuracy with angle bracket links
3235        let temp_dir = tempdir().unwrap();
3236        let base_path = temp_dir.path();
3237
3238        // Position markers:     0         1         2
3239        //                       012345678901234567890
3240        let content = "[link](<missing.md>)";
3241        //             The URL "missing.md" starts at 0-indexed position 8 (1-indexed column 9)
3242
3243        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3244        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3245        let result = rule.check(&ctx).unwrap();
3246
3247        assert_eq!(result.len(), 1, "Should have exactly one warning");
3248        assert_eq!(result[0].line, 1, "Should be on line 1");
3249        assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
3250    }
3251
3252    #[test]
3253    fn test_diagnostic_position_multiline() {
3254        // Test that line numbers are correct for links on different lines
3255        let temp_dir = tempdir().unwrap();
3256        let base_path = temp_dir.path();
3257
3258        let content = r#"# Title
3259Some text on line 2
3260[link on line 3](missing1.md)
3261More text
3262[link on line 5](missing2.md)"#;
3263
3264        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3265        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3266        let result = rule.check(&ctx).unwrap();
3267
3268        assert_eq!(result.len(), 2, "Should have two warnings");
3269
3270        // First warning should be on line 3
3271        assert_eq!(result[0].line, 3, "First warning should be on line 3");
3272        assert!(result[0].message.contains("missing1.md"));
3273
3274        // Second warning should be on line 5
3275        assert_eq!(result[1].line, 5, "Second warning should be on line 5");
3276        assert!(result[1].message.contains("missing2.md"));
3277    }
3278
3279    #[test]
3280    fn test_diagnostic_position_with_spaces() {
3281        // Test position with URLs that have spaces in parentheses
3282        let temp_dir = tempdir().unwrap();
3283        let base_path = temp_dir.path();
3284
3285        let content = "[link]( missing.md )";
3286        //             0123456789012345678901
3287        //             0-indexed position 8 is 'm' in 'missing.md' (after space and paren)
3288        //             which is 1-indexed column 9
3289
3290        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3291        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3292        let result = rule.check(&ctx).unwrap();
3293
3294        assert_eq!(result.len(), 1, "Should have exactly one warning");
3295        // The regex captures the URL without leading/trailing spaces
3296        assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
3297    }
3298
3299    #[test]
3300    fn test_diagnostic_position_image() {
3301        // Test that image diagnostics also have correct positions
3302        let temp_dir = tempdir().unwrap();
3303        let base_path = temp_dir.path();
3304
3305        let content = "![alt text](missing.jpg)";
3306
3307        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3308        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3309        let result = rule.check(&ctx).unwrap();
3310
3311        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3312        assert_eq!(result[0].line, 1);
3313        // Images use start_col from the parser, which should point to the URL
3314        assert!(result[0].column > 0, "Should have valid column position");
3315        assert!(result[0].message.contains("missing.jpg"));
3316    }
3317
3318    #[test]
3319    fn test_diagnostic_position_non_ascii_image() {
3320        // Issue #670: image columns are character offsets, not byte offsets.
3321        let temp_dir = tempdir().unwrap();
3322        let base_path = temp_dir.path();
3323
3324        // Character columns: 1:你 2:好 3:你 4:好 5:! 6:[ 7:你 8:好 9:] 10:( 11:n ...
3325        // The image syntax starts at the '!' which is 1-indexed character column 5.
3326        let content = "你好你好![你好](not-exist.png)";
3327
3328        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3329        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3330        let result = rule.check(&ctx).unwrap();
3331
3332        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3333        assert_eq!(result[0].line, 1, "Should be on line 1");
3334        assert_eq!(
3335            result[0].column, 5,
3336            "Column must be a character offset, not a byte offset"
3337        );
3338        assert!(result[0].message.contains("not-exist.png"));
3339    }
3340
3341    #[test]
3342    fn test_diagnostic_position_non_ascii_reference_def() {
3343        // Issue #670: reference-definition columns are character offsets. A
3344        // multi-byte label shifts the URL's byte offset away from its character
3345        // column.
3346        let temp_dir = tempdir().unwrap();
3347        let base_path = temp_dir.path();
3348
3349        // Character columns: 1:[ 2:你 3:好 4:] 5:: 6:space 7:n ...
3350        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 7.
3351        let content = "[你好]: not-exist.md";
3352
3353        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3354        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3355        let result = rule.check(&ctx).unwrap();
3356
3357        assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
3358        assert_eq!(result[0].line, 1, "Should be on line 1");
3359        assert_eq!(
3360            result[0].column, 7,
3361            "Column must be a character offset, not a byte offset"
3362        );
3363        assert_eq!(result[0].end_column, 19, "End column must be character-based");
3364    }
3365
3366    #[test]
3367    fn test_wikilinks_skipped() {
3368        // Wikilinks should not trigger MD057 warnings
3369        // They use a different linking system (e.g., Obsidian, wiki software)
3370        let temp_dir = tempdir().unwrap();
3371        let base_path = temp_dir.path();
3372
3373        let content = r#"# Test Document
3374
3375[[Microsoft#Windows OS]]
3376[[SomePage]]
3377[[Page With Spaces]]
3378[[path/to/page#section]]
3379[[page|Display Text]]
3380
3381This is a [real missing link](missing.md) that should be flagged.
3382"#;
3383
3384        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3385        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3386        let result = rule.check(&ctx).unwrap();
3387
3388        // Should only warn about the regular markdown link, not wikilinks
3389        assert_eq!(
3390            result.len(),
3391            1,
3392            "Should only warn about missing.md, not wikilinks. Got: {result:?}"
3393        );
3394        assert!(
3395            result[0].message.contains("missing.md"),
3396            "Warning should be for missing.md, not wikilinks"
3397        );
3398    }
3399
3400    #[test]
3401    fn test_wiki_embeds_skipped() {
3402        // A wiki embed names a vault entry, not a path relative to this file,
3403        // so `![[diagram.png]]` is not a missing relative link even though no
3404        // such file sits next to the document.
3405        let temp_dir = tempdir().unwrap();
3406        let base_path = temp_dir.path();
3407
3408        let content = r#"# Test Document
3409
3410![[diagram.png]]
3411![[subfolder/diagram.png]]
3412![[diagram.png|300]]
3413![[Some Note]]
3414
3415This is a [real missing link](missing.md) that should be flagged.
3416"#;
3417
3418        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3419        for flavor in [
3420            crate::config::MarkdownFlavor::Obsidian,
3421            crate::config::MarkdownFlavor::Standard,
3422        ] {
3423            let ctx = crate::lint_context::LintContext::new(content, flavor, None);
3424            let result = rule.check(&ctx).unwrap();
3425
3426            assert_eq!(
3427                result.len(),
3428                1,
3429                "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
3430            );
3431            assert!(result[0].message.contains("missing.md"));
3432        }
3433    }
3434
3435    #[test]
3436    fn test_wikilinks_not_added_to_index() {
3437        // Wikilinks should not be added to the cross-file link index
3438        let temp_dir = tempdir().unwrap();
3439        let base_path = temp_dir.path();
3440
3441        let content = r#"# Test Document
3442
3443[[Microsoft#Windows OS]]
3444[[SomePage#section]]
3445[Regular Link](other.md)
3446"#;
3447
3448        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3449        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3450
3451        let mut file_index = FileIndex::new();
3452        rule.contribute_to_index(&ctx, &mut file_index);
3453
3454        // Should only have the regular markdown link (if it's a markdown file)
3455        // Wikilinks should not be added
3456        let cross_file_links = &file_index.cross_file_links;
3457        assert_eq!(
3458            cross_file_links.len(),
3459            1,
3460            "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
3461        );
3462        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
3463    }
3464
3465    #[test]
3466    fn test_reference_definition_missing_file() {
3467        // Reference definitions [ref]: ./path.md should be checked
3468        let temp_dir = tempdir().unwrap();
3469        let base_path = temp_dir.path();
3470
3471        let content = r#"# Test Document
3472
3473[test]: ./missing.md
3474[example]: ./nonexistent.html
3475
3476Use [test] and [example] here.
3477"#;
3478
3479        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3480        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3481        let result = rule.check(&ctx).unwrap();
3482
3483        // Should have warnings for both reference definitions
3484        assert_eq!(
3485            result.len(),
3486            2,
3487            "Should have warnings for missing reference definition targets. Got: {result:?}"
3488        );
3489        assert!(
3490            result.iter().any(|w| w.message.contains("missing.md")),
3491            "Should warn about missing.md"
3492        );
3493        assert!(
3494            result.iter().any(|w| w.message.contains("nonexistent.html")),
3495            "Should warn about nonexistent.html"
3496        );
3497    }
3498
3499    #[test]
3500    fn test_reference_definition_existing_file() {
3501        // Reference definitions to existing files should NOT trigger warnings
3502        let temp_dir = tempdir().unwrap();
3503        let base_path = temp_dir.path();
3504
3505        // Create an existing file
3506        let exists_path = base_path.join("exists.md");
3507        File::create(&exists_path)
3508            .unwrap()
3509            .write_all(b"# Existing file")
3510            .unwrap();
3511
3512        let content = r#"# Test Document
3513
3514[test]: ./exists.md
3515
3516Use [test] here.
3517"#;
3518
3519        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3520        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3521        let result = rule.check(&ctx).unwrap();
3522
3523        // Should have NO warnings since the file exists
3524        assert!(
3525            result.is_empty(),
3526            "Should not warn about existing file. Got: {result:?}"
3527        );
3528    }
3529
3530    #[test]
3531    fn test_reference_definition_external_url_skipped() {
3532        // Reference definitions with external URLs should be skipped
3533        let temp_dir = tempdir().unwrap();
3534        let base_path = temp_dir.path();
3535
3536        let content = r#"# Test Document
3537
3538[google]: https://google.com
3539[example]: http://example.org
3540[mail]: mailto:test@example.com
3541[ftp]: ftp://files.example.com
3542[local]: ./missing.md
3543
3544Use [google], [example], [mail], [ftp], [local] here.
3545"#;
3546
3547        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3548        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3549        let result = rule.check(&ctx).unwrap();
3550
3551        // Should only warn about the local missing file, not external URLs
3552        assert_eq!(
3553            result.len(),
3554            1,
3555            "Should only warn about local missing file. Got: {result:?}"
3556        );
3557        assert!(
3558            result[0].message.contains("missing.md"),
3559            "Warning should be for missing.md"
3560        );
3561    }
3562
3563    #[test]
3564    fn test_reference_definition_fragment_only_skipped() {
3565        // Reference definitions with fragment-only URLs should be skipped
3566        let temp_dir = tempdir().unwrap();
3567        let base_path = temp_dir.path();
3568
3569        let content = r#"# Test Document
3570
3571[section]: #my-section
3572
3573Use [section] here.
3574"#;
3575
3576        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3577        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3578        let result = rule.check(&ctx).unwrap();
3579
3580        // Should have NO warnings for fragment-only links
3581        assert!(
3582            result.is_empty(),
3583            "Should not warn about fragment-only reference. Got: {result:?}"
3584        );
3585    }
3586
3587    #[test]
3588    fn test_reference_definition_column_position() {
3589        // Test that column position points to the URL in the reference definition
3590        let temp_dir = tempdir().unwrap();
3591        let base_path = temp_dir.path();
3592
3593        // Position markers:     0         1         2
3594        //                       0123456789012345678901
3595        let content = "[ref]: ./missing.md";
3596        //             The URL "./missing.md" starts at 0-indexed position 7
3597        //             which is 1-indexed column 8
3598
3599        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3600        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3601        let result = rule.check(&ctx).unwrap();
3602
3603        assert_eq!(result.len(), 1, "Should have exactly one warning");
3604        assert_eq!(result[0].line, 1, "Should be on line 1");
3605        assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3606    }
3607
3608    #[test]
3609    fn test_reference_definition_html_with_md_source() {
3610        // Reference definitions to .html files should pass if corresponding .md source exists
3611        let temp_dir = tempdir().unwrap();
3612        let base_path = temp_dir.path();
3613
3614        // Create guide.md (source file)
3615        let md_file = base_path.join("guide.md");
3616        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3617
3618        let content = r#"# Test Document
3619
3620[guide]: ./guide.html
3621[missing]: ./missing.html
3622
3623Use [guide] and [missing] here.
3624"#;
3625
3626        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3627        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3628        let result = rule.check(&ctx).unwrap();
3629
3630        // guide.html passes (guide.md exists), missing.html fails
3631        assert_eq!(
3632            result.len(),
3633            1,
3634            "Should only warn about missing source. Got: {result:?}"
3635        );
3636        assert!(result[0].message.contains("missing.html"));
3637    }
3638
3639    #[test]
3640    fn test_reference_definition_url_encoded() {
3641        // Reference definitions with URL-encoded paths should be decoded before checking
3642        let temp_dir = tempdir().unwrap();
3643        let base_path = temp_dir.path();
3644
3645        // Create a file with spaces in the name
3646        let file_with_spaces = base_path.join("file with spaces.md");
3647        File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3648
3649        let content = r#"# Test Document
3650
3651[spaces]: ./file%20with%20spaces.md
3652[missing]: ./missing%20file.md
3653
3654Use [spaces] and [missing] here.
3655"#;
3656
3657        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3658        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3659        let result = rule.check(&ctx).unwrap();
3660
3661        // Should only warn about the missing file
3662        assert_eq!(
3663            result.len(),
3664            1,
3665            "Should only warn about missing URL-encoded file. Got: {result:?}"
3666        );
3667        assert!(result[0].message.contains("missing%20file.md"));
3668    }
3669
3670    #[test]
3671    fn test_inline_and_reference_both_checked() {
3672        // Both inline links and reference definitions should be checked
3673        let temp_dir = tempdir().unwrap();
3674        let base_path = temp_dir.path();
3675
3676        let content = r#"# Test Document
3677
3678[inline link](./inline-missing.md)
3679[ref]: ./ref-missing.md
3680
3681Use [ref] here.
3682"#;
3683
3684        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3685        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3686        let result = rule.check(&ctx).unwrap();
3687
3688        // Should warn about both the inline link and the reference definition
3689        assert_eq!(
3690            result.len(),
3691            2,
3692            "Should warn about both inline and reference links. Got: {result:?}"
3693        );
3694        assert!(
3695            result.iter().any(|w| w.message.contains("inline-missing.md")),
3696            "Should warn about inline-missing.md"
3697        );
3698        assert!(
3699            result.iter().any(|w| w.message.contains("ref-missing.md")),
3700            "Should warn about ref-missing.md"
3701        );
3702    }
3703
3704    #[test]
3705    fn test_footnote_definitions_not_flagged() {
3706        // Regression test for issue #286: footnote definitions should not be
3707        // treated as reference definitions and flagged as broken links
3708        let rule = MD057ExistingRelativeLinks::default();
3709
3710        let content = r#"# Title
3711
3712A footnote[^1].
3713
3714[^1]: [link](https://www.google.com).
3715"#;
3716
3717        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3718        let result = rule.check(&ctx).unwrap();
3719
3720        assert!(
3721            result.is_empty(),
3722            "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3723        );
3724    }
3725
3726    #[test]
3727    fn test_footnote_with_relative_link_inside() {
3728        // Footnotes containing relative links should not be checked
3729        // (the footnote content is not a URL, it's content that may contain links)
3730        let rule = MD057ExistingRelativeLinks::default();
3731
3732        let content = r#"# Title
3733
3734See the footnote[^1].
3735
3736[^1]: Check out [this file](./existing.md) for more info.
3737[^2]: Also see [missing](./does-not-exist.md).
3738"#;
3739
3740        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3741        let result = rule.check(&ctx).unwrap();
3742
3743        // The inline links INSIDE footnotes should be checked (./existing.md, ./does-not-exist.md)
3744        // but the footnote definition itself should not be treated as a reference definition
3745        // Note: This test verifies that [^1]: and [^2]: are not parsed as ref defs with
3746        // URLs like "[this file](./existing.md)" or "[missing](./does-not-exist.md)"
3747        for warning in &result {
3748            assert!(
3749                !warning.message.contains("[this file]"),
3750                "Footnote content should not be treated as URL: {warning:?}"
3751            );
3752            assert!(
3753                !warning.message.contains("[missing]"),
3754                "Footnote content should not be treated as URL: {warning:?}"
3755            );
3756        }
3757    }
3758
3759    #[test]
3760    fn test_mixed_footnotes_and_reference_definitions() {
3761        // Ensure regular reference definitions are still checked while footnotes are skipped
3762        let temp_dir = tempdir().unwrap();
3763        let base_path = temp_dir.path();
3764
3765        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3766
3767        let content = r#"# Title
3768
3769A footnote[^1] and a [ref link][myref].
3770
3771[^1]: This is a footnote with [link](https://example.com).
3772
3773[myref]: ./missing-file.md "This should be checked"
3774"#;
3775
3776        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3777        let result = rule.check(&ctx).unwrap();
3778
3779        // Should only warn about the regular reference definition, not the footnote
3780        assert_eq!(
3781            result.len(),
3782            1,
3783            "Should only warn about the regular reference definition. Got: {result:?}"
3784        );
3785        assert!(
3786            result[0].message.contains("missing-file.md"),
3787            "Should warn about missing-file.md in reference definition"
3788        );
3789    }
3790
3791    #[test]
3792    fn test_absolute_links_ignore_by_default() {
3793        // By default, absolute links are ignored (not validated)
3794        let temp_dir = tempdir().unwrap();
3795        let base_path = temp_dir.path();
3796
3797        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3798
3799        let content = r#"# Links
3800
3801[API docs](/api/v1/users)
3802[Blog post](/blog/2024/release.html)
3803![Logo](/assets/logo.png)
3804
3805[ref]: /docs/reference.md
3806"#;
3807
3808        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3809        let result = rule.check(&ctx).unwrap();
3810
3811        // No warnings - absolute links are ignored by default
3812        assert!(
3813            result.is_empty(),
3814            "Absolute links should be ignored by default. Got: {result:?}"
3815        );
3816    }
3817
3818    #[test]
3819    fn test_absolute_links_warn_config() {
3820        // When configured to warn, absolute links should generate warnings
3821        let temp_dir = tempdir().unwrap();
3822        let base_path = temp_dir.path();
3823
3824        let config = MD057Config {
3825            absolute_links: AbsoluteLinksOption::Warn,
3826            ..Default::default()
3827        };
3828        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3829
3830        let content = r#"# Links
3831
3832[API docs](/api/v1/users)
3833[Blog post](/blog/2024/release.html)
3834"#;
3835
3836        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3837        let result = rule.check(&ctx).unwrap();
3838
3839        // Should have 2 warnings for the 2 absolute links
3840        assert_eq!(
3841            result.len(),
3842            2,
3843            "Should warn about both absolute links. Got: {result:?}"
3844        );
3845        assert!(
3846            result[0].message.contains("cannot be validated locally"),
3847            "Warning should explain why: {}",
3848            result[0].message
3849        );
3850        assert!(
3851            result[0].message.contains("/api/v1/users"),
3852            "Warning should include the link path"
3853        );
3854    }
3855
3856    #[test]
3857    fn test_absolute_links_warn_images() {
3858        // Images with absolute paths should also warn when configured
3859        let temp_dir = tempdir().unwrap();
3860        let base_path = temp_dir.path();
3861
3862        let config = MD057Config {
3863            absolute_links: AbsoluteLinksOption::Warn,
3864            ..Default::default()
3865        };
3866        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3867
3868        let content = r#"# Images
3869
3870![Logo](/assets/logo.png)
3871"#;
3872
3873        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3874        let result = rule.check(&ctx).unwrap();
3875
3876        assert_eq!(
3877            result.len(),
3878            1,
3879            "Should warn about absolute image path. Got: {result:?}"
3880        );
3881        assert!(
3882            result[0].message.contains("/assets/logo.png"),
3883            "Warning should include the image path"
3884        );
3885    }
3886
3887    #[test]
3888    fn test_absolute_links_warn_reference_definitions() {
3889        // Reference definitions with absolute paths should also warn when configured
3890        let temp_dir = tempdir().unwrap();
3891        let base_path = temp_dir.path();
3892
3893        let config = MD057Config {
3894            absolute_links: AbsoluteLinksOption::Warn,
3895            ..Default::default()
3896        };
3897        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3898
3899        let content = r#"# Reference
3900
3901See the [docs][ref].
3902
3903[ref]: /docs/reference.md
3904"#;
3905
3906        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3907        let result = rule.check(&ctx).unwrap();
3908
3909        assert_eq!(
3910            result.len(),
3911            1,
3912            "Should warn about absolute reference definition. Got: {result:?}"
3913        );
3914        assert!(
3915            result[0].message.contains("/docs/reference.md"),
3916            "Warning should include the reference path"
3917        );
3918    }
3919
3920    #[test]
3921    fn test_search_paths_inline_link() {
3922        let temp_dir = tempdir().unwrap();
3923        let base_path = temp_dir.path();
3924
3925        // Create an "assets" directory with an image
3926        let assets_dir = base_path.join("assets");
3927        std::fs::create_dir_all(&assets_dir).unwrap();
3928        std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3929
3930        let config = MD057Config {
3931            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3932            ..Default::default()
3933        };
3934        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3935
3936        let content = "# Test\n\n[Photo](photo.png)\n";
3937        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3938        let result = rule.check(&ctx).unwrap();
3939
3940        assert!(
3941            result.is_empty(),
3942            "Should find photo.png via search-paths. Got: {result:?}"
3943        );
3944    }
3945
3946    #[test]
3947    fn test_search_paths_image() {
3948        let temp_dir = tempdir().unwrap();
3949        let base_path = temp_dir.path();
3950
3951        let assets_dir = base_path.join("attachments");
3952        std::fs::create_dir_all(&assets_dir).unwrap();
3953        std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3954
3955        let config = MD057Config {
3956            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3957            ..Default::default()
3958        };
3959        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3960
3961        let content = "# Test\n\n![Diagram](diagram.svg)\n";
3962        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3963        let result = rule.check(&ctx).unwrap();
3964
3965        assert!(
3966            result.is_empty(),
3967            "Should find diagram.svg via search-paths. Got: {result:?}"
3968        );
3969    }
3970
3971    #[test]
3972    fn test_search_paths_reference_definition() {
3973        let temp_dir = tempdir().unwrap();
3974        let base_path = temp_dir.path();
3975
3976        let assets_dir = base_path.join("images");
3977        std::fs::create_dir_all(&assets_dir).unwrap();
3978        std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3979
3980        let config = MD057Config {
3981            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3982            ..Default::default()
3983        };
3984        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3985
3986        let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3987        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3988        let result = rule.check(&ctx).unwrap();
3989
3990        assert!(
3991            result.is_empty(),
3992            "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3993        );
3994    }
3995
3996    #[test]
3997    fn test_search_paths_still_warns_when_truly_missing() {
3998        let temp_dir = tempdir().unwrap();
3999        let base_path = temp_dir.path();
4000
4001        let assets_dir = base_path.join("assets");
4002        std::fs::create_dir_all(&assets_dir).unwrap();
4003
4004        let config = MD057Config {
4005            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4006            ..Default::default()
4007        };
4008        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4009
4010        let content = "# Test\n\n![Missing](nonexistent.png)\n";
4011        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4012        let result = rule.check(&ctx).unwrap();
4013
4014        assert_eq!(
4015            result.len(),
4016            1,
4017            "Should still warn when file doesn't exist in any search path. Got: {result:?}"
4018        );
4019    }
4020
4021    #[test]
4022    fn test_search_paths_nonexistent_directory() {
4023        let temp_dir = tempdir().unwrap();
4024        let base_path = temp_dir.path();
4025
4026        let config = MD057Config {
4027            search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
4028            ..Default::default()
4029        };
4030        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4031
4032        let content = "# Test\n\n![Missing](photo.png)\n";
4033        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4034        let result = rule.check(&ctx).unwrap();
4035
4036        assert_eq!(
4037            result.len(),
4038            1,
4039            "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
4040        );
4041    }
4042
4043    #[test]
4044    fn test_obsidian_attachment_folder_named() {
4045        let temp_dir = tempdir().unwrap();
4046        let vault = temp_dir.path().join("vault");
4047        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4048        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4049        std::fs::create_dir_all(vault.join("notes")).unwrap();
4050
4051        std::fs::write(
4052            vault.join(".obsidian/app.json"),
4053            r#"{"attachmentFolderPath": "Attachments"}"#,
4054        )
4055        .unwrap();
4056        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4057
4058        let notes_dir = vault.join("notes");
4059        let source_file = notes_dir.join("test.md");
4060        std::fs::write(&source_file, "# Test\n\n![Photo](photo.png)\n").unwrap();
4061
4062        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4063
4064        let content = "# Test\n\n![Photo](photo.png)\n";
4065        let ctx =
4066            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4067        let result = rule.check(&ctx).unwrap();
4068
4069        assert!(
4070            result.is_empty(),
4071            "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
4072        );
4073    }
4074
4075    #[test]
4076    fn test_obsidian_attachment_same_folder_as_file() {
4077        let temp_dir = tempdir().unwrap();
4078        let vault = temp_dir.path().join("vault-rf");
4079        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4080        std::fs::create_dir_all(vault.join("notes")).unwrap();
4081
4082        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
4083
4084        // Image in the same directory as the file — default behavior, no extra search needed
4085        let notes_dir = vault.join("notes");
4086        let source_file = notes_dir.join("test.md");
4087        std::fs::write(&source_file, "placeholder").unwrap();
4088        std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
4089
4090        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4091
4092        let content = "# Test\n\n![Photo](photo.png)\n";
4093        let ctx =
4094            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4095        let result = rule.check(&ctx).unwrap();
4096
4097        assert!(
4098            result.is_empty(),
4099            "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
4100        );
4101    }
4102
4103    #[test]
4104    fn test_obsidian_not_triggered_without_obsidian_flavor() {
4105        let temp_dir = tempdir().unwrap();
4106        let vault = temp_dir.path().join("vault-nf");
4107        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4108        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4109        std::fs::create_dir_all(vault.join("notes")).unwrap();
4110
4111        std::fs::write(
4112            vault.join(".obsidian/app.json"),
4113            r#"{"attachmentFolderPath": "Attachments"}"#,
4114        )
4115        .unwrap();
4116        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4117
4118        let notes_dir = vault.join("notes");
4119        let source_file = notes_dir.join("test.md");
4120        std::fs::write(&source_file, "placeholder").unwrap();
4121
4122        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4123
4124        let content = "# Test\n\n![Photo](photo.png)\n";
4125        // Standard flavor — NOT Obsidian
4126        let ctx =
4127            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4128        let result = rule.check(&ctx).unwrap();
4129
4130        assert_eq!(
4131            result.len(),
4132            1,
4133            "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
4134        );
4135    }
4136
4137    #[test]
4138    fn test_search_paths_combined_with_obsidian() {
4139        let temp_dir = tempdir().unwrap();
4140        let vault = temp_dir.path().join("vault-combo");
4141        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4142        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4143        std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
4144        std::fs::create_dir_all(vault.join("notes")).unwrap();
4145
4146        std::fs::write(
4147            vault.join(".obsidian/app.json"),
4148            r#"{"attachmentFolderPath": "Attachments"}"#,
4149        )
4150        .unwrap();
4151        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4152        std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
4153
4154        let notes_dir = vault.join("notes");
4155        let source_file = notes_dir.join("test.md");
4156        std::fs::write(&source_file, "placeholder").unwrap();
4157
4158        let extra_assets_dir = vault.join("extra-assets");
4159        let config = MD057Config {
4160            search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
4161            ..Default::default()
4162        };
4163        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&notes_dir);
4164
4165        // Both links should resolve: photo.png via Obsidian, diagram.svg via search-paths
4166        let content = "# Test\n\n![Photo](photo.png)\n\n![Diagram](diagram.svg)\n";
4167        let ctx =
4168            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4169        let result = rule.check(&ctx).unwrap();
4170
4171        assert!(
4172            result.is_empty(),
4173            "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
4174        );
4175    }
4176
4177    #[test]
4178    fn test_obsidian_attachment_subfolder_under_file() {
4179        let temp_dir = tempdir().unwrap();
4180        let vault = temp_dir.path().join("vault-sub");
4181        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4182        std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
4183
4184        std::fs::write(
4185            vault.join(".obsidian/app.json"),
4186            r#"{"attachmentFolderPath": "./assets"}"#,
4187        )
4188        .unwrap();
4189        std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
4190
4191        let notes_dir = vault.join("notes");
4192        let source_file = notes_dir.join("test.md");
4193        std::fs::write(&source_file, "placeholder").unwrap();
4194
4195        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4196
4197        let content = "# Test\n\n![Photo](photo.png)\n";
4198        let ctx =
4199            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4200        let result = rule.check(&ctx).unwrap();
4201
4202        assert!(
4203            result.is_empty(),
4204            "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
4205        );
4206    }
4207
4208    #[test]
4209    fn test_obsidian_attachment_vault_root() {
4210        let temp_dir = tempdir().unwrap();
4211        let vault = temp_dir.path().join("vault-root");
4212        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4213        std::fs::create_dir_all(vault.join("notes")).unwrap();
4214
4215        // Empty string = vault root
4216        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
4217        std::fs::write(vault.join("photo.png"), "fake").unwrap();
4218
4219        let notes_dir = vault.join("notes");
4220        let source_file = notes_dir.join("test.md");
4221        std::fs::write(&source_file, "placeholder").unwrap();
4222
4223        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4224
4225        let content = "# Test\n\n![Photo](photo.png)\n";
4226        let ctx =
4227            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4228        let result = rule.check(&ctx).unwrap();
4229
4230        assert!(
4231            result.is_empty(),
4232            "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
4233        );
4234    }
4235
4236    #[test]
4237    fn test_search_paths_multiple_directories() {
4238        let temp_dir = tempdir().unwrap();
4239        let base_path = temp_dir.path();
4240
4241        let dir_a = base_path.join("dir-a");
4242        let dir_b = base_path.join("dir-b");
4243        std::fs::create_dir_all(&dir_a).unwrap();
4244        std::fs::create_dir_all(&dir_b).unwrap();
4245        std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
4246        std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
4247
4248        let config = MD057Config {
4249            search_paths: vec![
4250                dir_a.to_string_lossy().into_owned(),
4251                dir_b.to_string_lossy().into_owned(),
4252            ],
4253            ..Default::default()
4254        };
4255        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4256
4257        let content = "# Test\n\n![A](alpha.png)\n\n![B](beta.png)\n";
4258        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4259        let result = rule.check(&ctx).unwrap();
4260
4261        assert!(
4262            result.is_empty(),
4263            "Should find files across multiple search paths. Got: {result:?}"
4264        );
4265    }
4266
4267    /// MD057 validates every link target in `check()`, so its `cross_file_check`
4268    /// deliberately reports nothing: emitting there too would double every broken
4269    /// link warning.
4270    ///
4271    /// The target here does not exist anywhere the rule would look, so a
4272    /// `cross_file_check` that started resolving paths would report it and fail
4273    /// this test. The paired `check()` call is the positive control proving the
4274    /// link really is broken, which is what keeps the empty result meaningful.
4275    #[test]
4276    fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
4277        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
4278
4279        let temp_dir = tempdir().unwrap();
4280        let base_path = temp_dir.path();
4281
4282        let file_path = base_path.join("README.md");
4283        let content = "# Readme\n\n[Guide](missing-guide.md)\n";
4284        std::fs::write(&file_path, content).unwrap();
4285
4286        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
4287
4288        let ctx = crate::lint_context::LintContext::new(
4289            content,
4290            crate::config::MarkdownFlavor::Standard,
4291            Some(file_path.clone()),
4292        );
4293        let per_file = rule.check(&ctx).unwrap();
4294        assert_eq!(
4295            per_file.len(),
4296            1,
4297            "control: check() is the pass that reports the broken link. Got: {per_file:?}"
4298        );
4299
4300        let mut file_index = FileIndex::default();
4301        file_index.cross_file_links.push(CrossFileLinkIndex {
4302            target_path: "missing-guide.md".to_string(),
4303            fragment: String::new(),
4304            line: 3,
4305            column: 1,
4306            origin: LinkOrigin::Body,
4307        });
4308
4309        let result = rule
4310            .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
4311            .unwrap();
4312
4313        assert!(
4314            result.is_empty(),
4315            "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
4316        );
4317    }
4318
4319    #[test]
4320    fn test_check_clears_stale_cache() {
4321        // Verify that check() resets the file existence cache so stale entries from
4322        // a previous lint cycle do not suppress valid warnings.
4323        let temp_dir = tempdir().unwrap();
4324        let base_path = temp_dir.path();
4325
4326        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4327
4328        // Seed the cache with a stale "exists" entry for a file that is NOT on disk.
4329        let phantom_path = base_path.join("phantom.md");
4330        FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(phantom_path.clone(), true));
4331
4332        let content = "[phantom](phantom.md)\n";
4333        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4334        let warnings = rule.check(&ctx).unwrap();
4335
4336        // check() must reset the cache; stale "exists=true" must not suppress the warning.
4337        assert_eq!(
4338            warnings.len(),
4339            1,
4340            "check() should report missing file after clearing stale cache. Got: {warnings:?}"
4341        );
4342        assert!(warnings[0].message.contains("phantom.md"));
4343    }
4344
4345    #[test]
4346    fn test_check_does_not_carry_over_cache_between_runs() {
4347        // Two consecutive check() calls should each start with a fresh cache.
4348        let temp_dir = tempdir().unwrap();
4349        let base_path = temp_dir.path();
4350
4351        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4352
4353        let content = "[missing](nonexistent.md)\n";
4354        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4355
4356        // First run: file doesn't exist — warning expected.
4357        let warnings_1 = rule.check(&ctx).unwrap();
4358        assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
4359
4360        // Inject a stale "exists = true" entry for the resolved path.
4361        let nonexistent_path = base_path.join("nonexistent.md");
4362        FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(nonexistent_path.clone(), true));
4363
4364        // Second run: cache says file exists, but check() should reset it first.
4365        let warnings_2 = rule.check(&ctx).unwrap();
4366        assert_eq!(
4367            warnings_2.len(),
4368            1,
4369            "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
4370        );
4371    }
4372
4373    /// Checks `content` as the file `name` in `dir` and counts the anchors the
4374    /// check worked out.
4375    fn anchor_resolutions_for(dir: &Path, name: &str, content: &str, config: MD057Config) -> (Vec<LintWarning>, usize) {
4376        let before = ANCHOR_RESOLUTIONS.with(std::cell::Cell::get);
4377        let warnings = self_referential_links_tests::check_as_file(dir, name, content, config);
4378        (warnings, ANCHOR_RESOLUTIONS.with(std::cell::Cell::get) - before)
4379    }
4380
4381    #[test]
4382    fn a_document_without_relative_destinations_never_resolves_its_anchor() {
4383        let temp_dir = tempdir().unwrap();
4384        let content = "[web](https://example.com/a.md) [top](#top) [abs](/docs/a.md)\n\
4385                       ![img](https://example.com/i.png) ![abs](/i.png)\n\n\
4386                       [ref]: https://example.com/r.md\n[aref]: /r.md\n";
4387        let (warnings, resolutions) =
4388            anchor_resolutions_for(temp_dir.path(), "doc.md", content, MD057Config::default());
4389        assert!(warnings.is_empty(), "{warnings:?}");
4390        assert_eq!(resolutions, 0, "no destination here reads the document's directory");
4391    }
4392
4393    #[test]
4394    fn a_document_resolves_its_anchor_once_for_every_relative_destination() {
4395        let temp_dir = tempdir().unwrap();
4396        std::fs::write(temp_dir.path().join("there.md"), "# There\n").unwrap();
4397        let content = "[a](there.md) [b](missing.md) ![c](missing.png)\n\n[d]: there.md\n";
4398        let (warnings, resolutions) =
4399            anchor_resolutions_for(temp_dir.path(), "doc.md", content, MD057Config::default());
4400        assert_eq!(warnings.len(), 2, "{warnings:?}");
4401        assert_eq!(resolutions, 1);
4402    }
4403
4404    #[test]
4405    fn an_absolute_link_resolved_from_the_docs_dir_resolves_the_anchor() {
4406        let temp_dir = tempdir().unwrap();
4407        std::fs::write(temp_dir.path().join("mkdocs.yml"), "site_name: x\n").unwrap();
4408        std::fs::create_dir(temp_dir.path().join("docs")).unwrap();
4409        let config = MD057Config {
4410            absolute_links: AbsoluteLinksOption::RelativeToDocs,
4411            ..Default::default()
4412        };
4413        let (warnings, resolutions) =
4414            anchor_resolutions_for(&temp_dir.path().join("docs"), "doc.md", "[abs](/missing.md)\n", config);
4415        assert_eq!(warnings.len(), 1, "{warnings:?}");
4416        assert!(warnings[0].message.contains("does not exist"), "{warnings:?}");
4417        assert_eq!(resolutions, 1);
4418    }
4419
4420    /// Fingerprints `content` as the file `doc.md` in `dir`, counting the anchors
4421    /// the fingerprint worked out.
4422    #[cfg(feature = "blake3")]
4423    fn fingerprint_anchor_resolutions(dir: &Path, content: &str) -> (String, usize) {
4424        let source_file = dir.join("doc.md");
4425        std::fs::write(&source_file, content).unwrap();
4426        let rule = MD057ExistingRelativeLinks::default();
4427        let ctx = crate::lint_context::LintContext::new(
4428            content,
4429            crate::config::MarkdownFlavor::Standard,
4430            Some(source_file.clone()),
4431        );
4432        let mut file_index = FileIndex::new();
4433        rule.contribute_to_index(&ctx, &mut file_index);
4434        // Each CLI run is a fresh process, so no existence answer carries over.
4435        reset_file_existence_cache();
4436        let before = ANCHOR_RESOLUTIONS.with(std::cell::Cell::get);
4437        let fingerprint =
4438            rule.cache_dependency_fingerprint(&source_file, crate::config::MarkdownFlavor::Standard, &file_index);
4439        (fingerprint, ANCHOR_RESOLUTIONS.with(std::cell::Cell::get) - before)
4440    }
4441
4442    #[cfg(feature = "blake3")]
4443    #[test]
4444    fn a_fingerprint_over_absolute_destinations_never_resolves_the_anchor() {
4445        let temp_dir = tempdir().unwrap();
4446        let (_, resolutions) =
4447            fingerprint_anchor_resolutions(temp_dir.path(), "[a](/docs/a.md) ![b](/i.png)\n\n[c]: /r.md\n");
4448        assert_eq!(resolutions, 0, "no destination here reads the document's directory");
4449    }
4450
4451    #[cfg(feature = "blake3")]
4452    #[test]
4453    fn a_fingerprint_over_relative_destinations_resolves_the_anchor_once_and_tracks_them() {
4454        let temp_dir = tempdir().unwrap();
4455        let content = "[a](there.md) [b](missing.md)\n\n[c]: there.md\n";
4456        let (missing, resolutions) = fingerprint_anchor_resolutions(temp_dir.path(), content);
4457        assert_eq!(resolutions, 1);
4458        std::fs::write(temp_dir.path().join("there.md"), "# There\n").unwrap();
4459        let (present, _) = fingerprint_anchor_resolutions(temp_dir.path(), content);
4460        assert_ne!(missing, present, "creating a link target must change the fingerprint");
4461    }
4462
4463    // --- Bug #631: duplicate warnings for broken relative links ---
4464
4465    /// Regression test: a single broken relative link must produce exactly one
4466    /// warning across both check() and cross_file_check(). Previously, each
4467    /// code path emitted an identical warning independently, causing duplicates.
4468    #[test]
4469    fn test_no_duplicate_warnings_for_broken_relative_link() {
4470        use crate::workspace_index::WorkspaceIndex;
4471
4472        let temp_dir = tempdir().unwrap();
4473        let base_path = temp_dir.path();
4474
4475        // The broken link target does NOT exist on disk.
4476        let source_file = base_path.join("index.md");
4477        std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
4478
4479        let content = "[broken](does/not/exist.md)\n";
4480
4481        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4482
4483        // Collect warnings from check() (per-file path)
4484        let ctx = crate::lint_context::LintContext::new(
4485            content,
4486            crate::config::MarkdownFlavor::Standard,
4487            Some(source_file.clone()),
4488        );
4489        let check_warnings = rule.check(&ctx).unwrap();
4490
4491        // Collect warnings from cross_file_check() (workspace-index path)
4492        let mut file_index = FileIndex::new();
4493        rule.contribute_to_index(&ctx, &mut file_index);
4494        let workspace_index = WorkspaceIndex::new();
4495        let cross_warnings = rule
4496            .cross_file_check(&source_file, &file_index, &workspace_index)
4497            .unwrap();
4498
4499        let total = check_warnings.len() + cross_warnings.len();
4500        assert_eq!(
4501            total, 1,
4502            "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
4503             check={check_warnings:?}, cross={cross_warnings:?}"
4504        );
4505    }
4506
4507    // --- Bug #632: absolute directory links incorrectly flagged ---
4508
4509    /// With absolute-links = "relative_to_roots", links to existing targets must
4510    /// be accepted for all four cases: {relative, absolute} x {file, directory}.
4511    #[test]
4512    fn test_absolute_dir_link_accepted_relative_to_roots() {
4513        let temp_dir = tempdir().unwrap();
4514        let root = temp_dir.path();
4515
4516        // Create directory `d` with a file inside (but no index.md)
4517        let dir_d = root.join("d");
4518        std::fs::create_dir_all(&dir_d).unwrap();
4519        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4520
4521        // Content exercises all four matrix cells:
4522        //   relative file, relative dir, absolute file, absolute dir
4523        let content = "\
4524[absolute dir](/d)\n\
4525[relative dir](d)\n\
4526[absolute file](/d/foo.md)\n\
4527[relative file](d/foo.md)\n";
4528
4529        let config = MD057Config {
4530            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4531            roots: vec![],
4532            ..Default::default()
4533        };
4534        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4535
4536        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4537        let result = rule.check(&ctx).unwrap();
4538
4539        assert!(
4540            result.is_empty(),
4541            "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
4542        );
4543    }
4544
4545    /// In filesystem mode every spelling of a link to an existing directory is
4546    /// accepted, and a link to a directory that does not exist is still reported.
4547    /// The trailing slash is punctuation here: nothing routes `/d/` to `d/index.md`
4548    /// outside a site generator, and the relative form `../d/` is already accepted
4549    /// by `path.exists()` with no such requirement. (#863)
4550    #[test]
4551    fn test_absolute_directory_link_is_accepted_however_it_is_spelled() {
4552        let temp_dir = tempdir().unwrap();
4553        let root = temp_dir.path();
4554
4555        // Create directory `d` WITHOUT index.md
4556        let dir_d = root.join("d");
4557        std::fs::create_dir_all(&dir_d).unwrap();
4558        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4559
4560        let content = "\
4561[no slash](/d)\n\
4562[trailing slash](/d/)\n\
4563[trailing slash and fragment](/d/#intro)\n\
4564[relative](d/)\n";
4565
4566        let config = MD057Config {
4567            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4568            roots: vec![],
4569            ..Default::default()
4570        };
4571        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4572
4573        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4574        let result = rule.check(&ctx).unwrap();
4575
4576        assert!(
4577            result.is_empty(),
4578            "Every spelling of a link to an existing directory must agree. Got: {result:?}"
4579        );
4580    }
4581
4582    /// The control for the test above: dropping the index.md requirement must not
4583    /// have dropped the existence check with it.
4584    #[test]
4585    fn test_absolute_directory_link_to_a_missing_directory_is_still_reported() {
4586        let temp_dir = tempdir().unwrap();
4587        let root = temp_dir.path();
4588
4589        let content = "[gone](/nodir/)\n";
4590
4591        let config = MD057Config {
4592            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4593            roots: vec![],
4594            ..Default::default()
4595        };
4596        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4597
4598        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4599        let result = rule.check(&ctx).unwrap();
4600
4601        assert_eq!(
4602            result.len(),
4603            1,
4604            "A directory link naming nothing on disk must still be reported. Got: {result:?}"
4605        );
4606    }
4607
4608    /// The docs_dir (MkDocs) variant must still flag a directory link when index.md
4609    /// is absent. This is tested via the full check() path with RelativeToDocs config
4610    /// and a real mkdocs.yml pointing at a docs dir that contains the directory target.
4611    #[test]
4612    fn test_docs_dir_variant_still_enforces_index_md() {
4613        let temp_dir = tempdir().unwrap();
4614        let root = temp_dir.path();
4615
4616        // Create a minimal mkdocs.yml pointing at a "docs" directory
4617        std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4618
4619        // Create docs/section/ WITHOUT index.md
4620        let docs_dir = root.join("docs");
4621        std::fs::create_dir_all(&docs_dir).unwrap();
4622        let section_dir = docs_dir.join("section");
4623        std::fs::create_dir_all(&section_dir).unwrap();
4624        std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
4625
4626        // Create the source markdown file inside docs/
4627        let source_file = docs_dir.join("index.md");
4628        std::fs::write(&source_file, "[sec](/section)\n").unwrap();
4629
4630        let config = MD057Config {
4631            absolute_links: AbsoluteLinksOption::RelativeToDocs,
4632            ..Default::default()
4633        };
4634        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4635
4636        let content = "[sec](/section)\n";
4637        let ctx = crate::lint_context::LintContext::new(
4638            content,
4639            crate::config::MarkdownFlavor::Standard,
4640            Some(source_file.clone()),
4641        );
4642        let result = rule.check(&ctx).unwrap();
4643
4644        // MkDocs enforces index.md for directory links, so this should be flagged.
4645        assert_eq!(
4646            result.len(),
4647            1,
4648            "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
4649        );
4650        assert!(
4651            result[0].message.contains("index.md") || result[0].message.contains("section"),
4652            "Message should mention the directory or missing index.md: {}",
4653            result[0].message
4654        );
4655    }
4656
4657    /// MkDocs mode is where the index.md requirement belongs, and every spelling
4658    /// of a directory link has to reach it — including one whose trailing slash is
4659    /// hidden behind a fragment (`/guide/#intro`). This is the control proving the
4660    /// roots-mode change did not disable the check everywhere.
4661    #[test]
4662    fn test_docs_mode_requires_index_for_every_spelling_of_a_directory_link() {
4663        let temp_dir = tempdir().unwrap();
4664        let root = temp_dir.path();
4665        std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4666
4667        // Create docs/guide WITHOUT index.md
4668        let docs_dir = root.join("docs");
4669        let guide_dir = docs_dir.join("guide");
4670        std::fs::create_dir_all(&guide_dir).unwrap();
4671        std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4672
4673        let source_file = docs_dir.join("t.md");
4674        let content = "\
4675[no slash](/guide)\n\
4676[trailing slash](/guide/)\n\
4677[trailing slash and fragment](/guide/#intro)\n";
4678        std::fs::write(&source_file, content).unwrap();
4679
4680        let config = MD057Config {
4681            absolute_links: AbsoluteLinksOption::RelativeToDocs,
4682            ..Default::default()
4683        };
4684        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4685        let ctx = crate::lint_context::LintContext::new(
4686            content,
4687            crate::config::MarkdownFlavor::Standard,
4688            Some(source_file.clone()),
4689        );
4690        let result = rule.check(&ctx).unwrap();
4691
4692        assert_eq!(
4693            result.len(),
4694            3,
4695            "Every directory link must be routed through the index.md check. Got: {result:?}"
4696        );
4697        for warning in &result {
4698            assert!(
4699                warning.message.contains("which has no index.md"),
4700                "The message must name the reason, not report the directory as missing: {}",
4701                warning.message
4702            );
4703        }
4704    }
4705}
4706
4707#[cfg(test)]
4708mod self_referential_links_tests {
4709    use super::*;
4710    use tempfile::tempdir;
4711
4712    /// A document written to `dir/<name>`, checked as itself.
4713    pub(super) fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4714        let source_file = dir.join(name);
4715        std::fs::write(&source_file, content).unwrap();
4716        let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4717        let ctx =
4718            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4719        rule.check(&ctx).unwrap()
4720    }
4721
4722    fn enabled() -> MD057Config {
4723        MD057Config {
4724            self_referential_links: true,
4725            ..Default::default()
4726        }
4727    }
4728
4729    #[test]
4730    fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4731        let temp_dir = tempdir().unwrap();
4732        let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4733        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4734
4735        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4736        assert_eq!(
4737            result[0].message,
4738            "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4739        );
4740        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4741        assert_eq!(fix.replacement, "#level-2-heading");
4742        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4743    }
4744
4745    #[test]
4746    fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4747        let temp_dir = tempdir().unwrap();
4748        let content = "# Title\n\nSee [this file](test.md).\n";
4749        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4750
4751        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4752        assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4753        assert!(
4754            result[0].fix.is_none(),
4755            "Dropping the link would change the document, so there is no fix"
4756        );
4757    }
4758
4759    #[test]
4760    fn test_the_check_is_off_by_default() {
4761        let temp_dir = tempdir().unwrap();
4762        let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4763        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4764
4765        assert!(result.is_empty(), "Off by default. Got: {result:?}");
4766    }
4767
4768    #[test]
4769    fn test_a_link_to_another_file_is_left_alone() {
4770        let temp_dir = tempdir().unwrap();
4771        std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4772        let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4773        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4774
4775        assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4776    }
4777
4778    #[test]
4779    fn test_a_self_link_written_with_traversal_reports_once() {
4780        let temp_dir = tempdir().unwrap();
4781        let sub_dir = temp_dir.path().join("sub");
4782        std::fs::create_dir_all(&sub_dir).unwrap();
4783
4784        let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4785        let config = MD057Config {
4786            self_referential_links: true,
4787            compact_paths: true,
4788            ..Default::default()
4789        };
4790        let result = check_as_file(&sub_dir, "test.md", content, config);
4791
4792        assert_eq!(
4793            result.len(),
4794            1,
4795            "A compacted path would still be a link back to this file. Got: {result:?}"
4796        );
4797        assert_eq!(
4798            result[0].message,
4799            "Relative link '../sub/test.md' points to the file it is in"
4800        );
4801    }
4802
4803    #[test]
4804    fn test_compact_paths_still_reports_a_link_to_another_file() {
4805        let temp_dir = tempdir().unwrap();
4806        let sub_dir = temp_dir.path().join("sub");
4807        std::fs::create_dir_all(&sub_dir).unwrap();
4808        std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4809
4810        let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4811        let config = MD057Config {
4812            self_referential_links: true,
4813            compact_paths: true,
4814            ..Default::default()
4815        };
4816        let result = check_as_file(&sub_dir, "test.md", content, config);
4817
4818        assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4819        assert_eq!(
4820            result[0].message,
4821            "Relative link '../sub/other.md' can be simplified to 'other.md'"
4822        );
4823    }
4824
4825    #[test]
4826    fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4827        let temp_dir = tempdir().unwrap();
4828        let content = "# Title\n\nSee [this file](test#title).\n";
4829        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4830
4831        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4832        assert_eq!(
4833            result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4834            Some("#title"),
4835            "Got: {result:?}"
4836        );
4837    }
4838
4839    #[test]
4840    fn test_a_reference_definition_pointing_at_its_own_file() {
4841        let temp_dir = tempdir().unwrap();
4842        let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4843        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4844
4845        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4846        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4847        assert_eq!(fix.replacement, "#level-2-heading");
4848        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4849    }
4850
4851    #[test]
4852    fn test_a_reference_definition_whose_label_repeats_the_destination() {
4853        let temp_dir = tempdir().unwrap();
4854        let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4855        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4856
4857        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4858        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4859        // The label reads the same as the destination, so an unanchored search
4860        // would rewrite the label and orphan the usage above.
4861        assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4862        let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4863            .fix(&crate::lint_context::LintContext::new(
4864                content,
4865                crate::config::MarkdownFlavor::Standard,
4866                Some(temp_dir.path().join("test.md")),
4867            ))
4868            .unwrap();
4869        assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4870        assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4871    }
4872
4873    #[test]
4874    fn test_a_self_link_resolved_through_a_search_path() {
4875        let temp_dir = tempdir().unwrap();
4876        let guide_dir = temp_dir.path().join("docs/guide");
4877        std::fs::create_dir_all(&guide_dir).unwrap();
4878        let config = MD057Config {
4879            search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4880            ..enabled()
4881        };
4882        let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4883        let result = check_as_file(&guide_dir, "test.md", content, config);
4884
4885        assert_eq!(
4886            result.len(),
4887            1,
4888            "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4889        );
4890        assert_eq!(
4891            result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4892            Some("#title"),
4893            "Got: {result:?}"
4894        );
4895    }
4896
4897    #[test]
4898    fn test_a_target_next_to_the_document_outranks_a_search_path() {
4899        let temp_dir = tempdir().unwrap();
4900        let guide_dir = temp_dir.path().join("docs/guide");
4901        std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4902        std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4903        let config = MD057Config {
4904            search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4905            ..enabled()
4906        };
4907        let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4908        let result = check_as_file(&guide_dir, "test.md", content, config);
4909
4910        assert!(
4911            result.is_empty(),
4912            "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4913        );
4914    }
4915
4916    #[test]
4917    fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4918        let temp_dir = tempdir().unwrap();
4919        let content = "# Title\n\n![not a navigation link](test.md)\n";
4920        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4921
4922        assert!(
4923            result.is_empty(),
4924            "An image is not a link the reader follows. Got: {result:?}"
4925        );
4926    }
4927
4928    #[test]
4929    fn test_a_query_string_is_reported_without_a_suggestion() {
4930        let temp_dir = tempdir().unwrap();
4931        let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4932        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4933
4934        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4935        assert!(
4936            result[0].fix.is_none(),
4937            "A query does not survive losing its path. Got: {result:?}"
4938        );
4939    }
4940
4941    #[test]
4942    fn test_fix_rewrites_the_document_and_settles() {
4943        let temp_dir = tempdir().unwrap();
4944        let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4945        let source_file = temp_dir.path().join("test.md");
4946        std::fs::write(&source_file, content).unwrap();
4947
4948        let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4949        let ctx = crate::lint_context::LintContext::new(
4950            content,
4951            crate::config::MarkdownFlavor::Standard,
4952            Some(source_file.clone()),
4953        );
4954        let fixed = rule.fix(&ctx).unwrap();
4955        assert_eq!(
4956            fixed,
4957            "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
4958        );
4959
4960        let refixed =
4961            crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
4962        assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
4963    }
4964
4965    #[test]
4966    fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
4967        let unfixable = MD057ExistingRelativeLinks::default();
4968        assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
4969
4970        let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
4971        assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
4972    }
4973
4974    #[test]
4975    fn test_the_option_is_read_from_kebab_and_snake_case() {
4976        let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
4977        assert!(kebab.self_referential_links);
4978
4979        let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
4980        assert!(snake.self_referential_links);
4981    }
4982
4983    fn front_matter_checked() -> MD057Config {
4984        MD057Config {
4985            check_frontmatter: true,
4986            ..Default::default()
4987        }
4988    }
4989
4990    #[test]
4991    fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
4992        let temp_dir = tempdir().unwrap();
4993        let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4994        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4995
4996        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4997        assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
4998        assert_eq!(result[0].line, 2);
4999        assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
5000        assert_eq!(result[0].end_column, 23);
5001    }
5002
5003    #[test]
5004    fn test_frontmatter_paths_are_not_checked_by_default() {
5005        let temp_dir = tempdir().unwrap();
5006        let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
5007        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5008
5009        assert!(
5010            result.is_empty(),
5011            "Frontmatter is only checked on request. Got: {result:?}"
5012        );
5013    }
5014
5015    #[test]
5016    fn test_an_existing_frontmatter_path_is_not_reported() {
5017        let temp_dir = tempdir().unwrap();
5018        std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
5019        let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
5020        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5021
5022        assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
5023        assert_eq!(result[0].line, 3);
5024    }
5025
5026    #[test]
5027    fn test_an_ignored_frontmatter_field_is_not_checked() {
5028        let temp_dir = tempdir().unwrap();
5029        let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
5030        let config = MD057Config {
5031            check_frontmatter: true,
5032            ignore_frontmatter_fields: vec!["Image".to_string()],
5033            ..Default::default()
5034        };
5035        let result = check_as_file(temp_dir.path(), "test.md", content, config);
5036
5037        assert_eq!(
5038            result.len(),
5039            1,
5040            "The ignored field is skipped and the other is not. Got: {result:?}"
5041        );
5042        assert_eq!(result[0].line, 3);
5043    }
5044
5045    #[test]
5046    fn test_an_external_frontmatter_url_is_not_reported() {
5047        let temp_dir = tempdir().unwrap();
5048        let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
5049        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5050
5051        assert!(
5052            result.is_empty(),
5053            "An external URL has no local target. Got: {result:?}"
5054        );
5055    }
5056
5057    #[test]
5058    fn test_a_frontmatter_fragment_is_left_to_md051() {
5059        let temp_dir = tempdir().unwrap();
5060        let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
5061        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5062
5063        assert!(
5064            result.is_empty(),
5065            "A fragment names a heading, not a file. Got: {result:?}"
5066        );
5067    }
5068
5069    #[test]
5070    fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
5071        let temp_dir = tempdir().unwrap();
5072        let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
5073
5074        let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
5075        assert!(
5076            ignored.is_empty(),
5077            "Absolute paths are ignored by default. Got: {ignored:?}"
5078        );
5079
5080        let warning_config = MD057Config {
5081            check_frontmatter: true,
5082            absolute_links: AbsoluteLinksOption::Warn,
5083            ..Default::default()
5084        };
5085        let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
5086        assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
5087        assert_eq!(
5088            warned[0].message,
5089            "Absolute link '/docs/guide.md' cannot be validated locally"
5090        );
5091    }
5092
5093    #[test]
5094    fn test_a_frontmatter_path_carrying_a_query_is_checked() {
5095        let temp_dir = tempdir().unwrap();
5096        std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
5097        let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
5098        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5099
5100        assert_eq!(
5101            result.len(),
5102            1,
5103            "A query names no file, so only the missing target is reported. Got: {result:?}"
5104        );
5105        assert_eq!(result[0].line, 2);
5106        assert_eq!(
5107            result[0].message,
5108            "Relative link 'docs/missing.md?raw=true' does not exist"
5109        );
5110    }
5111
5112    #[test]
5113    fn test_prose_in_frontmatter_is_not_read_as_a_path() {
5114        let temp_dir = tempdir().unwrap();
5115        let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
5116        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5117
5118        assert!(
5119            result.is_empty(),
5120            "Only path-shaped values are destinations. Got: {result:?}"
5121        );
5122    }
5123}
5124
5125/// An inline link whose text carries a newline is checked like any other.
5126///
5127/// The destination of such a link sits on the line the link ends on, so every
5128/// report on it names that line and the column the destination starts at.
5129#[cfg(test)]
5130mod wrapped_link_text_tests {
5131    use super::self_referential_links_tests::check_as_file;
5132    use super::*;
5133    use tempfile::tempdir;
5134
5135    #[test]
5136    fn test_a_wrapped_link_in_a_list_item_is_reported() {
5137        let temp_dir = tempdir().unwrap();
5138        let content = "- Items reimbursable by the various [one-off\n  expense](does-not-exist-anywhere)\n  budgets.\n";
5139        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5140
5141        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5142        assert_eq!(
5143            result[0].message,
5144            "Relative link 'does-not-exist-anywhere' does not exist"
5145        );
5146        assert_eq!(result[0].line, 2, "The destination sits on the second line");
5147        assert_eq!(result[0].end_line, 2);
5148        // Line 2 is `  expense](does-not-exist-anywhere)`, so the destination
5149        // starts at the twelfth character and runs 23 characters.
5150        assert_eq!(result[0].column, 12);
5151        assert_eq!(result[0].end_column, 35);
5152    }
5153
5154    #[test]
5155    fn test_a_wrapped_link_in_a_paragraph_is_reported() {
5156        let temp_dir = tempdir().unwrap();
5157        let content = "Paragraph with [wrapped\ntext](also-missing) here.\n";
5158        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5159
5160        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5161        assert_eq!(result[0].message, "Relative link 'also-missing' does not exist");
5162        assert_eq!(result[0].line, 2, "The destination sits on the second line");
5163        // Line 2 is `text](also-missing) here.`, so the destination starts at
5164        // the seventh character and runs 12 characters.
5165        assert_eq!(result[0].column, 7);
5166        assert_eq!(result[0].end_column, 19);
5167    }
5168
5169    #[test]
5170    fn test_a_wrapped_link_carrying_a_title_is_reported() {
5171        let temp_dir = tempdir().unwrap();
5172        let content = "[wrapped\ntext](missing-titled.md \"t\")\n";
5173        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5174
5175        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5176        assert_eq!(result[0].message, "Relative link 'missing-titled.md' does not exist");
5177        assert_eq!(result[0].line, 2);
5178        // The title is not part of the destination, so the warning ends with
5179        // the path at the twenty-fourth character.
5180        assert_eq!(result[0].column, 7);
5181        assert_eq!(result[0].end_column, 24);
5182    }
5183
5184    #[test]
5185    fn test_a_wrapped_link_to_an_existing_file_is_left_alone() {
5186        let temp_dir = tempdir().unwrap();
5187        std::fs::write(temp_dir.path().join("target.md"), "# Target\n").unwrap();
5188        let content = "Paragraph with [wrapped\ntext](target.md) here.\n";
5189        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5190
5191        assert!(result.is_empty(), "The target exists. Got: {result:?}");
5192    }
5193
5194    #[test]
5195    fn test_a_wrapped_link_target_reaches_the_dependency_index() {
5196        let rule = MD057ExistingRelativeLinks::new();
5197        let content = "Paragraph with [wrapped\ntext](./docs/guide.md) here.\n";
5198
5199        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
5200        let mut index = FileIndex::new();
5201        rule.contribute_to_index(&ctx, &mut index);
5202
5203        let targets: Vec<&str> = index
5204            .md057_link_targets
5205            .iter()
5206            .map(|target| target.target.as_str())
5207            .collect();
5208        assert_eq!(
5209            targets,
5210            vec!["./docs/guide.md"],
5211            "The index must record the target so a cached verdict is invalidated when the file appears"
5212        );
5213    }
5214
5215    #[test]
5216    fn test_a_wrapped_link_is_compacted_over_the_right_bytes() {
5217        let temp_dir = tempdir().unwrap();
5218        let sub_dir = temp_dir.path().join("sub");
5219        std::fs::create_dir_all(&sub_dir).unwrap();
5220        std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
5221
5222        let content = "See [the long way\nround](../sub/other.md#part) here.\n";
5223        let config = MD057Config {
5224            compact_paths: true,
5225            ..Default::default()
5226        };
5227        let result = check_as_file(&sub_dir, "test.md", content, config);
5228
5229        assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
5230        assert_eq!(
5231            result[0].message,
5232            "Relative link '../sub/other.md#part' can be simplified to 'other.md#part'"
5233        );
5234        assert_eq!(result[0].line, 2);
5235        let fix = result[0].fix.as_ref().expect("a compaction is fixable");
5236        assert_eq!(&content[fix.range.clone()], "../sub/other.md#part");
5237        assert_eq!(fix.replacement, "other.md#part");
5238    }
5239
5240    #[test]
5241    fn test_a_wrapped_self_link_is_reduced_over_the_right_bytes() {
5242        let temp_dir = tempdir().unwrap();
5243        let content = "# Title\n\nSee [the section\nbelow](test.md#level-2-heading).\n\n## Level 2 heading\n";
5244        let config = MD057Config {
5245            self_referential_links: true,
5246            ..Default::default()
5247        };
5248        let result = check_as_file(temp_dir.path(), "test.md", content, config);
5249
5250        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5251        assert_eq!(
5252            result[0].message,
5253            "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
5254        );
5255        assert_eq!(result[0].line, 4);
5256        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
5257        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
5258        assert_eq!(fix.replacement, "#level-2-heading");
5259    }
5260}
5261
5262/// The destination is read from inside the link's label, never from past it.
5263///
5264/// A label can spell `](` in a code span, and a destination title can spell it
5265/// in plain text. Neither opens a destination, and a shape that carries no
5266/// destination at all yields nothing to report and nothing to rewrite.
5267#[cfg(test)]
5268mod destination_boundary_tests {
5269    use super::self_referential_links_tests::check_as_file;
5270    use super::*;
5271    use tempfile::tempdir;
5272
5273    fn compacting() -> MD057Config {
5274        MD057Config {
5275            compact_paths: true,
5276            ..Default::default()
5277        }
5278    }
5279
5280    /// A label holding an unmatched backtick still ends where the parse closes
5281    /// it. The destination is the one the label closes on, not the `](` the
5282    /// title happens to contain.
5283    #[test]
5284    fn test_a_title_spelling_a_bracket_paren_is_not_the_destination() {
5285        let temp_dir = tempdir().unwrap();
5286        std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5287        let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
5288
5289        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5290
5291        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5292        assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5293        assert_eq!(result[0].line, 1);
5294        // `missing.md` opens at the thirteenth character, just past `](`.
5295        assert_eq!(result[0].column, 13);
5296        assert_eq!(result[0].end_column, 23);
5297        assert!(result[0].fix.is_none(), "A missing target carries no fix");
5298    }
5299
5300    /// The same document with compaction on. The path inside the title looks
5301    /// compactable, so a report naming it would rewrite the title.
5302    #[test]
5303    fn test_compaction_never_rewrites_a_title() {
5304        let temp_dir = tempdir().unwrap();
5305        std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5306        let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
5307
5308        let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5309
5310        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5311        assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5312        assert_eq!(result[0].column, 13);
5313        assert!(
5314            result.iter().all(|warning| warning.fix.is_none()),
5315            "Nothing in this document is rewritable. Got: {result:?}"
5316        );
5317    }
5318
5319    /// Brackets nest inside a label, so the first `]` is not the one that
5320    /// closes it.
5321    #[test]
5322    fn test_a_nested_bracket_does_not_close_the_label() {
5323        let temp_dir = tempdir().unwrap();
5324        let content = "[see [note]](./missing.md)\n";
5325
5326        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5327
5328        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5329        assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
5330        // `./missing.md` opens at the fourteenth character, past both brackets.
5331        assert_eq!(result[0].column, 14);
5332        assert_eq!(result[0].end_column, 26);
5333    }
5334
5335    /// An autolink is a URL between angle brackets. Its text is the URL itself,
5336    /// so a `](` written in it opens no destination and nothing in it is a path
5337    /// this rule may rewrite.
5338    #[test]
5339    fn test_an_autolink_carries_no_destination() {
5340        let temp_dir = tempdir().unwrap();
5341        std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5342        let content = "<https://example.com/](./existing.md)> [ok](existing.md)\n";
5343
5344        let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5345
5346        assert!(result.is_empty(), "An autolink is not a relative link. Got: {result:?}");
5347    }
5348
5349    /// Control for the autolink case: an email autolink is the same shape.
5350    #[test]
5351    fn test_an_email_autolink_carries_no_destination() {
5352        let temp_dir = tempdir().unwrap();
5353        let content = "<user@example.com>\n";
5354
5355        let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5356
5357        assert!(
5358            result.is_empty(),
5359            "An email autolink is not a relative link. Got: {result:?}"
5360        );
5361    }
5362
5363    /// A code span holds its own `](`, and the span runs past it to the second
5364    /// run of backticks. The link closes on the bracket after that run, so the
5365    /// path inside the code span is not a destination and nothing about it is
5366    /// reportable or rewritable.
5367    #[test]
5368    fn test_a_code_span_spelling_a_bracket_paren_is_not_the_destination() {
5369        let temp_dir = tempdir().unwrap();
5370        std::fs::write(temp_dir.path().join("exists.md"), "# Exists\n").unwrap();
5371        let content = "[``a\n](./exists.md)``](exists.md)\n";
5372
5373        let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5374
5375        assert!(
5376            result.is_empty(),
5377            "Both destinations exist, so nothing is reported. Got: {result:?}"
5378        );
5379        assert!(
5380            result.iter().all(|warning| warning.fix.is_none()),
5381            "Nothing here is rewritable, the code span least of all. Got: {result:?}"
5382        );
5383    }
5384
5385    /// Inline HTML in a label can spell `[` inside an attribute value. The
5386    /// parse reads the attribute as text rather than as an opening bracket, so
5387    /// the label closes where it is written to.
5388    #[test]
5389    fn test_a_bracket_in_an_html_attribute_does_not_open_a_label() {
5390        let temp_dir = tempdir().unwrap();
5391        let content = "[<i title=\"[\">text</i>](missing.md)\n";
5392
5393        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5394
5395        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5396        assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5397        assert_eq!(result[0].line, 1);
5398        // `missing.md` opens at the twenty fifth character, just past `](`.
5399        assert_eq!(result[0].column, 25);
5400        assert_eq!(result[0].end_column, 35);
5401    }
5402
5403    /// Link syntax inside an image's description is not a link. The renderer
5404    /// puts the description in an `alt` attribute, where a hyperlink cannot
5405    /// exist, so its destination names no target this document reaches.
5406    #[test]
5407    fn test_a_link_inside_an_image_description_is_not_a_link() {
5408        let temp_dir = tempdir().unwrap();
5409        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5410        let content = "![an [example](missing.md)](exists.png)\n";
5411
5412        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5413
5414        assert!(
5415            result.is_empty(),
5416            "The description of an image carries no link. Got: {result:?}"
5417        );
5418    }
5419
5420    /// The control for the case above. A link cannot nest inside a link, so
5421    /// the outer brackets here are literal text and the inner link is the only
5422    /// one in the line. Nothing about it sits inside an image.
5423    #[test]
5424    fn test_a_link_inside_literal_brackets_is_still_a_link() {
5425        let temp_dir = tempdir().unwrap();
5426        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5427        let content = "[outer [inner](missing2.md)](exists.png)\n";
5428
5429        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5430
5431        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5432        assert_eq!(result[0].message, "Relative link 'missing2.md' does not exist");
5433        assert_eq!(result[0].line, 1);
5434        // `missing2.md` opens at the sixteenth character, just past `](`.
5435        assert_eq!(result[0].column, 16);
5436    }
5437
5438    /// The second control. An image is still read by the image pass, which the
5439    /// link pass does not touch.
5440    #[test]
5441    fn test_an_image_of_its_own_is_still_reported() {
5442        let temp_dir = tempdir().unwrap();
5443        let content = "![plain](missing.png)\n";
5444
5445        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5446
5447        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5448        assert_eq!(result[0].message, "Relative link 'missing.png' does not exist");
5449        assert_eq!(result[0].column, 1);
5450    }
5451
5452    /// The third control. An image used as a link's text leaves the link
5453    /// itself outside the image, so the link's own destination is still read.
5454    #[test]
5455    fn test_an_image_used_as_link_text_leaves_the_link_readable() {
5456        let temp_dir = tempdir().unwrap();
5457        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5458        let content = "[![alt](exists.png)](missing.md)\n";
5459
5460        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5461
5462        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5463        assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5464        // `missing.md` opens at the twenty second character.
5465        assert_eq!(result[0].column, 22);
5466    }
5467
5468    /// The image used as link text is a collapsed reference. The label closes
5469    /// on the bracket after the image's `[]`, and the link's own destination
5470    /// is read from past that bracket.
5471    #[test]
5472    fn test_a_link_wrapping_a_collapsed_reference_image_is_still_read() {
5473        let temp_dir = tempdir().unwrap();
5474        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5475        let content = "[![alt][]](missing.md)\n\n[alt]: exists.png\n";
5476
5477        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5478
5479        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5480        assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5481        assert_eq!(result[0].line, 1);
5482        // `missing.md` opens at the twelfth character, just past `](`.
5483        assert_eq!(result[0].column, 12);
5484        assert_eq!(result[0].end_column, 22);
5485    }
5486
5487    /// A reference image with no definition is not an image. The renderer
5488    /// leaves its brackets as text, so a link written between them is a real
5489    /// link and its destination is a real target. All three reference
5490    /// spellings behave the same way.
5491    #[test]
5492    fn test_a_link_inside_an_undefined_reference_image_is_a_link() {
5493        for content in [
5494            "![alt [x](missing.md)][nodef]\n",
5495            "![alt [x](missing.md)][]\n",
5496            "![alt [x](missing.md)]\n",
5497        ] {
5498            let temp_dir = tempdir().unwrap();
5499
5500            let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5501
5502            assert_eq!(result.len(), 1, "Expected one warning for {content:?}. Got: {result:?}");
5503            assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5504            // `missing.md` opens at the eleventh character, just past `](`.
5505            assert_eq!(result[0].column, 11, "Wrong column for {content:?}");
5506        }
5507    }
5508
5509    /// An inline image with an empty destination is still an image, so what is
5510    /// written between its brackets is still alt text.
5511    #[test]
5512    fn test_an_image_with_an_empty_destination_is_still_an_image() {
5513        let temp_dir = tempdir().unwrap();
5514        let content = "![alt [x](missing.md)]()\n";
5515
5516        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5517
5518        assert!(
5519            result.is_empty(),
5520            "An image with no destination is still an image. Got: {result:?}"
5521        );
5522    }
5523
5524    /// An image's description can hold another image before the link. The
5525    /// inner image ends before the link starts, so the image that contains
5526    /// the link is the outer one, and the link is alt text all the same.
5527    #[test]
5528    fn test_a_link_after_an_inner_image_is_still_inside_the_outer_image() {
5529        let temp_dir = tempdir().unwrap();
5530        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5531        let content = "![outer ![inner](exists.png) [link](missing.md)](exists.png)\n";
5532
5533        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5534
5535        assert!(
5536            result.is_empty(),
5537            "The link sits inside the outer image's description. Got: {result:?}"
5538        );
5539    }
5540
5541    /// The control for the case above. Without the outer image, the link
5542    /// follows an image rather than sitting inside one.
5543    #[test]
5544    fn test_a_link_after_an_image_is_a_link() {
5545        let temp_dir = tempdir().unwrap();
5546        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5547        let content = "![inner](exists.png) [link](missing.md)\n";
5548
5549        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5550
5551        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5552        assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5553        // `missing.md` opens at the twenty ninth character, just past `](`.
5554        assert_eq!(result[0].column, 29);
5555    }
5556
5557    /// A reference image whose definition exists is an image like any other.
5558    #[test]
5559    fn test_a_link_inside_a_defined_reference_image_is_not_a_link() {
5560        let temp_dir = tempdir().unwrap();
5561        std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5562        let content = "![alt [x](missing.md)][def]\n\n[def]: exists.png\n";
5563
5564        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5565
5566        assert!(
5567            result.is_empty(),
5568            "The description of a resolved reference image carries no link. Got: {result:?}"
5569        );
5570    }
5571
5572    /// A destination may start on the line after the `](` that opens it. The
5573    /// report names the line the destination is written on.
5574    #[test]
5575    fn test_a_destination_on_the_next_line_is_reported_there() {
5576        let temp_dir = tempdir().unwrap();
5577        let content = "[a](\n  ./gone.md)\n";
5578
5579        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5580
5581        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5582        assert_eq!(result[0].message, "Relative link './gone.md' does not exist");
5583        assert_eq!(result[0].line, 2, "The destination sits on the second line");
5584        assert_eq!(result[0].end_line, 2);
5585        // Line 2 is `  ./gone.md)`, so the destination opens at the third character.
5586        assert_eq!(result[0].column, 3);
5587        assert_eq!(result[0].end_column, 12);
5588    }
5589}
5590
5591/// A link target has to be spelled the way the filesystem stores it.
5592///
5593/// macOS and Windows resolve a differently cased spelling to the same file, so
5594/// a link that 404s once the project is served, or once it is checked out on
5595/// Linux, passes on the machine it was written on. On Linux the metadata probe
5596/// alone reports every one of these rows, so the walk is only observable on a
5597/// case-insensitive volume.
5598#[cfg(test)]
5599mod exact_case_tests {
5600    use super::self_referential_links_tests::check_as_file;
5601    use super::*;
5602    use tempfile::tempdir;
5603
5604    /// Whether the volume holding `dir` resolves a differently cased spelling
5605    /// to the same entry. Only such a volume can hide a case mismatch, so the
5606    /// rows that turn on one skip elsewhere, where the filesystem cannot
5607    /// produce the difference they assert.
5608    fn volume_folds_case(dir: &Path) -> bool {
5609        let probe = dir.join("case-fold-probe.tmp");
5610        std::fs::write(&probe, "").unwrap();
5611        let folded = dir.join("CASE-FOLD-PROBE.TMP").exists();
5612        std::fs::remove_file(&probe).unwrap();
5613        folded
5614    }
5615
5616    /// `Foo.md` and `Docs/Guide.md` on disk, linked in every spelling from the
5617    /// report: the four case mismatches, the three exact spellings and a target
5618    /// that is not there at all.
5619    fn write_case_fixture(dir: &Path) {
5620        std::fs::write(dir.join("Foo.md"), "# Foo\n").unwrap();
5621        std::fs::create_dir_all(dir.join("Docs")).unwrap();
5622        std::fs::write(dir.join("Docs").join("Guide.md"), "# Guide\n").unwrap();
5623    }
5624
5625    /// The control for every row below: on a volume that folds case the
5626    /// filesystem answers for a spelling it does not store, which is the whole
5627    /// reason the spelling has to be confirmed against the listing. On a volume
5628    /// that does not fold, the rows hold for the plainer reason that the path
5629    /// is simply not there.
5630    #[test]
5631    fn test_the_filesystem_answers_for_a_spelling_it_does_not_store() {
5632        let temp_dir = tempdir().unwrap();
5633        let anchor = temp_dir.path();
5634        write_case_fixture(anchor);
5635        reset_file_existence_cache();
5636
5637        assert_eq!(anchor.join("foo.md").exists(), volume_folds_case(anchor));
5638        assert!(!exists_exact_case(anchor, &anchor.join("foo.md")));
5639    }
5640
5641    #[test]
5642    fn test_only_the_stored_spelling_of_a_target_exists() {
5643        let temp_dir = tempdir().unwrap();
5644        let anchor = temp_dir.path();
5645        write_case_fixture(anchor);
5646        reset_file_existence_cache();
5647
5648        for target in ["Foo.md", "Docs/Guide.md", "Docs"] {
5649            assert!(
5650                exists_exact_case(anchor, &anchor.join(target)),
5651                "{target} is on disk under this spelling"
5652            );
5653        }
5654        for target in ["foo.md", "docs/Guide.md", "Docs/guide.md", "docs", "Bar.md"] {
5655            assert!(
5656                !exists_exact_case(anchor, &anchor.join(target)),
5657                "{target} is not on disk under this spelling"
5658            );
5659        }
5660    }
5661
5662    /// A target without an extension is looked for under each markdown
5663    /// extension, and every candidate answers for its own spelling.
5664    #[test]
5665    fn test_the_extension_fallback_keeps_the_case_of_the_target() {
5666        let temp_dir = tempdir().unwrap();
5667        let anchor = temp_dir.path();
5668        write_case_fixture(anchor);
5669        reset_file_existence_cache();
5670
5671        assert!(resolve_existing_target(anchor, &anchor.join("Foo")).is_some());
5672        assert!(resolve_existing_target(anchor, &anchor.join("foo")).is_none());
5673    }
5674
5675    /// A link out of a subdirectory names entries of the directory it climbs
5676    /// to, and those are checked like any other.
5677    #[test]
5678    fn test_a_target_reached_through_a_parent_step_is_checked() {
5679        let temp_dir = tempdir().unwrap();
5680        write_case_fixture(temp_dir.path());
5681        let anchor = temp_dir.path().join("Docs");
5682        reset_file_existence_cache();
5683
5684        assert!(exists_exact_case(&anchor, &anchor.join("..").join("Foo.md")));
5685        assert!(!exists_exact_case(&anchor, &anchor.join("..").join("foo.md")));
5686    }
5687
5688    /// The path to the project is not the link author's, so the anchor and
5689    /// everything above it are taken as given whatever their case.
5690    #[test]
5691    fn test_a_component_above_the_anchor_is_accepted() {
5692        let temp_dir = tempdir().unwrap();
5693        write_case_fixture(temp_dir.path());
5694        let anchor = temp_dir.path().join("docs");
5695        reset_file_existence_cache();
5696
5697        assert!(has_exact_case_components(&anchor, &anchor.join("Guide.md")));
5698    }
5699
5700    /// A symlinked directory is named by the link, and the name the symlink
5701    /// points at is the filesystem's business. Resolving the link to its
5702    /// target's path would compare that other name and report a link that works.
5703    #[cfg(unix)]
5704    #[test]
5705    fn test_a_link_through_a_symlinked_directory_exists() {
5706        let temp_dir = tempdir().unwrap();
5707        let anchor = temp_dir.path();
5708        std::fs::create_dir_all(anchor.join("shared").join("Docs")).unwrap();
5709        std::fs::write(anchor.join("shared").join("Docs").join("Guide.md"), "# Guide\n").unwrap();
5710        std::os::unix::fs::symlink("shared/Docs", anchor.join("docs")).unwrap();
5711        reset_file_existence_cache();
5712
5713        assert!(exists_exact_case(anchor, &anchor.join("docs").join("Guide.md")));
5714    }
5715
5716    /// macOS stores a name outside ASCII in whichever normalization it is
5717    /// created with and answers for either, so the two forms spell one name.
5718    #[cfg(target_os = "macos")]
5719    #[test]
5720    fn test_a_decomposed_name_matches_a_composed_link() {
5721        let temp_dir = tempdir().unwrap();
5722        let anchor = temp_dir.path();
5723        std::fs::write(anchor.join("cafe\u{301}.md"), "# Cafe\n").unwrap();
5724        reset_file_existence_cache();
5725
5726        assert!(exists_exact_case(anchor, &anchor.join("caf\u{e9}.md")));
5727        assert!(!exists_exact_case(anchor, &anchor.join("CAF\u{c9}.md")));
5728    }
5729
5730    #[test]
5731    fn test_a_link_reports_every_spelling_the_filesystem_does_not_store() {
5732        let temp_dir = tempdir().unwrap();
5733        write_case_fixture(temp_dir.path());
5734        let content = "\
5735[a](foo.md)\n\
5736[b](docs/Guide.md)\n\
5737[c](foo)\n\
5738[d](docs)\n\
5739[e](Foo.md)\n\
5740[f](Docs/Guide.md)\n\
5741[g](Docs)\n\
5742[h](Bar.md)\n";
5743
5744        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5745
5746        let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5747        assert_eq!(
5748            messages,
5749            vec![
5750                "Relative link 'foo.md' does not exist",
5751                "Relative link 'docs/Guide.md' does not exist",
5752                "Relative link 'foo' does not exist",
5753                "Relative link 'docs' does not exist",
5754                "Relative link 'Bar.md' does not exist",
5755            ],
5756            "Got: {result:?}"
5757        );
5758    }
5759
5760    /// The reporter's own document: the file on disk is `changelog.md` and the
5761    /// link names `CHANGELOG.md`.
5762    #[test]
5763    fn test_the_reporters_link_is_reported() {
5764        let temp_dir = tempdir().unwrap();
5765        std::fs::write(temp_dir.path().join("changelog.md"), "").unwrap();
5766        let content = "# Test\n\nSee the [changelog](CHANGELOG.md).\n";
5767
5768        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5769
5770        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5771        assert_eq!(result[0].message, "Relative link 'CHANGELOG.md' does not exist");
5772        assert_eq!(result[0].line, 3);
5773        assert_eq!(result[0].column, 21);
5774    }
5775
5776    /// The markdown source behind an `.html` link is looked for under the stem
5777    /// the link spells, so a mis-cased stem finds nothing.
5778    #[test]
5779    fn test_the_html_fallback_keeps_the_case_of_the_stem() {
5780        let temp_dir = tempdir().unwrap();
5781        std::fs::write(temp_dir.path().join("Guide.md"), "# Guide\n").unwrap();
5782        let content = "[a](Guide.html)\n[b](guide.html)\n";
5783
5784        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5785
5786        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5787        assert_eq!(result[0].message, "Relative link 'guide.html' does not exist");
5788        assert_eq!(result[0].line, 2);
5789    }
5790
5791    /// A search path answers for the spelling it stores, like the document's
5792    /// own directory does.
5793    #[test]
5794    fn test_a_search_path_answers_for_the_exact_spelling() {
5795        let temp_dir = tempdir().unwrap();
5796        let assets = temp_dir.path().join("assets");
5797        std::fs::create_dir_all(&assets).unwrap();
5798        std::fs::write(assets.join("Logo.png"), "").unwrap();
5799        let config = MD057Config {
5800            search_paths: vec![assets.to_string_lossy().into_owned()],
5801            ..Default::default()
5802        };
5803        let content = "[a](Logo.png)\n[b](logo.png)\n";
5804
5805        let result = check_as_file(temp_dir.path(), "test.md", content, config);
5806
5807        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5808        assert_eq!(result[0].message, "Relative link 'logo.png' does not exist");
5809        assert_eq!(result[0].line, 2);
5810    }
5811
5812    /// An absolute link resolves under a root, and the components below that
5813    /// root are the link author's spelling just as a relative link's are.
5814    #[test]
5815    fn test_an_absolute_link_under_a_root_keeps_its_case() {
5816        let temp_dir = tempdir().unwrap();
5817        let root = temp_dir.path();
5818        write_case_fixture(root);
5819        let content = "[a](/Docs/Guide.md)\n[b](/docs/Guide.md)\n[c](/Docs)\n[d](/docs)\n";
5820
5821        let config = MD057Config {
5822            absolute_links: AbsoluteLinksOption::RelativeToRoots,
5823            roots: vec![],
5824            ..Default::default()
5825        };
5826        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
5827        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
5828        let result = rule.check(&ctx).unwrap();
5829
5830        let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5831        assert_eq!(
5832            messages,
5833            vec![
5834                "Absolute link '/docs/Guide.md' was not found under the project root",
5835                "Absolute link '/docs' was not found under the project root",
5836            ],
5837            "Got: {result:?}"
5838        );
5839    }
5840
5841    /// A `..` is resolved lexically, so `docs/../Foo.md` names `Foo.md` beside
5842    /// the document however `docs` is stored. Every renderer of the document
5843    /// resolves the link that way, and the reader gets whatever is at that
5844    /// path, so the rule reports what the reader sees.
5845    #[cfg(unix)]
5846    #[test]
5847    fn test_a_parent_step_is_resolved_lexically_through_a_symlink() {
5848        let temp_dir = tempdir().unwrap();
5849        let anchor = temp_dir.path();
5850        std::fs::create_dir_all(anchor.join("shared").join("Docs")).unwrap();
5851        std::fs::write(anchor.join("shared").join("Foo.md"), "# Foo\n").unwrap();
5852        std::fs::write(anchor.join("shared").join("Docs").join("Bar.md"), "# Bar\n").unwrap();
5853        std::os::unix::fs::symlink("shared/Docs", anchor.join("docs")).unwrap();
5854        let content = "[a](docs/../Foo.md)\n[b](docs/Bar.md)\n[c](docs/bar.md)\n";
5855
5856        let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5857
5858        let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5859        assert_eq!(
5860            messages,
5861            vec![
5862                "Relative link 'docs/../Foo.md' does not exist",
5863                "Relative link 'docs/bar.md' does not exist",
5864            ],
5865            "Got: {result:?}"
5866        );
5867    }
5868
5869    /// Restores the mode a directory had when the guard was taken, so a test
5870    /// that takes read permission away gives it back even when an assertion
5871    /// between the two panics and the temporary directory still has to be
5872    /// removable.
5873    #[cfg(unix)]
5874    struct RestoreMode {
5875        directory: PathBuf,
5876        mode: u32,
5877    }
5878
5879    #[cfg(unix)]
5880    impl RestoreMode {
5881        fn take(directory: &Path) -> Self {
5882            use std::os::unix::fs::PermissionsExt;
5883            Self {
5884                directory: directory.to_path_buf(),
5885                mode: std::fs::metadata(directory).unwrap().permissions().mode(),
5886            }
5887        }
5888    }
5889
5890    #[cfg(unix)]
5891    impl Drop for RestoreMode {
5892        fn drop(&mut self) {
5893            use std::os::unix::fs::PermissionsExt;
5894            let _ = std::fs::set_permissions(&self.directory, std::fs::Permissions::from_mode(self.mode));
5895        }
5896    }
5897
5898    /// A directory the process may enter but not read says nothing about how
5899    /// its entries are spelled, so every name under it is accepted. That is the
5900    /// branch making the walk unable to report a link that is really there.
5901    #[cfg(unix)]
5902    #[test]
5903    fn test_a_directory_that_cannot_be_listed_is_accepted() {
5904        use std::os::unix::fs::PermissionsExt;
5905
5906        let temp_dir = tempdir().unwrap();
5907        let anchor = temp_dir.path();
5908        let locked = anchor.join("locked");
5909        std::fs::create_dir_all(&locked).unwrap();
5910        std::fs::write(locked.join("Target.md"), "# Target\n").unwrap();
5911
5912        let _restore = RestoreMode::take(&locked);
5913        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o111)).unwrap();
5914        if std::fs::read_dir(&locked).is_ok() {
5915            // A process with the privilege to read any directory cannot reach
5916            // the branch under test.
5917            return;
5918        }
5919        assert!(locked.join("Target.md").exists(), "the directory can still be entered");
5920        let content = "[a](locked/Target.md)\n";
5921
5922        let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5923
5924        assert!(result.is_empty(), "Expected no warning. Got: {result:?}");
5925    }
5926
5927    /// The other direction of the same name: a composed name on disk answers
5928    /// for a decomposed link, because the two spell one name. Case is a
5929    /// difference in the name itself and is still reported.
5930    #[cfg(target_os = "macos")]
5931    #[test]
5932    fn test_a_composed_name_matches_a_decomposed_link() {
5933        let temp_dir = tempdir().unwrap();
5934        let anchor = temp_dir.path();
5935        std::fs::write(anchor.join("caf\u{e9}.md"), "# Cafe\n").unwrap();
5936        reset_file_existence_cache();
5937
5938        assert!(exists_exact_case(anchor, &anchor.join("cafe\u{301}.md")));
5939        assert!(!exists_exact_case(anchor, &anchor.join("CAFE\u{301}.md")));
5940
5941        let content = "[a](cafe\u{301}.md)\n[b](CAFE\u{301}.md)\n";
5942
5943        let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5944
5945        let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5946        assert_eq!(
5947            messages,
5948            vec!["Relative link 'CAFE\u{301}.md' does not exist"],
5949            "Got: {result:?}"
5950        );
5951    }
5952
5953    /// The Kelvin sign composes to the ASCII letter K, so a name written with
5954    /// it and the same name written with the letter spell one name, and the
5955    /// volume answers for both. A link in the ASCII spelling is accepted; a
5956    /// case difference is still reported.
5957    #[cfg(target_os = "macos")]
5958    #[test]
5959    fn test_a_name_composing_to_ascii_matches_an_ascii_link() {
5960        let temp_dir = tempdir().unwrap();
5961        let anchor = temp_dir.path();
5962        std::fs::write(anchor.join("\u{212a}elvin.md"), "# Kelvin\n").unwrap();
5963        reset_file_existence_cache();
5964
5965        assert!(exists_exact_case(anchor, &anchor.join("Kelvin.md")));
5966        assert!(!exists_exact_case(anchor, &anchor.join("kelvin.md")));
5967
5968        let content = "[a](Kelvin.md)\n[b](kelvin.md)\n";
5969
5970        let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5971
5972        let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5973        assert_eq!(
5974            messages,
5975            vec!["Relative link 'kelvin.md' does not exist"],
5976            "Got: {result:?}"
5977        );
5978    }
5979
5980    /// The other direction: an ASCII name on disk answers for a link whose
5981    /// spelling composes to it, because the two spell one name. Case is a
5982    /// difference in the name itself and is still reported.
5983    #[cfg(target_os = "macos")]
5984    #[test]
5985    fn test_an_ascii_name_matches_a_link_composing_to_it() {
5986        let temp_dir = tempdir().unwrap();
5987        let anchor = temp_dir.path();
5988        std::fs::write(anchor.join("Kelvin.md"), "# Kelvin\n").unwrap();
5989        reset_file_existence_cache();
5990
5991        assert!(exists_exact_case(anchor, &anchor.join("\u{212a}elvin.md")));
5992        assert!(!exists_exact_case(anchor, &anchor.join("\u{212a}ELVIN.md")));
5993
5994        let content = "[a](\u{212a}elvin.md)\n[b](\u{212a}ELVIN.md)\n";
5995
5996        let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5997
5998        let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5999        assert_eq!(
6000            messages,
6001            vec!["Relative link '\u{212a}ELVIN.md' does not exist"],
6002            "Got: {result:?}"
6003        );
6004    }
6005
6006    /// Puts a listing in the cache for `directory` naming only `Other.md`,
6007    /// recorded at `modified`. Any other name is absent from it, so a link to
6008    /// a file that is really on disk is reported for as long as this listing
6009    /// is the one a lookup accepts.
6010    ///
6011    /// The key is the directory's canonical path, which is what a check
6012    /// resolves a document's directory to and so what a lookup asks for.
6013    fn seed_listing(directory: &Path, modified: SystemTime) {
6014        let listing = DirectoryListing {
6015            modified: Some(modified),
6016            listed: true,
6017            names: HashSet::from([OsString::from("Other.md")]),
6018            composed: HashSet::new(),
6019        };
6020        let key = std::fs::canonicalize(directory).unwrap();
6021        DIRECTORY_LISTING_CACHE.lock().unwrap().insert(key, Arc::new(listing));
6022    }
6023
6024    // Choose distinct times explicitly: filesystems and VM shared mounts may
6025    // report one timestamp for several writes within the same clock tick.
6026    fn set_directory_modified(directory: &Path, modified: SystemTime) {
6027        let mut options = std::fs::OpenOptions::new();
6028        options.read(true);
6029        #[cfg(windows)]
6030        {
6031            use std::os::windows::fs::OpenOptionsExt;
6032            // FILE_WRITE_ATTRIBUTES and FILE_FLAG_BACKUP_SEMANTICS permit
6033            // setting timestamps on a Windows directory without opening data.
6034            options.access_mode(0x0100).custom_flags(0x02000000);
6035        }
6036        options
6037            .open(directory)
6038            .unwrap()
6039            .set_times(std::fs::FileTimes::new().set_modified(modified))
6040            .unwrap();
6041    }
6042
6043    /// A listing answers for as long as the directory's modification time
6044    /// stands, so checking many documents in one directory reads it once; and
6045    /// it is read again as soon as that time moves, so a file appearing
6046    /// between two checks is seen.
6047    #[test]
6048    fn test_a_listing_answers_until_the_directory_changes() {
6049        let temp_dir = tempdir().unwrap();
6050        let anchor = temp_dir.path();
6051        std::fs::write(anchor.join("README.md"), "# Readme\n").unwrap();
6052        // The document is written before the time is taken, so the check's own
6053        // write of it does not move the directory on.
6054        std::fs::write(anchor.join("test.md"), "").unwrap();
6055        let initial_time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
6056        set_directory_modified(anchor, initial_time);
6057        let read_at = std::fs::metadata(anchor).unwrap().modified().unwrap();
6058        seed_listing(anchor, read_at);
6059
6060        let reported = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6061
6062        let messages: Vec<&str> = reported.iter().map(|warning| warning.message.as_str()).collect();
6063        assert_eq!(
6064            messages,
6065            vec!["Relative link 'README.md' does not exist"],
6066            "the seeded listing answers while the directory's time stands. Got: {reported:?}"
6067        );
6068
6069        std::fs::write(anchor.join("Another.md"), "# Another\n").unwrap();
6070        set_directory_modified(anchor, initial_time + std::time::Duration::from_secs(10));
6071        assert_ne!(
6072            std::fs::metadata(anchor).unwrap().modified().unwrap(),
6073            read_at,
6074            "the fixture must expose a distinct directory modification time"
6075        );
6076
6077        let accepted = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6078
6079        assert!(
6080            accepted.is_empty(),
6081            "the listing is read again once the directory has moved on. Got: {accepted:?}"
6082        );
6083    }
6084
6085    /// A listing recorded at a moment that is not the directory's is
6086    /// discarded, so a directory that changed while nothing was checking it is
6087    /// read again rather than answered from.
6088    #[test]
6089    fn test_a_listing_from_another_moment_is_discarded() {
6090        let temp_dir = tempdir().unwrap();
6091        let anchor = temp_dir.path();
6092        std::fs::write(anchor.join("README.md"), "# Readme\n").unwrap();
6093        seed_listing(anchor, SystemTime::UNIX_EPOCH);
6094
6095        let result = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6096
6097        assert!(result.is_empty(), "Expected no warning. Got: {result:?}");
6098    }
6099}