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