Skip to main content

rumdl_lib/rules/
md057_existing_relative_links.rs

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