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::{CrossFileScope, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::element_cache::ElementCache;
8use crate::workspace_index::{FileIndex, extract_cross_file_links};
9use regex::Regex;
10use std::collections::HashMap;
11use std::env;
12use std::path::{Path, PathBuf};
13use std::sync::LazyLock;
14use std::sync::{Arc, Mutex};
15
16mod md057_config;
17use crate::rule_config_serde::RuleConfig;
18use crate::utils::mkdocs_config::resolve_docs_dir;
19pub use md057_config::{AbsoluteLinksOption, MD057Config};
20
21// Thread-safe cache for file existence checks to avoid redundant filesystem operations
22static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
23    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
24
25// Reset the file existence cache (typically between rule runs)
26fn reset_file_existence_cache() {
27    if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
28        cache.clear();
29    }
30}
31
32// Check if a file exists with caching
33fn file_exists_with_cache(path: &Path) -> bool {
34    match FILE_EXISTENCE_CACHE.lock() {
35        Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
36        Err(_) => path.exists(), // Fallback to uncached check on mutex poison
37    }
38}
39
40/// Check if a file exists, also trying markdown extensions for extensionless links.
41/// This supports wiki-style links like `[Link](page)` that resolve to `page.md`.
42fn file_exists_or_markdown_extension(path: &Path) -> bool {
43    // First, check exact path
44    if file_exists_with_cache(path) {
45        return true;
46    }
47
48    // If the path has no extension, try adding markdown extensions
49    if path.extension().is_none() {
50        for ext in MARKDOWN_EXTENSIONS {
51            // MARKDOWN_EXTENSIONS includes the dot, e.g., ".md"
52            let path_with_ext = path.with_extension(&ext[1..]);
53            if file_exists_with_cache(&path_with_ext) {
54                return true;
55            }
56        }
57    }
58
59    false
60}
61
62// Regex to match the start of a link - simplified for performance
63static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
64
65/// Regex to extract the URL from an angle-bracketed markdown link
66/// Format: `](<URL>)` or `](<URL> "title")`
67/// This handles URLs with parentheses like `](<path/(with)/parens.md>)`
68static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
69    LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
70
71/// Regex to extract the URL from a normal markdown link (without angle brackets)
72/// Format: `](URL)` or `](URL "title")`
73static URL_EXTRACT_REGEX: LazyLock<Regex> =
74    LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
75
76/// Regex to detect URLs with explicit schemes (should not be checked as relative links)
77/// Matches: scheme:// or scheme: (per RFC 3986)
78/// This covers http, https, ftp, file, smb, mailto, tel, data, macappstores, etc.
79static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
80    LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
81
82// Current working directory
83static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
84
85/// Convert a hex digit (0-9, a-f, A-F) to its numeric value.
86/// Returns None for non-hex characters.
87#[inline]
88fn hex_digit_to_value(byte: u8) -> Option<u8> {
89    match byte {
90        b'0'..=b'9' => Some(byte - b'0'),
91        b'a'..=b'f' => Some(byte - b'a' + 10),
92        b'A'..=b'F' => Some(byte - b'A' + 10),
93        _ => None,
94    }
95}
96
97/// Supported markdown file extensions
98const MARKDOWN_EXTENSIONS: &[&str] = &[
99    ".md",
100    ".markdown",
101    ".mdx",
102    ".mkd",
103    ".mkdn",
104    ".mdown",
105    ".mdwn",
106    ".qmd",
107    ".rmd",
108];
109
110/// Rule MD057: Existing relative links should point to valid files or directories.
111#[derive(Debug, Clone)]
112pub struct MD057ExistingRelativeLinks {
113    /// Base directory for resolving relative links
114    base_path: Arc<Mutex<Option<PathBuf>>>,
115    /// Configuration for the rule
116    config: MD057Config,
117}
118
119impl Default for MD057ExistingRelativeLinks {
120    fn default() -> Self {
121        Self {
122            base_path: Arc::new(Mutex::new(None)),
123            config: MD057Config::default(),
124        }
125    }
126}
127
128impl MD057ExistingRelativeLinks {
129    /// Create a new instance with default settings
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    /// Set the base path for resolving relative links
135    pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
136        let path = path.as_ref();
137        let dir_path = if path.is_file() {
138            path.parent().map(|p| p.to_path_buf())
139        } else {
140            Some(path.to_path_buf())
141        };
142
143        if let Ok(mut guard) = self.base_path.lock() {
144            *guard = dir_path;
145        }
146        self
147    }
148
149    pub fn from_config_struct(config: MD057Config) -> Self {
150        Self {
151            base_path: Arc::new(Mutex::new(None)),
152            config,
153        }
154    }
155
156    /// Check if a URL is external or should be skipped for validation.
157    ///
158    /// Returns `true` (skip validation) for:
159    /// - URLs with protocols: `https://`, `http://`, `ftp://`, `mailto:`, etc.
160    /// - Bare domains: `www.example.com`, `example.com`
161    /// - Email addresses: `user@example.com` (without `mailto:`)
162    /// - Template variables: `{{URL}}`, `{{% include %}}`
163    /// - Absolute web URL paths: `/api/docs`, `/blog/post.html`
164    ///
165    /// Returns `false` (validate) for:
166    /// - Relative filesystem paths: `./file.md`, `../parent/file.md`, `file.md`
167    #[inline]
168    fn is_external_url(&self, url: &str) -> bool {
169        if url.is_empty() {
170            return false;
171        }
172
173        // Quick checks for common external URL patterns
174        if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
175            return true;
176        }
177
178        // Skip template variables (Handlebars/Mustache/Jinja2 syntax)
179        // Examples: {{URL}}, {{#URL}}, {{> partial}}, {{% include %}}, {{ variable }}
180        if url.starts_with("{{") || url.starts_with("{%") {
181            return true;
182        }
183
184        // Simple check: if URL contains @, it's almost certainly an email address
185        // File paths with @ are extremely rare, so this is a safe heuristic
186        if url.contains('@') {
187            return true; // It's an email address, skip it
188        }
189
190        // Bare domain check (e.g., "example.com")
191        // Note: We intentionally DON'T skip all TLDs like .org, .net, etc.
192        // Links like [text](nodejs.org/path) without a protocol are broken -
193        // they'll be treated as relative paths by markdown renderers.
194        // Flagging them helps users find missing protocols.
195        // We only skip .com as a minimal safety net for the most common case.
196        if url.ends_with(".com") {
197            return true;
198        }
199
200        // Framework path aliases (resolved by build tools like Vite, webpack, etc.)
201        // These are not filesystem paths but module/asset aliases
202        // Examples: ~/assets/image.png, @images/photo.jpg, @/components/Button.vue
203        if url.starts_with('~') || url.starts_with('@') {
204            return true;
205        }
206
207        // All other cases (relative paths, etc.) are not external
208        false
209    }
210
211    /// Check if the URL is a fragment-only link (internal document link)
212    #[inline]
213    fn is_fragment_only_link(&self, url: &str) -> bool {
214        url.starts_with('#')
215    }
216
217    /// Check if the URL is an absolute path (starts with /)
218    /// These are typically routes for published documentation sites.
219    #[inline]
220    fn is_absolute_path(url: &str) -> bool {
221        url.starts_with('/')
222    }
223
224    /// Decode URL percent-encoded sequences in a path.
225    /// Converts `%20` to space, `%2F` to `/`, etc.
226    /// Returns the original string if decoding fails or produces invalid UTF-8.
227    fn url_decode(path: &str) -> String {
228        // Quick check: if no percent sign, return as-is
229        if !path.contains('%') {
230            return path.to_string();
231        }
232
233        let bytes = path.as_bytes();
234        let mut result = Vec::with_capacity(bytes.len());
235        let mut i = 0;
236
237        while i < bytes.len() {
238            if bytes[i] == b'%' && i + 2 < bytes.len() {
239                // Try to parse the two hex digits following %
240                let hex1 = bytes[i + 1];
241                let hex2 = bytes[i + 2];
242                if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
243                    result.push(d1 * 16 + d2);
244                    i += 3;
245                    continue;
246                }
247            }
248            result.push(bytes[i]);
249            i += 1;
250        }
251
252        // Convert to UTF-8, falling back to original if invalid
253        String::from_utf8(result).unwrap_or_else(|_| path.to_string())
254    }
255
256    /// Strip query parameters and fragments from a URL for file existence checking.
257    /// URLs like `path/to/image.png?raw=true` or `file.md#section` should check
258    /// for `path/to/image.png` or `file.md` respectively.
259    ///
260    /// Note: In standard URLs, query parameters (`?`) come before fragments (`#`),
261    /// so we check for `?` first. If a URL has both, only the query is stripped here
262    /// (fragments are handled separately by the regex in `contribute_to_index`).
263    fn strip_query_and_fragment(url: &str) -> &str {
264        // Find the first occurrence of '?' or '#', whichever comes first
265        // This handles both standard URLs (? before #) and edge cases (# before ?)
266        let query_pos = url.find('?');
267        let fragment_pos = url.find('#');
268
269        match (query_pos, fragment_pos) {
270            (Some(q), Some(f)) => {
271                // Both exist - strip at whichever comes first
272                &url[..q.min(f)]
273            }
274            (Some(q), None) => &url[..q],
275            (None, Some(f)) => &url[..f],
276            (None, None) => url,
277        }
278    }
279
280    /// Resolve a relative link against a provided base path
281    fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
282        base_path.join(link)
283    }
284
285    /// Validate an absolute link by resolving it relative to MkDocs docs_dir.
286    ///
287    /// Returns `Some(warning_message)` if the link is broken, `None` if valid.
288    /// Falls back to a generic warning if no mkdocs.yml is found.
289    fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
290        let Some(docs_dir) = resolve_docs_dir(source_path) else {
291            // No mkdocs.yml found — fall back to warn behavior
292            return Some(format!(
293                "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
294            ));
295        };
296
297        // Strip leading / and resolve relative to docs_dir
298        let relative_url = url.trim_start_matches('/');
299
300        // Strip query/fragment before checking existence
301        let file_path = Self::strip_query_and_fragment(relative_url);
302        let decoded = Self::url_decode(file_path);
303        let resolved_path = docs_dir.join(&decoded);
304
305        // For directory-style links (ending with /, bare path to a directory, or empty
306        // decoded path like "/"), check for index.md inside the directory.
307        // This must be checked BEFORE file_exists_or_markdown_extension because
308        // path.exists() returns true for directories — we need to verify index.md exists.
309        let is_directory_link = url.ends_with('/') || decoded.is_empty();
310        if is_directory_link || resolved_path.is_dir() {
311            let index_path = resolved_path.join("index.md");
312            if file_exists_with_cache(&index_path) {
313                return None; // Valid directory link with index.md
314            }
315            // Directory exists but no index.md — fall through to error
316            if resolved_path.is_dir() {
317                return Some(format!(
318                    "Absolute link '{url}' resolves to directory '{}' which has no index.md",
319                    resolved_path.display()
320                ));
321            }
322        }
323
324        // Check existence (with markdown extension fallback for extensionless links)
325        if file_exists_or_markdown_extension(&resolved_path) {
326            return None; // Valid link
327        }
328
329        // For .html/.htm links, check for corresponding markdown source
330        if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
331            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
332            && let (Some(stem), Some(parent)) = (
333                resolved_path.file_stem().and_then(|s| s.to_str()),
334                resolved_path.parent(),
335            )
336        {
337            let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
338                let source_path = parent.join(format!("{stem}{md_ext}"));
339                file_exists_with_cache(&source_path)
340            });
341            if has_md_source {
342                return None; // Markdown source exists
343            }
344        }
345
346        Some(format!(
347            "Absolute link '{url}' resolves to '{}' which does not exist",
348            resolved_path.display()
349        ))
350    }
351}
352
353impl Rule for MD057ExistingRelativeLinks {
354    fn name(&self) -> &'static str {
355        "MD057"
356    }
357
358    fn description(&self) -> &'static str {
359        "Relative links should point to existing files"
360    }
361
362    fn category(&self) -> RuleCategory {
363        RuleCategory::Link
364    }
365
366    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
367        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
368    }
369
370    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
371        let content = ctx.content;
372
373        // Early returns for performance
374        if content.is_empty() || !content.contains('[') {
375            return Ok(Vec::new());
376        }
377
378        // Quick check for any potential links before expensive operations
379        // Check for inline links "](", reference definitions "]:", or images "!["
380        if !content.contains("](") && !content.contains("]:") {
381            return Ok(Vec::new());
382        }
383
384        // Reset the file existence cache for a fresh run
385        reset_file_existence_cache();
386
387        let mut warnings = Vec::new();
388
389        // Determine base path for resolving relative links
390        // ALWAYS compute from ctx.source_file for each file - do not reuse cached base_path
391        // This ensures each file resolves links relative to its own directory
392        let base_path: Option<PathBuf> = {
393            // First check if base_path was explicitly set via with_path() (for tests)
394            let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
395            if explicit_base.is_some() {
396                explicit_base
397            } else if let Some(ref source_file) = ctx.source_file {
398                // Resolve symlinks to get the actual file location
399                // This ensures relative links are resolved from the target's directory,
400                // not the symlink's directory
401                let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
402                resolved_file
403                    .parent()
404                    .map(|p| p.to_path_buf())
405                    .or_else(|| Some(CURRENT_DIR.clone()))
406            } else {
407                // No source file available - cannot validate relative links
408                None
409            }
410        };
411
412        // If we still don't have a base path, we can't validate relative links
413        let Some(base_path) = base_path else {
414            return Ok(warnings);
415        };
416
417        // Use LintContext links instead of expensive regex parsing
418        if !ctx.links.is_empty() {
419            // Use LineIndex for correct position calculation across all line ending types
420            let line_index = &ctx.line_index;
421
422            // Create element cache once for all links
423            let element_cache = ElementCache::new(content);
424
425            // Pre-collected lines from context
426            let lines = ctx.raw_lines();
427
428            // Track which lines we've already processed to avoid duplicates
429            // (ctx.links may have multiple entries for the same line, especially with malformed markdown)
430            let mut processed_lines = std::collections::HashSet::new();
431
432            for link in &ctx.links {
433                let line_idx = link.line - 1;
434                if line_idx >= lines.len() {
435                    continue;
436                }
437
438                // Skip lines inside PyMdown blocks (MkDocs flavor)
439                // This must be checked BEFORE processed_lines to skip the entire line
440                if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
441                    continue;
442                }
443
444                // Skip if we've already processed this line
445                if !processed_lines.insert(line_idx) {
446                    continue;
447                }
448
449                let line = lines[line_idx];
450
451                // Quick check for link pattern in this line
452                if !line.contains("](") {
453                    continue;
454                }
455
456                // Find all links in this line using optimized regex
457                for link_match in LINK_START_REGEX.find_iter(line) {
458                    let start_pos = link_match.start();
459                    let end_pos = link_match.end();
460
461                    // Calculate absolute position using LineIndex
462                    let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
463                    let absolute_start_pos = line_start_byte + start_pos;
464
465                    // Skip if this link is in a code span
466                    if element_cache.is_in_code_span(absolute_start_pos) {
467                        continue;
468                    }
469
470                    // Skip if this link is in a math span (LaTeX $...$ or $$...$$)
471                    if ctx.is_in_math_span(absolute_start_pos) {
472                        continue;
473                    }
474
475                    // Find the URL part after the link text
476                    // Try angle-bracket regex first (handles URLs with parens like `<path/(with)/parens.md>`)
477                    // Then fall back to normal URL regex
478                    let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
479                        .captures_at(line, end_pos - 1)
480                        .and_then(|caps| caps.get(1).map(|g| (caps, g)))
481                        .or_else(|| {
482                            URL_EXTRACT_REGEX
483                                .captures_at(line, end_pos - 1)
484                                .and_then(|caps| caps.get(1).map(|g| (caps, g)))
485                        });
486
487                    if let Some((_caps, url_group)) = caps_and_url {
488                        let url = url_group.as_str().trim();
489
490                        // Skip empty URLs
491                        if url.is_empty() {
492                            continue;
493                        }
494
495                        // Skip rustdoc intra-doc links (backtick-wrapped URLs)
496                        // These are Rust API references, not file paths
497                        // Example: [`f32::is_subnormal`], [`Vec::push`]
498                        if url.starts_with('`') && url.ends_with('`') {
499                            continue;
500                        }
501
502                        // Skip external URLs and fragment-only links
503                        if self.is_external_url(url) || self.is_fragment_only_link(url) {
504                            continue;
505                        }
506
507                        // Handle absolute paths based on config
508                        if Self::is_absolute_path(url) {
509                            match self.config.absolute_links {
510                                AbsoluteLinksOption::Warn => {
511                                    let url_start = url_group.start();
512                                    let url_end = url_group.end();
513                                    warnings.push(LintWarning {
514                                        rule_name: Some(self.name().to_string()),
515                                        line: link.line,
516                                        column: url_start + 1,
517                                        end_line: link.line,
518                                        end_column: url_end + 1,
519                                        message: format!("Absolute link '{url}' cannot be validated locally"),
520                                        severity: Severity::Warning,
521                                        fix: None,
522                                    });
523                                }
524                                AbsoluteLinksOption::RelativeToDocs => {
525                                    if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
526                                        let url_start = url_group.start();
527                                        let url_end = url_group.end();
528                                        warnings.push(LintWarning {
529                                            rule_name: Some(self.name().to_string()),
530                                            line: link.line,
531                                            column: url_start + 1,
532                                            end_line: link.line,
533                                            end_column: url_end + 1,
534                                            message: msg,
535                                            severity: Severity::Warning,
536                                            fix: None,
537                                        });
538                                    }
539                                }
540                                AbsoluteLinksOption::Ignore => {}
541                            }
542                            continue;
543                        }
544
545                        // Strip query parameters and fragments before checking file existence
546                        let file_path = Self::strip_query_and_fragment(url);
547
548                        // URL-decode the path to handle percent-encoded characters
549                        let decoded_path = Self::url_decode(file_path);
550
551                        // Resolve the relative link against the base path
552                        let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
553
554                        // Check if the file exists, also trying markdown extensions for extensionless links
555                        if file_exists_or_markdown_extension(&resolved_path) {
556                            continue; // File exists, no warning needed
557                        }
558
559                        // For .html/.htm links, check if a corresponding markdown source exists
560                        let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
561                            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
562                            && let (Some(stem), Some(parent)) = (
563                                resolved_path.file_stem().and_then(|s| s.to_str()),
564                                resolved_path.parent(),
565                            ) {
566                            MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
567                                let source_path = parent.join(format!("{stem}{md_ext}"));
568                                file_exists_with_cache(&source_path)
569                            })
570                        } else {
571                            false
572                        };
573
574                        if has_md_source {
575                            continue; // Markdown source exists, link is valid
576                        }
577
578                        // File doesn't exist and no source file found
579                        // Use actual URL position from regex capture group
580                        // Note: capture group positions are absolute within the line string
581                        let url_start = url_group.start();
582                        let url_end = url_group.end();
583
584                        warnings.push(LintWarning {
585                            rule_name: Some(self.name().to_string()),
586                            line: link.line,
587                            column: url_start + 1, // 1-indexed
588                            end_line: link.line,
589                            end_column: url_end + 1, // 1-indexed
590                            message: format!("Relative link '{url}' does not exist"),
591                            severity: Severity::Error,
592                            fix: None,
593                        });
594                    }
595                }
596            }
597        }
598
599        // Also process images - they have URLs already parsed
600        for image in &ctx.images {
601            // Skip images inside PyMdown blocks (MkDocs flavor)
602            if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
603                continue;
604            }
605
606            let url = image.url.as_ref();
607
608            // Skip empty URLs
609            if url.is_empty() {
610                continue;
611            }
612
613            // Skip external URLs and fragment-only links
614            if self.is_external_url(url) || self.is_fragment_only_link(url) {
615                continue;
616            }
617
618            // Handle absolute paths based on config
619            if Self::is_absolute_path(url) {
620                match self.config.absolute_links {
621                    AbsoluteLinksOption::Warn => {
622                        warnings.push(LintWarning {
623                            rule_name: Some(self.name().to_string()),
624                            line: image.line,
625                            column: image.start_col + 1,
626                            end_line: image.line,
627                            end_column: image.start_col + 1 + url.len(),
628                            message: format!("Absolute link '{url}' cannot be validated locally"),
629                            severity: Severity::Warning,
630                            fix: None,
631                        });
632                    }
633                    AbsoluteLinksOption::RelativeToDocs => {
634                        if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
635                            warnings.push(LintWarning {
636                                rule_name: Some(self.name().to_string()),
637                                line: image.line,
638                                column: image.start_col + 1,
639                                end_line: image.line,
640                                end_column: image.start_col + 1 + url.len(),
641                                message: msg,
642                                severity: Severity::Warning,
643                                fix: None,
644                            });
645                        }
646                    }
647                    AbsoluteLinksOption::Ignore => {}
648                }
649                continue;
650            }
651
652            // Strip query parameters and fragments before checking file existence
653            let file_path = Self::strip_query_and_fragment(url);
654
655            // URL-decode the path to handle percent-encoded characters
656            let decoded_path = Self::url_decode(file_path);
657
658            // Resolve the relative link against the base path
659            let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
660
661            // Check if the file exists, also trying markdown extensions for extensionless links
662            if file_exists_or_markdown_extension(&resolved_path) {
663                continue; // File exists, no warning needed
664            }
665
666            // For .html/.htm links, check if a corresponding markdown source exists
667            let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
668                && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
669                && let (Some(stem), Some(parent)) = (
670                    resolved_path.file_stem().and_then(|s| s.to_str()),
671                    resolved_path.parent(),
672                ) {
673                MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
674                    let source_path = parent.join(format!("{stem}{md_ext}"));
675                    file_exists_with_cache(&source_path)
676                })
677            } else {
678                false
679            };
680
681            if has_md_source {
682                continue; // Markdown source exists, link is valid
683            }
684
685            // File doesn't exist and no source file found
686            // Images already have correct position from parser
687            warnings.push(LintWarning {
688                rule_name: Some(self.name().to_string()),
689                line: image.line,
690                column: image.start_col + 1,
691                end_line: image.line,
692                end_column: image.start_col + 1 + url.len(),
693                message: format!("Relative link '{url}' does not exist"),
694                severity: Severity::Error,
695                fix: None,
696            });
697        }
698
699        // Also process reference definitions: [ref]: ./path.md
700        for ref_def in &ctx.reference_defs {
701            let url = &ref_def.url;
702
703            // Skip empty URLs
704            if url.is_empty() {
705                continue;
706            }
707
708            // Skip external URLs and fragment-only links
709            if self.is_external_url(url) || self.is_fragment_only_link(url) {
710                continue;
711            }
712
713            // Handle absolute paths based on config
714            if Self::is_absolute_path(url) {
715                match self.config.absolute_links {
716                    AbsoluteLinksOption::Warn => {
717                        let line_idx = ref_def.line - 1;
718                        let column = content.lines().nth(line_idx).map_or(1, |line_content| {
719                            line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
720                        });
721                        warnings.push(LintWarning {
722                            rule_name: Some(self.name().to_string()),
723                            line: ref_def.line,
724                            column,
725                            end_line: ref_def.line,
726                            end_column: column + url.len(),
727                            message: format!("Absolute link '{url}' cannot be validated locally"),
728                            severity: Severity::Warning,
729                            fix: None,
730                        });
731                    }
732                    AbsoluteLinksOption::RelativeToDocs => {
733                        if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
734                            let line_idx = ref_def.line - 1;
735                            let column = content.lines().nth(line_idx).map_or(1, |line_content| {
736                                line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
737                            });
738                            warnings.push(LintWarning {
739                                rule_name: Some(self.name().to_string()),
740                                line: ref_def.line,
741                                column,
742                                end_line: ref_def.line,
743                                end_column: column + url.len(),
744                                message: msg,
745                                severity: Severity::Warning,
746                                fix: None,
747                            });
748                        }
749                    }
750                    AbsoluteLinksOption::Ignore => {}
751                }
752                continue;
753            }
754
755            // Strip query parameters and fragments before checking file existence
756            let file_path = Self::strip_query_and_fragment(url);
757
758            // URL-decode the path to handle percent-encoded characters
759            let decoded_path = Self::url_decode(file_path);
760
761            // Resolve the relative link against the base path
762            let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
763
764            // Check if the file exists, also trying markdown extensions for extensionless links
765            if file_exists_or_markdown_extension(&resolved_path) {
766                continue; // File exists, no warning needed
767            }
768
769            // For .html/.htm links, check if a corresponding markdown source exists
770            let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
771                && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
772                && let (Some(stem), Some(parent)) = (
773                    resolved_path.file_stem().and_then(|s| s.to_str()),
774                    resolved_path.parent(),
775                ) {
776                MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
777                    let source_path = parent.join(format!("{stem}{md_ext}"));
778                    file_exists_with_cache(&source_path)
779                })
780            } else {
781                false
782            };
783
784            if has_md_source {
785                continue; // Markdown source exists, link is valid
786            }
787
788            // File doesn't exist and no source file found
789            // Calculate column position: find URL within the line
790            let line_idx = ref_def.line - 1;
791            let column = content.lines().nth(line_idx).map_or(1, |line_content| {
792                // Find URL position in line (after ]: )
793                line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
794            });
795
796            warnings.push(LintWarning {
797                rule_name: Some(self.name().to_string()),
798                line: ref_def.line,
799                column,
800                end_line: ref_def.line,
801                end_column: column + url.len(),
802                message: format!("Relative link '{url}' does not exist"),
803                severity: Severity::Error,
804                fix: None,
805            });
806        }
807
808        Ok(warnings)
809    }
810
811    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
812        Ok(ctx.content.to_string())
813    }
814
815    fn as_any(&self) -> &dyn std::any::Any {
816        self
817    }
818
819    fn default_config_section(&self) -> Option<(String, toml::Value)> {
820        let default_config = MD057Config::default();
821        let json_value = serde_json::to_value(&default_config).ok()?;
822        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
823
824        if let toml::Value::Table(table) = toml_value {
825            if !table.is_empty() {
826                Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
827            } else {
828                None
829            }
830        } else {
831            None
832        }
833    }
834
835    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
836    where
837        Self: Sized,
838    {
839        let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
840        Box::new(Self::from_config_struct(rule_config))
841    }
842
843    fn cross_file_scope(&self) -> CrossFileScope {
844        CrossFileScope::Workspace
845    }
846
847    fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
848        // Use the shared utility for cross-file link extraction
849        // This ensures consistent position tracking between CLI and LSP
850        for link in extract_cross_file_links(ctx) {
851            index.add_cross_file_link(link);
852        }
853    }
854
855    fn cross_file_check(
856        &self,
857        file_path: &Path,
858        file_index: &FileIndex,
859        workspace_index: &crate::workspace_index::WorkspaceIndex,
860    ) -> LintResult {
861        let mut warnings = Vec::new();
862
863        // Get the directory containing this file for resolving relative links
864        let file_dir = file_path.parent();
865
866        for cross_link in &file_index.cross_file_links {
867            // URL-decode the path for filesystem operations
868            // The stored path is URL-encoded (e.g., "%F0%9F%91%A4" for emoji 👤)
869            let decoded_target = Self::url_decode(&cross_link.target_path);
870
871            // Skip absolute paths — they are already handled by check()
872            // which validates them according to the absolute_links config.
873            // Handling them here too would produce duplicate warnings.
874            if decoded_target.starts_with('/') {
875                continue;
876            }
877
878            // Resolve relative path
879            let target_path = if let Some(dir) = file_dir {
880                dir.join(&decoded_target)
881            } else {
882                Path::new(&decoded_target).to_path_buf()
883            };
884
885            // Normalize the path (handle .., ., etc.)
886            let target_path = normalize_path(&target_path);
887
888            // Check if the target file exists, also trying markdown extensions for extensionless links
889            let file_exists =
890                workspace_index.contains_file(&target_path) || file_exists_or_markdown_extension(&target_path);
891
892            if !file_exists {
893                // For .html/.htm links, check if a corresponding markdown source exists
894                // This handles doc sites (mdBook, etc.) where .md is compiled to .html
895                let has_md_source = if let Some(ext) = target_path.extension().and_then(|e| e.to_str())
896                    && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
897                    && let (Some(stem), Some(parent)) =
898                        (target_path.file_stem().and_then(|s| s.to_str()), target_path.parent())
899                {
900                    MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
901                        let source_path = parent.join(format!("{stem}{md_ext}"));
902                        workspace_index.contains_file(&source_path) || source_path.exists()
903                    })
904                } else {
905                    false
906                };
907
908                if !has_md_source {
909                    warnings.push(LintWarning {
910                        rule_name: Some(self.name().to_string()),
911                        line: cross_link.line,
912                        column: cross_link.column,
913                        end_line: cross_link.line,
914                        end_column: cross_link.column + cross_link.target_path.len(),
915                        message: format!("Relative link '{}' does not exist", cross_link.target_path),
916                        severity: Severity::Error,
917                        fix: None,
918                    });
919                }
920            }
921        }
922
923        Ok(warnings)
924    }
925}
926
927/// Normalize a path by resolving . and .. components
928fn normalize_path(path: &Path) -> PathBuf {
929    let mut components = Vec::new();
930
931    for component in path.components() {
932        match component {
933            std::path::Component::ParentDir => {
934                // Go up one level if possible
935                if !components.is_empty() {
936                    components.pop();
937                }
938            }
939            std::path::Component::CurDir => {
940                // Skip current directory markers
941            }
942            _ => {
943                components.push(component);
944            }
945        }
946    }
947
948    components.iter().collect()
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954    use crate::workspace_index::CrossFileLinkIndex;
955    use std::fs::File;
956    use std::io::Write;
957    use tempfile::tempdir;
958
959    #[test]
960    fn test_strip_query_and_fragment() {
961        // Test query parameter stripping
962        assert_eq!(
963            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
964            "file.png"
965        );
966        assert_eq!(
967            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
968            "file.png"
969        );
970        assert_eq!(
971            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
972            "file.png"
973        );
974
975        // Test fragment stripping
976        assert_eq!(
977            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
978            "file.md"
979        );
980        assert_eq!(
981            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
982            "file.md"
983        );
984
985        // Test both query and fragment (query comes first, per RFC 3986)
986        assert_eq!(
987            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
988            "file.md"
989        );
990
991        // Test no query or fragment
992        assert_eq!(
993            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
994            "file.png"
995        );
996
997        // Test with path
998        assert_eq!(
999            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1000            "path/to/image.png"
1001        );
1002        assert_eq!(
1003            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1004            "path/to/image.png"
1005        );
1006
1007        // Edge case: fragment before query (non-standard but possible)
1008        assert_eq!(
1009            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1010            "file.md"
1011        );
1012    }
1013
1014    #[test]
1015    fn test_url_decode() {
1016        // Simple space encoding
1017        assert_eq!(
1018            MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1019            "penguin with space.jpg"
1020        );
1021
1022        // Path with encoded spaces
1023        assert_eq!(
1024            MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1025            "assets/my file name.png"
1026        );
1027
1028        // Multiple encoded characters
1029        assert_eq!(
1030            MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1031            "hello world!.md"
1032        );
1033
1034        // Lowercase hex
1035        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1036
1037        // Uppercase hex
1038        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1039
1040        // Mixed case hex
1041        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1042
1043        // No encoding - return as-is
1044        assert_eq!(
1045            MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1046            "normal-file.md"
1047        );
1048
1049        // Incomplete percent encoding - leave as-is
1050        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1051
1052        // Percent at end - leave as-is
1053        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1054
1055        // Invalid hex digits - leave as-is
1056        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1057
1058        // Plus sign (should NOT be decoded - that's form encoding, not URL encoding)
1059        assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1060
1061        // Empty string
1062        assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1063
1064        // UTF-8 multi-byte characters (é = C3 A9 in UTF-8)
1065        assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1066
1067        // Multiple consecutive encoded characters
1068        assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), "   ");
1069
1070        // Encoded path separators
1071        assert_eq!(
1072            MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1073            "path/to/file.md"
1074        );
1075
1076        // Mixed encoded and non-encoded
1077        assert_eq!(
1078            MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1079            "hello world/foo bar.md"
1080        );
1081
1082        // Special characters that are commonly encoded
1083        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1084
1085        // Percent at position that looks like encoding but isn't valid
1086        assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1087    }
1088
1089    #[test]
1090    fn test_url_encoded_filenames() {
1091        // Create a temporary directory for test files
1092        let temp_dir = tempdir().unwrap();
1093        let base_path = temp_dir.path();
1094
1095        // Create a file with spaces in the name
1096        let file_with_spaces = base_path.join("penguin with space.jpg");
1097        File::create(&file_with_spaces)
1098            .unwrap()
1099            .write_all(b"image data")
1100            .unwrap();
1101
1102        // Create a subdirectory with spaces
1103        let subdir = base_path.join("my images");
1104        std::fs::create_dir(&subdir).unwrap();
1105        let nested_file = subdir.join("photo 1.png");
1106        File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1107
1108        // Test content with URL-encoded links
1109        let content = r#"
1110# Test Document with URL-Encoded Links
1111
1112![Penguin](penguin%20with%20space.jpg)
1113![Photo](my%20images/photo%201.png)
1114![Missing](missing%20file.jpg)
1115"#;
1116
1117        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1118
1119        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120        let result = rule.check(&ctx).unwrap();
1121
1122        // Should only have one warning for the missing file
1123        assert_eq!(
1124            result.len(),
1125            1,
1126            "Should only warn about missing%20file.jpg. Got: {result:?}"
1127        );
1128        assert!(
1129            result[0].message.contains("missing%20file.jpg"),
1130            "Warning should mention the URL-encoded filename"
1131        );
1132    }
1133
1134    #[test]
1135    fn test_external_urls() {
1136        let rule = MD057ExistingRelativeLinks::new();
1137
1138        // Common web protocols
1139        assert!(rule.is_external_url("https://example.com"));
1140        assert!(rule.is_external_url("http://example.com"));
1141        assert!(rule.is_external_url("ftp://example.com"));
1142        assert!(rule.is_external_url("www.example.com"));
1143        assert!(rule.is_external_url("example.com"));
1144
1145        // Special URI schemes
1146        assert!(rule.is_external_url("file:///path/to/file"));
1147        assert!(rule.is_external_url("smb://server/share"));
1148        assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1149        assert!(rule.is_external_url("mailto:user@example.com"));
1150        assert!(rule.is_external_url("tel:+1234567890"));
1151        assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1152        assert!(rule.is_external_url("javascript:void(0)"));
1153        assert!(rule.is_external_url("ssh://git@github.com/repo"));
1154        assert!(rule.is_external_url("git://github.com/repo.git"));
1155
1156        // Email addresses without mailto: protocol
1157        // These are clearly not file links and should be skipped
1158        assert!(rule.is_external_url("user@example.com"));
1159        assert!(rule.is_external_url("steering@kubernetes.io"));
1160        assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1161        assert!(rule.is_external_url("user_name@sub.domain.com"));
1162        assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1163
1164        // Template variables should be skipped (not checked as relative links)
1165        assert!(rule.is_external_url("{{URL}}")); // Handlebars/Mustache
1166        assert!(rule.is_external_url("{{#URL}}")); // Handlebars block helper
1167        assert!(rule.is_external_url("{{> partial}}")); // Handlebars partial
1168        assert!(rule.is_external_url("{{ variable }}")); // Mustache with spaces
1169        assert!(rule.is_external_url("{{% include %}}")); // Jinja2/Hugo shortcode
1170        assert!(rule.is_external_url("{{")); // Even partial matches (regex edge case)
1171
1172        // Absolute paths are NOT external (handled separately via is_absolute_path)
1173        // By default they are ignored, but can be configured to warn
1174        assert!(!rule.is_external_url("/api/v1/users"));
1175        assert!(!rule.is_external_url("/blog/2024/release.html"));
1176        assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1177        assert!(!rule.is_external_url("/pkg/runtime"));
1178        assert!(!rule.is_external_url("/doc/go1compat"));
1179        assert!(!rule.is_external_url("/index.html"));
1180        assert!(!rule.is_external_url("/assets/logo.png"));
1181
1182        // But is_absolute_path should detect them
1183        assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1184        assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1185        assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1186        assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1187        assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1188
1189        // Framework path aliases should be skipped (resolved by build tools)
1190        // Tilde prefix (common in Vite, Nuxt, Astro for project root)
1191        assert!(rule.is_external_url("~/assets/image.png"));
1192        assert!(rule.is_external_url("~/components/Button.vue"));
1193        assert!(rule.is_external_url("~assets/logo.svg")); // Nuxt style without /
1194
1195        // @ prefix (common in Vue, webpack, Vite aliases)
1196        assert!(rule.is_external_url("@/components/Header.vue"));
1197        assert!(rule.is_external_url("@images/photo.jpg"));
1198        assert!(rule.is_external_url("@assets/styles.css"));
1199
1200        // Relative paths should NOT be external (should be validated)
1201        assert!(!rule.is_external_url("./relative/path.md"));
1202        assert!(!rule.is_external_url("relative/path.md"));
1203        assert!(!rule.is_external_url("../parent/path.md"));
1204    }
1205
1206    #[test]
1207    fn test_framework_path_aliases() {
1208        // Create a temporary directory for test files
1209        let temp_dir = tempdir().unwrap();
1210        let base_path = temp_dir.path();
1211
1212        // Test content with framework path aliases (should all be skipped)
1213        let content = r#"
1214# Framework Path Aliases
1215
1216![Image 1](~/assets/penguin.jpg)
1217![Image 2](~assets/logo.svg)
1218![Image 3](@images/photo.jpg)
1219![Image 4](@/components/icon.svg)
1220[Link](@/pages/about.md)
1221
1222This is a [real missing link](missing.md) that should be flagged.
1223"#;
1224
1225        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1226
1227        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228        let result = rule.check(&ctx).unwrap();
1229
1230        // Should only have one warning for the real missing link
1231        assert_eq!(
1232            result.len(),
1233            1,
1234            "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1235        );
1236        assert!(
1237            result[0].message.contains("missing.md"),
1238            "Warning should be for missing.md"
1239        );
1240    }
1241
1242    #[test]
1243    fn test_url_decode_security_path_traversal() {
1244        // Ensure URL decoding doesn't enable path traversal attacks
1245        // The decoded path is still validated against the base path
1246        let temp_dir = tempdir().unwrap();
1247        let base_path = temp_dir.path();
1248
1249        // Create a file in the temp directory
1250        let file_in_base = base_path.join("safe.md");
1251        File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1252
1253        // Test with encoded path traversal attempt
1254        // Use a path that definitely won't exist on any platform (not /etc/passwd which exists on Linux)
1255        // %2F = /, so ..%2F..%2Fnonexistent%2Ffile = ../../nonexistent/file
1256        // %252F = %2F (double encoded), so ..%252F..%252F = ..%2F..%2F (literal, won't decode to ..)
1257        let content = r#"
1258[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1259[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1260[Safe link](safe.md)
1261"#;
1262
1263        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1264
1265        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266        let result = rule.check(&ctx).unwrap();
1267
1268        // The traversal attempts should still be flagged as missing
1269        // (they don't exist relative to base_path after decoding)
1270        assert_eq!(
1271            result.len(),
1272            2,
1273            "Should have warnings for traversal attempts. Got: {result:?}"
1274        );
1275    }
1276
1277    #[test]
1278    fn test_url_encoded_utf8_filenames() {
1279        // Test with actual UTF-8 encoded filenames
1280        let temp_dir = tempdir().unwrap();
1281        let base_path = temp_dir.path();
1282
1283        // Create files with unicode names
1284        let cafe_file = base_path.join("café.md");
1285        File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1286
1287        let content = r#"
1288[Café link](caf%C3%A9.md)
1289[Missing unicode](r%C3%A9sum%C3%A9.md)
1290"#;
1291
1292        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1293
1294        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295        let result = rule.check(&ctx).unwrap();
1296
1297        // Should only warn about the missing file
1298        assert_eq!(
1299            result.len(),
1300            1,
1301            "Should only warn about missing résumé.md. Got: {result:?}"
1302        );
1303        assert!(
1304            result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1305            "Warning should mention the URL-encoded filename"
1306        );
1307    }
1308
1309    #[test]
1310    fn test_url_encoded_emoji_filenames() {
1311        // URL-encoded emoji paths should be correctly resolved
1312        // 👤 = U+1F464 = F0 9F 91 A4 in UTF-8
1313        let temp_dir = tempdir().unwrap();
1314        let base_path = temp_dir.path();
1315
1316        // Create directory with emoji in name: 👤 Personal
1317        let emoji_dir = base_path.join("👤 Personal");
1318        std::fs::create_dir(&emoji_dir).unwrap();
1319
1320        // Create file in that directory: TV Shows.md
1321        let file_path = emoji_dir.join("TV Shows.md");
1322        File::create(&file_path)
1323            .unwrap()
1324            .write_all(b"# TV Shows\n\nContent here.")
1325            .unwrap();
1326
1327        // Test content with URL-encoded emoji link
1328        // %F0%9F%91%A4 = 👤, %20 = space
1329        let content = r#"
1330# Test Document
1331
1332[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1333[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1334"#;
1335
1336        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1337
1338        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339        let result = rule.check(&ctx).unwrap();
1340
1341        // Should only warn about the missing file, not the valid emoji path
1342        assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1343        assert!(
1344            result[0].message.contains("Missing.md"),
1345            "Warning should be for Missing.md, got: {}",
1346            result[0].message
1347        );
1348    }
1349
1350    #[test]
1351    fn test_no_warnings_without_base_path() {
1352        let rule = MD057ExistingRelativeLinks::new();
1353        let content = "[Link](missing.md)";
1354
1355        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356        let result = rule.check(&ctx).unwrap();
1357        assert!(result.is_empty(), "Should have no warnings without base path");
1358    }
1359
1360    #[test]
1361    fn test_existing_and_missing_links() {
1362        // Create a temporary directory for test files
1363        let temp_dir = tempdir().unwrap();
1364        let base_path = temp_dir.path();
1365
1366        // Create an existing file
1367        let exists_path = base_path.join("exists.md");
1368        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1369
1370        // Verify the file exists
1371        assert!(exists_path.exists(), "exists.md should exist for this test");
1372
1373        // Create test content with both existing and missing links
1374        let content = r#"
1375# Test Document
1376
1377[Valid Link](exists.md)
1378[Invalid Link](missing.md)
1379[External Link](https://example.com)
1380[Media Link](image.jpg)
1381        "#;
1382
1383        // Initialize rule with the base path (default: check all files including media)
1384        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1385
1386        // Test the rule
1387        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1388        let result = rule.check(&ctx).unwrap();
1389
1390        // Should have two warnings: missing.md and image.jpg (both don't exist)
1391        assert_eq!(result.len(), 2);
1392        let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1393        assert!(messages.iter().any(|m| m.contains("missing.md")));
1394        assert!(messages.iter().any(|m| m.contains("image.jpg")));
1395    }
1396
1397    #[test]
1398    fn test_angle_bracket_links() {
1399        // Create a temporary directory for test files
1400        let temp_dir = tempdir().unwrap();
1401        let base_path = temp_dir.path();
1402
1403        // Create an existing file
1404        let exists_path = base_path.join("exists.md");
1405        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1406
1407        // Create test content with angle bracket links
1408        let content = r#"
1409# Test Document
1410
1411[Valid Link](<exists.md>)
1412[Invalid Link](<missing.md>)
1413[External Link](<https://example.com>)
1414    "#;
1415
1416        // Test with default settings
1417        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1418
1419        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1420        let result = rule.check(&ctx).unwrap();
1421
1422        // Should have one warning for missing.md
1423        assert_eq!(result.len(), 1, "Should have exactly one warning");
1424        assert!(
1425            result[0].message.contains("missing.md"),
1426            "Warning should mention missing.md"
1427        );
1428    }
1429
1430    #[test]
1431    fn test_angle_bracket_links_with_parens() {
1432        // Create a temporary directory for test files
1433        let temp_dir = tempdir().unwrap();
1434        let base_path = temp_dir.path();
1435
1436        // Create directory structure with parentheses in path
1437        let app_dir = base_path.join("app");
1438        std::fs::create_dir(&app_dir).unwrap();
1439        let upload_dir = app_dir.join("(upload)");
1440        std::fs::create_dir(&upload_dir).unwrap();
1441        let page_file = upload_dir.join("page.tsx");
1442        File::create(&page_file)
1443            .unwrap()
1444            .write_all(b"export default function Page() {}")
1445            .unwrap();
1446
1447        // Create test content with angle bracket links containing parentheses
1448        let content = r#"
1449# Test Document with Paths Containing Parens
1450
1451[Upload Page](<app/(upload)/page.tsx>)
1452[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1453[Missing](<app/(missing)/file.md>)
1454"#;
1455
1456        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1457
1458        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1459        let result = rule.check(&ctx).unwrap();
1460
1461        // Should only have one warning for the missing file
1462        assert_eq!(
1463            result.len(),
1464            1,
1465            "Should have exactly one warning for missing file. Got: {result:?}"
1466        );
1467        assert!(
1468            result[0].message.contains("app/(missing)/file.md"),
1469            "Warning should mention app/(missing)/file.md"
1470        );
1471    }
1472
1473    #[test]
1474    fn test_all_file_types_checked() {
1475        // Create a temporary directory for test files
1476        let temp_dir = tempdir().unwrap();
1477        let base_path = temp_dir.path();
1478
1479        // Create a test with various file types - all should be checked
1480        let content = r#"
1481[Image Link](image.jpg)
1482[Video Link](video.mp4)
1483[Markdown Link](document.md)
1484[PDF Link](file.pdf)
1485"#;
1486
1487        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1488
1489        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490        let result = rule.check(&ctx).unwrap();
1491
1492        // Should warn about all missing files regardless of extension
1493        assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1494    }
1495
1496    #[test]
1497    fn test_code_span_detection() {
1498        let rule = MD057ExistingRelativeLinks::new();
1499
1500        // Create a temporary directory for test files
1501        let temp_dir = tempdir().unwrap();
1502        let base_path = temp_dir.path();
1503
1504        let rule = rule.with_path(base_path);
1505
1506        // Test with document structure
1507        let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1508
1509        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1510        let result = rule.check(&ctx).unwrap();
1511
1512        // Should only find the real link, not the one in code
1513        assert_eq!(result.len(), 1, "Should only flag the real link");
1514        assert!(result[0].message.contains("nonexistent.md"));
1515    }
1516
1517    #[test]
1518    fn test_inline_code_spans() {
1519        // Create a temporary directory for test files
1520        let temp_dir = tempdir().unwrap();
1521        let base_path = temp_dir.path();
1522
1523        // Create test content with links in inline code spans
1524        let content = r#"
1525# Test Document
1526
1527This is a normal link: [Link](missing.md)
1528
1529This is a code span with a link: `[Link](another-missing.md)`
1530
1531Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1532
1533    "#;
1534
1535        // Initialize rule with the base path
1536        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1537
1538        // Test the rule
1539        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1540        let result = rule.check(&ctx).unwrap();
1541
1542        // Should only have warning for the normal link, not for links in code spans
1543        assert_eq!(result.len(), 1, "Should have exactly one warning");
1544        assert!(
1545            result[0].message.contains("missing.md"),
1546            "Warning should be for missing.md"
1547        );
1548        assert!(
1549            !result.iter().any(|w| w.message.contains("another-missing.md")),
1550            "Should not warn about link in code span"
1551        );
1552        assert!(
1553            !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1554            "Should not warn about link in inline code"
1555        );
1556    }
1557
1558    #[test]
1559    fn test_extensionless_link_resolution() {
1560        // Create a temporary directory for test files
1561        let temp_dir = tempdir().unwrap();
1562        let base_path = temp_dir.path();
1563
1564        // Create a markdown file WITHOUT specifying .md extension in the link
1565        let page_path = base_path.join("page.md");
1566        File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1567
1568        // Test content with extensionless link that should resolve to page.md
1569        let content = r#"
1570# Test Document
1571
1572[Link without extension](page)
1573[Link with extension](page.md)
1574[Missing link](nonexistent)
1575"#;
1576
1577        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1578
1579        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1580        let result = rule.check(&ctx).unwrap();
1581
1582        // Should only have warning for nonexistent link
1583        // Both "page" and "page.md" should resolve to the same file
1584        assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1585        assert!(
1586            result[0].message.contains("nonexistent"),
1587            "Warning should be for 'nonexistent' not 'page'"
1588        );
1589    }
1590
1591    // Cross-file validation tests
1592    #[test]
1593    fn test_cross_file_scope() {
1594        let rule = MD057ExistingRelativeLinks::new();
1595        assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1596    }
1597
1598    #[test]
1599    fn test_contribute_to_index_extracts_markdown_links() {
1600        let rule = MD057ExistingRelativeLinks::new();
1601        let content = r#"
1602# Document
1603
1604[Link to docs](./docs/guide.md)
1605[Link with fragment](./other.md#section)
1606[External link](https://example.com)
1607[Image link](image.png)
1608[Media file](video.mp4)
1609"#;
1610
1611        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612        let mut index = FileIndex::new();
1613        rule.contribute_to_index(&ctx, &mut index);
1614
1615        // Should only index markdown file links
1616        assert_eq!(index.cross_file_links.len(), 2);
1617
1618        // Check first link
1619        assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1620        assert_eq!(index.cross_file_links[0].fragment, "");
1621
1622        // Check second link (with fragment)
1623        assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1624        assert_eq!(index.cross_file_links[1].fragment, "section");
1625    }
1626
1627    #[test]
1628    fn test_contribute_to_index_skips_external_and_anchors() {
1629        let rule = MD057ExistingRelativeLinks::new();
1630        let content = r#"
1631# Document
1632
1633[External](https://example.com)
1634[Another external](http://example.org)
1635[Fragment only](#section)
1636[FTP link](ftp://files.example.com)
1637[Mail link](mailto:test@example.com)
1638[WWW link](www.example.com)
1639"#;
1640
1641        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1642        let mut index = FileIndex::new();
1643        rule.contribute_to_index(&ctx, &mut index);
1644
1645        // Should not index any of these
1646        assert_eq!(index.cross_file_links.len(), 0);
1647    }
1648
1649    #[test]
1650    fn test_cross_file_check_valid_link() {
1651        use crate::workspace_index::WorkspaceIndex;
1652
1653        let rule = MD057ExistingRelativeLinks::new();
1654
1655        // Create a workspace index with the target file
1656        let mut workspace_index = WorkspaceIndex::new();
1657        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1658
1659        // Create file index with a link to an existing file
1660        let mut file_index = FileIndex::new();
1661        file_index.add_cross_file_link(CrossFileLinkIndex {
1662            target_path: "guide.md".to_string(),
1663            fragment: "".to_string(),
1664            line: 5,
1665            column: 1,
1666        });
1667
1668        // Run cross-file check from docs/index.md
1669        let warnings = rule
1670            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1671            .unwrap();
1672
1673        // Should have no warnings - file exists
1674        assert!(warnings.is_empty());
1675    }
1676
1677    #[test]
1678    fn test_cross_file_check_missing_link() {
1679        use crate::workspace_index::WorkspaceIndex;
1680
1681        let rule = MD057ExistingRelativeLinks::new();
1682
1683        // Create an empty workspace index
1684        let workspace_index = WorkspaceIndex::new();
1685
1686        // Create file index with a link to a missing file
1687        let mut file_index = FileIndex::new();
1688        file_index.add_cross_file_link(CrossFileLinkIndex {
1689            target_path: "missing.md".to_string(),
1690            fragment: "".to_string(),
1691            line: 5,
1692            column: 1,
1693        });
1694
1695        // Run cross-file check
1696        let warnings = rule
1697            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1698            .unwrap();
1699
1700        // Should have one warning for the missing file
1701        assert_eq!(warnings.len(), 1);
1702        assert!(warnings[0].message.contains("missing.md"));
1703        assert!(warnings[0].message.contains("does not exist"));
1704    }
1705
1706    #[test]
1707    fn test_cross_file_check_parent_path() {
1708        use crate::workspace_index::WorkspaceIndex;
1709
1710        let rule = MD057ExistingRelativeLinks::new();
1711
1712        // Create a workspace index with the target file at the root
1713        let mut workspace_index = WorkspaceIndex::new();
1714        workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1715
1716        // Create file index with a parent path link
1717        let mut file_index = FileIndex::new();
1718        file_index.add_cross_file_link(CrossFileLinkIndex {
1719            target_path: "../readme.md".to_string(),
1720            fragment: "".to_string(),
1721            line: 5,
1722            column: 1,
1723        });
1724
1725        // Run cross-file check from docs/guide.md
1726        let warnings = rule
1727            .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
1728            .unwrap();
1729
1730        // Should have no warnings - file exists at normalized path
1731        assert!(warnings.is_empty());
1732    }
1733
1734    #[test]
1735    fn test_cross_file_check_html_link_with_md_source() {
1736        // Test that .html links are accepted when corresponding .md source exists
1737        // This supports mdBook and similar doc generators that compile .md to .html
1738        use crate::workspace_index::WorkspaceIndex;
1739
1740        let rule = MD057ExistingRelativeLinks::new();
1741
1742        // Create a workspace index with the .md source file
1743        let mut workspace_index = WorkspaceIndex::new();
1744        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1745
1746        // Create file index with an .html link (from another rule like MD051)
1747        let mut file_index = FileIndex::new();
1748        file_index.add_cross_file_link(CrossFileLinkIndex {
1749            target_path: "guide.html".to_string(),
1750            fragment: "section".to_string(),
1751            line: 10,
1752            column: 5,
1753        });
1754
1755        // Run cross-file check from docs/index.md
1756        let warnings = rule
1757            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1758            .unwrap();
1759
1760        // Should have no warnings - .md source exists for the .html link
1761        assert!(
1762            warnings.is_empty(),
1763            "Expected no warnings for .html link with .md source, got: {warnings:?}"
1764        );
1765    }
1766
1767    #[test]
1768    fn test_cross_file_check_html_link_without_source() {
1769        // Test that .html links without corresponding .md source ARE flagged
1770        use crate::workspace_index::WorkspaceIndex;
1771
1772        let rule = MD057ExistingRelativeLinks::new();
1773
1774        // Create an empty workspace index
1775        let workspace_index = WorkspaceIndex::new();
1776
1777        // Create file index with an .html link to a non-existent file
1778        let mut file_index = FileIndex::new();
1779        file_index.add_cross_file_link(CrossFileLinkIndex {
1780            target_path: "missing.html".to_string(),
1781            fragment: "".to_string(),
1782            line: 10,
1783            column: 5,
1784        });
1785
1786        // Run cross-file check from docs/index.md
1787        let warnings = rule
1788            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1789            .unwrap();
1790
1791        // Should have one warning - no .md source exists
1792        assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
1793        assert!(warnings[0].message.contains("missing.html"));
1794    }
1795
1796    #[test]
1797    fn test_normalize_path_function() {
1798        // Test simple cases
1799        assert_eq!(
1800            normalize_path(Path::new("docs/guide.md")),
1801            PathBuf::from("docs/guide.md")
1802        );
1803
1804        // Test current directory removal
1805        assert_eq!(
1806            normalize_path(Path::new("./docs/guide.md")),
1807            PathBuf::from("docs/guide.md")
1808        );
1809
1810        // Test parent directory resolution
1811        assert_eq!(
1812            normalize_path(Path::new("docs/sub/../guide.md")),
1813            PathBuf::from("docs/guide.md")
1814        );
1815
1816        // Test multiple parent directories
1817        assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
1818    }
1819
1820    #[test]
1821    fn test_html_link_with_md_source() {
1822        // Links to .html files should pass if corresponding .md source exists
1823        let temp_dir = tempdir().unwrap();
1824        let base_path = temp_dir.path();
1825
1826        // Create guide.md (source file)
1827        let md_file = base_path.join("guide.md");
1828        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
1829
1830        let content = r#"
1831[Read the guide](guide.html)
1832[Also here](getting-started.html)
1833"#;
1834
1835        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1836        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1837        let result = rule.check(&ctx).unwrap();
1838
1839        // guide.html passes (guide.md exists), getting-started.html fails
1840        assert_eq!(
1841            result.len(),
1842            1,
1843            "Should only warn about missing source. Got: {result:?}"
1844        );
1845        assert!(result[0].message.contains("getting-started.html"));
1846    }
1847
1848    #[test]
1849    fn test_htm_link_with_md_source() {
1850        // .htm extension should also check for markdown source
1851        let temp_dir = tempdir().unwrap();
1852        let base_path = temp_dir.path();
1853
1854        let md_file = base_path.join("page.md");
1855        File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
1856
1857        let content = "[Page](page.htm)";
1858
1859        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1860        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1861        let result = rule.check(&ctx).unwrap();
1862
1863        assert!(
1864            result.is_empty(),
1865            "Should not warn when .md source exists for .htm link"
1866        );
1867    }
1868
1869    #[test]
1870    fn test_html_link_finds_various_markdown_extensions() {
1871        // Should find .mdx, .markdown, etc. as source files
1872        let temp_dir = tempdir().unwrap();
1873        let base_path = temp_dir.path();
1874
1875        File::create(base_path.join("doc.md")).unwrap();
1876        File::create(base_path.join("tutorial.mdx")).unwrap();
1877        File::create(base_path.join("guide.markdown")).unwrap();
1878
1879        let content = r#"
1880[Doc](doc.html)
1881[Tutorial](tutorial.html)
1882[Guide](guide.html)
1883"#;
1884
1885        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1886        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1887        let result = rule.check(&ctx).unwrap();
1888
1889        assert!(
1890            result.is_empty(),
1891            "Should find all markdown variants as source files. Got: {result:?}"
1892        );
1893    }
1894
1895    #[test]
1896    fn test_html_link_in_subdirectory() {
1897        // Should find markdown source in subdirectories
1898        let temp_dir = tempdir().unwrap();
1899        let base_path = temp_dir.path();
1900
1901        let docs_dir = base_path.join("docs");
1902        std::fs::create_dir(&docs_dir).unwrap();
1903        File::create(docs_dir.join("guide.md"))
1904            .unwrap()
1905            .write_all(b"# Guide")
1906            .unwrap();
1907
1908        let content = "[Guide](docs/guide.html)";
1909
1910        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1911        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1912        let result = rule.check(&ctx).unwrap();
1913
1914        assert!(result.is_empty(), "Should find markdown source in subdirectory");
1915    }
1916
1917    #[test]
1918    fn test_absolute_path_skipped_in_check() {
1919        // Test that absolute paths are skipped during link validation
1920        // This fixes the bug where /pkg/runtime was being flagged
1921        let temp_dir = tempdir().unwrap();
1922        let base_path = temp_dir.path();
1923
1924        let content = r#"
1925# Test Document
1926
1927[Go Runtime](/pkg/runtime)
1928[Go Runtime with Fragment](/pkg/runtime#section)
1929[API Docs](/api/v1/users)
1930[Blog Post](/blog/2024/release.html)
1931[React Hook](/react/hooks/use-state.html)
1932"#;
1933
1934        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1935        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936        let result = rule.check(&ctx).unwrap();
1937
1938        // Should have NO warnings - all absolute paths should be skipped
1939        assert!(
1940            result.is_empty(),
1941            "Absolute paths should be skipped. Got warnings: {result:?}"
1942        );
1943    }
1944
1945    #[test]
1946    fn test_absolute_path_skipped_in_cross_file_check() {
1947        // Test that absolute paths are skipped in cross_file_check()
1948        use crate::workspace_index::WorkspaceIndex;
1949
1950        let rule = MD057ExistingRelativeLinks::new();
1951
1952        // Create an empty workspace index (no files exist)
1953        let workspace_index = WorkspaceIndex::new();
1954
1955        // Create file index with absolute path links (should be skipped)
1956        let mut file_index = FileIndex::new();
1957        file_index.add_cross_file_link(CrossFileLinkIndex {
1958            target_path: "/pkg/runtime.md".to_string(),
1959            fragment: "".to_string(),
1960            line: 5,
1961            column: 1,
1962        });
1963        file_index.add_cross_file_link(CrossFileLinkIndex {
1964            target_path: "/api/v1/users.md".to_string(),
1965            fragment: "section".to_string(),
1966            line: 10,
1967            column: 1,
1968        });
1969
1970        // Run cross-file check
1971        let warnings = rule
1972            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1973            .unwrap();
1974
1975        // Should have NO warnings - absolute paths should be skipped
1976        assert!(
1977            warnings.is_empty(),
1978            "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
1979        );
1980    }
1981
1982    #[test]
1983    fn test_protocol_relative_url_not_skipped() {
1984        // Test that protocol-relative URLs (//example.com) are NOT skipped as absolute paths
1985        // They should still be caught by is_external_url() though
1986        let temp_dir = tempdir().unwrap();
1987        let base_path = temp_dir.path();
1988
1989        let content = r#"
1990# Test Document
1991
1992[External](//example.com/page)
1993[Another](//cdn.example.com/asset.js)
1994"#;
1995
1996        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1997        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1998        let result = rule.check(&ctx).unwrap();
1999
2000        // Should have NO warnings - protocol-relative URLs are external and should be skipped
2001        assert!(
2002            result.is_empty(),
2003            "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2004        );
2005    }
2006
2007    #[test]
2008    fn test_email_addresses_skipped() {
2009        // Test that email addresses without mailto: are skipped
2010        // These are clearly not file links (the @ symbol is definitive)
2011        let temp_dir = tempdir().unwrap();
2012        let base_path = temp_dir.path();
2013
2014        let content = r#"
2015# Test Document
2016
2017[Contact](user@example.com)
2018[Steering](steering@kubernetes.io)
2019[Support](john.doe+filter@company.co.uk)
2020[User](user_name@sub.domain.com)
2021"#;
2022
2023        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2024        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2025        let result = rule.check(&ctx).unwrap();
2026
2027        // Should have NO warnings - email addresses are clearly not file links and should be skipped
2028        assert!(
2029            result.is_empty(),
2030            "Email addresses should be skipped. Got warnings: {result:?}"
2031        );
2032    }
2033
2034    #[test]
2035    fn test_email_addresses_vs_file_paths() {
2036        // Test that email addresses (anything with @) are skipped
2037        // Note: File paths with @ are extremely rare, so we treat anything with @ as an email
2038        let temp_dir = tempdir().unwrap();
2039        let base_path = temp_dir.path();
2040
2041        let content = r#"
2042# Test Document
2043
2044[Email](user@example.com)  <!-- Should be skipped (email) -->
2045[Email2](steering@kubernetes.io)  <!-- Should be skipped (email) -->
2046[Email3](user@file.md)  <!-- Should be skipped (has @, treated as email) -->
2047"#;
2048
2049        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2050        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2051        let result = rule.check(&ctx).unwrap();
2052
2053        // All should be skipped - anything with @ is treated as an email
2054        assert!(
2055            result.is_empty(),
2056            "All email addresses should be skipped. Got: {result:?}"
2057        );
2058    }
2059
2060    #[test]
2061    fn test_diagnostic_position_accuracy() {
2062        // Test that diagnostics point to the URL, not the link text
2063        let temp_dir = tempdir().unwrap();
2064        let base_path = temp_dir.path();
2065
2066        // Position markers:     0         1         2         3
2067        //                       0123456789012345678901234567890123456789
2068        let content = "prefix [text](missing.md) suffix";
2069        //             The URL "missing.md" starts at 0-indexed position 14
2070        //             which is 1-indexed column 15, and ends at 0-indexed 24 (1-indexed column 25)
2071
2072        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2073        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2074        let result = rule.check(&ctx).unwrap();
2075
2076        assert_eq!(result.len(), 1, "Should have exactly one warning");
2077        assert_eq!(result[0].line, 1, "Should be on line 1");
2078        assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2079        assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2080    }
2081
2082    #[test]
2083    fn test_diagnostic_position_angle_brackets() {
2084        // Test position accuracy with angle bracket links
2085        let temp_dir = tempdir().unwrap();
2086        let base_path = temp_dir.path();
2087
2088        // Position markers:     0         1         2
2089        //                       012345678901234567890
2090        let content = "[link](<missing.md>)";
2091        //             The URL "missing.md" starts at 0-indexed position 8 (1-indexed column 9)
2092
2093        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2094        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2095        let result = rule.check(&ctx).unwrap();
2096
2097        assert_eq!(result.len(), 1, "Should have exactly one warning");
2098        assert_eq!(result[0].line, 1, "Should be on line 1");
2099        assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2100    }
2101
2102    #[test]
2103    fn test_diagnostic_position_multiline() {
2104        // Test that line numbers are correct for links on different lines
2105        let temp_dir = tempdir().unwrap();
2106        let base_path = temp_dir.path();
2107
2108        let content = r#"# Title
2109Some text on line 2
2110[link on line 3](missing1.md)
2111More text
2112[link on line 5](missing2.md)"#;
2113
2114        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2115        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2116        let result = rule.check(&ctx).unwrap();
2117
2118        assert_eq!(result.len(), 2, "Should have two warnings");
2119
2120        // First warning should be on line 3
2121        assert_eq!(result[0].line, 3, "First warning should be on line 3");
2122        assert!(result[0].message.contains("missing1.md"));
2123
2124        // Second warning should be on line 5
2125        assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2126        assert!(result[1].message.contains("missing2.md"));
2127    }
2128
2129    #[test]
2130    fn test_diagnostic_position_with_spaces() {
2131        // Test position with URLs that have spaces in parentheses
2132        let temp_dir = tempdir().unwrap();
2133        let base_path = temp_dir.path();
2134
2135        let content = "[link]( missing.md )";
2136        //             0123456789012345678901
2137        //             0-indexed position 8 is 'm' in 'missing.md' (after space and paren)
2138        //             which is 1-indexed column 9
2139
2140        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2141        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2142        let result = rule.check(&ctx).unwrap();
2143
2144        assert_eq!(result.len(), 1, "Should have exactly one warning");
2145        // The regex captures the URL without leading/trailing spaces
2146        assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2147    }
2148
2149    #[test]
2150    fn test_diagnostic_position_image() {
2151        // Test that image diagnostics also have correct positions
2152        let temp_dir = tempdir().unwrap();
2153        let base_path = temp_dir.path();
2154
2155        let content = "![alt text](missing.jpg)";
2156
2157        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2158        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2159        let result = rule.check(&ctx).unwrap();
2160
2161        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2162        assert_eq!(result[0].line, 1);
2163        // Images use start_col from the parser, which should point to the URL
2164        assert!(result[0].column > 0, "Should have valid column position");
2165        assert!(result[0].message.contains("missing.jpg"));
2166    }
2167
2168    #[test]
2169    fn test_wikilinks_skipped() {
2170        // Wikilinks should not trigger MD057 warnings
2171        // They use a different linking system (e.g., Obsidian, wiki software)
2172        let temp_dir = tempdir().unwrap();
2173        let base_path = temp_dir.path();
2174
2175        let content = r#"# Test Document
2176
2177[[Microsoft#Windows OS]]
2178[[SomePage]]
2179[[Page With Spaces]]
2180[[path/to/page#section]]
2181[[page|Display Text]]
2182
2183This is a [real missing link](missing.md) that should be flagged.
2184"#;
2185
2186        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2187        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2188        let result = rule.check(&ctx).unwrap();
2189
2190        // Should only warn about the regular markdown link, not wikilinks
2191        assert_eq!(
2192            result.len(),
2193            1,
2194            "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2195        );
2196        assert!(
2197            result[0].message.contains("missing.md"),
2198            "Warning should be for missing.md, not wikilinks"
2199        );
2200    }
2201
2202    #[test]
2203    fn test_wikilinks_not_added_to_index() {
2204        // Wikilinks should not be added to the cross-file link index
2205        let temp_dir = tempdir().unwrap();
2206        let base_path = temp_dir.path();
2207
2208        let content = r#"# Test Document
2209
2210[[Microsoft#Windows OS]]
2211[[SomePage#section]]
2212[Regular Link](other.md)
2213"#;
2214
2215        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2216        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2217
2218        let mut file_index = FileIndex::new();
2219        rule.contribute_to_index(&ctx, &mut file_index);
2220
2221        // Should only have the regular markdown link (if it's a markdown file)
2222        // Wikilinks should not be added
2223        let cross_file_links = &file_index.cross_file_links;
2224        assert_eq!(
2225            cross_file_links.len(),
2226            1,
2227            "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2228        );
2229        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2230    }
2231
2232    #[test]
2233    fn test_reference_definition_missing_file() {
2234        // Reference definitions [ref]: ./path.md should be checked
2235        let temp_dir = tempdir().unwrap();
2236        let base_path = temp_dir.path();
2237
2238        let content = r#"# Test Document
2239
2240[test]: ./missing.md
2241[example]: ./nonexistent.html
2242
2243Use [test] and [example] here.
2244"#;
2245
2246        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2247        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2248        let result = rule.check(&ctx).unwrap();
2249
2250        // Should have warnings for both reference definitions
2251        assert_eq!(
2252            result.len(),
2253            2,
2254            "Should have warnings for missing reference definition targets. Got: {result:?}"
2255        );
2256        assert!(
2257            result.iter().any(|w| w.message.contains("missing.md")),
2258            "Should warn about missing.md"
2259        );
2260        assert!(
2261            result.iter().any(|w| w.message.contains("nonexistent.html")),
2262            "Should warn about nonexistent.html"
2263        );
2264    }
2265
2266    #[test]
2267    fn test_reference_definition_existing_file() {
2268        // Reference definitions to existing files should NOT trigger warnings
2269        let temp_dir = tempdir().unwrap();
2270        let base_path = temp_dir.path();
2271
2272        // Create an existing file
2273        let exists_path = base_path.join("exists.md");
2274        File::create(&exists_path)
2275            .unwrap()
2276            .write_all(b"# Existing file")
2277            .unwrap();
2278
2279        let content = r#"# Test Document
2280
2281[test]: ./exists.md
2282
2283Use [test] here.
2284"#;
2285
2286        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2287        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2288        let result = rule.check(&ctx).unwrap();
2289
2290        // Should have NO warnings since the file exists
2291        assert!(
2292            result.is_empty(),
2293            "Should not warn about existing file. Got: {result:?}"
2294        );
2295    }
2296
2297    #[test]
2298    fn test_reference_definition_external_url_skipped() {
2299        // Reference definitions with external URLs should be skipped
2300        let temp_dir = tempdir().unwrap();
2301        let base_path = temp_dir.path();
2302
2303        let content = r#"# Test Document
2304
2305[google]: https://google.com
2306[example]: http://example.org
2307[mail]: mailto:test@example.com
2308[ftp]: ftp://files.example.com
2309[local]: ./missing.md
2310
2311Use [google], [example], [mail], [ftp], [local] here.
2312"#;
2313
2314        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2315        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2316        let result = rule.check(&ctx).unwrap();
2317
2318        // Should only warn about the local missing file, not external URLs
2319        assert_eq!(
2320            result.len(),
2321            1,
2322            "Should only warn about local missing file. Got: {result:?}"
2323        );
2324        assert!(
2325            result[0].message.contains("missing.md"),
2326            "Warning should be for missing.md"
2327        );
2328    }
2329
2330    #[test]
2331    fn test_reference_definition_fragment_only_skipped() {
2332        // Reference definitions with fragment-only URLs should be skipped
2333        let temp_dir = tempdir().unwrap();
2334        let base_path = temp_dir.path();
2335
2336        let content = r#"# Test Document
2337
2338[section]: #my-section
2339
2340Use [section] here.
2341"#;
2342
2343        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2344        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2345        let result = rule.check(&ctx).unwrap();
2346
2347        // Should have NO warnings for fragment-only links
2348        assert!(
2349            result.is_empty(),
2350            "Should not warn about fragment-only reference. Got: {result:?}"
2351        );
2352    }
2353
2354    #[test]
2355    fn test_reference_definition_column_position() {
2356        // Test that column position points to the URL in the reference definition
2357        let temp_dir = tempdir().unwrap();
2358        let base_path = temp_dir.path();
2359
2360        // Position markers:     0         1         2
2361        //                       0123456789012345678901
2362        let content = "[ref]: ./missing.md";
2363        //             The URL "./missing.md" starts at 0-indexed position 7
2364        //             which is 1-indexed column 8
2365
2366        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2367        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2368        let result = rule.check(&ctx).unwrap();
2369
2370        assert_eq!(result.len(), 1, "Should have exactly one warning");
2371        assert_eq!(result[0].line, 1, "Should be on line 1");
2372        assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2373    }
2374
2375    #[test]
2376    fn test_reference_definition_html_with_md_source() {
2377        // Reference definitions to .html files should pass if corresponding .md source exists
2378        let temp_dir = tempdir().unwrap();
2379        let base_path = temp_dir.path();
2380
2381        // Create guide.md (source file)
2382        let md_file = base_path.join("guide.md");
2383        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2384
2385        let content = r#"# Test Document
2386
2387[guide]: ./guide.html
2388[missing]: ./missing.html
2389
2390Use [guide] and [missing] here.
2391"#;
2392
2393        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2394        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2395        let result = rule.check(&ctx).unwrap();
2396
2397        // guide.html passes (guide.md exists), missing.html fails
2398        assert_eq!(
2399            result.len(),
2400            1,
2401            "Should only warn about missing source. Got: {result:?}"
2402        );
2403        assert!(result[0].message.contains("missing.html"));
2404    }
2405
2406    #[test]
2407    fn test_reference_definition_url_encoded() {
2408        // Reference definitions with URL-encoded paths should be decoded before checking
2409        let temp_dir = tempdir().unwrap();
2410        let base_path = temp_dir.path();
2411
2412        // Create a file with spaces in the name
2413        let file_with_spaces = base_path.join("file with spaces.md");
2414        File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2415
2416        let content = r#"# Test Document
2417
2418[spaces]: ./file%20with%20spaces.md
2419[missing]: ./missing%20file.md
2420
2421Use [spaces] and [missing] here.
2422"#;
2423
2424        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2425        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2426        let result = rule.check(&ctx).unwrap();
2427
2428        // Should only warn about the missing file
2429        assert_eq!(
2430            result.len(),
2431            1,
2432            "Should only warn about missing URL-encoded file. Got: {result:?}"
2433        );
2434        assert!(result[0].message.contains("missing%20file.md"));
2435    }
2436
2437    #[test]
2438    fn test_inline_and_reference_both_checked() {
2439        // Both inline links and reference definitions should be checked
2440        let temp_dir = tempdir().unwrap();
2441        let base_path = temp_dir.path();
2442
2443        let content = r#"# Test Document
2444
2445[inline link](./inline-missing.md)
2446[ref]: ./ref-missing.md
2447
2448Use [ref] here.
2449"#;
2450
2451        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2452        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2453        let result = rule.check(&ctx).unwrap();
2454
2455        // Should warn about both the inline link and the reference definition
2456        assert_eq!(
2457            result.len(),
2458            2,
2459            "Should warn about both inline and reference links. Got: {result:?}"
2460        );
2461        assert!(
2462            result.iter().any(|w| w.message.contains("inline-missing.md")),
2463            "Should warn about inline-missing.md"
2464        );
2465        assert!(
2466            result.iter().any(|w| w.message.contains("ref-missing.md")),
2467            "Should warn about ref-missing.md"
2468        );
2469    }
2470
2471    #[test]
2472    fn test_footnote_definitions_not_flagged() {
2473        // Regression test for issue #286: footnote definitions should not be
2474        // treated as reference definitions and flagged as broken links
2475        let rule = MD057ExistingRelativeLinks::default();
2476
2477        let content = r#"# Title
2478
2479A footnote[^1].
2480
2481[^1]: [link](https://www.google.com).
2482"#;
2483
2484        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2485        let result = rule.check(&ctx).unwrap();
2486
2487        assert!(
2488            result.is_empty(),
2489            "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2490        );
2491    }
2492
2493    #[test]
2494    fn test_footnote_with_relative_link_inside() {
2495        // Footnotes containing relative links should not be checked
2496        // (the footnote content is not a URL, it's content that may contain links)
2497        let rule = MD057ExistingRelativeLinks::default();
2498
2499        let content = r#"# Title
2500
2501See the footnote[^1].
2502
2503[^1]: Check out [this file](./existing.md) for more info.
2504[^2]: Also see [missing](./does-not-exist.md).
2505"#;
2506
2507        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2508        let result = rule.check(&ctx).unwrap();
2509
2510        // The inline links INSIDE footnotes should be checked (./existing.md, ./does-not-exist.md)
2511        // but the footnote definition itself should not be treated as a reference definition
2512        // Note: This test verifies that [^1]: and [^2]: are not parsed as ref defs with
2513        // URLs like "[this file](./existing.md)" or "[missing](./does-not-exist.md)"
2514        for warning in &result {
2515            assert!(
2516                !warning.message.contains("[this file]"),
2517                "Footnote content should not be treated as URL: {warning:?}"
2518            );
2519            assert!(
2520                !warning.message.contains("[missing]"),
2521                "Footnote content should not be treated as URL: {warning:?}"
2522            );
2523        }
2524    }
2525
2526    #[test]
2527    fn test_mixed_footnotes_and_reference_definitions() {
2528        // Ensure regular reference definitions are still checked while footnotes are skipped
2529        let temp_dir = tempdir().unwrap();
2530        let base_path = temp_dir.path();
2531
2532        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2533
2534        let content = r#"# Title
2535
2536A footnote[^1] and a [ref link][myref].
2537
2538[^1]: This is a footnote with [link](https://example.com).
2539
2540[myref]: ./missing-file.md "This should be checked"
2541"#;
2542
2543        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2544        let result = rule.check(&ctx).unwrap();
2545
2546        // Should only warn about the regular reference definition, not the footnote
2547        assert_eq!(
2548            result.len(),
2549            1,
2550            "Should only warn about the regular reference definition. Got: {result:?}"
2551        );
2552        assert!(
2553            result[0].message.contains("missing-file.md"),
2554            "Should warn about missing-file.md in reference definition"
2555        );
2556    }
2557
2558    #[test]
2559    fn test_absolute_links_ignore_by_default() {
2560        // By default, absolute links are ignored (not validated)
2561        let temp_dir = tempdir().unwrap();
2562        let base_path = temp_dir.path();
2563
2564        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2565
2566        let content = r#"# Links
2567
2568[API docs](/api/v1/users)
2569[Blog post](/blog/2024/release.html)
2570![Logo](/assets/logo.png)
2571
2572[ref]: /docs/reference.md
2573"#;
2574
2575        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2576        let result = rule.check(&ctx).unwrap();
2577
2578        // No warnings - absolute links are ignored by default
2579        assert!(
2580            result.is_empty(),
2581            "Absolute links should be ignored by default. Got: {result:?}"
2582        );
2583    }
2584
2585    #[test]
2586    fn test_absolute_links_warn_config() {
2587        // When configured to warn, absolute links should generate warnings
2588        let temp_dir = tempdir().unwrap();
2589        let base_path = temp_dir.path();
2590
2591        let config = MD057Config {
2592            absolute_links: AbsoluteLinksOption::Warn,
2593        };
2594        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2595
2596        let content = r#"# Links
2597
2598[API docs](/api/v1/users)
2599[Blog post](/blog/2024/release.html)
2600"#;
2601
2602        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2603        let result = rule.check(&ctx).unwrap();
2604
2605        // Should have 2 warnings for the 2 absolute links
2606        assert_eq!(
2607            result.len(),
2608            2,
2609            "Should warn about both absolute links. Got: {result:?}"
2610        );
2611        assert!(
2612            result[0].message.contains("cannot be validated locally"),
2613            "Warning should explain why: {}",
2614            result[0].message
2615        );
2616        assert!(
2617            result[0].message.contains("/api/v1/users"),
2618            "Warning should include the link path"
2619        );
2620    }
2621
2622    #[test]
2623    fn test_absolute_links_warn_images() {
2624        // Images with absolute paths should also warn when configured
2625        let temp_dir = tempdir().unwrap();
2626        let base_path = temp_dir.path();
2627
2628        let config = MD057Config {
2629            absolute_links: AbsoluteLinksOption::Warn,
2630        };
2631        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2632
2633        let content = r#"# Images
2634
2635![Logo](/assets/logo.png)
2636"#;
2637
2638        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2639        let result = rule.check(&ctx).unwrap();
2640
2641        assert_eq!(
2642            result.len(),
2643            1,
2644            "Should warn about absolute image path. Got: {result:?}"
2645        );
2646        assert!(
2647            result[0].message.contains("/assets/logo.png"),
2648            "Warning should include the image path"
2649        );
2650    }
2651
2652    #[test]
2653    fn test_absolute_links_warn_reference_definitions() {
2654        // Reference definitions with absolute paths should also warn when configured
2655        let temp_dir = tempdir().unwrap();
2656        let base_path = temp_dir.path();
2657
2658        let config = MD057Config {
2659            absolute_links: AbsoluteLinksOption::Warn,
2660        };
2661        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2662
2663        let content = r#"# Reference
2664
2665See the [docs][ref].
2666
2667[ref]: /docs/reference.md
2668"#;
2669
2670        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2671        let result = rule.check(&ctx).unwrap();
2672
2673        assert_eq!(
2674            result.len(),
2675            1,
2676            "Should warn about absolute reference definition. Got: {result:?}"
2677        );
2678        assert!(
2679            result[0].message.contains("/docs/reference.md"),
2680            "Warning should include the reference path"
2681        );
2682    }
2683}