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