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::{FileIndex, extract_cross_file_links};
12use regex::Regex;
13use std::collections::{HashMap, HashSet};
14use std::env;
15use std::path::{Path, PathBuf};
16use std::sync::LazyLock;
17use std::sync::{Arc, Mutex};
18
19mod md057_config;
20use crate::rule_config_serde::RuleConfig;
21use crate::utils::mkdocs_config::resolve_docs_dir;
22use crate::utils::obsidian_config::resolve_attachment_folder;
23use crate::utils::project_root::discover_project_root_from;
24pub use md057_config::{AbsoluteLinksOption, MD057Config};
25
26// Thread-safe cache for file existence checks to avoid redundant filesystem operations
27static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
28    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
29
30// Reset the file existence cache (typically between rule runs)
31fn reset_file_existence_cache() {
32    if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
33        cache.clear();
34    }
35}
36
37// Check if a file exists with caching
38fn file_exists_with_cache(path: &Path) -> bool {
39    match FILE_EXISTENCE_CACHE.lock() {
40        Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
41        Err(_) => path.exists(), // Fallback to uncached check on mutex poison
42    }
43}
44
45/// Check if a file exists, also trying markdown extensions for extensionless links.
46/// This supports wiki-style links like `[Link](page)` that resolve to `page.md`.
47fn file_exists_or_markdown_extension(path: &Path) -> bool {
48    resolve_existing_target(path).is_some()
49}
50
51/// The file a link path resolves to, or `None` when nothing is there.
52///
53/// An extensionless link is tried against the markdown extensions in turn, so
54/// `[Link](page)` resolves to `page.md`. Callers that only need existence go
55/// through `file_exists_or_markdown_extension`; the resolved path itself
56/// matters when the answer has to be compared against another file.
57fn resolve_existing_target(path: &Path) -> Option<PathBuf> {
58    // First, check exact path
59    if file_exists_with_cache(path) {
60        return Some(path.to_path_buf());
61    }
62
63    // If the path has no extension, try adding markdown extensions
64    if path.extension().is_none() {
65        for ext in MARKDOWN_EXTENSIONS {
66            // MARKDOWN_EXTENSIONS includes the dot, e.g., ".md"
67            let path_with_ext = path.with_extension(&ext[1..]);
68            if file_exists_with_cache(&path_with_ext) {
69                return Some(path_with_ext);
70            }
71        }
72    }
73
74    None
75}
76
77// Regex to match the start of a link - simplified for performance
78static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
79
80/// Regex to extract the URL from an angle-bracketed markdown link
81/// Format: `](<URL>)` or `](<URL> "title")`
82/// This handles URLs with parentheses like `](<path/(with)/parens.md>)`
83static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
84    LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
85
86/// Regex to extract the URL from a normal markdown link (without angle brackets)
87/// Format: `](URL)` or `](URL "title")`
88static URL_EXTRACT_REGEX: LazyLock<Regex> =
89    LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
90
91/// Regex to detect URLs with explicit schemes (should not be checked as relative links)
92/// Matches: scheme:// or scheme: (per RFC 3986)
93/// This covers http, https, ftp, file, smb, mailto, tel, data, macappstores, etc.
94static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
95    LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
96
97// Current working directory
98static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
99
100/// Project root discovered once at process start by walking up from CWD looking
101/// for `.git`, `.rumdl.toml`, `pyproject.toml`, or `.markdownlint.json`. Used as
102/// the anchor for resolving non-absolute paths in `roots` and `search-paths`,
103/// and as the implicit fallback root for absolute-link validation. Tests that
104/// pass a path via `with_path()` bypass this discovery.
105static PROJECT_ROOT: LazyLock<PathBuf> = LazyLock::new(|| discover_project_root_from(&CURRENT_DIR));
106
107/// Convert a hex digit (0-9, a-f, A-F) to its numeric value.
108/// Returns None for non-hex characters.
109#[inline]
110fn hex_digit_to_value(byte: u8) -> Option<u8> {
111    match byte {
112        b'0'..=b'9' => Some(byte - b'0'),
113        b'a'..=b'f' => Some(byte - b'a' + 10),
114        b'A'..=b'F' => Some(byte - b'A' + 10),
115        _ => None,
116    }
117}
118
119/// Supported markdown file extensions
120const MARKDOWN_EXTENSIONS: &[&str] = &[
121    ".md",
122    ".markdown",
123    ".mdx",
124    ".mkd",
125    ".mkdn",
126    ".mdown",
127    ".mdwn",
128    ".qmd",
129    ".rmd",
130];
131
132/// A relative link that resolves to the file it is written in.
133#[derive(Debug, PartialEq, Eq)]
134enum SelfReferentialLink {
135    /// The link addresses the whole file, so there is no shorter way to write
136    /// the same destination.
137    WholeFile,
138    /// The link carries a fragment, which on its own reaches the same heading
139    /// without leaving the page.
140    Fragment(String),
141}
142
143/// Rule MD057: Existing relative links should point to valid files or directories.
144#[derive(Debug, Clone)]
145pub struct MD057ExistingRelativeLinks {
146    /// Base directory for resolving relative links. Behind an `Arc<Mutex<..>>`
147    /// so it is shared across clones (the rule is cloned per config group and
148    /// for inline-config overrides); the base is resolved from the file under
149    /// check and consulted while validating that file's links.
150    base_path: Arc<Mutex<Option<PathBuf>>>,
151    /// Configuration for the rule
152    config: MD057Config,
153    /// Markdown flavor (used for Obsidian attachment folder auto-detection)
154    flavor: crate::config::MarkdownFlavor,
155}
156
157impl Default for MD057ExistingRelativeLinks {
158    fn default() -> Self {
159        Self {
160            base_path: Arc::new(Mutex::new(None)),
161            config: MD057Config::default(),
162            flavor: crate::config::MarkdownFlavor::default(),
163        }
164    }
165}
166
167impl MD057ExistingRelativeLinks {
168    /// Create a new instance with default settings
169    pub fn new() -> Self {
170        Self::default()
171    }
172
173    /// Set the base path for resolving relative links
174    pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
175        let path = path.as_ref();
176        let dir_path = if path.is_file() {
177            path.parent().map(std::path::Path::to_path_buf)
178        } else {
179            Some(path.to_path_buf())
180        };
181
182        if let Ok(mut guard) = self.base_path.lock() {
183            *guard = dir_path;
184        }
185        self
186    }
187
188    pub fn from_config_struct(config: MD057Config) -> Self {
189        Self {
190            base_path: Arc::new(Mutex::new(None)),
191            config,
192            flavor: crate::config::MarkdownFlavor::default(),
193        }
194    }
195
196    /// Resolve a config-supplied path string (from `roots` or `search-paths`)
197    /// against the project root: absolute strings are taken verbatim, relative
198    /// strings are joined onto `project_root`.
199    fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
200        if Path::new(path_str).is_absolute() {
201            PathBuf::from(path_str)
202        } else {
203            project_root.join(path_str)
204        }
205    }
206
207    /// Set the markdown flavor for Obsidian attachment auto-detection
208    #[cfg(test)]
209    fn with_flavor(mut self, flavor: crate::config::MarkdownFlavor) -> Self {
210        self.flavor = flavor;
211        self
212    }
213
214    /// Check if a URL is external or should be skipped for validation.
215    ///
216    /// Returns `true` (skip validation) for:
217    /// - URLs with protocols: `https://`, `http://`, `ftp://`, `mailto:`, etc.
218    /// - Bare domains: `www.example.com`, `example.com`
219    /// - Email addresses: `user@example.com` (without `mailto:`)
220    /// - Template variables: `{{URL}}`, `{{% include %}}`
221    /// - Absolute web URL paths: `/api/docs`, `/blog/post.html`
222    ///
223    /// Returns `false` (validate) for:
224    /// - Relative filesystem paths: `./file.md`, `../parent/file.md`, `file.md`
225    #[inline]
226    fn is_external_url(&self, url: &str) -> bool {
227        if url.is_empty() {
228            return false;
229        }
230
231        // Quick checks for common external URL patterns
232        if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
233            return true;
234        }
235
236        // Skip template variables (Handlebars/Mustache/Jinja2 syntax)
237        // Examples: {{URL}}, {{#URL}}, {{> partial}}, {{% include %}}, {{ variable }}
238        if url.starts_with("{{") || url.starts_with("{%") {
239            return true;
240        }
241
242        // Simple check: if URL contains @, it's almost certainly an email address
243        // File paths with @ are extremely rare, so this is a safe heuristic
244        if url.contains('@') {
245            return true; // It's an email address, skip it
246        }
247
248        // Bare domain check (e.g., "example.com")
249        // Note: We intentionally DON'T skip all TLDs like .org, .net, etc.
250        // Links like [text](nodejs.org/path) without a protocol are broken -
251        // they'll be treated as relative paths by markdown renderers.
252        // Flagging them helps users find missing protocols.
253        // We only skip .com as a minimal safety net for the most common case.
254        // Require the absence of a path separator so a relative file reference
255        // that merely ends in ".com" (e.g. "../../vendor.com") is still
256        // validated rather than assumed to be a bare domain.
257        if !url.contains('/') && url.ends_with(".com") {
258            return true;
259        }
260
261        // Framework path aliases (resolved by build tools like Vite, webpack, etc.)
262        // These are not filesystem paths but module/asset aliases
263        // Examples: ~/assets/image.png, @images/photo.jpg, @/components/Button.vue
264        if url.starts_with('~') || url.starts_with('@') {
265            return true;
266        }
267
268        // All other cases (relative paths, etc.) are not external
269        false
270    }
271
272    /// Check if the URL is a fragment-only link (internal document link)
273    #[inline]
274    fn is_fragment_only_link(&self, url: &str) -> bool {
275        url.starts_with('#')
276    }
277
278    /// Check if the URL is an absolute path (starts with /)
279    /// These are typically routes for published documentation sites.
280    #[inline]
281    fn is_absolute_path(url: &str) -> bool {
282        url.starts_with('/')
283    }
284
285    /// Decode URL percent-encoded sequences in a path.
286    /// Converts `%20` to space, `%2F` to `/`, etc.
287    /// Returns the original string if decoding fails or produces invalid UTF-8.
288    fn url_decode(path: &str) -> String {
289        // Quick check: if no percent sign, return as-is
290        if !path.contains('%') {
291            return path.to_string();
292        }
293
294        let bytes = path.as_bytes();
295        let mut result = Vec::with_capacity(bytes.len());
296        let mut i = 0;
297
298        while i < bytes.len() {
299            if bytes[i] == b'%' && i + 2 < bytes.len() {
300                // Try to parse the two hex digits following %
301                let hex1 = bytes[i + 1];
302                let hex2 = bytes[i + 2];
303                if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
304                    result.push(d1 * 16 + d2);
305                    i += 3;
306                    continue;
307                }
308            }
309            result.push(bytes[i]);
310            i += 1;
311        }
312
313        // Convert to UTF-8, falling back to original if invalid
314        String::from_utf8(result).unwrap_or_else(|_| path.to_string())
315    }
316
317    /// Strip query parameters and fragments from a URL for file existence checking.
318    /// URLs like `path/to/image.png?raw=true` or `file.md#section` should check
319    /// for `path/to/image.png` or `file.md` respectively.
320    ///
321    /// Note: In standard URLs, query parameters (`?`) come before fragments (`#`),
322    /// so we check for `?` first. If a URL has both, only the query is stripped here
323    /// (fragments are handled separately by the regex in `contribute_to_index`).
324    fn strip_query_and_fragment(url: &str) -> &str {
325        // Find the first occurrence of '?' or '#', whichever comes first
326        // This handles both standard URLs (? before #) and edge cases (# before ?)
327        let query_pos = url.find('?');
328        let fragment_pos = url.find('#');
329
330        match (query_pos, fragment_pos) {
331            (Some(q), Some(f)) => {
332                // Both exist - strip at whichever comes first
333                &url[..q.min(f)]
334            }
335            (Some(q), None) => &url[..q],
336            (None, Some(f)) => &url[..f],
337            (None, None) => url,
338        }
339    }
340
341    /// Resolve a relative link against a provided base path
342    fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
343        base_path.join(link)
344    }
345
346    /// Compute additional search paths for fallback link resolution.
347    ///
348    /// Combines Obsidian attachment folder auto-detection (when flavor is Obsidian)
349    /// with explicitly configured `search-paths`.
350    fn compute_search_paths(
351        &self,
352        flavor: crate::config::MarkdownFlavor,
353        source_file: Option<&Path>,
354        base_path: &Path,
355        project_root: &Path,
356    ) -> Vec<PathBuf> {
357        let mut paths = Vec::new();
358
359        // Auto-detect Obsidian attachment folder
360        if flavor == crate::config::MarkdownFlavor::Obsidian
361            && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
362            && attachment_dir != *base_path
363        {
364            paths.push(attachment_dir);
365        }
366
367        // Add explicitly configured search paths. Resolved relative to the
368        // discovered project root so paths are stable regardless of which
369        // subdirectory rumdl is invoked from.
370        for search_path in &self.config.search_paths {
371            let resolved = Self::resolve_against_project_root(search_path, project_root);
372            if resolved != *base_path && !paths.contains(&resolved) {
373                paths.push(resolved);
374            }
375        }
376
377        paths
378    }
379
380    /// Check if a link target exists in any of the additional search paths.
381    fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
382        search_paths.iter().any(|dir| {
383            let candidate = dir.join(decoded_path);
384            file_exists_or_markdown_extension(&candidate)
385        })
386    }
387
388    /// Check if a relative link can be compacted and return the simplified form.
389    ///
390    /// Returns `None` if compact-paths is disabled, the link has no traversal,
391    /// or the link is already the shortest form.
392    /// Returns `Some(suggestion)` with the full compacted URL (including fragment/query suffix).
393    fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
394        if !self.config.compact_paths {
395            return None;
396        }
397
398        // Split URL into path and suffix (fragment/query)
399        let path_end = url
400            .find('?')
401            .unwrap_or(url.len())
402            .min(url.find('#').unwrap_or(url.len()));
403        let path_part = &url[..path_end];
404        let suffix = &url[path_end..];
405
406        // URL-decode the path portion for filesystem resolution
407        let decoded_path = Self::url_decode(path_part);
408
409        compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
410    }
411
412    /// Classify a relative link that points back at the file it is written in.
413    ///
414    /// Returns `None` when the option is off, when the file under check is
415    /// unknown, or when the link addresses anything else. Fragment-only links
416    /// never reach here: they are already the form this reports towards.
417    ///
418    /// The link is resolved against the file's own directory first and then
419    /// against each search path, matching how the existence check resolves it:
420    /// a link that MD057 accepts through a search path is recognized here as
421    /// well, and one that already resolves next to the document is judged
422    /// against that target alone.
423    fn self_referential_link(
424        &self,
425        url: &str,
426        base_path: &Path,
427        search_paths: &[PathBuf],
428        source_file: Option<&Path>,
429    ) -> Option<SelfReferentialLink> {
430        if !self.config.self_referential_links {
431            return None;
432        }
433        let source_file = source_file?;
434
435        let path_part = Self::strip_query_and_fragment(url);
436        if path_part.is_empty() {
437            return None;
438        }
439        let suffix = &url[path_part.len()..];
440
441        let decoded_path = Self::url_decode(path_part);
442        // First hit wins, as it does for the existence check: a target next to
443        // the document is the one the link addresses, and a search path only
444        // answers for a link that resolves nowhere else.
445        let resolved = std::iter::once(base_path)
446            .chain(search_paths.iter().map(PathBuf::as_path))
447            .find_map(|dir| resolve_existing_target(&Self::resolve_link_path_with_base(&decoded_path, dir)))?;
448        if !Self::is_same_file(&resolved, source_file) {
449            return None;
450        }
451
452        // A bare fragment reaches the same place. A query string does not
453        // survive being detached from its path, and neither does an empty
454        // fragment, so those are reported without a suggestion.
455        match suffix.strip_prefix('#') {
456            Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
457            _ => Some(SelfReferentialLink::WholeFile),
458        }
459    }
460
461    /// Byte range of a reference definition's destination in the document.
462    ///
463    /// Both the label and the title can repeat the destination text, so the
464    /// search is bounded to what sits between the label's closing bracket and
465    /// the title. Anchoring anywhere else risks a fix rewriting the label,
466    /// which would leave every usage of the reference dangling.
467    fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
468        let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
469        let label_end = Self::label_end(def)?;
470        let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
471            title.saturating_sub(ref_def.byte_offset).min(def.len())
472        });
473        let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
474        let start = ref_def.byte_offset + offset;
475        Some(start..start + ref_def.url.len())
476    }
477
478    /// Offset just past the `]:` that closes a reference definition's label.
479    ///
480    /// A label may itself contain a bracket when the bracket is escaped, so
481    /// escapes are skipped rather than matched.
482    fn label_end(def: &str) -> Option<usize> {
483        let bytes = def.as_bytes();
484        let mut i = 0;
485        while i < bytes.len() {
486            match bytes[i] {
487                b'\\' => i += 2,
488                b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
489                _ => i += 1,
490            }
491        }
492        None
493    }
494
495    /// Whether this document has frontmatter the rule is configured to check.
496    /// Gates the body-link early exits, which would otherwise skip a document
497    /// whose only destinations sit in its frontmatter.
498    fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
499        self.config.check_frontmatter && ctx.front_matter_end_line() > 0
500    }
501
502    /// The warning text for an absolute destination, or `None` when the
503    /// configured handling accepts it.
504    fn absolute_link_message(&self, url: &str, base_path: &Path, project_root: &Path) -> Option<String> {
505        match self.config.absolute_links {
506            AbsoluteLinksOption::Ignore => None,
507            AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
508            AbsoluteLinksOption::RelativeToDocs => Self::validate_absolute_link_via_docs_dir(url, base_path),
509            AbsoluteLinksOption::RelativeToRoots => {
510                Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root)
511            }
512        }
513    }
514
515    /// Report frontmatter values that read as relative destinations but point
516    /// at nothing.
517    ///
518    /// Only existence is checked. The compact-path and self-referential
519    /// suggestions stay out of frontmatter: both rewrite a destination, and a
520    /// frontmatter value is only ever a guess at being one.
521    fn check_front_matter(
522        &self,
523        ctx: &crate::lint_context::LintContext,
524        base_path: &Path,
525        search_paths: &[PathBuf],
526        project_root: &Path,
527        warnings: &mut Vec<LintWarning>,
528    ) {
529        if !self.config.check_frontmatter {
530            return;
531        }
532
533        let ignored: HashSet<String> = self
534            .config
535            .ignore_frontmatter_fields
536            .iter()
537            .map(|field| field.to_lowercase())
538            .collect();
539
540        for link in frontmatter_values::link_destinations(ctx, &ignored) {
541            let line = ctx.lines[link.line - 1].content(ctx.content);
542            let url = &line[link.range.clone()];
543
544            // A fragment belongs to MD051, which validates it against the
545            // document's own headings.
546            if self.is_external_url(url) || self.is_fragment_only_link(url) {
547                continue;
548            }
549
550            let column = byte_to_char_count(line, link.range.start);
551            let end_column = column + url.chars().count();
552
553            if Self::is_absolute_path(url) {
554                if let Some(message) = self.absolute_link_message(url, base_path, project_root) {
555                    warnings.push(LintWarning {
556                        rule_name: Some(self.name().to_string()),
557                        line: link.line,
558                        column,
559                        end_line: link.line,
560                        end_column,
561                        message,
562                        severity: Severity::Warning,
563                        fix: None,
564                    });
565                }
566                continue;
567            }
568
569            if Self::relative_target_exists(url, base_path, search_paths) {
570                continue;
571            }
572
573            warnings.push(LintWarning {
574                rule_name: Some(self.name().to_string()),
575                line: link.line,
576                column,
577                end_line: link.line,
578                end_column,
579                message: format!("Relative link '{url}' does not exist"),
580                severity: Severity::Error,
581                fix: None,
582            });
583        }
584    }
585
586    /// Whether a relative destination resolves to something on disk.
587    ///
588    /// The destination is stripped of its query and fragment, percent-decoded,
589    /// then resolved against `base_path`, with two fallbacks: an `.html`/`.htm`
590    /// target passes when the markdown source it is generated from exists, and
591    /// any target passes when one of `search_paths` holds it.
592    fn relative_target_exists(url: &str, base_path: &Path, search_paths: &[PathBuf]) -> bool {
593        let decoded_path = Self::url_decode(Self::strip_query_and_fragment(url));
594        let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
595
596        // An extensionless link is also tried with each markdown extension.
597        if file_exists_or_markdown_extension(&resolved_path) {
598            return true;
599        }
600
601        if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
602            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
603            && let (Some(stem), Some(parent)) = (
604                resolved_path.file_stem().and_then(|s| s.to_str()),
605                resolved_path.parent(),
606            )
607            && MARKDOWN_EXTENSIONS
608                .iter()
609                .any(|md_ext| file_exists_with_cache(&parent.join(format!("{stem}{md_ext}"))))
610        {
611            return true;
612        }
613
614        Self::exists_in_search_paths(&decoded_path, search_paths)
615    }
616
617    /// Whether any enabled check can offer a fix. Broken links and absolute
618    /// links are reported without one, so the answer rests on the two options
619    /// that rewrite a destination.
620    fn produces_fixes(&self) -> bool {
621        self.config.compact_paths || self.config.self_referential_links
622    }
623
624    /// The warning text for a link that points at its own file.
625    fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
626        match self_link {
627            SelfReferentialLink::Fragment(fragment) => {
628                format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
629            }
630            SelfReferentialLink::WholeFile => {
631                format!("Relative link '{url}' points to the file it is in")
632            }
633        }
634    }
635
636    /// Whether two existing paths are the same file.
637    ///
638    /// Both sides are canonicalized, which settles symlinks and the platform's
639    /// path representation; the lexical form is the fallback for a path the
640    /// filesystem cannot answer for.
641    fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
642        // Cheap reject before the syscall: a different name is a different file.
643        if resolved.file_name() != source_file.file_name() {
644            return false;
645        }
646        match (resolved.canonicalize(), source_file.canonicalize()) {
647            (Ok(link), Ok(source)) => link == source,
648            _ => normalize_path(resolved) == normalize_path(source_file),
649        }
650    }
651
652    /// Validate an absolute link by resolving it relative to MkDocs docs_dir.
653    ///
654    /// Returns `Some(warning_message)` if the link is broken, `None` if valid.
655    /// Falls back to a generic warning if no mkdocs.yml is found.
656    /// Validate an absolute link against the MkDocs `docs_dir`.
657    fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
658        let Some(docs_dir) = resolve_docs_dir(source_path) else {
659            return Some(format!(
660                "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
661            ));
662        };
663
664        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
665
666        // MkDocs mode: an extensionless directory link must have index.md.
667        // `require_index_for_dirs = true` enforces this for all directory hits.
668        match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
669            Resolution::Found => None,
670            Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
671                "Absolute link '{url}' resolves to directory '{}' which has no index.md",
672                resolved.display()
673            )),
674            Resolution::NotFound { resolved } => Some(format!(
675                "Absolute link '{url}' resolves to '{}' which does not exist",
676                resolved.display()
677            )),
678        }
679    }
680
681    /// Validate an absolute link by resolving it against each configured root and the project root.
682    ///
683    /// Configured `roots` are tried first (first match wins), then the project
684    /// root is tried as an implicit fallback. The fallback supports links
685    /// written as literal absolute paths from the project root (e.g.
686    /// `/content/en/foo.md`) alongside links written relative to a configured
687    /// root (e.g. `/foo.md` with `roots = ["content/en"]`). A warning is
688    /// emitted only when no root — configured or implicit — contains the target.
689    fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
690        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
691
692        for root in roots {
693            let root_path = Self::resolve_against_project_root(root, project_root);
694            // Filesystem mode: an existing directory without trailing slash is valid.
695            // `require_index_for_dirs = false` aligns with relative-link behavior. (#632)
696            if matches!(
697                Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
698                Resolution::Found
699            ) {
700                return None;
701            }
702        }
703
704        if matches!(
705            // Filesystem mode: see above.
706            Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
707            Resolution::Found
708        ) {
709            return None;
710        }
711
712        let msg = if roots.is_empty() {
713            format!("Absolute link '{url}' was not found under the project root")
714        } else {
715            format!("Absolute link '{url}' was not found under any configured root or the project root")
716        };
717        Some(msg)
718    }
719
720    /// Decode an absolute-link URL into a filesystem-relative path and a
721    /// directory-link flag. Strips the leading `/`, query/fragment suffix, and
722    /// percent-encoding.
723    fn prepare_absolute_url(url: &str) -> (String, bool) {
724        let relative_url = url.trim_start_matches('/');
725        let file_path = Self::strip_query_and_fragment(relative_url);
726        let decoded = Self::url_decode(file_path);
727        let is_directory_link = url.ends_with('/') || decoded.is_empty();
728        (decoded, is_directory_link)
729    }
730
731    /// Try to resolve a decoded absolute-link path under a single root directory.
732    ///
733    /// `require_index_for_dirs` controls how extensionless links that resolve to a
734    /// directory are treated:
735    ///
736    /// - `true` (MkDocs / docs-dir mode): a directory must contain `index.md` to be
737    ///   considered valid, even when the link has no trailing slash. This matches
738    ///   MkDocs' URL routing convention where `/section` serves `section/index.md`.
739    ///
740    /// - `false` (roots / filesystem mode): an existing directory is accepted as a
741    ///   valid target for an extensionless link, matching the behavior of relative
742    ///   links (which use `path.exists()`). Only an explicit trailing-slash link
743    ///   (`is_directory_link == true`) still requires `index.md`.
744    ///
745    /// Applies resolution strategies in order:
746    /// 1. Directory-style links (explicit `/` suffix or `require_index_for_dirs`):
747    ///    look for `<resolved>/index.md`; report `DirectoryWithoutIndex` on failure.
748    /// 2. Filesystem-mode directory hit (`require_index_for_dirs == false` and
749    ///    `is_directory_link == false`): accept the existing directory as `Found`.
750    /// 3. Direct existence (with markdown-extension fallback for extensionless links).
751    /// 4. `.html`/`.htm` links: look for a markdown source with the same stem.
752    fn resolve_under_root_with_opts(
753        root_path: &Path,
754        decoded: &str,
755        is_directory_link: bool,
756        require_index_for_dirs: bool,
757    ) -> Resolution {
758        let resolved = root_path.join(decoded);
759
760        let is_dir = resolved.is_dir();
761
762        // When the link explicitly ends with `/` or the caller requires index.md
763        // for all directory hits (MkDocs mode), apply the stricter check first.
764        // Must be checked before `file_exists_or_markdown_extension` because
765        // `path.exists()` returns `true` for directories.
766        if is_directory_link || (require_index_for_dirs && is_dir) {
767            let index_path = resolved.join("index.md");
768            if file_exists_with_cache(&index_path) {
769                return Resolution::Found;
770            }
771            if is_dir {
772                return Resolution::DirectoryWithoutIndex { resolved };
773            }
774        }
775
776        // Filesystem mode (roots): an existing directory without a trailing slash
777        // is valid — mirrors how relative links accept directories via `path.exists()`.
778        // Exclude decoded paths that end with `/`: a URL like `/guide/#intro` strips
779        // the fragment to `guide/`, so `decoded` carries the trailing slash even though
780        // `is_directory_link` is false (the raw URL ends with `#intro`, not `/`).
781        let decoded_has_trailing_slash = decoded.ends_with('/');
782        if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
783            return Resolution::Found;
784        }
785
786        if file_exists_or_markdown_extension(&resolved) {
787            return Resolution::Found;
788        }
789
790        // For .html/.htm links, accept a matching markdown source in the same
791        // directory — supports doc sites that compile .md to .html.
792        if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
793            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
794            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
795        {
796            let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
797                let source_path = parent.join(format!("{stem}{md_ext}"));
798                file_exists_with_cache(&source_path)
799            });
800            if has_md_source {
801                return Resolution::Found;
802            }
803        }
804
805        Resolution::NotFound { resolved }
806    }
807}
808
809/// Outcome of trying to resolve an absolute link under a single root directory.
810/// Carries the resolved path on the failure variants so callers can build
811/// specific error messages without recomputing it.
812enum Resolution {
813    Found,
814    DirectoryWithoutIndex { resolved: PathBuf },
815    NotFound { resolved: PathBuf },
816}
817
818/// Search `re` in `line` starting at `expected_start`, accepting the match
819/// only if it begins exactly there.
820///
821/// `Regex::captures_at` searches starting at the given offset but does not
822/// require the match to *begin* there. A bracket's own destination that
823/// fails to match at its own position (a fragment-only `(#bar)`, an empty
824/// `()`) would otherwise silently slide forward and return a later,
825/// unrelated match belonging to a different bracket on the same line.
826/// Rejecting a non-anchored match treats that case as "no destination for
827/// this bracket" instead of borrowing a sibling bracket's URL.
828fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
829    let caps = re.captures_at(line, expected_start)?;
830    if caps.get(0)?.start() != expected_start {
831        return None;
832    }
833    Some(caps)
834}
835
836impl Rule for MD057ExistingRelativeLinks {
837    fn name(&self) -> &'static str {
838        "MD057"
839    }
840
841    fn description(&self) -> &'static str {
842        "Relative links should point to existing files"
843    }
844
845    fn category(&self) -> RuleCategory {
846        RuleCategory::Link
847    }
848
849    fn skippable_by_category(&self) -> bool {
850        // A frontmatter path is a link this rule resolves, and the document
851        // holding it needs no link syntax anywhere else.
852        !self.config.check_frontmatter
853    }
854
855    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
856        ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
857    }
858
859    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
860        let content = ctx.content;
861
862        if content.is_empty() {
863            return Ok(Vec::new());
864        }
865
866        // Early returns for performance. A document whose only destinations
867        // sit in its frontmatter has no link syntax to find, so the body-link
868        // shortcuts only apply when frontmatter is not being checked.
869        let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
870        if !has_body_links && !self.checks_front_matter_of(ctx) {
871            return Ok(Vec::new());
872        }
873
874        // Reset the file existence cache for a fresh run
875        reset_file_existence_cache();
876
877        let mut warnings = Vec::new();
878
879        // Read the explicit base path (set via `with_path()` in tests) once; it
880        // doubles as both the per-file base path and the project root override
881        // for absolute-link resolution.
882        let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
883
884        // Project root used for absolute-link resolution against configured
885        // `roots` and as the implicit fallback root. The explicit base wins
886        // when set; otherwise the discovered project root is used.
887        let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
888
889        // The file under check, as the filesystem sees it. Links are compared
890        // against it to find the ones that point back at their own document.
891        let self_path: Option<PathBuf> = ctx
892            .source_file
893            .as_ref()
894            .map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.clone()));
895
896        // Determine base path for resolving relative links.
897        // ALWAYS compute from ctx.source_file for each file - do not reuse cached base_path
898        // This ensures each file resolves links relative to its own directory.
899        let base_path: Option<PathBuf> = {
900            if explicit_base.is_some() {
901                explicit_base
902            } else if let Some(ref resolved_file) = self_path {
903                // Resolve symlinks to get the actual file location
904                // This ensures relative links are resolved from the target's directory,
905                // not the symlink's directory
906                resolved_file
907                    .parent()
908                    .map(std::path::Path::to_path_buf)
909                    .or_else(|| Some(CURRENT_DIR.clone()))
910            } else {
911                // No source file available - cannot validate relative links
912                None
913            }
914        };
915
916        // If we still don't have a base path, we can't validate relative links
917        let Some(base_path) = base_path else {
918            return Ok(warnings);
919        };
920
921        // Compute additional search paths for fallback link resolution
922        let extra_search_paths =
923            self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path, &project_root);
924
925        // Use LintContext links instead of expensive regex parsing
926        if !ctx.links.is_empty() {
927            // Use LineIndex for correct position calculation across all line ending types
928            let line_index = &ctx.line_index;
929
930            // Pre-collected lines from context
931            let lines = ctx.raw_lines();
932
933            // Track which lines we've already processed to avoid duplicates
934            // (ctx.links may have multiple entries for the same line, especially with malformed markdown)
935            let mut processed_lines = std::collections::HashSet::new();
936
937            for link in &ctx.links {
938                let line_idx = link.line - 1;
939                if line_idx >= lines.len() {
940                    continue;
941                }
942
943                // Skip lines inside PyMdown blocks
944                if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
945                    continue;
946                }
947
948                // Skip if we've already processed this line
949                if !processed_lines.insert(line_idx) {
950                    continue;
951                }
952
953                let line = lines[line_idx];
954
955                // Quick check for link pattern in this line
956                if !line.contains("](") {
957                    continue;
958                }
959
960                // Find all links in this line using optimized regex
961                for link_match in LINK_START_REGEX.find_iter(line) {
962                    // Skip image syntax (`![...]`) here, images are already fully
963                    // validated by the dedicated ctx.images loop below, and processing
964                    // them again here would duplicate that warning. A bang preceded by
965                    // an odd number of backslashes is escaped, literal text per
966                    // CommonMark, making the bracket a normal link that the image loop
967                    // never sees, so it must stay in this loop.
968                    if link_match.as_str().starts_with('!') {
969                        let escapes = line[..link_match.start()]
970                            .bytes()
971                            .rev()
972                            .take_while(|&b| b == b'\\')
973                            .count();
974                        if escapes % 2 == 0 {
975                            continue;
976                        }
977                    }
978
979                    let start_pos = link_match.start();
980                    let end_pos = link_match.end();
981
982                    // Calculate absolute position using LineIndex
983                    let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
984                    let absolute_start_pos = line_start_byte + start_pos;
985
986                    // Skip if this link is in a code span
987                    if ctx.is_in_code_span_byte(absolute_start_pos) {
988                        continue;
989                    }
990
991                    // Skip if this link is in a math span (LaTeX $...$ or $$...$$)
992                    if ctx.is_in_math_span(absolute_start_pos) {
993                        continue;
994                    }
995
996                    // Find the URL part after the link text
997                    // Try angle-bracket regex first (handles URLs with parens like `<path/(with)/parens.md>`)
998                    // Then fall back to normal URL regex. Both searches are anchored to
999                    // this bracket's own position so a destination that cannot match
1000                    // here (fragment-only, empty) yields no URL instead of borrowing
1001                    // the next bracket's destination.
1002                    let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, end_pos - 1)
1003                        .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1004                        .or_else(|| {
1005                            extract_url_at(&URL_EXTRACT_REGEX, line, end_pos - 1)
1006                                .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1007                        });
1008
1009                    if let Some((caps, url_group)) = caps_and_url {
1010                        let url = url_group.as_str().trim();
1011
1012                        // Skip empty URLs
1013                        if url.is_empty() {
1014                            continue;
1015                        }
1016
1017                        // Skip rustdoc intra-doc links (backtick-wrapped URLs)
1018                        // These are Rust API references, not file paths
1019                        // Example: [`f32::is_subnormal`], [`Vec::push`]
1020                        if url.starts_with('`') && url.ends_with('`') {
1021                            continue;
1022                        }
1023
1024                        // Skip external URLs and fragment-only links
1025                        if self.is_external_url(url) || self.is_fragment_only_link(url) {
1026                            continue;
1027                        }
1028
1029                        // Handle absolute paths based on config
1030                        if Self::is_absolute_path(url) {
1031                            if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1032                                warnings.push(LintWarning {
1033                                    rule_name: Some(self.name().to_string()),
1034                                    line: link.line,
1035                                    column: byte_to_char_count(line, url_group.start()),
1036                                    end_line: link.line,
1037                                    end_column: byte_to_char_count(line, url_group.end()),
1038                                    message,
1039                                    severity: Severity::Warning,
1040                                    fix: None,
1041                                });
1042                            }
1043                            continue;
1044                        }
1045
1046                        // Check for unnecessary path traversal (compact-paths)
1047                        // Reconstruct full URL including fragment (regex group 2)
1048                        // since url_group (group 1) contains only the path part
1049                        let full_url_for_compact = if let Some(frag) = caps.get(2) {
1050                            format!("{url}{}", frag.as_str())
1051                        } else {
1052                            url.to_string()
1053                        };
1054                        // A link back into the current file. Reported instead of
1055                        // the compaction below, whose shorter path would still
1056                        // be a link the reader should not follow, and instead
1057                        // of the existence check, which this target passes.
1058                        if let Some(self_link) = self.self_referential_link(
1059                            &full_url_for_compact,
1060                            &base_path,
1061                            &extra_search_paths,
1062                            self_path.as_deref(),
1063                        ) {
1064                            let url_start = url_group.start();
1065                            let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1066                            let fix_byte_start = line_start_byte + url_start;
1067                            let fix_byte_end = line_start_byte + url_end;
1068                            warnings.push(LintWarning {
1069                                rule_name: Some(self.name().to_string()),
1070                                line: link.line,
1071                                column: byte_to_char_count(line, url_start),
1072                                end_line: link.line,
1073                                end_column: byte_to_char_count(line, url_end),
1074                                message: Self::self_referential_message(&full_url_for_compact, &self_link),
1075                                severity: Severity::Warning,
1076                                fix: match &self_link {
1077                                    SelfReferentialLink::Fragment(fragment) => {
1078                                        Some(Fix::new(fix_byte_start..fix_byte_end, fragment.clone()))
1079                                    }
1080                                    SelfReferentialLink::WholeFile => None,
1081                                },
1082                            });
1083                            continue;
1084                        }
1085
1086                        if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
1087                            let url_start = url_group.start();
1088                            let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1089                            let fix_byte_start = line_start_byte + url_start;
1090                            let fix_byte_end = line_start_byte + url_end;
1091                            warnings.push(LintWarning {
1092                                rule_name: Some(self.name().to_string()),
1093                                line: link.line,
1094                                column: byte_to_char_count(line, url_start),
1095                                end_line: link.line,
1096                                end_column: byte_to_char_count(line, url_end),
1097                                message: format!(
1098                                    "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
1099                                ),
1100                                severity: Severity::Warning,
1101                                fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1102                            });
1103                        }
1104
1105                        if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1106                            continue;
1107                        }
1108
1109                        // File doesn't exist and no source file found
1110                        // Use actual URL position from regex capture group
1111                        // Note: capture group positions are absolute within the line string
1112                        let url_start = url_group.start();
1113                        let url_end = url_group.end();
1114
1115                        warnings.push(LintWarning {
1116                            rule_name: Some(self.name().to_string()),
1117                            line: link.line,
1118                            column: byte_to_char_count(line, url_start),
1119                            end_line: link.line,
1120                            end_column: byte_to_char_count(line, url_end),
1121                            message: format!("Relative link '{url}' does not exist"),
1122                            severity: Severity::Error,
1123                            fix: None,
1124                        });
1125                    }
1126                }
1127            }
1128        }
1129
1130        // Also process images - they have URLs already parsed
1131        for image in &ctx.images {
1132            // Skip images inside PyMdown blocks (MkDocs flavor)
1133            if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
1134                continue;
1135            }
1136
1137            let url = image.url.as_ref();
1138
1139            // Skip empty URLs
1140            if url.is_empty() {
1141                continue;
1142            }
1143
1144            // Skip external URLs and fragment-only links
1145            if self.is_external_url(url) || self.is_fragment_only_link(url) {
1146                continue;
1147            }
1148
1149            // Handle absolute paths based on config
1150            if Self::is_absolute_path(url) {
1151                if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1152                    warnings.push(LintWarning {
1153                        rule_name: Some(self.name().to_string()),
1154                        line: image.line,
1155                        column: image.start_col + 1,
1156                        end_line: image.line,
1157                        end_column: image.start_col + 1 + url.chars().count(),
1158                        message,
1159                        severity: Severity::Warning,
1160                        fix: None,
1161                    });
1162                }
1163                continue;
1164            }
1165
1166            // Check for unnecessary path traversal (compact-paths)
1167            if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1168                // Find the URL position within the image syntax using document byte offsets.
1169                // Search from image.byte_offset (the `!` character) to locate the URL string.
1170                let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1171                    let fix_byte_start = image.byte_offset + url_offset;
1172                    let fix_byte_end = fix_byte_start + url.len();
1173                    Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1174                });
1175
1176                let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1177                let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
1178                // The fix range is a document byte offset; the displayed column is
1179                // the corresponding character offset within the line.
1180                let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1181                    byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1182                });
1183                warnings.push(LintWarning {
1184                    rule_name: Some(self.name().to_string()),
1185                    line: image.line,
1186                    column: url_col,
1187                    end_line: image.line,
1188                    end_column: url_col + url.chars().count(),
1189                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1190                    severity: Severity::Warning,
1191                    fix,
1192                });
1193            }
1194
1195            if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1196                continue;
1197            }
1198
1199            // File doesn't exist and no source file found
1200            // Images already have correct position from parser
1201            warnings.push(LintWarning {
1202                rule_name: Some(self.name().to_string()),
1203                line: image.line,
1204                column: image.start_col + 1,
1205                end_line: image.line,
1206                end_column: image.start_col + 1 + url.chars().count(),
1207                message: format!("Relative link '{url}' does not exist"),
1208                severity: Severity::Error,
1209                fix: None,
1210            });
1211        }
1212
1213        // Also process reference definitions: [ref]: ./path.md
1214        for ref_def in &ctx.reference_defs {
1215            let url = &ref_def.url;
1216
1217            // Skip empty URLs
1218            if url.is_empty() {
1219                continue;
1220            }
1221
1222            // Skip external URLs and fragment-only links
1223            if self.is_external_url(url) || self.is_fragment_only_link(url) {
1224                continue;
1225            }
1226
1227            // Where this definition's destination sits, shared by every report
1228            // on it. Without a located destination a warning falls back to the
1229            // start of the definition's line and offers no fix.
1230            let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1231            let (line, col) = url_range
1232                .as_ref()
1233                .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1234            let end_col = col + url.chars().count();
1235
1236            // Handle absolute paths based on config
1237            if Self::is_absolute_path(url) {
1238                if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1239                    warnings.push(LintWarning {
1240                        rule_name: Some(self.name().to_string()),
1241                        line,
1242                        column: col,
1243                        end_line: line,
1244                        end_column: end_col,
1245                        message,
1246                        severity: Severity::Warning,
1247                        fix: None,
1248                    });
1249                }
1250                continue;
1251            }
1252
1253            // A definition whose destination is the file holding it.
1254            if let Some(self_link) =
1255                self.self_referential_link(url, &base_path, &extra_search_paths, self_path.as_deref())
1256            {
1257                warnings.push(LintWarning {
1258                    rule_name: Some(self.name().to_string()),
1259                    line,
1260                    column: col,
1261                    end_line: line,
1262                    end_column: end_col,
1263                    message: Self::self_referential_message(url, &self_link),
1264                    severity: Severity::Warning,
1265                    fix: match (&self_link, &url_range) {
1266                        (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1267                            Some(Fix::new(range.clone(), fragment.clone()))
1268                        }
1269                        _ => None,
1270                    },
1271                });
1272                continue;
1273            }
1274
1275            // Check for unnecessary path traversal (compact-paths)
1276            if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1277                warnings.push(LintWarning {
1278                    rule_name: Some(self.name().to_string()),
1279                    line,
1280                    column: col,
1281                    end_line: line,
1282                    end_column: end_col,
1283                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1284                    severity: Severity::Warning,
1285                    fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1286                });
1287            }
1288
1289            if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
1290                continue;
1291            }
1292
1293            // File doesn't exist and no source file found
1294            warnings.push(LintWarning {
1295                rule_name: Some(self.name().to_string()),
1296                line,
1297                column: col,
1298                end_line: line,
1299                end_column: end_col,
1300                message: format!("Relative link '{url}' does not exist"),
1301                severity: Severity::Error,
1302                fix: None,
1303            });
1304        }
1305
1306        self.check_front_matter(ctx, &base_path, &extra_search_paths, &project_root, &mut warnings);
1307
1308        Ok(warnings)
1309    }
1310
1311    fn fix_capability(&self) -> FixCapability {
1312        if self.produces_fixes() {
1313            FixCapability::ConditionallyFixable
1314        } else {
1315            FixCapability::Unfixable
1316        }
1317    }
1318
1319    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1320        if !self.produces_fixes() {
1321            return Ok(ctx.content.to_string());
1322        }
1323
1324        let warnings = self.check(ctx)?;
1325        let warnings =
1326            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1327        let mut content = ctx.content.to_string();
1328
1329        // Collect fixable warnings (compact-paths) sorted by byte offset descending
1330        let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1331        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1332
1333        // Applying fixes right-to-left lets each range stay valid against the
1334        // still-unshifted content to its left. A duplicate or overlapping fix
1335        // would otherwise be applied a second time against content already
1336        // rewritten by an earlier fix, corrupting it; skip any fix whose range
1337        // overlaps the one most recently applied.
1338        let mut last_applied_start: Option<usize> = None;
1339        for fix in fixes {
1340            if let Some(prev_start) = last_applied_start
1341                && fix.range.end > prev_start
1342            {
1343                continue;
1344            }
1345            if fix.range.end <= content.len() {
1346                content.replace_range(fix.range.clone(), &fix.replacement);
1347                last_applied_start = Some(fix.range.start);
1348            }
1349        }
1350
1351        Ok(content)
1352    }
1353
1354    fn as_any(&self) -> &dyn std::any::Any {
1355        self
1356    }
1357
1358    fn default_config_section(&self) -> Option<(String, toml::Value)> {
1359        let default_config = MD057Config::default();
1360        let json_value = serde_json::to_value(&default_config).ok()?;
1361        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
1362
1363        if let toml::Value::Table(table) = toml_value {
1364            if !table.is_empty() {
1365                Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1366            } else {
1367                None
1368            }
1369        } else {
1370            None
1371        }
1372    }
1373
1374    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1375    where
1376        Self: Sized,
1377    {
1378        let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1379        let mut rule = Self::from_config_struct(rule_config);
1380        rule.flavor = config.global.flavor;
1381        Box::new(rule)
1382    }
1383
1384    fn cross_file_scope(&self) -> CrossFileScope {
1385        CrossFileScope::Workspace
1386    }
1387
1388    fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1389        // Use the shared utility for cross-file link extraction
1390        // This ensures consistent position tracking between CLI and LSP
1391        let links = extract_cross_file_links(ctx);
1392        for link in links.relative {
1393            index.add_cross_file_link(link);
1394        }
1395        // Root-relative links are not linted, but indexing them keeps the cached
1396        // index complete so the LSP can resolve them for find-references.
1397        for link in links.root_relative {
1398            index.add_root_relative_link(link);
1399        }
1400    }
1401
1402    fn cross_file_check(
1403        &self,
1404        _file_path: &Path,
1405        _file_index: &FileIndex,
1406        _workspace_index: &crate::workspace_index::WorkspaceIndex,
1407    ) -> LintResult {
1408        // All link targets are already validated by check() on each per-file pass.
1409        // check() resolves relative links against the file's own directory, handles
1410        // configured search paths, and applies the absolute_links config.
1411        // Validating them here too would produce identical duplicate warnings for
1412        // every broken link. (#631)
1413        //
1414        // The cross_file_scope / contribute_to_index / workspace-index infrastructure
1415        // remains in place to support future cross-file analyses (e.g. heading-anchor
1416        // validation across files).
1417        Ok(Vec::new())
1418    }
1419}
1420
1421/// Compute the shortest relative path from `from_dir` to `to_path`.
1422///
1423/// Both paths must be normalized (no `.` or `..` components).
1424/// Returns a relative `PathBuf` that navigates from `from_dir` to `to_path`.
1425fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1426    let from_components: Vec<_> = from_dir.components().collect();
1427    let to_components: Vec<_> = to_path.components().collect();
1428
1429    // Find common prefix length
1430    let common_len = from_components
1431        .iter()
1432        .zip(to_components.iter())
1433        .take_while(|(a, b)| a == b)
1434        .count();
1435
1436    let mut result = PathBuf::new();
1437
1438    // Go up for each remaining component in from_dir
1439    for _ in common_len..from_components.len() {
1440        result.push("..");
1441    }
1442
1443    // Append remaining components from to_path
1444    for component in &to_components[common_len..] {
1445        result.push(component);
1446    }
1447
1448    result
1449}
1450
1451/// Check if a relative link path can be shortened.
1452///
1453/// Given the source directory and the raw link path, computes whether there's
1454/// a shorter equivalent path. Returns `Some(compact_path)` if the link can
1455/// be simplified, `None` if it's already optimal.
1456fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1457    let link_path = Path::new(raw_link_path);
1458
1459    // Only check paths that contain traversal (../ or ./)
1460    let has_traversal = link_path
1461        .components()
1462        .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1463
1464    if !has_traversal {
1465        return None;
1466    }
1467
1468    // Resolve: source_dir + raw_link_path, then normalize
1469    let combined = source_dir.join(link_path);
1470    let normalized_target = normalize_path(&combined);
1471
1472    // Compute shortest path from source_dir back to the normalized target
1473    let normalized_source = normalize_path(source_dir);
1474    let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1475
1476    // Compare against the raw link path — if it differs, the path can be compacted
1477    if shortest != link_path {
1478        let compact = shortest.to_string_lossy().to_string();
1479        // Avoid suggesting empty path
1480        if compact.is_empty() {
1481            return None;
1482        }
1483        // Markdown links always use forward slashes regardless of platform
1484        Some(compact.replace('\\', "/"))
1485    } else {
1486        None
1487    }
1488}
1489
1490/// Normalize a path by resolving . and .. components
1491fn normalize_path(path: &Path) -> PathBuf {
1492    let mut components = Vec::new();
1493
1494    for component in path.components() {
1495        match component {
1496            std::path::Component::ParentDir => {
1497                // Go up one level if possible
1498                if !components.is_empty() {
1499                    components.pop();
1500                }
1501            }
1502            std::path::Component::CurDir => {
1503                // Skip current directory markers
1504            }
1505            _ => {
1506                components.push(component);
1507            }
1508        }
1509    }
1510
1511    components.iter().collect()
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516    use super::*;
1517    use crate::workspace_index::CrossFileLinkIndex;
1518    use std::fs::File;
1519    use std::io::Write;
1520    use tempfile::tempdir;
1521
1522    #[test]
1523    fn test_strip_query_and_fragment() {
1524        // Test query parameter stripping
1525        assert_eq!(
1526            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1527            "file.png"
1528        );
1529        assert_eq!(
1530            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1531            "file.png"
1532        );
1533        assert_eq!(
1534            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1535            "file.png"
1536        );
1537
1538        // Test fragment stripping
1539        assert_eq!(
1540            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1541            "file.md"
1542        );
1543        assert_eq!(
1544            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1545            "file.md"
1546        );
1547
1548        // Test both query and fragment (query comes first, per RFC 3986)
1549        assert_eq!(
1550            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1551            "file.md"
1552        );
1553
1554        // Test no query or fragment
1555        assert_eq!(
1556            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1557            "file.png"
1558        );
1559
1560        // Test with path
1561        assert_eq!(
1562            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1563            "path/to/image.png"
1564        );
1565        assert_eq!(
1566            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1567            "path/to/image.png"
1568        );
1569
1570        // Edge case: fragment before query (non-standard but possible)
1571        assert_eq!(
1572            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1573            "file.md"
1574        );
1575    }
1576
1577    #[test]
1578    fn test_url_decode() {
1579        // Simple space encoding
1580        assert_eq!(
1581            MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1582            "penguin with space.jpg"
1583        );
1584
1585        // Path with encoded spaces
1586        assert_eq!(
1587            MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1588            "assets/my file name.png"
1589        );
1590
1591        // Multiple encoded characters
1592        assert_eq!(
1593            MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1594            "hello world!.md"
1595        );
1596
1597        // Lowercase hex
1598        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1599
1600        // Uppercase hex
1601        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1602
1603        // Mixed case hex
1604        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1605
1606        // No encoding - return as-is
1607        assert_eq!(
1608            MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1609            "normal-file.md"
1610        );
1611
1612        // Incomplete percent encoding - leave as-is
1613        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1614
1615        // Percent at end - leave as-is
1616        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1617
1618        // Invalid hex digits - leave as-is
1619        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1620
1621        // Plus sign (should NOT be decoded - that's form encoding, not URL encoding)
1622        assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1623
1624        // Empty string
1625        assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1626
1627        // UTF-8 multi-byte characters (é = C3 A9 in UTF-8)
1628        assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1629
1630        // Multiple consecutive encoded characters
1631        assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), "   ");
1632
1633        // Encoded path separators
1634        assert_eq!(
1635            MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1636            "path/to/file.md"
1637        );
1638
1639        // Mixed encoded and non-encoded
1640        assert_eq!(
1641            MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1642            "hello world/foo bar.md"
1643        );
1644
1645        // Special characters that are commonly encoded
1646        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1647
1648        // Percent at position that looks like encoding but isn't valid
1649        assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1650    }
1651
1652    #[test]
1653    fn test_url_encoded_filenames() {
1654        // Create a temporary directory for test files
1655        let temp_dir = tempdir().unwrap();
1656        let base_path = temp_dir.path();
1657
1658        // Create a file with spaces in the name
1659        let file_with_spaces = base_path.join("penguin with space.jpg");
1660        File::create(&file_with_spaces)
1661            .unwrap()
1662            .write_all(b"image data")
1663            .unwrap();
1664
1665        // Create a subdirectory with spaces
1666        let subdir = base_path.join("my images");
1667        std::fs::create_dir(&subdir).unwrap();
1668        let nested_file = subdir.join("photo 1.png");
1669        File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1670
1671        // Test content with URL-encoded links
1672        let content = r#"
1673# Test Document with URL-Encoded Links
1674
1675![Penguin](penguin%20with%20space.jpg)
1676![Photo](my%20images/photo%201.png)
1677![Missing](missing%20file.jpg)
1678"#;
1679
1680        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1681
1682        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683        let result = rule.check(&ctx).unwrap();
1684
1685        // Should only have one warning for the missing file
1686        assert_eq!(
1687            result.len(),
1688            1,
1689            "Should only warn about missing%20file.jpg. Got: {result:?}"
1690        );
1691        assert!(
1692            result[0].message.contains("missing%20file.jpg"),
1693            "Warning should mention the URL-encoded filename"
1694        );
1695    }
1696
1697    #[test]
1698    fn test_external_urls() {
1699        let rule = MD057ExistingRelativeLinks::new();
1700
1701        // Common web protocols
1702        assert!(rule.is_external_url("https://example.com"));
1703        assert!(rule.is_external_url("http://example.com"));
1704        assert!(rule.is_external_url("ftp://example.com"));
1705        assert!(rule.is_external_url("www.example.com"));
1706        assert!(rule.is_external_url("example.com"));
1707
1708        // Special URI schemes
1709        assert!(rule.is_external_url("file:///path/to/file"));
1710        assert!(rule.is_external_url("smb://server/share"));
1711        assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1712        assert!(rule.is_external_url("mailto:user@example.com"));
1713        assert!(rule.is_external_url("tel:+1234567890"));
1714        assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1715        assert!(rule.is_external_url("javascript:void(0)"));
1716        assert!(rule.is_external_url("ssh://git@github.com/repo"));
1717        assert!(rule.is_external_url("git://github.com/repo.git"));
1718
1719        // Email addresses without mailto: protocol
1720        // These are clearly not file links and should be skipped
1721        assert!(rule.is_external_url("user@example.com"));
1722        assert!(rule.is_external_url("steering@kubernetes.io"));
1723        assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1724        assert!(rule.is_external_url("user_name@sub.domain.com"));
1725        assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1726
1727        // Template variables should be skipped (not checked as relative links)
1728        assert!(rule.is_external_url("{{URL}}")); // Handlebars/Mustache
1729        assert!(rule.is_external_url("{{#URL}}")); // Handlebars block helper
1730        assert!(rule.is_external_url("{{> partial}}")); // Handlebars partial
1731        assert!(rule.is_external_url("{{ variable }}")); // Mustache with spaces
1732        assert!(rule.is_external_url("{{% include %}}")); // Jinja2/Hugo shortcode
1733        assert!(rule.is_external_url("{{")); // Even partial matches (regex edge case)
1734
1735        // Absolute paths are NOT external (handled separately via is_absolute_path)
1736        // By default they are ignored, but can be configured to warn
1737        assert!(!rule.is_external_url("/api/v1/users"));
1738        assert!(!rule.is_external_url("/blog/2024/release.html"));
1739        assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1740        assert!(!rule.is_external_url("/pkg/runtime"));
1741        assert!(!rule.is_external_url("/doc/go1compat"));
1742        assert!(!rule.is_external_url("/index.html"));
1743        assert!(!rule.is_external_url("/assets/logo.png"));
1744
1745        // But is_absolute_path should detect them
1746        assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1747        assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1748        assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1749        assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1750        assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1751
1752        // Framework path aliases should be skipped (resolved by build tools)
1753        // Tilde prefix (common in Vite, Nuxt, Astro for project root)
1754        assert!(rule.is_external_url("~/assets/image.png"));
1755        assert!(rule.is_external_url("~/components/Button.vue"));
1756        assert!(rule.is_external_url("~assets/logo.svg")); // Nuxt style without /
1757
1758        // @ prefix (common in Vue, webpack, Vite aliases)
1759        assert!(rule.is_external_url("@/components/Header.vue"));
1760        assert!(rule.is_external_url("@images/photo.jpg"));
1761        assert!(rule.is_external_url("@assets/styles.css"));
1762
1763        // Relative paths should NOT be external (should be validated)
1764        assert!(!rule.is_external_url("./relative/path.md"));
1765        assert!(!rule.is_external_url("relative/path.md"));
1766        assert!(!rule.is_external_url("../parent/path.md"));
1767    }
1768
1769    #[test]
1770    fn test_dot_com_only_skips_bare_domains() {
1771        let rule = MD057ExistingRelativeLinks::new();
1772
1773        // Bare domains ending in .com are treated as external (skipped).
1774        assert!(rule.is_external_url("example.com"));
1775        assert!(rule.is_external_url("sub.example.com"));
1776
1777        // A relative path that merely ends in ".com" must NOT be skipped:
1778        // it contains a path separator, so it is a relative file reference
1779        // that should be validated, not assumed external.
1780        assert!(!rule.is_external_url("../../vendor.com"));
1781        assert!(!rule.is_external_url("./vendor.com"));
1782        assert!(!rule.is_external_url("docs/vendor.com"));
1783    }
1784
1785    #[test]
1786    fn test_framework_path_aliases() {
1787        // Create a temporary directory for test files
1788        let temp_dir = tempdir().unwrap();
1789        let base_path = temp_dir.path();
1790
1791        // Test content with framework path aliases (should all be skipped)
1792        let content = r#"
1793# Framework Path Aliases
1794
1795![Image 1](~/assets/penguin.jpg)
1796![Image 2](~assets/logo.svg)
1797![Image 3](@images/photo.jpg)
1798![Image 4](@/components/icon.svg)
1799[Link](@/pages/about.md)
1800
1801This is a [real missing link](missing.md) that should be flagged.
1802"#;
1803
1804        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1805
1806        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1807        let result = rule.check(&ctx).unwrap();
1808
1809        // Should only have one warning for the real missing link
1810        assert_eq!(
1811            result.len(),
1812            1,
1813            "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1814        );
1815        assert!(
1816            result[0].message.contains("missing.md"),
1817            "Warning should be for missing.md"
1818        );
1819    }
1820
1821    #[test]
1822    fn test_url_decode_security_path_traversal() {
1823        // Ensure URL decoding doesn't enable path traversal attacks
1824        // The decoded path is still validated against the base path
1825        let temp_dir = tempdir().unwrap();
1826        let base_path = temp_dir.path();
1827
1828        // Create a file in the temp directory
1829        let file_in_base = base_path.join("safe.md");
1830        File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1831
1832        // Test with encoded path traversal attempt
1833        // Use a path that definitely won't exist on any platform (not /etc/passwd which exists on Linux)
1834        // %2F = /, so ..%2F..%2Fnonexistent%2Ffile = ../../nonexistent/file
1835        // %252F = %2F (double encoded), so ..%252F..%252F = ..%2F..%2F (literal, won't decode to ..)
1836        let content = r#"
1837[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1838[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1839[Safe link](safe.md)
1840"#;
1841
1842        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1843
1844        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1845        let result = rule.check(&ctx).unwrap();
1846
1847        // The traversal attempts should still be flagged as missing
1848        // (they don't exist relative to base_path after decoding)
1849        assert_eq!(
1850            result.len(),
1851            2,
1852            "Should have warnings for traversal attempts. Got: {result:?}"
1853        );
1854    }
1855
1856    #[test]
1857    fn test_url_encoded_utf8_filenames() {
1858        // Test with actual UTF-8 encoded filenames
1859        let temp_dir = tempdir().unwrap();
1860        let base_path = temp_dir.path();
1861
1862        // Create files with unicode names
1863        let cafe_file = base_path.join("café.md");
1864        File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1865
1866        let content = r#"
1867[Café link](caf%C3%A9.md)
1868[Missing unicode](r%C3%A9sum%C3%A9.md)
1869"#;
1870
1871        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1872
1873        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1874        let result = rule.check(&ctx).unwrap();
1875
1876        // Should only warn about the missing file
1877        assert_eq!(
1878            result.len(),
1879            1,
1880            "Should only warn about missing résumé.md. Got: {result:?}"
1881        );
1882        assert!(
1883            result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1884            "Warning should mention the URL-encoded filename"
1885        );
1886    }
1887
1888    #[test]
1889    fn test_url_encoded_emoji_filenames() {
1890        // URL-encoded emoji paths should be correctly resolved
1891        // 👤 = U+1F464 = F0 9F 91 A4 in UTF-8
1892        let temp_dir = tempdir().unwrap();
1893        let base_path = temp_dir.path();
1894
1895        // Create directory with emoji in name: 👤 Personal
1896        let emoji_dir = base_path.join("👤 Personal");
1897        std::fs::create_dir(&emoji_dir).unwrap();
1898
1899        // Create file in that directory: TV Shows.md
1900        let file_path = emoji_dir.join("TV Shows.md");
1901        File::create(&file_path)
1902            .unwrap()
1903            .write_all(b"# TV Shows\n\nContent here.")
1904            .unwrap();
1905
1906        // Test content with URL-encoded emoji link
1907        // %F0%9F%91%A4 = 👤, %20 = space
1908        let content = r#"
1909# Test Document
1910
1911[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1912[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1913"#;
1914
1915        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1916
1917        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1918        let result = rule.check(&ctx).unwrap();
1919
1920        // Should only warn about the missing file, not the valid emoji path
1921        assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1922        assert!(
1923            result[0].message.contains("Missing.md"),
1924            "Warning should be for Missing.md, got: {}",
1925            result[0].message
1926        );
1927    }
1928
1929    #[test]
1930    fn test_no_warnings_without_base_path() {
1931        let rule = MD057ExistingRelativeLinks::new();
1932        let content = "[Link](missing.md)";
1933
1934        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1935        let result = rule.check(&ctx).unwrap();
1936        assert!(result.is_empty(), "Should have no warnings without base path");
1937    }
1938
1939    #[test]
1940    fn test_existing_and_missing_links() {
1941        // Create a temporary directory for test files
1942        let temp_dir = tempdir().unwrap();
1943        let base_path = temp_dir.path();
1944
1945        // Create an existing file
1946        let exists_path = base_path.join("exists.md");
1947        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1948
1949        // Verify the file exists
1950        assert!(exists_path.exists(), "exists.md should exist for this test");
1951
1952        // Create test content with both existing and missing links
1953        let content = r#"
1954# Test Document
1955
1956[Valid Link](exists.md)
1957[Invalid Link](missing.md)
1958[External Link](https://example.com)
1959[Media Link](image.jpg)
1960        "#;
1961
1962        // Initialize rule with the base path (default: check all files including media)
1963        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1964
1965        // Test the rule
1966        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1967        let result = rule.check(&ctx).unwrap();
1968
1969        // Should have two warnings: missing.md and image.jpg (both don't exist)
1970        assert_eq!(result.len(), 2);
1971        let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1972        assert!(messages.iter().any(|m| m.contains("missing.md")));
1973        assert!(messages.iter().any(|m| m.contains("image.jpg")));
1974    }
1975
1976    #[test]
1977    fn test_angle_bracket_links() {
1978        // Create a temporary directory for test files
1979        let temp_dir = tempdir().unwrap();
1980        let base_path = temp_dir.path();
1981
1982        // Create an existing file
1983        let exists_path = base_path.join("exists.md");
1984        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1985
1986        // Create test content with angle bracket links
1987        let content = r#"
1988# Test Document
1989
1990[Valid Link](<exists.md>)
1991[Invalid Link](<missing.md>)
1992[External Link](<https://example.com>)
1993    "#;
1994
1995        // Test with default settings
1996        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1997
1998        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1999        let result = rule.check(&ctx).unwrap();
2000
2001        // Should have one warning for missing.md
2002        assert_eq!(result.len(), 1, "Should have exactly one warning");
2003        assert!(
2004            result[0].message.contains("missing.md"),
2005            "Warning should mention missing.md"
2006        );
2007    }
2008
2009    #[test]
2010    fn test_angle_bracket_links_with_parens() {
2011        // Create a temporary directory for test files
2012        let temp_dir = tempdir().unwrap();
2013        let base_path = temp_dir.path();
2014
2015        // Create directory structure with parentheses in path
2016        let app_dir = base_path.join("app");
2017        std::fs::create_dir(&app_dir).unwrap();
2018        let upload_dir = app_dir.join("(upload)");
2019        std::fs::create_dir(&upload_dir).unwrap();
2020        let page_file = upload_dir.join("page.tsx");
2021        File::create(&page_file)
2022            .unwrap()
2023            .write_all(b"export default function Page() {}")
2024            .unwrap();
2025
2026        // Create test content with angle bracket links containing parentheses
2027        let content = r#"
2028# Test Document with Paths Containing Parens
2029
2030[Upload Page](<app/(upload)/page.tsx>)
2031[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2032[Missing](<app/(missing)/file.md>)
2033"#;
2034
2035        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2036
2037        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2038        let result = rule.check(&ctx).unwrap();
2039
2040        // Should only have one warning for the missing file
2041        assert_eq!(
2042            result.len(),
2043            1,
2044            "Should have exactly one warning for missing file. Got: {result:?}"
2045        );
2046        assert!(
2047            result[0].message.contains("app/(missing)/file.md"),
2048            "Warning should mention app/(missing)/file.md"
2049        );
2050    }
2051
2052    #[test]
2053    fn test_all_file_types_checked() {
2054        // Create a temporary directory for test files
2055        let temp_dir = tempdir().unwrap();
2056        let base_path = temp_dir.path();
2057
2058        // Create a test with various file types - all should be checked
2059        let content = r#"
2060[Image Link](image.jpg)
2061[Video Link](video.mp4)
2062[Markdown Link](document.md)
2063[PDF Link](file.pdf)
2064"#;
2065
2066        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2067
2068        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2069        let result = rule.check(&ctx).unwrap();
2070
2071        // Should warn about all missing files regardless of extension
2072        assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2073    }
2074
2075    #[test]
2076    fn test_code_span_detection() {
2077        let rule = MD057ExistingRelativeLinks::new();
2078
2079        // Create a temporary directory for test files
2080        let temp_dir = tempdir().unwrap();
2081        let base_path = temp_dir.path();
2082
2083        let rule = rule.with_path(base_path);
2084
2085        // Test with document structure
2086        let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2087
2088        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2089        let result = rule.check(&ctx).unwrap();
2090
2091        // Should only find the real link, not the one in code
2092        assert_eq!(result.len(), 1, "Should only flag the real link");
2093        assert!(result[0].message.contains("nonexistent.md"));
2094    }
2095
2096    #[test]
2097    fn test_inline_code_spans() {
2098        // Create a temporary directory for test files
2099        let temp_dir = tempdir().unwrap();
2100        let base_path = temp_dir.path();
2101
2102        // Create test content with links in inline code spans
2103        let content = r#"
2104# Test Document
2105
2106This is a normal link: [Link](missing.md)
2107
2108This is a code span with a link: `[Link](another-missing.md)`
2109
2110Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2111
2112    "#;
2113
2114        // Initialize rule with the base path
2115        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2116
2117        // Test the rule
2118        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2119        let result = rule.check(&ctx).unwrap();
2120
2121        // Should only have warning for the normal link, not for links in code spans
2122        assert_eq!(result.len(), 1, "Should have exactly one warning");
2123        assert!(
2124            result[0].message.contains("missing.md"),
2125            "Warning should be for missing.md"
2126        );
2127        assert!(
2128            !result.iter().any(|w| w.message.contains("another-missing.md")),
2129            "Should not warn about link in code span"
2130        );
2131        assert!(
2132            !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2133            "Should not warn about link in inline code"
2134        );
2135    }
2136
2137    #[test]
2138    fn test_extensionless_link_resolution() {
2139        // Create a temporary directory for test files
2140        let temp_dir = tempdir().unwrap();
2141        let base_path = temp_dir.path();
2142
2143        // Create a markdown file WITHOUT specifying .md extension in the link
2144        let page_path = base_path.join("page.md");
2145        File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2146
2147        // Test content with extensionless link that should resolve to page.md
2148        let content = r#"
2149# Test Document
2150
2151[Link without extension](page)
2152[Link with extension](page.md)
2153[Missing link](nonexistent)
2154"#;
2155
2156        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2157
2158        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2159        let result = rule.check(&ctx).unwrap();
2160
2161        // Should only have warning for nonexistent link
2162        // Both "page" and "page.md" should resolve to the same file
2163        assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2164        assert!(
2165            result[0].message.contains("nonexistent"),
2166            "Warning should be for 'nonexistent' not 'page'"
2167        );
2168    }
2169
2170    // Cross-file validation tests
2171    #[test]
2172    fn test_cross_file_scope() {
2173        let rule = MD057ExistingRelativeLinks::new();
2174        assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2175    }
2176
2177    #[test]
2178    fn test_contribute_to_index_extracts_markdown_links() {
2179        let rule = MD057ExistingRelativeLinks::new();
2180        let content = r#"
2181# Document
2182
2183[Link to docs](./docs/guide.md)
2184[Link with fragment](./other.md#section)
2185[External link](https://example.com)
2186[Image link](image.png)
2187[Media file](video.mp4)
2188"#;
2189
2190        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2191        let mut index = FileIndex::new();
2192        rule.contribute_to_index(&ctx, &mut index);
2193
2194        // Should only index markdown file links
2195        assert_eq!(index.cross_file_links.len(), 2);
2196
2197        // Check first link
2198        assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2199        assert_eq!(index.cross_file_links[0].fragment, "");
2200
2201        // Check second link (with fragment)
2202        assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2203        assert_eq!(index.cross_file_links[1].fragment, "section");
2204    }
2205
2206    #[test]
2207    fn test_contribute_to_index_skips_external_and_anchors() {
2208        let rule = MD057ExistingRelativeLinks::new();
2209        let content = r#"
2210# Document
2211
2212[External](https://example.com)
2213[Another external](http://example.org)
2214[Fragment only](#section)
2215[FTP link](ftp://files.example.com)
2216[Mail link](mailto:test@example.com)
2217[WWW link](www.example.com)
2218"#;
2219
2220        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2221        let mut index = FileIndex::new();
2222        rule.contribute_to_index(&ctx, &mut index);
2223
2224        // Should not index any of these
2225        assert_eq!(index.cross_file_links.len(), 0);
2226    }
2227
2228    #[test]
2229    fn test_cross_file_check_valid_link() {
2230        use crate::workspace_index::WorkspaceIndex;
2231
2232        let rule = MD057ExistingRelativeLinks::new();
2233
2234        // Create a workspace index with the target file
2235        let mut workspace_index = WorkspaceIndex::new();
2236        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2237
2238        // Create file index with a link to an existing file
2239        let mut file_index = FileIndex::new();
2240        file_index.add_cross_file_link(CrossFileLinkIndex {
2241            target_path: "guide.md".to_string(),
2242            fragment: "".to_string(),
2243            line: 5,
2244            column: 1,
2245        });
2246
2247        // Run cross-file check from docs/index.md
2248        let warnings = rule
2249            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2250            .unwrap();
2251
2252        // Should have no warnings - file exists
2253        assert!(warnings.is_empty());
2254    }
2255
2256    #[test]
2257    fn test_cross_file_check_missing_link() {
2258        // cross_file_check delegates all validation to check() to avoid duplicates.
2259        // It always returns empty — the per-file check() path is authoritative.
2260        use crate::workspace_index::WorkspaceIndex;
2261
2262        let rule = MD057ExistingRelativeLinks::new();
2263        let workspace_index = WorkspaceIndex::new();
2264
2265        let mut file_index = FileIndex::new();
2266        file_index.add_cross_file_link(CrossFileLinkIndex {
2267            target_path: "missing.md".to_string(),
2268            fragment: "".to_string(),
2269            line: 5,
2270            column: 1,
2271        });
2272
2273        let warnings = rule
2274            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2275            .unwrap();
2276
2277        // cross_file_check defers to check(); it produces no warnings of its own.
2278        assert!(
2279            warnings.is_empty(),
2280            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2281        );
2282    }
2283
2284    #[test]
2285    fn test_cross_file_check_parent_path() {
2286        use crate::workspace_index::WorkspaceIndex;
2287
2288        let rule = MD057ExistingRelativeLinks::new();
2289
2290        // Create a workspace index with the target file at the root
2291        let mut workspace_index = WorkspaceIndex::new();
2292        workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2293
2294        // Create file index with a parent path link
2295        let mut file_index = FileIndex::new();
2296        file_index.add_cross_file_link(CrossFileLinkIndex {
2297            target_path: "../readme.md".to_string(),
2298            fragment: "".to_string(),
2299            line: 5,
2300            column: 1,
2301        });
2302
2303        // Run cross-file check from docs/guide.md
2304        let warnings = rule
2305            .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2306            .unwrap();
2307
2308        // Should have no warnings - file exists at normalized path
2309        assert!(warnings.is_empty());
2310    }
2311
2312    #[test]
2313    fn test_cross_file_check_html_link_with_md_source() {
2314        // Test that .html links are accepted when corresponding .md source exists
2315        // This supports mdBook and similar doc generators that compile .md to .html
2316        use crate::workspace_index::WorkspaceIndex;
2317
2318        let rule = MD057ExistingRelativeLinks::new();
2319
2320        // Create a workspace index with the .md source file
2321        let mut workspace_index = WorkspaceIndex::new();
2322        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2323
2324        // Create file index with an .html link (from another rule like MD051)
2325        let mut file_index = FileIndex::new();
2326        file_index.add_cross_file_link(CrossFileLinkIndex {
2327            target_path: "guide.html".to_string(),
2328            fragment: "section".to_string(),
2329            line: 10,
2330            column: 5,
2331        });
2332
2333        // Run cross-file check from docs/index.md
2334        let warnings = rule
2335            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2336            .unwrap();
2337
2338        // Should have no warnings - .md source exists for the .html link
2339        assert!(
2340            warnings.is_empty(),
2341            "Expected no warnings for .html link with .md source, got: {warnings:?}"
2342        );
2343    }
2344
2345    #[test]
2346    fn test_cross_file_check_html_link_without_source() {
2347        // cross_file_check delegates all validation to check() to avoid duplicates.
2348        // Verifying that .html links without a matching .md source are caught is
2349        // already covered by test_html_link_with_md_source (check() path).
2350        use crate::workspace_index::WorkspaceIndex;
2351
2352        let rule = MD057ExistingRelativeLinks::new();
2353        let workspace_index = WorkspaceIndex::new();
2354
2355        let mut file_index = FileIndex::new();
2356        file_index.add_cross_file_link(CrossFileLinkIndex {
2357            target_path: "missing.html".to_string(),
2358            fragment: "".to_string(),
2359            line: 10,
2360            column: 5,
2361        });
2362
2363        let warnings = rule
2364            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2365            .unwrap();
2366
2367        // cross_file_check defers to check(); it produces no warnings of its own.
2368        assert!(
2369            warnings.is_empty(),
2370            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2371        );
2372    }
2373
2374    #[test]
2375    fn test_normalize_path_function() {
2376        // Test simple cases
2377        assert_eq!(
2378            normalize_path(Path::new("docs/guide.md")),
2379            PathBuf::from("docs/guide.md")
2380        );
2381
2382        // Test current directory removal
2383        assert_eq!(
2384            normalize_path(Path::new("./docs/guide.md")),
2385            PathBuf::from("docs/guide.md")
2386        );
2387
2388        // Test parent directory resolution
2389        assert_eq!(
2390            normalize_path(Path::new("docs/sub/../guide.md")),
2391            PathBuf::from("docs/guide.md")
2392        );
2393
2394        // Test multiple parent directories
2395        assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2396    }
2397
2398    #[test]
2399    fn test_html_link_with_md_source() {
2400        // Links to .html files should pass if corresponding .md source exists
2401        let temp_dir = tempdir().unwrap();
2402        let base_path = temp_dir.path();
2403
2404        // Create guide.md (source file)
2405        let md_file = base_path.join("guide.md");
2406        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2407
2408        let content = r#"
2409[Read the guide](guide.html)
2410[Also here](getting-started.html)
2411"#;
2412
2413        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2414        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2415        let result = rule.check(&ctx).unwrap();
2416
2417        // guide.html passes (guide.md exists), getting-started.html fails
2418        assert_eq!(
2419            result.len(),
2420            1,
2421            "Should only warn about missing source. Got: {result:?}"
2422        );
2423        assert!(result[0].message.contains("getting-started.html"));
2424    }
2425
2426    #[test]
2427    fn test_htm_link_with_md_source() {
2428        // .htm extension should also check for markdown source
2429        let temp_dir = tempdir().unwrap();
2430        let base_path = temp_dir.path();
2431
2432        let md_file = base_path.join("page.md");
2433        File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2434
2435        let content = "[Page](page.htm)";
2436
2437        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2438        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2439        let result = rule.check(&ctx).unwrap();
2440
2441        assert!(
2442            result.is_empty(),
2443            "Should not warn when .md source exists for .htm link"
2444        );
2445    }
2446
2447    #[test]
2448    fn test_html_link_finds_various_markdown_extensions() {
2449        // Should find .mdx, .markdown, etc. as source files
2450        let temp_dir = tempdir().unwrap();
2451        let base_path = temp_dir.path();
2452
2453        File::create(base_path.join("doc.md")).unwrap();
2454        File::create(base_path.join("tutorial.mdx")).unwrap();
2455        File::create(base_path.join("guide.markdown")).unwrap();
2456
2457        let content = r#"
2458[Doc](doc.html)
2459[Tutorial](tutorial.html)
2460[Guide](guide.html)
2461"#;
2462
2463        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2464        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2465        let result = rule.check(&ctx).unwrap();
2466
2467        assert!(
2468            result.is_empty(),
2469            "Should find all markdown variants as source files. Got: {result:?}"
2470        );
2471    }
2472
2473    #[test]
2474    fn test_html_link_in_subdirectory() {
2475        // Should find markdown source in subdirectories
2476        let temp_dir = tempdir().unwrap();
2477        let base_path = temp_dir.path();
2478
2479        let docs_dir = base_path.join("docs");
2480        std::fs::create_dir(&docs_dir).unwrap();
2481        File::create(docs_dir.join("guide.md"))
2482            .unwrap()
2483            .write_all(b"# Guide")
2484            .unwrap();
2485
2486        let content = "[Guide](docs/guide.html)";
2487
2488        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2489        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2490        let result = rule.check(&ctx).unwrap();
2491
2492        assert!(result.is_empty(), "Should find markdown source in subdirectory");
2493    }
2494
2495    #[test]
2496    fn test_absolute_path_skipped_in_check() {
2497        // Test that absolute paths are skipped during link validation
2498        // This fixes the bug where /pkg/runtime was being flagged
2499        let temp_dir = tempdir().unwrap();
2500        let base_path = temp_dir.path();
2501
2502        let content = r#"
2503# Test Document
2504
2505[Go Runtime](/pkg/runtime)
2506[Go Runtime with Fragment](/pkg/runtime#section)
2507[API Docs](/api/v1/users)
2508[Blog Post](/blog/2024/release.html)
2509[React Hook](/react/hooks/use-state.html)
2510"#;
2511
2512        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2513        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2514        let result = rule.check(&ctx).unwrap();
2515
2516        // Should have NO warnings - all absolute paths should be skipped
2517        assert!(
2518            result.is_empty(),
2519            "Absolute paths should be skipped. Got warnings: {result:?}"
2520        );
2521    }
2522
2523    #[test]
2524    fn test_absolute_path_skipped_in_cross_file_check() {
2525        // Test that absolute paths are skipped in cross_file_check()
2526        use crate::workspace_index::WorkspaceIndex;
2527
2528        let rule = MD057ExistingRelativeLinks::new();
2529
2530        // Create an empty workspace index (no files exist)
2531        let workspace_index = WorkspaceIndex::new();
2532
2533        // Create file index with absolute path links (should be skipped)
2534        let mut file_index = FileIndex::new();
2535        file_index.add_cross_file_link(CrossFileLinkIndex {
2536            target_path: "/pkg/runtime.md".to_string(),
2537            fragment: "".to_string(),
2538            line: 5,
2539            column: 1,
2540        });
2541        file_index.add_cross_file_link(CrossFileLinkIndex {
2542            target_path: "/api/v1/users.md".to_string(),
2543            fragment: "section".to_string(),
2544            line: 10,
2545            column: 1,
2546        });
2547
2548        // Run cross-file check
2549        let warnings = rule
2550            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2551            .unwrap();
2552
2553        // Should have NO warnings - absolute paths should be skipped
2554        assert!(
2555            warnings.is_empty(),
2556            "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2557        );
2558    }
2559
2560    #[test]
2561    fn test_protocol_relative_url_not_skipped() {
2562        // Test that protocol-relative URLs (//example.com) are NOT skipped as absolute paths
2563        // They should still be caught by is_external_url() though
2564        let temp_dir = tempdir().unwrap();
2565        let base_path = temp_dir.path();
2566
2567        let content = r#"
2568# Test Document
2569
2570[External](//example.com/page)
2571[Another](//cdn.example.com/asset.js)
2572"#;
2573
2574        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2575        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2576        let result = rule.check(&ctx).unwrap();
2577
2578        // Should have NO warnings - protocol-relative URLs are external and should be skipped
2579        assert!(
2580            result.is_empty(),
2581            "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2582        );
2583    }
2584
2585    #[test]
2586    fn test_email_addresses_skipped() {
2587        // Test that email addresses without mailto: are skipped
2588        // These are clearly not file links (the @ symbol is definitive)
2589        let temp_dir = tempdir().unwrap();
2590        let base_path = temp_dir.path();
2591
2592        let content = r#"
2593# Test Document
2594
2595[Contact](user@example.com)
2596[Steering](steering@kubernetes.io)
2597[Support](john.doe+filter@company.co.uk)
2598[User](user_name@sub.domain.com)
2599"#;
2600
2601        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2602        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2603        let result = rule.check(&ctx).unwrap();
2604
2605        // Should have NO warnings - email addresses are clearly not file links and should be skipped
2606        assert!(
2607            result.is_empty(),
2608            "Email addresses should be skipped. Got warnings: {result:?}"
2609        );
2610    }
2611
2612    #[test]
2613    fn test_email_addresses_vs_file_paths() {
2614        // Test that email addresses (anything with @) are skipped
2615        // Note: File paths with @ are extremely rare, so we treat anything with @ as an email
2616        let temp_dir = tempdir().unwrap();
2617        let base_path = temp_dir.path();
2618
2619        let content = r#"
2620# Test Document
2621
2622[Email](user@example.com)  <!-- Should be skipped (email) -->
2623[Email2](steering@kubernetes.io)  <!-- Should be skipped (email) -->
2624[Email3](user@file.md)  <!-- Should be skipped (has @, treated as email) -->
2625"#;
2626
2627        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2628        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2629        let result = rule.check(&ctx).unwrap();
2630
2631        // All should be skipped - anything with @ is treated as an email
2632        assert!(
2633            result.is_empty(),
2634            "All email addresses should be skipped. Got: {result:?}"
2635        );
2636    }
2637
2638    #[test]
2639    fn test_diagnostic_position_accuracy() {
2640        // Test that diagnostics point to the URL, not the link text
2641        let temp_dir = tempdir().unwrap();
2642        let base_path = temp_dir.path();
2643
2644        // Position markers:     0         1         2         3
2645        //                       0123456789012345678901234567890123456789
2646        let content = "prefix [text](missing.md) suffix";
2647        //             The URL "missing.md" starts at 0-indexed position 14
2648        //             which is 1-indexed column 15, and ends at 0-indexed 24 (1-indexed column 25)
2649
2650        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2651        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2652        let result = rule.check(&ctx).unwrap();
2653
2654        assert_eq!(result.len(), 1, "Should have exactly one warning");
2655        assert_eq!(result[0].line, 1, "Should be on line 1");
2656        assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2657        assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2658    }
2659
2660    #[test]
2661    fn test_diagnostic_position_non_ascii_link() {
2662        // Issue #670: columns are character offsets, not byte offsets. The CJK
2663        // prefix is multi-byte in UTF-8, so a byte offset over-counts the column.
2664        let temp_dir = tempdir().unwrap();
2665        let base_path = temp_dir.path();
2666
2667        // Character columns: 1:你 2:好 3:你 4:好 5:[ 6:你 7:好 8:] 9:( 10:n ...
2668        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 10
2669        // and ends past character column 21, i.e. end_column 22.
2670        let content = "你好你好[你好](not-exist.md) bar";
2671
2672        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2673        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2674        let result = rule.check(&ctx).unwrap();
2675
2676        assert_eq!(result.len(), 1, "Should have exactly one warning");
2677        assert_eq!(result[0].line, 1, "Should be on line 1");
2678        assert_eq!(
2679            result[0].column, 10,
2680            "Column must be a character offset, not a byte offset"
2681        );
2682        assert_eq!(result[0].end_column, 22, "End column must be character-based");
2683    }
2684
2685    #[test]
2686    fn test_diagnostic_position_angle_brackets() {
2687        // Test position accuracy with angle bracket links
2688        let temp_dir = tempdir().unwrap();
2689        let base_path = temp_dir.path();
2690
2691        // Position markers:     0         1         2
2692        //                       012345678901234567890
2693        let content = "[link](<missing.md>)";
2694        //             The URL "missing.md" starts at 0-indexed position 8 (1-indexed column 9)
2695
2696        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2697        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2698        let result = rule.check(&ctx).unwrap();
2699
2700        assert_eq!(result.len(), 1, "Should have exactly one warning");
2701        assert_eq!(result[0].line, 1, "Should be on line 1");
2702        assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2703    }
2704
2705    #[test]
2706    fn test_diagnostic_position_multiline() {
2707        // Test that line numbers are correct for links on different lines
2708        let temp_dir = tempdir().unwrap();
2709        let base_path = temp_dir.path();
2710
2711        let content = r#"# Title
2712Some text on line 2
2713[link on line 3](missing1.md)
2714More text
2715[link on line 5](missing2.md)"#;
2716
2717        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2718        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2719        let result = rule.check(&ctx).unwrap();
2720
2721        assert_eq!(result.len(), 2, "Should have two warnings");
2722
2723        // First warning should be on line 3
2724        assert_eq!(result[0].line, 3, "First warning should be on line 3");
2725        assert!(result[0].message.contains("missing1.md"));
2726
2727        // Second warning should be on line 5
2728        assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2729        assert!(result[1].message.contains("missing2.md"));
2730    }
2731
2732    #[test]
2733    fn test_diagnostic_position_with_spaces() {
2734        // Test position with URLs that have spaces in parentheses
2735        let temp_dir = tempdir().unwrap();
2736        let base_path = temp_dir.path();
2737
2738        let content = "[link]( missing.md )";
2739        //             0123456789012345678901
2740        //             0-indexed position 8 is 'm' in 'missing.md' (after space and paren)
2741        //             which is 1-indexed column 9
2742
2743        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2744        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2745        let result = rule.check(&ctx).unwrap();
2746
2747        assert_eq!(result.len(), 1, "Should have exactly one warning");
2748        // The regex captures the URL without leading/trailing spaces
2749        assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2750    }
2751
2752    #[test]
2753    fn test_diagnostic_position_image() {
2754        // Test that image diagnostics also have correct positions
2755        let temp_dir = tempdir().unwrap();
2756        let base_path = temp_dir.path();
2757
2758        let content = "![alt text](missing.jpg)";
2759
2760        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2761        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2762        let result = rule.check(&ctx).unwrap();
2763
2764        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2765        assert_eq!(result[0].line, 1);
2766        // Images use start_col from the parser, which should point to the URL
2767        assert!(result[0].column > 0, "Should have valid column position");
2768        assert!(result[0].message.contains("missing.jpg"));
2769    }
2770
2771    #[test]
2772    fn test_diagnostic_position_non_ascii_image() {
2773        // Issue #670: image columns are character offsets, not byte offsets.
2774        let temp_dir = tempdir().unwrap();
2775        let base_path = temp_dir.path();
2776
2777        // Character columns: 1:你 2:好 3:你 4:好 5:! 6:[ 7:你 8:好 9:] 10:( 11:n ...
2778        // The image syntax starts at the '!' which is 1-indexed character column 5.
2779        let content = "你好你好![你好](not-exist.png)";
2780
2781        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2782        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2783        let result = rule.check(&ctx).unwrap();
2784
2785        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2786        assert_eq!(result[0].line, 1, "Should be on line 1");
2787        assert_eq!(
2788            result[0].column, 5,
2789            "Column must be a character offset, not a byte offset"
2790        );
2791        assert!(result[0].message.contains("not-exist.png"));
2792    }
2793
2794    #[test]
2795    fn test_diagnostic_position_non_ascii_reference_def() {
2796        // Issue #670: reference-definition columns are character offsets. A
2797        // multi-byte label shifts the URL's byte offset away from its character
2798        // column.
2799        let temp_dir = tempdir().unwrap();
2800        let base_path = temp_dir.path();
2801
2802        // Character columns: 1:[ 2:你 3:好 4:] 5:: 6:space 7:n ...
2803        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 7.
2804        let content = "[你好]: not-exist.md";
2805
2806        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2807        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2808        let result = rule.check(&ctx).unwrap();
2809
2810        assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
2811        assert_eq!(result[0].line, 1, "Should be on line 1");
2812        assert_eq!(
2813            result[0].column, 7,
2814            "Column must be a character offset, not a byte offset"
2815        );
2816        assert_eq!(result[0].end_column, 19, "End column must be character-based");
2817    }
2818
2819    #[test]
2820    fn test_wikilinks_skipped() {
2821        // Wikilinks should not trigger MD057 warnings
2822        // They use a different linking system (e.g., Obsidian, wiki software)
2823        let temp_dir = tempdir().unwrap();
2824        let base_path = temp_dir.path();
2825
2826        let content = r#"# Test Document
2827
2828[[Microsoft#Windows OS]]
2829[[SomePage]]
2830[[Page With Spaces]]
2831[[path/to/page#section]]
2832[[page|Display Text]]
2833
2834This is a [real missing link](missing.md) that should be flagged.
2835"#;
2836
2837        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2838        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2839        let result = rule.check(&ctx).unwrap();
2840
2841        // Should only warn about the regular markdown link, not wikilinks
2842        assert_eq!(
2843            result.len(),
2844            1,
2845            "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2846        );
2847        assert!(
2848            result[0].message.contains("missing.md"),
2849            "Warning should be for missing.md, not wikilinks"
2850        );
2851    }
2852
2853    #[test]
2854    fn test_wikilinks_not_added_to_index() {
2855        // Wikilinks should not be added to the cross-file link index
2856        let temp_dir = tempdir().unwrap();
2857        let base_path = temp_dir.path();
2858
2859        let content = r#"# Test Document
2860
2861[[Microsoft#Windows OS]]
2862[[SomePage#section]]
2863[Regular Link](other.md)
2864"#;
2865
2866        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2867        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2868
2869        let mut file_index = FileIndex::new();
2870        rule.contribute_to_index(&ctx, &mut file_index);
2871
2872        // Should only have the regular markdown link (if it's a markdown file)
2873        // Wikilinks should not be added
2874        let cross_file_links = &file_index.cross_file_links;
2875        assert_eq!(
2876            cross_file_links.len(),
2877            1,
2878            "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2879        );
2880        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2881    }
2882
2883    #[test]
2884    fn test_reference_definition_missing_file() {
2885        // Reference definitions [ref]: ./path.md should be checked
2886        let temp_dir = tempdir().unwrap();
2887        let base_path = temp_dir.path();
2888
2889        let content = r#"# Test Document
2890
2891[test]: ./missing.md
2892[example]: ./nonexistent.html
2893
2894Use [test] and [example] here.
2895"#;
2896
2897        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2898        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2899        let result = rule.check(&ctx).unwrap();
2900
2901        // Should have warnings for both reference definitions
2902        assert_eq!(
2903            result.len(),
2904            2,
2905            "Should have warnings for missing reference definition targets. Got: {result:?}"
2906        );
2907        assert!(
2908            result.iter().any(|w| w.message.contains("missing.md")),
2909            "Should warn about missing.md"
2910        );
2911        assert!(
2912            result.iter().any(|w| w.message.contains("nonexistent.html")),
2913            "Should warn about nonexistent.html"
2914        );
2915    }
2916
2917    #[test]
2918    fn test_reference_definition_existing_file() {
2919        // Reference definitions to existing files should NOT trigger warnings
2920        let temp_dir = tempdir().unwrap();
2921        let base_path = temp_dir.path();
2922
2923        // Create an existing file
2924        let exists_path = base_path.join("exists.md");
2925        File::create(&exists_path)
2926            .unwrap()
2927            .write_all(b"# Existing file")
2928            .unwrap();
2929
2930        let content = r#"# Test Document
2931
2932[test]: ./exists.md
2933
2934Use [test] here.
2935"#;
2936
2937        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2938        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2939        let result = rule.check(&ctx).unwrap();
2940
2941        // Should have NO warnings since the file exists
2942        assert!(
2943            result.is_empty(),
2944            "Should not warn about existing file. Got: {result:?}"
2945        );
2946    }
2947
2948    #[test]
2949    fn test_reference_definition_external_url_skipped() {
2950        // Reference definitions with external URLs should be skipped
2951        let temp_dir = tempdir().unwrap();
2952        let base_path = temp_dir.path();
2953
2954        let content = r#"# Test Document
2955
2956[google]: https://google.com
2957[example]: http://example.org
2958[mail]: mailto:test@example.com
2959[ftp]: ftp://files.example.com
2960[local]: ./missing.md
2961
2962Use [google], [example], [mail], [ftp], [local] here.
2963"#;
2964
2965        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2966        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2967        let result = rule.check(&ctx).unwrap();
2968
2969        // Should only warn about the local missing file, not external URLs
2970        assert_eq!(
2971            result.len(),
2972            1,
2973            "Should only warn about local missing file. Got: {result:?}"
2974        );
2975        assert!(
2976            result[0].message.contains("missing.md"),
2977            "Warning should be for missing.md"
2978        );
2979    }
2980
2981    #[test]
2982    fn test_reference_definition_fragment_only_skipped() {
2983        // Reference definitions with fragment-only URLs should be skipped
2984        let temp_dir = tempdir().unwrap();
2985        let base_path = temp_dir.path();
2986
2987        let content = r#"# Test Document
2988
2989[section]: #my-section
2990
2991Use [section] here.
2992"#;
2993
2994        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2995        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2996        let result = rule.check(&ctx).unwrap();
2997
2998        // Should have NO warnings for fragment-only links
2999        assert!(
3000            result.is_empty(),
3001            "Should not warn about fragment-only reference. Got: {result:?}"
3002        );
3003    }
3004
3005    #[test]
3006    fn test_reference_definition_column_position() {
3007        // Test that column position points to the URL in the reference definition
3008        let temp_dir = tempdir().unwrap();
3009        let base_path = temp_dir.path();
3010
3011        // Position markers:     0         1         2
3012        //                       0123456789012345678901
3013        let content = "[ref]: ./missing.md";
3014        //             The URL "./missing.md" starts at 0-indexed position 7
3015        //             which is 1-indexed column 8
3016
3017        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3018        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3019        let result = rule.check(&ctx).unwrap();
3020
3021        assert_eq!(result.len(), 1, "Should have exactly one warning");
3022        assert_eq!(result[0].line, 1, "Should be on line 1");
3023        assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3024    }
3025
3026    #[test]
3027    fn test_reference_definition_html_with_md_source() {
3028        // Reference definitions to .html files should pass if corresponding .md source exists
3029        let temp_dir = tempdir().unwrap();
3030        let base_path = temp_dir.path();
3031
3032        // Create guide.md (source file)
3033        let md_file = base_path.join("guide.md");
3034        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3035
3036        let content = r#"# Test Document
3037
3038[guide]: ./guide.html
3039[missing]: ./missing.html
3040
3041Use [guide] and [missing] here.
3042"#;
3043
3044        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3045        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3046        let result = rule.check(&ctx).unwrap();
3047
3048        // guide.html passes (guide.md exists), missing.html fails
3049        assert_eq!(
3050            result.len(),
3051            1,
3052            "Should only warn about missing source. Got: {result:?}"
3053        );
3054        assert!(result[0].message.contains("missing.html"));
3055    }
3056
3057    #[test]
3058    fn test_reference_definition_url_encoded() {
3059        // Reference definitions with URL-encoded paths should be decoded before checking
3060        let temp_dir = tempdir().unwrap();
3061        let base_path = temp_dir.path();
3062
3063        // Create a file with spaces in the name
3064        let file_with_spaces = base_path.join("file with spaces.md");
3065        File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3066
3067        let content = r#"# Test Document
3068
3069[spaces]: ./file%20with%20spaces.md
3070[missing]: ./missing%20file.md
3071
3072Use [spaces] and [missing] here.
3073"#;
3074
3075        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3076        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3077        let result = rule.check(&ctx).unwrap();
3078
3079        // Should only warn about the missing file
3080        assert_eq!(
3081            result.len(),
3082            1,
3083            "Should only warn about missing URL-encoded file. Got: {result:?}"
3084        );
3085        assert!(result[0].message.contains("missing%20file.md"));
3086    }
3087
3088    #[test]
3089    fn test_inline_and_reference_both_checked() {
3090        // Both inline links and reference definitions should be checked
3091        let temp_dir = tempdir().unwrap();
3092        let base_path = temp_dir.path();
3093
3094        let content = r#"# Test Document
3095
3096[inline link](./inline-missing.md)
3097[ref]: ./ref-missing.md
3098
3099Use [ref] here.
3100"#;
3101
3102        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3103        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3104        let result = rule.check(&ctx).unwrap();
3105
3106        // Should warn about both the inline link and the reference definition
3107        assert_eq!(
3108            result.len(),
3109            2,
3110            "Should warn about both inline and reference links. Got: {result:?}"
3111        );
3112        assert!(
3113            result.iter().any(|w| w.message.contains("inline-missing.md")),
3114            "Should warn about inline-missing.md"
3115        );
3116        assert!(
3117            result.iter().any(|w| w.message.contains("ref-missing.md")),
3118            "Should warn about ref-missing.md"
3119        );
3120    }
3121
3122    #[test]
3123    fn test_footnote_definitions_not_flagged() {
3124        // Regression test for issue #286: footnote definitions should not be
3125        // treated as reference definitions and flagged as broken links
3126        let rule = MD057ExistingRelativeLinks::default();
3127
3128        let content = r#"# Title
3129
3130A footnote[^1].
3131
3132[^1]: [link](https://www.google.com).
3133"#;
3134
3135        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3136        let result = rule.check(&ctx).unwrap();
3137
3138        assert!(
3139            result.is_empty(),
3140            "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3141        );
3142    }
3143
3144    #[test]
3145    fn test_footnote_with_relative_link_inside() {
3146        // Footnotes containing relative links should not be checked
3147        // (the footnote content is not a URL, it's content that may contain links)
3148        let rule = MD057ExistingRelativeLinks::default();
3149
3150        let content = r#"# Title
3151
3152See the footnote[^1].
3153
3154[^1]: Check out [this file](./existing.md) for more info.
3155[^2]: Also see [missing](./does-not-exist.md).
3156"#;
3157
3158        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3159        let result = rule.check(&ctx).unwrap();
3160
3161        // The inline links INSIDE footnotes should be checked (./existing.md, ./does-not-exist.md)
3162        // but the footnote definition itself should not be treated as a reference definition
3163        // Note: This test verifies that [^1]: and [^2]: are not parsed as ref defs with
3164        // URLs like "[this file](./existing.md)" or "[missing](./does-not-exist.md)"
3165        for warning in &result {
3166            assert!(
3167                !warning.message.contains("[this file]"),
3168                "Footnote content should not be treated as URL: {warning:?}"
3169            );
3170            assert!(
3171                !warning.message.contains("[missing]"),
3172                "Footnote content should not be treated as URL: {warning:?}"
3173            );
3174        }
3175    }
3176
3177    #[test]
3178    fn test_mixed_footnotes_and_reference_definitions() {
3179        // Ensure regular reference definitions are still checked while footnotes are skipped
3180        let temp_dir = tempdir().unwrap();
3181        let base_path = temp_dir.path();
3182
3183        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3184
3185        let content = r#"# Title
3186
3187A footnote[^1] and a [ref link][myref].
3188
3189[^1]: This is a footnote with [link](https://example.com).
3190
3191[myref]: ./missing-file.md "This should be checked"
3192"#;
3193
3194        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3195        let result = rule.check(&ctx).unwrap();
3196
3197        // Should only warn about the regular reference definition, not the footnote
3198        assert_eq!(
3199            result.len(),
3200            1,
3201            "Should only warn about the regular reference definition. Got: {result:?}"
3202        );
3203        assert!(
3204            result[0].message.contains("missing-file.md"),
3205            "Should warn about missing-file.md in reference definition"
3206        );
3207    }
3208
3209    #[test]
3210    fn test_absolute_links_ignore_by_default() {
3211        // By default, absolute links are ignored (not validated)
3212        let temp_dir = tempdir().unwrap();
3213        let base_path = temp_dir.path();
3214
3215        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3216
3217        let content = r#"# Links
3218
3219[API docs](/api/v1/users)
3220[Blog post](/blog/2024/release.html)
3221![Logo](/assets/logo.png)
3222
3223[ref]: /docs/reference.md
3224"#;
3225
3226        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3227        let result = rule.check(&ctx).unwrap();
3228
3229        // No warnings - absolute links are ignored by default
3230        assert!(
3231            result.is_empty(),
3232            "Absolute links should be ignored by default. Got: {result:?}"
3233        );
3234    }
3235
3236    #[test]
3237    fn test_absolute_links_warn_config() {
3238        // When configured to warn, absolute links should generate warnings
3239        let temp_dir = tempdir().unwrap();
3240        let base_path = temp_dir.path();
3241
3242        let config = MD057Config {
3243            absolute_links: AbsoluteLinksOption::Warn,
3244            ..Default::default()
3245        };
3246        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3247
3248        let content = r#"# Links
3249
3250[API docs](/api/v1/users)
3251[Blog post](/blog/2024/release.html)
3252"#;
3253
3254        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3255        let result = rule.check(&ctx).unwrap();
3256
3257        // Should have 2 warnings for the 2 absolute links
3258        assert_eq!(
3259            result.len(),
3260            2,
3261            "Should warn about both absolute links. Got: {result:?}"
3262        );
3263        assert!(
3264            result[0].message.contains("cannot be validated locally"),
3265            "Warning should explain why: {}",
3266            result[0].message
3267        );
3268        assert!(
3269            result[0].message.contains("/api/v1/users"),
3270            "Warning should include the link path"
3271        );
3272    }
3273
3274    #[test]
3275    fn test_absolute_links_warn_images() {
3276        // Images with absolute paths should also warn when configured
3277        let temp_dir = tempdir().unwrap();
3278        let base_path = temp_dir.path();
3279
3280        let config = MD057Config {
3281            absolute_links: AbsoluteLinksOption::Warn,
3282            ..Default::default()
3283        };
3284        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3285
3286        let content = r#"# Images
3287
3288![Logo](/assets/logo.png)
3289"#;
3290
3291        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3292        let result = rule.check(&ctx).unwrap();
3293
3294        assert_eq!(
3295            result.len(),
3296            1,
3297            "Should warn about absolute image path. Got: {result:?}"
3298        );
3299        assert!(
3300            result[0].message.contains("/assets/logo.png"),
3301            "Warning should include the image path"
3302        );
3303    }
3304
3305    #[test]
3306    fn test_absolute_links_warn_reference_definitions() {
3307        // Reference definitions with absolute paths should also warn when configured
3308        let temp_dir = tempdir().unwrap();
3309        let base_path = temp_dir.path();
3310
3311        let config = MD057Config {
3312            absolute_links: AbsoluteLinksOption::Warn,
3313            ..Default::default()
3314        };
3315        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3316
3317        let content = r#"# Reference
3318
3319See the [docs][ref].
3320
3321[ref]: /docs/reference.md
3322"#;
3323
3324        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3325        let result = rule.check(&ctx).unwrap();
3326
3327        assert_eq!(
3328            result.len(),
3329            1,
3330            "Should warn about absolute reference definition. Got: {result:?}"
3331        );
3332        assert!(
3333            result[0].message.contains("/docs/reference.md"),
3334            "Warning should include the reference path"
3335        );
3336    }
3337
3338    #[test]
3339    fn test_search_paths_inline_link() {
3340        let temp_dir = tempdir().unwrap();
3341        let base_path = temp_dir.path();
3342
3343        // Create an "assets" directory with an image
3344        let assets_dir = base_path.join("assets");
3345        std::fs::create_dir_all(&assets_dir).unwrap();
3346        std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3347
3348        let config = MD057Config {
3349            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3350            ..Default::default()
3351        };
3352        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3353
3354        let content = "# Test\n\n[Photo](photo.png)\n";
3355        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3356        let result = rule.check(&ctx).unwrap();
3357
3358        assert!(
3359            result.is_empty(),
3360            "Should find photo.png via search-paths. Got: {result:?}"
3361        );
3362    }
3363
3364    #[test]
3365    fn test_search_paths_image() {
3366        let temp_dir = tempdir().unwrap();
3367        let base_path = temp_dir.path();
3368
3369        let assets_dir = base_path.join("attachments");
3370        std::fs::create_dir_all(&assets_dir).unwrap();
3371        std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3372
3373        let config = MD057Config {
3374            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3375            ..Default::default()
3376        };
3377        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3378
3379        let content = "# Test\n\n![Diagram](diagram.svg)\n";
3380        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3381        let result = rule.check(&ctx).unwrap();
3382
3383        assert!(
3384            result.is_empty(),
3385            "Should find diagram.svg via search-paths. Got: {result:?}"
3386        );
3387    }
3388
3389    #[test]
3390    fn test_search_paths_reference_definition() {
3391        let temp_dir = tempdir().unwrap();
3392        let base_path = temp_dir.path();
3393
3394        let assets_dir = base_path.join("images");
3395        std::fs::create_dir_all(&assets_dir).unwrap();
3396        std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3397
3398        let config = MD057Config {
3399            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3400            ..Default::default()
3401        };
3402        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3403
3404        let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3405        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3406        let result = rule.check(&ctx).unwrap();
3407
3408        assert!(
3409            result.is_empty(),
3410            "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3411        );
3412    }
3413
3414    #[test]
3415    fn test_search_paths_still_warns_when_truly_missing() {
3416        let temp_dir = tempdir().unwrap();
3417        let base_path = temp_dir.path();
3418
3419        let assets_dir = base_path.join("assets");
3420        std::fs::create_dir_all(&assets_dir).unwrap();
3421
3422        let config = MD057Config {
3423            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3424            ..Default::default()
3425        };
3426        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3427
3428        let content = "# Test\n\n![Missing](nonexistent.png)\n";
3429        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3430        let result = rule.check(&ctx).unwrap();
3431
3432        assert_eq!(
3433            result.len(),
3434            1,
3435            "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3436        );
3437    }
3438
3439    #[test]
3440    fn test_search_paths_nonexistent_directory() {
3441        let temp_dir = tempdir().unwrap();
3442        let base_path = temp_dir.path();
3443
3444        let config = MD057Config {
3445            search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3446            ..Default::default()
3447        };
3448        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3449
3450        let content = "# Test\n\n![Missing](photo.png)\n";
3451        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3452        let result = rule.check(&ctx).unwrap();
3453
3454        assert_eq!(
3455            result.len(),
3456            1,
3457            "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3458        );
3459    }
3460
3461    #[test]
3462    fn test_obsidian_attachment_folder_named() {
3463        let temp_dir = tempdir().unwrap();
3464        let vault = temp_dir.path().join("vault");
3465        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3466        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3467        std::fs::create_dir_all(vault.join("notes")).unwrap();
3468
3469        std::fs::write(
3470            vault.join(".obsidian/app.json"),
3471            r#"{"attachmentFolderPath": "Attachments"}"#,
3472        )
3473        .unwrap();
3474        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3475
3476        let notes_dir = vault.join("notes");
3477        let source_file = notes_dir.join("test.md");
3478        std::fs::write(&source_file, "# Test\n\n![Photo](photo.png)\n").unwrap();
3479
3480        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3481
3482        let content = "# Test\n\n![Photo](photo.png)\n";
3483        let ctx =
3484            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3485        let result = rule.check(&ctx).unwrap();
3486
3487        assert!(
3488            result.is_empty(),
3489            "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3490        );
3491    }
3492
3493    #[test]
3494    fn test_obsidian_attachment_same_folder_as_file() {
3495        let temp_dir = tempdir().unwrap();
3496        let vault = temp_dir.path().join("vault-rf");
3497        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3498        std::fs::create_dir_all(vault.join("notes")).unwrap();
3499
3500        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3501
3502        // Image in the same directory as the file — default behavior, no extra search needed
3503        let notes_dir = vault.join("notes");
3504        let source_file = notes_dir.join("test.md");
3505        std::fs::write(&source_file, "placeholder").unwrap();
3506        std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3507
3508        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3509
3510        let content = "# Test\n\n![Photo](photo.png)\n";
3511        let ctx =
3512            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3513        let result = rule.check(&ctx).unwrap();
3514
3515        assert!(
3516            result.is_empty(),
3517            "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3518        );
3519    }
3520
3521    #[test]
3522    fn test_obsidian_not_triggered_without_obsidian_flavor() {
3523        let temp_dir = tempdir().unwrap();
3524        let vault = temp_dir.path().join("vault-nf");
3525        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3526        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3527        std::fs::create_dir_all(vault.join("notes")).unwrap();
3528
3529        std::fs::write(
3530            vault.join(".obsidian/app.json"),
3531            r#"{"attachmentFolderPath": "Attachments"}"#,
3532        )
3533        .unwrap();
3534        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3535
3536        let notes_dir = vault.join("notes");
3537        let source_file = notes_dir.join("test.md");
3538        std::fs::write(&source_file, "placeholder").unwrap();
3539
3540        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3541
3542        let content = "# Test\n\n![Photo](photo.png)\n";
3543        // Standard flavor — NOT Obsidian
3544        let ctx =
3545            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3546        let result = rule.check(&ctx).unwrap();
3547
3548        assert_eq!(
3549            result.len(),
3550            1,
3551            "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3552        );
3553    }
3554
3555    #[test]
3556    fn test_search_paths_combined_with_obsidian() {
3557        let temp_dir = tempdir().unwrap();
3558        let vault = temp_dir.path().join("vault-combo");
3559        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3560        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3561        std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3562        std::fs::create_dir_all(vault.join("notes")).unwrap();
3563
3564        std::fs::write(
3565            vault.join(".obsidian/app.json"),
3566            r#"{"attachmentFolderPath": "Attachments"}"#,
3567        )
3568        .unwrap();
3569        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3570        std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3571
3572        let notes_dir = vault.join("notes");
3573        let source_file = notes_dir.join("test.md");
3574        std::fs::write(&source_file, "placeholder").unwrap();
3575
3576        let extra_assets_dir = vault.join("extra-assets");
3577        let config = MD057Config {
3578            search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3579            ..Default::default()
3580        };
3581        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&notes_dir);
3582
3583        // Both links should resolve: photo.png via Obsidian, diagram.svg via search-paths
3584        let content = "# Test\n\n![Photo](photo.png)\n\n![Diagram](diagram.svg)\n";
3585        let ctx =
3586            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3587        let result = rule.check(&ctx).unwrap();
3588
3589        assert!(
3590            result.is_empty(),
3591            "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3592        );
3593    }
3594
3595    #[test]
3596    fn test_obsidian_attachment_subfolder_under_file() {
3597        let temp_dir = tempdir().unwrap();
3598        let vault = temp_dir.path().join("vault-sub");
3599        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3600        std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3601
3602        std::fs::write(
3603            vault.join(".obsidian/app.json"),
3604            r#"{"attachmentFolderPath": "./assets"}"#,
3605        )
3606        .unwrap();
3607        std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3608
3609        let notes_dir = vault.join("notes");
3610        let source_file = notes_dir.join("test.md");
3611        std::fs::write(&source_file, "placeholder").unwrap();
3612
3613        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3614
3615        let content = "# Test\n\n![Photo](photo.png)\n";
3616        let ctx =
3617            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3618        let result = rule.check(&ctx).unwrap();
3619
3620        assert!(
3621            result.is_empty(),
3622            "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3623        );
3624    }
3625
3626    #[test]
3627    fn test_obsidian_attachment_vault_root() {
3628        let temp_dir = tempdir().unwrap();
3629        let vault = temp_dir.path().join("vault-root");
3630        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3631        std::fs::create_dir_all(vault.join("notes")).unwrap();
3632
3633        // Empty string = vault root
3634        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3635        std::fs::write(vault.join("photo.png"), "fake").unwrap();
3636
3637        let notes_dir = vault.join("notes");
3638        let source_file = notes_dir.join("test.md");
3639        std::fs::write(&source_file, "placeholder").unwrap();
3640
3641        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3642
3643        let content = "# Test\n\n![Photo](photo.png)\n";
3644        let ctx =
3645            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3646        let result = rule.check(&ctx).unwrap();
3647
3648        assert!(
3649            result.is_empty(),
3650            "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3651        );
3652    }
3653
3654    #[test]
3655    fn test_search_paths_multiple_directories() {
3656        let temp_dir = tempdir().unwrap();
3657        let base_path = temp_dir.path();
3658
3659        let dir_a = base_path.join("dir-a");
3660        let dir_b = base_path.join("dir-b");
3661        std::fs::create_dir_all(&dir_a).unwrap();
3662        std::fs::create_dir_all(&dir_b).unwrap();
3663        std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3664        std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3665
3666        let config = MD057Config {
3667            search_paths: vec![
3668                dir_a.to_string_lossy().into_owned(),
3669                dir_b.to_string_lossy().into_owned(),
3670            ],
3671            ..Default::default()
3672        };
3673        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3674
3675        let content = "# Test\n\n![A](alpha.png)\n\n![B](beta.png)\n";
3676        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3677        let result = rule.check(&ctx).unwrap();
3678
3679        assert!(
3680            result.is_empty(),
3681            "Should find files across multiple search paths. Got: {result:?}"
3682        );
3683    }
3684
3685    #[test]
3686    fn test_cross_file_check_with_search_paths() {
3687        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3688
3689        let temp_dir = tempdir().unwrap();
3690        let base_path = temp_dir.path();
3691
3692        // Create docs directory with a markdown target in a search path
3693        let docs_dir = base_path.join("docs");
3694        std::fs::create_dir_all(&docs_dir).unwrap();
3695        std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3696
3697        let config = MD057Config {
3698            search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3699            ..Default::default()
3700        };
3701        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3702
3703        let file_path = base_path.join("README.md");
3704        std::fs::write(&file_path, "# Readme\n").unwrap();
3705
3706        let mut file_index = FileIndex::default();
3707        file_index.cross_file_links.push(CrossFileLinkIndex {
3708            target_path: "guide.md".to_string(),
3709            fragment: String::new(),
3710            line: 3,
3711            column: 1,
3712        });
3713
3714        let workspace_index = WorkspaceIndex::new();
3715
3716        let result = rule
3717            .cross_file_check(&file_path, &file_index, &workspace_index)
3718            .unwrap();
3719
3720        assert!(
3721            result.is_empty(),
3722            "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3723        );
3724    }
3725
3726    #[test]
3727    fn test_cross_file_check_with_obsidian_flavor() {
3728        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3729
3730        let temp_dir = tempdir().unwrap();
3731        let vault = temp_dir.path().join("vault-xf");
3732        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3733        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3734        std::fs::create_dir_all(vault.join("notes")).unwrap();
3735
3736        std::fs::write(
3737            vault.join(".obsidian/app.json"),
3738            r#"{"attachmentFolderPath": "Attachments"}"#,
3739        )
3740        .unwrap();
3741        std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3742
3743        let notes_dir = vault.join("notes");
3744        let file_path = notes_dir.join("test.md");
3745        std::fs::write(&file_path, "placeholder").unwrap();
3746
3747        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3748            .with_path(&notes_dir)
3749            .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3750
3751        let mut file_index = FileIndex::default();
3752        file_index.cross_file_links.push(CrossFileLinkIndex {
3753            target_path: "ref.md".to_string(),
3754            fragment: String::new(),
3755            line: 3,
3756            column: 1,
3757        });
3758
3759        let workspace_index = WorkspaceIndex::new();
3760
3761        let result = rule
3762            .cross_file_check(&file_path, &file_index, &workspace_index)
3763            .unwrap();
3764
3765        assert!(
3766            result.is_empty(),
3767            "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3768        );
3769    }
3770
3771    #[test]
3772    fn test_check_clears_stale_cache() {
3773        // Verify that check() resets the file existence cache so stale entries from
3774        // a previous lint cycle do not suppress valid warnings.
3775        let temp_dir = tempdir().unwrap();
3776        let base_path = temp_dir.path();
3777
3778        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3779
3780        // Seed the cache with a stale "exists" entry for a file that is NOT on disk.
3781        let phantom_path = base_path.join("phantom.md");
3782        {
3783            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3784            cache.insert(phantom_path.clone(), true);
3785        }
3786
3787        let content = "[phantom](phantom.md)\n";
3788        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3789        let warnings = rule.check(&ctx).unwrap();
3790
3791        // check() must reset the cache; stale "exists=true" must not suppress the warning.
3792        assert_eq!(
3793            warnings.len(),
3794            1,
3795            "check() should report missing file after clearing stale cache. Got: {warnings:?}"
3796        );
3797        assert!(warnings[0].message.contains("phantom.md"));
3798    }
3799
3800    #[test]
3801    fn test_check_does_not_carry_over_cache_between_runs() {
3802        // Two consecutive check() calls should each start with a fresh cache.
3803        let temp_dir = tempdir().unwrap();
3804        let base_path = temp_dir.path();
3805
3806        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3807
3808        let content = "[missing](nonexistent.md)\n";
3809        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3810
3811        // First run: file doesn't exist — warning expected.
3812        let warnings_1 = rule.check(&ctx).unwrap();
3813        assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3814
3815        // Inject a stale "exists = true" entry for the resolved path.
3816        let nonexistent_path = base_path.join("nonexistent.md");
3817        {
3818            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3819            cache.insert(nonexistent_path.clone(), true);
3820        }
3821
3822        // Second run: cache says file exists, but check() should reset it first.
3823        let warnings_2 = rule.check(&ctx).unwrap();
3824        assert_eq!(
3825            warnings_2.len(),
3826            1,
3827            "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
3828        );
3829    }
3830
3831    // --- Bug #631: duplicate warnings for broken relative links ---
3832
3833    /// Regression test: a single broken relative link must produce exactly one
3834    /// warning across both check() and cross_file_check(). Previously, each
3835    /// code path emitted an identical warning independently, causing duplicates.
3836    #[test]
3837    fn test_no_duplicate_warnings_for_broken_relative_link() {
3838        use crate::workspace_index::WorkspaceIndex;
3839
3840        let temp_dir = tempdir().unwrap();
3841        let base_path = temp_dir.path();
3842
3843        // The broken link target does NOT exist on disk.
3844        let source_file = base_path.join("index.md");
3845        std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
3846
3847        let content = "[broken](does/not/exist.md)\n";
3848
3849        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3850
3851        // Collect warnings from check() (per-file path)
3852        let ctx = crate::lint_context::LintContext::new(
3853            content,
3854            crate::config::MarkdownFlavor::Standard,
3855            Some(source_file.clone()),
3856        );
3857        let check_warnings = rule.check(&ctx).unwrap();
3858
3859        // Collect warnings from cross_file_check() (workspace-index path)
3860        let mut file_index = FileIndex::new();
3861        rule.contribute_to_index(&ctx, &mut file_index);
3862        let workspace_index = WorkspaceIndex::new();
3863        let cross_warnings = rule
3864            .cross_file_check(&source_file, &file_index, &workspace_index)
3865            .unwrap();
3866
3867        let total = check_warnings.len() + cross_warnings.len();
3868        assert_eq!(
3869            total, 1,
3870            "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
3871             check={check_warnings:?}, cross={cross_warnings:?}"
3872        );
3873    }
3874
3875    // --- Bug #632: absolute directory links incorrectly flagged ---
3876
3877    /// With absolute-links = "relative_to_roots", links to existing targets must
3878    /// be accepted for all four cases: {relative, absolute} x {file, directory}.
3879    #[test]
3880    fn test_absolute_dir_link_accepted_relative_to_roots() {
3881        let temp_dir = tempdir().unwrap();
3882        let root = temp_dir.path();
3883
3884        // Create directory `d` with a file inside (but no index.md)
3885        let dir_d = root.join("d");
3886        std::fs::create_dir_all(&dir_d).unwrap();
3887        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3888
3889        // Content exercises all four matrix cells:
3890        //   relative file, relative dir, absolute file, absolute dir
3891        let content = "\
3892[absolute dir](/d)\n\
3893[relative dir](d)\n\
3894[absolute file](/d/foo.md)\n\
3895[relative file](d/foo.md)\n";
3896
3897        let config = MD057Config {
3898            absolute_links: AbsoluteLinksOption::RelativeToRoots,
3899            roots: vec![],
3900            ..Default::default()
3901        };
3902        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3903
3904        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3905        let result = rule.check(&ctx).unwrap();
3906
3907        assert!(
3908            result.is_empty(),
3909            "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
3910        );
3911    }
3912
3913    /// A directory link with a trailing slash and no index.md should be reported
3914    /// as invalid under relative_to_roots (docs-convention: trailing slash implies index.md).
3915    #[test]
3916    fn test_absolute_trailing_slash_dir_link_requires_index() {
3917        let temp_dir = tempdir().unwrap();
3918        let root = temp_dir.path();
3919
3920        // Create directory `d` WITHOUT index.md
3921        let dir_d = root.join("d");
3922        std::fs::create_dir_all(&dir_d).unwrap();
3923        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3924
3925        // Trailing slash signals "this is a directory index" — index.md must exist.
3926        let content = "[dir with slash](/d/)\n";
3927
3928        let config = MD057Config {
3929            absolute_links: AbsoluteLinksOption::RelativeToRoots,
3930            roots: vec![],
3931            ..Default::default()
3932        };
3933        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3934
3935        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3936        let result = rule.check(&ctx).unwrap();
3937
3938        assert_eq!(
3939            result.len(),
3940            1,
3941            "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
3942        );
3943    }
3944
3945    /// The docs_dir (MkDocs) variant must still flag a directory link when index.md
3946    /// is absent. This is tested via the full check() path with RelativeToDocs config
3947    /// and a real mkdocs.yml pointing at a docs dir that contains the directory target.
3948    #[test]
3949    fn test_docs_dir_variant_still_enforces_index_md() {
3950        let temp_dir = tempdir().unwrap();
3951        let root = temp_dir.path();
3952
3953        // Create a minimal mkdocs.yml pointing at a "docs" directory
3954        std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
3955
3956        // Create docs/section/ WITHOUT index.md
3957        let docs_dir = root.join("docs");
3958        std::fs::create_dir_all(&docs_dir).unwrap();
3959        let section_dir = docs_dir.join("section");
3960        std::fs::create_dir_all(&section_dir).unwrap();
3961        std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
3962
3963        // Create the source markdown file inside docs/
3964        let source_file = docs_dir.join("index.md");
3965        std::fs::write(&source_file, "[sec](/section)\n").unwrap();
3966
3967        let config = MD057Config {
3968            absolute_links: AbsoluteLinksOption::RelativeToDocs,
3969            ..Default::default()
3970        };
3971        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
3972
3973        let content = "[sec](/section)\n";
3974        let ctx = crate::lint_context::LintContext::new(
3975            content,
3976            crate::config::MarkdownFlavor::Standard,
3977            Some(source_file.clone()),
3978        );
3979        let result = rule.check(&ctx).unwrap();
3980
3981        // MkDocs enforces index.md for directory links, so this should be flagged.
3982        assert_eq!(
3983            result.len(),
3984            1,
3985            "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
3986        );
3987        assert!(
3988            result[0].message.contains("index.md") || result[0].message.contains("section"),
3989            "Message should mention the directory or missing index.md: {}",
3990            result[0].message
3991        );
3992    }
3993
3994    /// Regression test for the edge case where a trailing-slash directory URL has a
3995    /// fragment suffix (e.g. `/guide/#intro`). After stripping the fragment, the
3996    /// decoded path is `guide/` (ends with `/`), but `is_directory_link` was computed
3997    /// from `url.ends_with('/')` which is false when the URL ends with `#intro`.
3998    /// The fix must still treat such links as directory links and require index.md.
3999    #[test]
4000    fn test_trailing_slash_with_fragment_treated_as_directory_link() {
4001        let temp_dir = tempdir().unwrap();
4002        let root = temp_dir.path();
4003
4004        // Create directory `guide` WITHOUT index.md
4005        let guide_dir = root.join("guide");
4006        std::fs::create_dir_all(&guide_dir).unwrap();
4007        std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4008
4009        // /guide/#intro has a trailing slash before the fragment — must require index.md
4010        let content = "[guide with fragment](/guide/#intro)\n";
4011
4012        let config = MD057Config {
4013            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4014            roots: vec![],
4015            ..Default::default()
4016        };
4017        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4018        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4019        let result = rule.check(&ctx).unwrap();
4020
4021        assert_eq!(
4022            result.len(),
4023            1,
4024            "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
4025        );
4026    }
4027}
4028
4029#[cfg(test)]
4030mod self_referential_links_tests {
4031    use super::*;
4032    use tempfile::tempdir;
4033
4034    /// A document written to `dir/<name>`, checked as itself.
4035    fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4036        let source_file = dir.join(name);
4037        std::fs::write(&source_file, content).unwrap();
4038        let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4039        let ctx =
4040            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4041        rule.check(&ctx).unwrap()
4042    }
4043
4044    fn enabled() -> MD057Config {
4045        MD057Config {
4046            self_referential_links: true,
4047            ..Default::default()
4048        }
4049    }
4050
4051    #[test]
4052    fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4053        let temp_dir = tempdir().unwrap();
4054        let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4055        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4056
4057        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4058        assert_eq!(
4059            result[0].message,
4060            "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4061        );
4062        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4063        assert_eq!(fix.replacement, "#level-2-heading");
4064        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4065    }
4066
4067    #[test]
4068    fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4069        let temp_dir = tempdir().unwrap();
4070        let content = "# Title\n\nSee [this file](test.md).\n";
4071        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4072
4073        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4074        assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4075        assert!(
4076            result[0].fix.is_none(),
4077            "Dropping the link would change the document, so there is no fix"
4078        );
4079    }
4080
4081    #[test]
4082    fn test_the_check_is_off_by_default() {
4083        let temp_dir = tempdir().unwrap();
4084        let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4085        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4086
4087        assert!(result.is_empty(), "Off by default. Got: {result:?}");
4088    }
4089
4090    #[test]
4091    fn test_a_link_to_another_file_is_left_alone() {
4092        let temp_dir = tempdir().unwrap();
4093        std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4094        let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4095        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4096
4097        assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4098    }
4099
4100    #[test]
4101    fn test_a_self_link_written_with_traversal_reports_once() {
4102        let temp_dir = tempdir().unwrap();
4103        let sub_dir = temp_dir.path().join("sub");
4104        std::fs::create_dir_all(&sub_dir).unwrap();
4105
4106        let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4107        let config = MD057Config {
4108            self_referential_links: true,
4109            compact_paths: true,
4110            ..Default::default()
4111        };
4112        let result = check_as_file(&sub_dir, "test.md", content, config);
4113
4114        assert_eq!(
4115            result.len(),
4116            1,
4117            "A compacted path would still be a link back to this file. Got: {result:?}"
4118        );
4119        assert_eq!(
4120            result[0].message,
4121            "Relative link '../sub/test.md' points to the file it is in"
4122        );
4123    }
4124
4125    #[test]
4126    fn test_compact_paths_still_reports_a_link_to_another_file() {
4127        let temp_dir = tempdir().unwrap();
4128        let sub_dir = temp_dir.path().join("sub");
4129        std::fs::create_dir_all(&sub_dir).unwrap();
4130        std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4131
4132        let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4133        let config = MD057Config {
4134            self_referential_links: true,
4135            compact_paths: true,
4136            ..Default::default()
4137        };
4138        let result = check_as_file(&sub_dir, "test.md", content, config);
4139
4140        assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4141        assert_eq!(
4142            result[0].message,
4143            "Relative link '../sub/other.md' can be simplified to 'other.md'"
4144        );
4145    }
4146
4147    #[test]
4148    fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4149        let temp_dir = tempdir().unwrap();
4150        let content = "# Title\n\nSee [this file](test#title).\n";
4151        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4152
4153        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4154        assert_eq!(
4155            result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4156            Some("#title"),
4157            "Got: {result:?}"
4158        );
4159    }
4160
4161    #[test]
4162    fn test_a_reference_definition_pointing_at_its_own_file() {
4163        let temp_dir = tempdir().unwrap();
4164        let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4165        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4166
4167        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4168        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4169        assert_eq!(fix.replacement, "#level-2-heading");
4170        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4171    }
4172
4173    #[test]
4174    fn test_a_reference_definition_whose_label_repeats_the_destination() {
4175        let temp_dir = tempdir().unwrap();
4176        let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4177        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4178
4179        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4180        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4181        // The label reads the same as the destination, so an unanchored search
4182        // would rewrite the label and orphan the usage above.
4183        assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4184        let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4185            .fix(&crate::lint_context::LintContext::new(
4186                content,
4187                crate::config::MarkdownFlavor::Standard,
4188                Some(temp_dir.path().join("test.md")),
4189            ))
4190            .unwrap();
4191        assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4192        assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4193    }
4194
4195    #[test]
4196    fn test_a_self_link_resolved_through_a_search_path() {
4197        let temp_dir = tempdir().unwrap();
4198        let guide_dir = temp_dir.path().join("docs/guide");
4199        std::fs::create_dir_all(&guide_dir).unwrap();
4200        let config = MD057Config {
4201            search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4202            ..enabled()
4203        };
4204        let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4205        let result = check_as_file(&guide_dir, "test.md", content, config);
4206
4207        assert_eq!(
4208            result.len(),
4209            1,
4210            "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4211        );
4212        assert_eq!(
4213            result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4214            Some("#title"),
4215            "Got: {result:?}"
4216        );
4217    }
4218
4219    #[test]
4220    fn test_a_target_next_to_the_document_outranks_a_search_path() {
4221        let temp_dir = tempdir().unwrap();
4222        let guide_dir = temp_dir.path().join("docs/guide");
4223        std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4224        std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4225        let config = MD057Config {
4226            search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4227            ..enabled()
4228        };
4229        let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4230        let result = check_as_file(&guide_dir, "test.md", content, config);
4231
4232        assert!(
4233            result.is_empty(),
4234            "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4235        );
4236    }
4237
4238    #[test]
4239    fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4240        let temp_dir = tempdir().unwrap();
4241        let content = "# Title\n\n![not a navigation link](test.md)\n";
4242        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4243
4244        assert!(
4245            result.is_empty(),
4246            "An image is not a link the reader follows. Got: {result:?}"
4247        );
4248    }
4249
4250    #[test]
4251    fn test_a_query_string_is_reported_without_a_suggestion() {
4252        let temp_dir = tempdir().unwrap();
4253        let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4254        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4255
4256        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4257        assert!(
4258            result[0].fix.is_none(),
4259            "A query does not survive losing its path. Got: {result:?}"
4260        );
4261    }
4262
4263    #[test]
4264    fn test_fix_rewrites_the_document_and_settles() {
4265        let temp_dir = tempdir().unwrap();
4266        let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4267        let source_file = temp_dir.path().join("test.md");
4268        std::fs::write(&source_file, content).unwrap();
4269
4270        let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4271        let ctx = crate::lint_context::LintContext::new(
4272            content,
4273            crate::config::MarkdownFlavor::Standard,
4274            Some(source_file.clone()),
4275        );
4276        let fixed = rule.fix(&ctx).unwrap();
4277        assert_eq!(
4278            fixed,
4279            "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
4280        );
4281
4282        let refixed =
4283            crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
4284        assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
4285    }
4286
4287    #[test]
4288    fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
4289        let unfixable = MD057ExistingRelativeLinks::default();
4290        assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
4291
4292        let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
4293        assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
4294    }
4295
4296    #[test]
4297    fn test_the_option_is_read_from_kebab_and_snake_case() {
4298        let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
4299        assert!(kebab.self_referential_links);
4300
4301        let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
4302        assert!(snake.self_referential_links);
4303    }
4304
4305    fn front_matter_checked() -> MD057Config {
4306        MD057Config {
4307            check_frontmatter: true,
4308            ..Default::default()
4309        }
4310    }
4311
4312    #[test]
4313    fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
4314        let temp_dir = tempdir().unwrap();
4315        let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4316        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4317
4318        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4319        assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
4320        assert_eq!(result[0].line, 2);
4321        assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
4322        assert_eq!(result[0].end_column, 23);
4323    }
4324
4325    #[test]
4326    fn test_frontmatter_paths_are_not_checked_by_default() {
4327        let temp_dir = tempdir().unwrap();
4328        let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4329        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4330
4331        assert!(
4332            result.is_empty(),
4333            "Frontmatter is only checked on request. Got: {result:?}"
4334        );
4335    }
4336
4337    #[test]
4338    fn test_an_existing_frontmatter_path_is_not_reported() {
4339        let temp_dir = tempdir().unwrap();
4340        std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4341        let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
4342        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4343
4344        assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
4345        assert_eq!(result[0].line, 3);
4346    }
4347
4348    #[test]
4349    fn test_an_ignored_frontmatter_field_is_not_checked() {
4350        let temp_dir = tempdir().unwrap();
4351        let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
4352        let config = MD057Config {
4353            check_frontmatter: true,
4354            ignore_frontmatter_fields: vec!["Image".to_string()],
4355            ..Default::default()
4356        };
4357        let result = check_as_file(temp_dir.path(), "test.md", content, config);
4358
4359        assert_eq!(
4360            result.len(),
4361            1,
4362            "The ignored field is skipped and the other is not. Got: {result:?}"
4363        );
4364        assert_eq!(result[0].line, 3);
4365    }
4366
4367    #[test]
4368    fn test_an_external_frontmatter_url_is_not_reported() {
4369        let temp_dir = tempdir().unwrap();
4370        let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
4371        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4372
4373        assert!(
4374            result.is_empty(),
4375            "An external URL has no local target. Got: {result:?}"
4376        );
4377    }
4378
4379    #[test]
4380    fn test_a_frontmatter_fragment_is_left_to_md051() {
4381        let temp_dir = tempdir().unwrap();
4382        let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
4383        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4384
4385        assert!(
4386            result.is_empty(),
4387            "A fragment names a heading, not a file. Got: {result:?}"
4388        );
4389    }
4390
4391    #[test]
4392    fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
4393        let temp_dir = tempdir().unwrap();
4394        let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
4395
4396        let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
4397        assert!(
4398            ignored.is_empty(),
4399            "Absolute paths are ignored by default. Got: {ignored:?}"
4400        );
4401
4402        let warning_config = MD057Config {
4403            check_frontmatter: true,
4404            absolute_links: AbsoluteLinksOption::Warn,
4405            ..Default::default()
4406        };
4407        let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
4408        assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
4409        assert_eq!(
4410            warned[0].message,
4411            "Absolute link '/docs/guide.md' cannot be validated locally"
4412        );
4413    }
4414
4415    #[test]
4416    fn test_a_frontmatter_path_carrying_a_query_is_checked() {
4417        let temp_dir = tempdir().unwrap();
4418        std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4419        let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
4420        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4421
4422        assert_eq!(
4423            result.len(),
4424            1,
4425            "A query names no file, so only the missing target is reported. Got: {result:?}"
4426        );
4427        assert_eq!(result[0].line, 2);
4428        assert_eq!(
4429            result[0].message,
4430            "Relative link 'docs/missing.md?raw=true' does not exist"
4431        );
4432    }
4433
4434    #[test]
4435    fn test_prose_in_frontmatter_is_not_read_as_a_path() {
4436        let temp_dir = tempdir().unwrap();
4437        let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
4438        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4439
4440        assert!(
4441            result.is_empty(),
4442            "Only path-shaped values are destinations. Got: {result:?}"
4443        );
4444    }
4445}