Skip to main content

rumdl_lib/rules/
md057_existing_relative_links.rs

1//!
2//! Rule MD057: Existing relative links
3//!
4//! See [docs/md057.md](../../docs/md057.md) for full documentation, configuration, and examples.
5
6use crate::rule::{
7    CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
8};
9use crate::workspace_index::{FileIndex, extract_cross_file_links};
10use regex::Regex;
11use std::collections::HashMap;
12use std::env;
13use std::path::{Path, PathBuf};
14use std::sync::LazyLock;
15use std::sync::{Arc, Mutex};
16
17mod md057_config;
18use crate::rule_config_serde::RuleConfig;
19use crate::utils::mkdocs_config::resolve_docs_dir;
20use crate::utils::obsidian_config::resolve_attachment_folder;
21pub use md057_config::{AbsoluteLinksOption, MD057Config};
22
23// Thread-safe cache for file existence checks to avoid redundant filesystem operations
24static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
25    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
26
27// Reset the file existence cache (typically between rule runs)
28fn reset_file_existence_cache() {
29    if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
30        cache.clear();
31    }
32}
33
34// Check if a file exists with caching
35fn file_exists_with_cache(path: &Path) -> bool {
36    match FILE_EXISTENCE_CACHE.lock() {
37        Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
38        Err(_) => path.exists(), // Fallback to uncached check on mutex poison
39    }
40}
41
42/// Check if a file exists, also trying markdown extensions for extensionless links.
43/// This supports wiki-style links like `[Link](page)` that resolve to `page.md`.
44fn file_exists_or_markdown_extension(path: &Path) -> bool {
45    // First, check exact path
46    if file_exists_with_cache(path) {
47        return true;
48    }
49
50    // If the path has no extension, try adding markdown extensions
51    if path.extension().is_none() {
52        for ext in MARKDOWN_EXTENSIONS {
53            // MARKDOWN_EXTENSIONS includes the dot, e.g., ".md"
54            let path_with_ext = path.with_extension(&ext[1..]);
55            if file_exists_with_cache(&path_with_ext) {
56                return true;
57            }
58        }
59    }
60
61    false
62}
63
64// Regex to match the start of a link - simplified for performance
65static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
66
67/// Regex to extract the URL from an angle-bracketed markdown link
68/// Format: `](<URL>)` or `](<URL> "title")`
69/// This handles URLs with parentheses like `](<path/(with)/parens.md>)`
70static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
71    LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
72
73/// Regex to extract the URL from a normal markdown link (without angle brackets)
74/// Format: `](URL)` or `](URL "title")`
75static URL_EXTRACT_REGEX: LazyLock<Regex> =
76    LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
77
78/// Regex to detect URLs with explicit schemes (should not be checked as relative links)
79/// Matches: scheme:// or scheme: (per RFC 3986)
80/// This covers http, https, ftp, file, smb, mailto, tel, data, macappstores, etc.
81static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
82    LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
83
84// Current working directory
85static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
86
87/// Convert a hex digit (0-9, a-f, A-F) to its numeric value.
88/// Returns None for non-hex characters.
89#[inline]
90fn hex_digit_to_value(byte: u8) -> Option<u8> {
91    match byte {
92        b'0'..=b'9' => Some(byte - b'0'),
93        b'a'..=b'f' => Some(byte - b'a' + 10),
94        b'A'..=b'F' => Some(byte - b'A' + 10),
95        _ => None,
96    }
97}
98
99/// Supported markdown file extensions
100const MARKDOWN_EXTENSIONS: &[&str] = &[
101    ".md",
102    ".markdown",
103    ".mdx",
104    ".mkd",
105    ".mkdn",
106    ".mdown",
107    ".mdwn",
108    ".qmd",
109    ".rmd",
110];
111
112/// Rule MD057: Existing relative links should point to valid files or directories.
113#[derive(Debug, Clone)]
114pub struct MD057ExistingRelativeLinks {
115    /// Base directory for resolving relative links
116    base_path: Arc<Mutex<Option<PathBuf>>>,
117    /// Configuration for the rule
118    config: MD057Config,
119    /// Markdown flavor (used for Obsidian attachment folder auto-detection)
120    flavor: crate::config::MarkdownFlavor,
121}
122
123impl Default for MD057ExistingRelativeLinks {
124    fn default() -> Self {
125        Self {
126            base_path: Arc::new(Mutex::new(None)),
127            config: MD057Config::default(),
128            flavor: crate::config::MarkdownFlavor::default(),
129        }
130    }
131}
132
133impl MD057ExistingRelativeLinks {
134    /// Create a new instance with default settings
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Set the base path for resolving relative links
140    pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
141        let path = path.as_ref();
142        let dir_path = if path.is_file() {
143            path.parent().map(|p| p.to_path_buf())
144        } else {
145            Some(path.to_path_buf())
146        };
147
148        if let Ok(mut guard) = self.base_path.lock() {
149            *guard = dir_path;
150        }
151        self
152    }
153
154    pub fn from_config_struct(config: MD057Config) -> Self {
155        Self {
156            base_path: Arc::new(Mutex::new(None)),
157            config,
158            flavor: crate::config::MarkdownFlavor::default(),
159        }
160    }
161
162    /// Set the markdown flavor for Obsidian attachment auto-detection
163    #[cfg(test)]
164    fn with_flavor(mut self, flavor: crate::config::MarkdownFlavor) -> Self {
165        self.flavor = flavor;
166        self
167    }
168
169    /// Check if a URL is external or should be skipped for validation.
170    ///
171    /// Returns `true` (skip validation) for:
172    /// - URLs with protocols: `https://`, `http://`, `ftp://`, `mailto:`, etc.
173    /// - Bare domains: `www.example.com`, `example.com`
174    /// - Email addresses: `user@example.com` (without `mailto:`)
175    /// - Template variables: `{{URL}}`, `{{% include %}}`
176    /// - Absolute web URL paths: `/api/docs`, `/blog/post.html`
177    ///
178    /// Returns `false` (validate) for:
179    /// - Relative filesystem paths: `./file.md`, `../parent/file.md`, `file.md`
180    #[inline]
181    fn is_external_url(&self, url: &str) -> bool {
182        if url.is_empty() {
183            return false;
184        }
185
186        // Quick checks for common external URL patterns
187        if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
188            return true;
189        }
190
191        // Skip template variables (Handlebars/Mustache/Jinja2 syntax)
192        // Examples: {{URL}}, {{#URL}}, {{> partial}}, {{% include %}}, {{ variable }}
193        if url.starts_with("{{") || url.starts_with("{%") {
194            return true;
195        }
196
197        // Simple check: if URL contains @, it's almost certainly an email address
198        // File paths with @ are extremely rare, so this is a safe heuristic
199        if url.contains('@') {
200            return true; // It's an email address, skip it
201        }
202
203        // Bare domain check (e.g., "example.com")
204        // Note: We intentionally DON'T skip all TLDs like .org, .net, etc.
205        // Links like [text](nodejs.org/path) without a protocol are broken -
206        // they'll be treated as relative paths by markdown renderers.
207        // Flagging them helps users find missing protocols.
208        // We only skip .com as a minimal safety net for the most common case.
209        if url.ends_with(".com") {
210            return true;
211        }
212
213        // Framework path aliases (resolved by build tools like Vite, webpack, etc.)
214        // These are not filesystem paths but module/asset aliases
215        // Examples: ~/assets/image.png, @images/photo.jpg, @/components/Button.vue
216        if url.starts_with('~') || url.starts_with('@') {
217            return true;
218        }
219
220        // All other cases (relative paths, etc.) are not external
221        false
222    }
223
224    /// Check if the URL is a fragment-only link (internal document link)
225    #[inline]
226    fn is_fragment_only_link(&self, url: &str) -> bool {
227        url.starts_with('#')
228    }
229
230    /// Check if the URL is an absolute path (starts with /)
231    /// These are typically routes for published documentation sites.
232    #[inline]
233    fn is_absolute_path(url: &str) -> bool {
234        url.starts_with('/')
235    }
236
237    /// Decode URL percent-encoded sequences in a path.
238    /// Converts `%20` to space, `%2F` to `/`, etc.
239    /// Returns the original string if decoding fails or produces invalid UTF-8.
240    fn url_decode(path: &str) -> String {
241        // Quick check: if no percent sign, return as-is
242        if !path.contains('%') {
243            return path.to_string();
244        }
245
246        let bytes = path.as_bytes();
247        let mut result = Vec::with_capacity(bytes.len());
248        let mut i = 0;
249
250        while i < bytes.len() {
251            if bytes[i] == b'%' && i + 2 < bytes.len() {
252                // Try to parse the two hex digits following %
253                let hex1 = bytes[i + 1];
254                let hex2 = bytes[i + 2];
255                if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
256                    result.push(d1 * 16 + d2);
257                    i += 3;
258                    continue;
259                }
260            }
261            result.push(bytes[i]);
262            i += 1;
263        }
264
265        // Convert to UTF-8, falling back to original if invalid
266        String::from_utf8(result).unwrap_or_else(|_| path.to_string())
267    }
268
269    /// Strip query parameters and fragments from a URL for file existence checking.
270    /// URLs like `path/to/image.png?raw=true` or `file.md#section` should check
271    /// for `path/to/image.png` or `file.md` respectively.
272    ///
273    /// Note: In standard URLs, query parameters (`?`) come before fragments (`#`),
274    /// so we check for `?` first. If a URL has both, only the query is stripped here
275    /// (fragments are handled separately by the regex in `contribute_to_index`).
276    fn strip_query_and_fragment(url: &str) -> &str {
277        // Find the first occurrence of '?' or '#', whichever comes first
278        // This handles both standard URLs (? before #) and edge cases (# before ?)
279        let query_pos = url.find('?');
280        let fragment_pos = url.find('#');
281
282        match (query_pos, fragment_pos) {
283            (Some(q), Some(f)) => {
284                // Both exist - strip at whichever comes first
285                &url[..q.min(f)]
286            }
287            (Some(q), None) => &url[..q],
288            (None, Some(f)) => &url[..f],
289            (None, None) => url,
290        }
291    }
292
293    /// Resolve a relative link against a provided base path
294    fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
295        base_path.join(link)
296    }
297
298    /// Compute additional search paths for fallback link resolution.
299    ///
300    /// Combines Obsidian attachment folder auto-detection (when flavor is Obsidian)
301    /// with explicitly configured `search-paths`.
302    fn compute_search_paths(
303        &self,
304        flavor: crate::config::MarkdownFlavor,
305        source_file: Option<&Path>,
306        base_path: &Path,
307    ) -> Vec<PathBuf> {
308        let mut paths = Vec::new();
309
310        // Auto-detect Obsidian attachment folder
311        if flavor == crate::config::MarkdownFlavor::Obsidian
312            && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
313            && attachment_dir != *base_path
314        {
315            paths.push(attachment_dir);
316        }
317
318        // Add explicitly configured search paths
319        for search_path in &self.config.search_paths {
320            let resolved = if Path::new(search_path).is_absolute() {
321                PathBuf::from(search_path)
322            } else {
323                // Resolve relative to CWD (project root)
324                CURRENT_DIR.join(search_path)
325            };
326            if resolved != *base_path && !paths.contains(&resolved) {
327                paths.push(resolved);
328            }
329        }
330
331        paths
332    }
333
334    /// Check if a link target exists in any of the additional search paths.
335    fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
336        search_paths.iter().any(|dir| {
337            let candidate = dir.join(decoded_path);
338            file_exists_or_markdown_extension(&candidate)
339        })
340    }
341
342    /// Check if a relative link can be compacted and return the simplified form.
343    ///
344    /// Returns `None` if compact-paths is disabled, the link has no traversal,
345    /// or the link is already the shortest form.
346    /// Returns `Some(suggestion)` with the full compacted URL (including fragment/query suffix).
347    fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
348        if !self.config.compact_paths {
349            return None;
350        }
351
352        // Split URL into path and suffix (fragment/query)
353        let path_end = url
354            .find('?')
355            .unwrap_or(url.len())
356            .min(url.find('#').unwrap_or(url.len()));
357        let path_part = &url[..path_end];
358        let suffix = &url[path_end..];
359
360        // URL-decode the path portion for filesystem resolution
361        let decoded_path = Self::url_decode(path_part);
362
363        compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
364    }
365
366    /// Validate an absolute link by resolving it relative to MkDocs docs_dir.
367    ///
368    /// Returns `Some(warning_message)` if the link is broken, `None` if valid.
369    /// Falls back to a generic warning if no mkdocs.yml is found.
370    fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
371        let Some(docs_dir) = resolve_docs_dir(source_path) else {
372            // No mkdocs.yml found — fall back to warn behavior
373            return Some(format!(
374                "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
375            ));
376        };
377
378        // Strip leading / and resolve relative to docs_dir
379        let relative_url = url.trim_start_matches('/');
380
381        // Strip query/fragment before checking existence
382        let file_path = Self::strip_query_and_fragment(relative_url);
383        let decoded = Self::url_decode(file_path);
384        let resolved_path = docs_dir.join(&decoded);
385
386        // For directory-style links (ending with /, bare path to a directory, or empty
387        // decoded path like "/"), check for index.md inside the directory.
388        // This must be checked BEFORE file_exists_or_markdown_extension because
389        // path.exists() returns true for directories — we need to verify index.md exists.
390        let is_directory_link = url.ends_with('/') || decoded.is_empty();
391        if is_directory_link || resolved_path.is_dir() {
392            let index_path = resolved_path.join("index.md");
393            if file_exists_with_cache(&index_path) {
394                return None; // Valid directory link with index.md
395            }
396            // Directory exists but no index.md — fall through to error
397            if resolved_path.is_dir() {
398                return Some(format!(
399                    "Absolute link '{url}' resolves to directory '{}' which has no index.md",
400                    resolved_path.display()
401                ));
402            }
403        }
404
405        // Check existence (with markdown extension fallback for extensionless links)
406        if file_exists_or_markdown_extension(&resolved_path) {
407            return None; // Valid link
408        }
409
410        // For .html/.htm links, check for corresponding markdown source
411        if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
412            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
413            && let (Some(stem), Some(parent)) = (
414                resolved_path.file_stem().and_then(|s| s.to_str()),
415                resolved_path.parent(),
416            )
417        {
418            let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
419                let source_path = parent.join(format!("{stem}{md_ext}"));
420                file_exists_with_cache(&source_path)
421            });
422            if has_md_source {
423                return None; // Markdown source exists
424            }
425        }
426
427        Some(format!(
428            "Absolute link '{url}' resolves to '{}' which does not exist",
429            resolved_path.display()
430        ))
431    }
432}
433
434impl Rule for MD057ExistingRelativeLinks {
435    fn name(&self) -> &'static str {
436        "MD057"
437    }
438
439    fn description(&self) -> &'static str {
440        "Relative links should point to existing files"
441    }
442
443    fn category(&self) -> RuleCategory {
444        RuleCategory::Link
445    }
446
447    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
448        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
449    }
450
451    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
452        let content = ctx.content;
453
454        // Early returns for performance
455        if content.is_empty() || !content.contains('[') {
456            return Ok(Vec::new());
457        }
458
459        // Quick check for any potential links before expensive operations
460        // Check for inline links "](", reference definitions "]:", or images "!["
461        if !content.contains("](") && !content.contains("]:") {
462            return Ok(Vec::new());
463        }
464
465        // Reset the file existence cache for a fresh run
466        reset_file_existence_cache();
467
468        let mut warnings = Vec::new();
469
470        // Determine base path for resolving relative links
471        // ALWAYS compute from ctx.source_file for each file - do not reuse cached base_path
472        // This ensures each file resolves links relative to its own directory
473        let base_path: Option<PathBuf> = {
474            // First check if base_path was explicitly set via with_path() (for tests)
475            let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
476            if explicit_base.is_some() {
477                explicit_base
478            } else if let Some(ref source_file) = ctx.source_file {
479                // Resolve symlinks to get the actual file location
480                // This ensures relative links are resolved from the target's directory,
481                // not the symlink's directory
482                let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
483                resolved_file
484                    .parent()
485                    .map(|p| p.to_path_buf())
486                    .or_else(|| Some(CURRENT_DIR.clone()))
487            } else {
488                // No source file available - cannot validate relative links
489                None
490            }
491        };
492
493        // If we still don't have a base path, we can't validate relative links
494        let Some(base_path) = base_path else {
495            return Ok(warnings);
496        };
497
498        // Compute additional search paths for fallback link resolution
499        let extra_search_paths = self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path);
500
501        // Use LintContext links instead of expensive regex parsing
502        if !ctx.links.is_empty() {
503            // Use LineIndex for correct position calculation across all line ending types
504            let line_index = &ctx.line_index;
505
506            // Pre-collected lines from context
507            let lines = ctx.raw_lines();
508
509            // Track which lines we've already processed to avoid duplicates
510            // (ctx.links may have multiple entries for the same line, especially with malformed markdown)
511            let mut processed_lines = std::collections::HashSet::new();
512
513            for link in &ctx.links {
514                let line_idx = link.line - 1;
515                if line_idx >= lines.len() {
516                    continue;
517                }
518
519                // Skip lines inside PyMdown blocks
520                if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
521                    continue;
522                }
523
524                // Skip if we've already processed this line
525                if !processed_lines.insert(line_idx) {
526                    continue;
527                }
528
529                let line = lines[line_idx];
530
531                // Quick check for link pattern in this line
532                if !line.contains("](") {
533                    continue;
534                }
535
536                // Find all links in this line using optimized regex
537                for link_match in LINK_START_REGEX.find_iter(line) {
538                    let start_pos = link_match.start();
539                    let end_pos = link_match.end();
540
541                    // Calculate absolute position using LineIndex
542                    let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
543                    let absolute_start_pos = line_start_byte + start_pos;
544
545                    // Skip if this link is in a code span
546                    if ctx.is_in_code_span_byte(absolute_start_pos) {
547                        continue;
548                    }
549
550                    // Skip if this link is in a math span (LaTeX $...$ or $$...$$)
551                    if ctx.is_in_math_span(absolute_start_pos) {
552                        continue;
553                    }
554
555                    // Find the URL part after the link text
556                    // Try angle-bracket regex first (handles URLs with parens like `<path/(with)/parens.md>`)
557                    // Then fall back to normal URL regex
558                    let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
559                        .captures_at(line, end_pos - 1)
560                        .and_then(|caps| caps.get(1).map(|g| (caps, g)))
561                        .or_else(|| {
562                            URL_EXTRACT_REGEX
563                                .captures_at(line, end_pos - 1)
564                                .and_then(|caps| caps.get(1).map(|g| (caps, g)))
565                        });
566
567                    if let Some((caps, url_group)) = caps_and_url {
568                        let url = url_group.as_str().trim();
569
570                        // Skip empty URLs
571                        if url.is_empty() {
572                            continue;
573                        }
574
575                        // Skip rustdoc intra-doc links (backtick-wrapped URLs)
576                        // These are Rust API references, not file paths
577                        // Example: [`f32::is_subnormal`], [`Vec::push`]
578                        if url.starts_with('`') && url.ends_with('`') {
579                            continue;
580                        }
581
582                        // Skip external URLs and fragment-only links
583                        if self.is_external_url(url) || self.is_fragment_only_link(url) {
584                            continue;
585                        }
586
587                        // Handle absolute paths based on config
588                        if Self::is_absolute_path(url) {
589                            match self.config.absolute_links {
590                                AbsoluteLinksOption::Warn => {
591                                    let url_start = url_group.start();
592                                    let url_end = url_group.end();
593                                    warnings.push(LintWarning {
594                                        rule_name: Some(self.name().to_string()),
595                                        line: link.line,
596                                        column: url_start + 1,
597                                        end_line: link.line,
598                                        end_column: url_end + 1,
599                                        message: format!("Absolute link '{url}' cannot be validated locally"),
600                                        severity: Severity::Warning,
601                                        fix: None,
602                                    });
603                                }
604                                AbsoluteLinksOption::RelativeToDocs => {
605                                    if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
606                                        let url_start = url_group.start();
607                                        let url_end = url_group.end();
608                                        warnings.push(LintWarning {
609                                            rule_name: Some(self.name().to_string()),
610                                            line: link.line,
611                                            column: url_start + 1,
612                                            end_line: link.line,
613                                            end_column: url_end + 1,
614                                            message: msg,
615                                            severity: Severity::Warning,
616                                            fix: None,
617                                        });
618                                    }
619                                }
620                                AbsoluteLinksOption::Ignore => {}
621                            }
622                            continue;
623                        }
624
625                        // Check for unnecessary path traversal (compact-paths)
626                        // Reconstruct full URL including fragment (regex group 2)
627                        // since url_group (group 1) contains only the path part
628                        let full_url_for_compact = if let Some(frag) = caps.get(2) {
629                            format!("{url}{}", frag.as_str())
630                        } else {
631                            url.to_string()
632                        };
633                        if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
634                            let url_start = url_group.start();
635                            let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
636                            let fix_byte_start = line_start_byte + url_start;
637                            let fix_byte_end = line_start_byte + url_end;
638                            warnings.push(LintWarning {
639                                rule_name: Some(self.name().to_string()),
640                                line: link.line,
641                                column: url_start + 1,
642                                end_line: link.line,
643                                end_column: url_end + 1,
644                                message: format!(
645                                    "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
646                                ),
647                                severity: Severity::Warning,
648                                fix: Some(Fix {
649                                    range: fix_byte_start..fix_byte_end,
650                                    replacement: suggestion,
651                                }),
652                            });
653                        }
654
655                        // Strip query parameters and fragments before checking file existence
656                        let file_path = Self::strip_query_and_fragment(url);
657
658                        // URL-decode the path to handle percent-encoded characters
659                        let decoded_path = Self::url_decode(file_path);
660
661                        // Resolve the relative link against the base path
662                        let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
663
664                        // Check if the file exists, also trying markdown extensions for extensionless links
665                        if file_exists_or_markdown_extension(&resolved_path) {
666                            continue; // File exists, no warning needed
667                        }
668
669                        // For .html/.htm links, check if a corresponding markdown source exists
670                        let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
671                            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
672                            && let (Some(stem), Some(parent)) = (
673                                resolved_path.file_stem().and_then(|s| s.to_str()),
674                                resolved_path.parent(),
675                            ) {
676                            MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
677                                let source_path = parent.join(format!("{stem}{md_ext}"));
678                                file_exists_with_cache(&source_path)
679                            })
680                        } else {
681                            false
682                        };
683
684                        if has_md_source {
685                            continue; // Markdown source exists, link is valid
686                        }
687
688                        // Try additional search paths (Obsidian attachment folder, configured paths)
689                        if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
690                            continue;
691                        }
692
693                        // File doesn't exist and no source file found
694                        // Use actual URL position from regex capture group
695                        // Note: capture group positions are absolute within the line string
696                        let url_start = url_group.start();
697                        let url_end = url_group.end();
698
699                        warnings.push(LintWarning {
700                            rule_name: Some(self.name().to_string()),
701                            line: link.line,
702                            column: url_start + 1, // 1-indexed
703                            end_line: link.line,
704                            end_column: url_end + 1, // 1-indexed
705                            message: format!("Relative link '{url}' does not exist"),
706                            severity: Severity::Error,
707                            fix: None,
708                        });
709                    }
710                }
711            }
712        }
713
714        // Also process images - they have URLs already parsed
715        for image in &ctx.images {
716            // Skip images inside PyMdown blocks (MkDocs flavor)
717            if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
718                continue;
719            }
720
721            let url = image.url.as_ref();
722
723            // Skip empty URLs
724            if url.is_empty() {
725                continue;
726            }
727
728            // Skip external URLs and fragment-only links
729            if self.is_external_url(url) || self.is_fragment_only_link(url) {
730                continue;
731            }
732
733            // Handle absolute paths based on config
734            if Self::is_absolute_path(url) {
735                match self.config.absolute_links {
736                    AbsoluteLinksOption::Warn => {
737                        warnings.push(LintWarning {
738                            rule_name: Some(self.name().to_string()),
739                            line: image.line,
740                            column: image.start_col + 1,
741                            end_line: image.line,
742                            end_column: image.start_col + 1 + url.len(),
743                            message: format!("Absolute link '{url}' cannot be validated locally"),
744                            severity: Severity::Warning,
745                            fix: None,
746                        });
747                    }
748                    AbsoluteLinksOption::RelativeToDocs => {
749                        if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
750                            warnings.push(LintWarning {
751                                rule_name: Some(self.name().to_string()),
752                                line: image.line,
753                                column: image.start_col + 1,
754                                end_line: image.line,
755                                end_column: image.start_col + 1 + url.len(),
756                                message: msg,
757                                severity: Severity::Warning,
758                                fix: None,
759                            });
760                        }
761                    }
762                    AbsoluteLinksOption::Ignore => {}
763                }
764                continue;
765            }
766
767            // Check for unnecessary path traversal (compact-paths)
768            if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
769                // Find the URL position within the image syntax using document byte offsets.
770                // Search from image.byte_offset (the `!` character) to locate the URL string.
771                let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
772                    let fix_byte_start = image.byte_offset + url_offset;
773                    let fix_byte_end = fix_byte_start + url.len();
774                    Fix {
775                        range: fix_byte_start..fix_byte_end,
776                        replacement: suggestion.clone(),
777                    }
778                });
779
780                let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
781                let url_col = fix
782                    .as_ref()
783                    .map_or(image.start_col + 1, |f| f.range.start - img_line_start_byte + 1);
784                warnings.push(LintWarning {
785                    rule_name: Some(self.name().to_string()),
786                    line: image.line,
787                    column: url_col,
788                    end_line: image.line,
789                    end_column: url_col + url.len(),
790                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
791                    severity: Severity::Warning,
792                    fix,
793                });
794            }
795
796            // Strip query parameters and fragments before checking file existence
797            let file_path = Self::strip_query_and_fragment(url);
798
799            // URL-decode the path to handle percent-encoded characters
800            let decoded_path = Self::url_decode(file_path);
801
802            // Resolve the relative link against the base path
803            let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
804
805            // Check if the file exists, also trying markdown extensions for extensionless links
806            if file_exists_or_markdown_extension(&resolved_path) {
807                continue; // File exists, no warning needed
808            }
809
810            // For .html/.htm links, check if a corresponding markdown source exists
811            let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
812                && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
813                && let (Some(stem), Some(parent)) = (
814                    resolved_path.file_stem().and_then(|s| s.to_str()),
815                    resolved_path.parent(),
816                ) {
817                MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
818                    let source_path = parent.join(format!("{stem}{md_ext}"));
819                    file_exists_with_cache(&source_path)
820                })
821            } else {
822                false
823            };
824
825            if has_md_source {
826                continue; // Markdown source exists, link is valid
827            }
828
829            // Try additional search paths (Obsidian attachment folder, configured paths)
830            if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
831                continue;
832            }
833
834            // File doesn't exist and no source file found
835            // Images already have correct position from parser
836            warnings.push(LintWarning {
837                rule_name: Some(self.name().to_string()),
838                line: image.line,
839                column: image.start_col + 1,
840                end_line: image.line,
841                end_column: image.start_col + 1 + url.len(),
842                message: format!("Relative link '{url}' does not exist"),
843                severity: Severity::Error,
844                fix: None,
845            });
846        }
847
848        // Also process reference definitions: [ref]: ./path.md
849        for ref_def in &ctx.reference_defs {
850            let url = &ref_def.url;
851
852            // Skip empty URLs
853            if url.is_empty() {
854                continue;
855            }
856
857            // Skip external URLs and fragment-only links
858            if self.is_external_url(url) || self.is_fragment_only_link(url) {
859                continue;
860            }
861
862            // Handle absolute paths based on config
863            if Self::is_absolute_path(url) {
864                match self.config.absolute_links {
865                    AbsoluteLinksOption::Warn => {
866                        let line_idx = ref_def.line - 1;
867                        let column = content.lines().nth(line_idx).map_or(1, |line_content| {
868                            line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
869                        });
870                        warnings.push(LintWarning {
871                            rule_name: Some(self.name().to_string()),
872                            line: ref_def.line,
873                            column,
874                            end_line: ref_def.line,
875                            end_column: column + url.len(),
876                            message: format!("Absolute link '{url}' cannot be validated locally"),
877                            severity: Severity::Warning,
878                            fix: None,
879                        });
880                    }
881                    AbsoluteLinksOption::RelativeToDocs => {
882                        if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
883                            let line_idx = ref_def.line - 1;
884                            let column = content.lines().nth(line_idx).map_or(1, |line_content| {
885                                line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
886                            });
887                            warnings.push(LintWarning {
888                                rule_name: Some(self.name().to_string()),
889                                line: ref_def.line,
890                                column,
891                                end_line: ref_def.line,
892                                end_column: column + url.len(),
893                                message: msg,
894                                severity: Severity::Warning,
895                                fix: None,
896                            });
897                        }
898                    }
899                    AbsoluteLinksOption::Ignore => {}
900                }
901                continue;
902            }
903
904            // Check for unnecessary path traversal (compact-paths)
905            if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
906                let ref_line_idx = ref_def.line - 1;
907                let col = content.lines().nth(ref_line_idx).map_or(1, |line_content| {
908                    line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
909                });
910                let ref_line_start_byte = ctx.line_index.get_line_start_byte(ref_def.line).unwrap_or(0);
911                let fix_byte_start = ref_line_start_byte + col - 1;
912                let fix_byte_end = fix_byte_start + url.len();
913                warnings.push(LintWarning {
914                    rule_name: Some(self.name().to_string()),
915                    line: ref_def.line,
916                    column: col,
917                    end_line: ref_def.line,
918                    end_column: col + url.len(),
919                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
920                    severity: Severity::Warning,
921                    fix: Some(Fix {
922                        range: fix_byte_start..fix_byte_end,
923                        replacement: suggestion,
924                    }),
925                });
926            }
927
928            // Strip query parameters and fragments before checking file existence
929            let file_path = Self::strip_query_and_fragment(url);
930
931            // URL-decode the path to handle percent-encoded characters
932            let decoded_path = Self::url_decode(file_path);
933
934            // Resolve the relative link against the base path
935            let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
936
937            // Check if the file exists, also trying markdown extensions for extensionless links
938            if file_exists_or_markdown_extension(&resolved_path) {
939                continue; // File exists, no warning needed
940            }
941
942            // For .html/.htm links, check if a corresponding markdown source exists
943            let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
944                && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
945                && let (Some(stem), Some(parent)) = (
946                    resolved_path.file_stem().and_then(|s| s.to_str()),
947                    resolved_path.parent(),
948                ) {
949                MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
950                    let source_path = parent.join(format!("{stem}{md_ext}"));
951                    file_exists_with_cache(&source_path)
952                })
953            } else {
954                false
955            };
956
957            if has_md_source {
958                continue; // Markdown source exists, link is valid
959            }
960
961            // Try additional search paths (Obsidian attachment folder, configured paths)
962            if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
963                continue;
964            }
965
966            // File doesn't exist and no source file found
967            // Calculate column position: find URL within the line
968            let line_idx = ref_def.line - 1;
969            let column = content.lines().nth(line_idx).map_or(1, |line_content| {
970                // Find URL position in line (after ]: )
971                line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
972            });
973
974            warnings.push(LintWarning {
975                rule_name: Some(self.name().to_string()),
976                line: ref_def.line,
977                column,
978                end_line: ref_def.line,
979                end_column: column + url.len(),
980                message: format!("Relative link '{url}' does not exist"),
981                severity: Severity::Error,
982                fix: None,
983            });
984        }
985
986        Ok(warnings)
987    }
988
989    fn fix_capability(&self) -> FixCapability {
990        if self.config.compact_paths {
991            FixCapability::ConditionallyFixable
992        } else {
993            FixCapability::Unfixable
994        }
995    }
996
997    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
998        if !self.config.compact_paths {
999            return Ok(ctx.content.to_string());
1000        }
1001
1002        let warnings = self.check(ctx)?;
1003        let warnings =
1004            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1005        let mut content = ctx.content.to_string();
1006
1007        // Collect fixable warnings (compact-paths) sorted by byte offset descending
1008        let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1009        fixes.sort_by(|a, b| b.range.start.cmp(&a.range.start));
1010
1011        for fix in fixes {
1012            if fix.range.end <= content.len() {
1013                content.replace_range(fix.range.clone(), &fix.replacement);
1014            }
1015        }
1016
1017        Ok(content)
1018    }
1019
1020    fn as_any(&self) -> &dyn std::any::Any {
1021        self
1022    }
1023
1024    fn default_config_section(&self) -> Option<(String, toml::Value)> {
1025        let default_config = MD057Config::default();
1026        let json_value = serde_json::to_value(&default_config).ok()?;
1027        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
1028
1029        if let toml::Value::Table(table) = toml_value {
1030            if !table.is_empty() {
1031                Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1032            } else {
1033                None
1034            }
1035        } else {
1036            None
1037        }
1038    }
1039
1040    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1041    where
1042        Self: Sized,
1043    {
1044        let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1045        let mut rule = Self::from_config_struct(rule_config);
1046        rule.flavor = config.global.flavor;
1047        Box::new(rule)
1048    }
1049
1050    fn cross_file_scope(&self) -> CrossFileScope {
1051        CrossFileScope::Workspace
1052    }
1053
1054    fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1055        // Use the shared utility for cross-file link extraction
1056        // This ensures consistent position tracking between CLI and LSP
1057        for link in extract_cross_file_links(ctx) {
1058            index.add_cross_file_link(link);
1059        }
1060    }
1061
1062    fn cross_file_check(
1063        &self,
1064        file_path: &Path,
1065        file_index: &FileIndex,
1066        workspace_index: &crate::workspace_index::WorkspaceIndex,
1067    ) -> LintResult {
1068        // Reset the file existence cache for a fresh run
1069        reset_file_existence_cache();
1070
1071        let mut warnings = Vec::new();
1072
1073        // Get the directory containing this file for resolving relative links
1074        let file_dir = file_path.parent();
1075
1076        // Compute additional search paths for fallback link resolution
1077        let base_path = file_dir.map(|d| d.to_path_buf()).unwrap_or_else(|| CURRENT_DIR.clone());
1078        let extra_search_paths = self.compute_search_paths(self.flavor, Some(file_path), &base_path);
1079
1080        for cross_link in &file_index.cross_file_links {
1081            // URL-decode the path for filesystem operations
1082            // The stored path is URL-encoded (e.g., "%F0%9F%91%A4" for emoji 👤)
1083            let decoded_target = Self::url_decode(&cross_link.target_path);
1084
1085            // Skip absolute paths — they are already handled by check()
1086            // which validates them according to the absolute_links config.
1087            // Handling them here too would produce duplicate warnings.
1088            if decoded_target.starts_with('/') {
1089                continue;
1090            }
1091
1092            // Resolve relative path
1093            let target_path = if let Some(dir) = file_dir {
1094                dir.join(&decoded_target)
1095            } else {
1096                Path::new(&decoded_target).to_path_buf()
1097            };
1098
1099            // Normalize the path (handle .., ., etc.)
1100            let target_path = normalize_path(&target_path);
1101
1102            // Check if the target file exists, also trying markdown extensions for extensionless links
1103            let file_exists =
1104                workspace_index.contains_file(&target_path) || file_exists_or_markdown_extension(&target_path);
1105
1106            if !file_exists {
1107                // For .html/.htm links, check if a corresponding markdown source exists
1108                // This handles doc sites (mdBook, etc.) where .md is compiled to .html
1109                let has_md_source = if let Some(ext) = target_path.extension().and_then(|e| e.to_str())
1110                    && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1111                    && let (Some(stem), Some(parent)) =
1112                        (target_path.file_stem().and_then(|s| s.to_str()), target_path.parent())
1113                {
1114                    MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1115                        let source_path = parent.join(format!("{stem}{md_ext}"));
1116                        workspace_index.contains_file(&source_path) || source_path.exists()
1117                    })
1118                } else {
1119                    false
1120                };
1121
1122                if !has_md_source && !Self::exists_in_search_paths(&decoded_target, &extra_search_paths) {
1123                    warnings.push(LintWarning {
1124                        rule_name: Some(self.name().to_string()),
1125                        line: cross_link.line,
1126                        column: cross_link.column,
1127                        end_line: cross_link.line,
1128                        end_column: cross_link.column + cross_link.target_path.len(),
1129                        message: format!("Relative link '{}' does not exist", cross_link.target_path),
1130                        severity: Severity::Error,
1131                        fix: None,
1132                    });
1133                }
1134            }
1135        }
1136
1137        Ok(warnings)
1138    }
1139}
1140
1141/// Compute the shortest relative path from `from_dir` to `to_path`.
1142///
1143/// Both paths must be normalized (no `.` or `..` components).
1144/// Returns a relative `PathBuf` that navigates from `from_dir` to `to_path`.
1145fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1146    let from_components: Vec<_> = from_dir.components().collect();
1147    let to_components: Vec<_> = to_path.components().collect();
1148
1149    // Find common prefix length
1150    let common_len = from_components
1151        .iter()
1152        .zip(to_components.iter())
1153        .take_while(|(a, b)| a == b)
1154        .count();
1155
1156    let mut result = PathBuf::new();
1157
1158    // Go up for each remaining component in from_dir
1159    for _ in common_len..from_components.len() {
1160        result.push("..");
1161    }
1162
1163    // Append remaining components from to_path
1164    for component in &to_components[common_len..] {
1165        result.push(component);
1166    }
1167
1168    result
1169}
1170
1171/// Check if a relative link path can be shortened.
1172///
1173/// Given the source directory and the raw link path, computes whether there's
1174/// a shorter equivalent path. Returns `Some(compact_path)` if the link can
1175/// be simplified, `None` if it's already optimal.
1176fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1177    let link_path = Path::new(raw_link_path);
1178
1179    // Only check paths that contain traversal (../ or ./)
1180    let has_traversal = link_path
1181        .components()
1182        .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1183
1184    if !has_traversal {
1185        return None;
1186    }
1187
1188    // Resolve: source_dir + raw_link_path, then normalize
1189    let combined = source_dir.join(link_path);
1190    let normalized_target = normalize_path(&combined);
1191
1192    // Compute shortest path from source_dir back to the normalized target
1193    let normalized_source = normalize_path(source_dir);
1194    let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1195
1196    // Compare against the raw link path — if it differs, the path can be compacted
1197    if shortest != link_path {
1198        let compact = shortest.to_string_lossy().to_string();
1199        // Avoid suggesting empty path
1200        if compact.is_empty() {
1201            return None;
1202        }
1203        // Markdown links always use forward slashes regardless of platform
1204        Some(compact.replace('\\', "/"))
1205    } else {
1206        None
1207    }
1208}
1209
1210/// Normalize a path by resolving . and .. components
1211fn normalize_path(path: &Path) -> PathBuf {
1212    let mut components = Vec::new();
1213
1214    for component in path.components() {
1215        match component {
1216            std::path::Component::ParentDir => {
1217                // Go up one level if possible
1218                if !components.is_empty() {
1219                    components.pop();
1220                }
1221            }
1222            std::path::Component::CurDir => {
1223                // Skip current directory markers
1224            }
1225            _ => {
1226                components.push(component);
1227            }
1228        }
1229    }
1230
1231    components.iter().collect()
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236    use super::*;
1237    use crate::workspace_index::CrossFileLinkIndex;
1238    use std::fs::File;
1239    use std::io::Write;
1240    use tempfile::tempdir;
1241
1242    #[test]
1243    fn test_strip_query_and_fragment() {
1244        // Test query parameter stripping
1245        assert_eq!(
1246            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1247            "file.png"
1248        );
1249        assert_eq!(
1250            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1251            "file.png"
1252        );
1253        assert_eq!(
1254            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1255            "file.png"
1256        );
1257
1258        // Test fragment stripping
1259        assert_eq!(
1260            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1261            "file.md"
1262        );
1263        assert_eq!(
1264            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1265            "file.md"
1266        );
1267
1268        // Test both query and fragment (query comes first, per RFC 3986)
1269        assert_eq!(
1270            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1271            "file.md"
1272        );
1273
1274        // Test no query or fragment
1275        assert_eq!(
1276            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1277            "file.png"
1278        );
1279
1280        // Test with path
1281        assert_eq!(
1282            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1283            "path/to/image.png"
1284        );
1285        assert_eq!(
1286            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1287            "path/to/image.png"
1288        );
1289
1290        // Edge case: fragment before query (non-standard but possible)
1291        assert_eq!(
1292            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1293            "file.md"
1294        );
1295    }
1296
1297    #[test]
1298    fn test_url_decode() {
1299        // Simple space encoding
1300        assert_eq!(
1301            MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1302            "penguin with space.jpg"
1303        );
1304
1305        // Path with encoded spaces
1306        assert_eq!(
1307            MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1308            "assets/my file name.png"
1309        );
1310
1311        // Multiple encoded characters
1312        assert_eq!(
1313            MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1314            "hello world!.md"
1315        );
1316
1317        // Lowercase hex
1318        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1319
1320        // Uppercase hex
1321        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1322
1323        // Mixed case hex
1324        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1325
1326        // No encoding - return as-is
1327        assert_eq!(
1328            MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1329            "normal-file.md"
1330        );
1331
1332        // Incomplete percent encoding - leave as-is
1333        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1334
1335        // Percent at end - leave as-is
1336        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1337
1338        // Invalid hex digits - leave as-is
1339        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1340
1341        // Plus sign (should NOT be decoded - that's form encoding, not URL encoding)
1342        assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1343
1344        // Empty string
1345        assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1346
1347        // UTF-8 multi-byte characters (é = C3 A9 in UTF-8)
1348        assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1349
1350        // Multiple consecutive encoded characters
1351        assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), "   ");
1352
1353        // Encoded path separators
1354        assert_eq!(
1355            MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1356            "path/to/file.md"
1357        );
1358
1359        // Mixed encoded and non-encoded
1360        assert_eq!(
1361            MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1362            "hello world/foo bar.md"
1363        );
1364
1365        // Special characters that are commonly encoded
1366        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1367
1368        // Percent at position that looks like encoding but isn't valid
1369        assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1370    }
1371
1372    #[test]
1373    fn test_url_encoded_filenames() {
1374        // Create a temporary directory for test files
1375        let temp_dir = tempdir().unwrap();
1376        let base_path = temp_dir.path();
1377
1378        // Create a file with spaces in the name
1379        let file_with_spaces = base_path.join("penguin with space.jpg");
1380        File::create(&file_with_spaces)
1381            .unwrap()
1382            .write_all(b"image data")
1383            .unwrap();
1384
1385        // Create a subdirectory with spaces
1386        let subdir = base_path.join("my images");
1387        std::fs::create_dir(&subdir).unwrap();
1388        let nested_file = subdir.join("photo 1.png");
1389        File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1390
1391        // Test content with URL-encoded links
1392        let content = r#"
1393# Test Document with URL-Encoded Links
1394
1395![Penguin](penguin%20with%20space.jpg)
1396![Photo](my%20images/photo%201.png)
1397![Missing](missing%20file.jpg)
1398"#;
1399
1400        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1401
1402        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1403        let result = rule.check(&ctx).unwrap();
1404
1405        // Should only have one warning for the missing file
1406        assert_eq!(
1407            result.len(),
1408            1,
1409            "Should only warn about missing%20file.jpg. Got: {result:?}"
1410        );
1411        assert!(
1412            result[0].message.contains("missing%20file.jpg"),
1413            "Warning should mention the URL-encoded filename"
1414        );
1415    }
1416
1417    #[test]
1418    fn test_external_urls() {
1419        let rule = MD057ExistingRelativeLinks::new();
1420
1421        // Common web protocols
1422        assert!(rule.is_external_url("https://example.com"));
1423        assert!(rule.is_external_url("http://example.com"));
1424        assert!(rule.is_external_url("ftp://example.com"));
1425        assert!(rule.is_external_url("www.example.com"));
1426        assert!(rule.is_external_url("example.com"));
1427
1428        // Special URI schemes
1429        assert!(rule.is_external_url("file:///path/to/file"));
1430        assert!(rule.is_external_url("smb://server/share"));
1431        assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1432        assert!(rule.is_external_url("mailto:user@example.com"));
1433        assert!(rule.is_external_url("tel:+1234567890"));
1434        assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1435        assert!(rule.is_external_url("javascript:void(0)"));
1436        assert!(rule.is_external_url("ssh://git@github.com/repo"));
1437        assert!(rule.is_external_url("git://github.com/repo.git"));
1438
1439        // Email addresses without mailto: protocol
1440        // These are clearly not file links and should be skipped
1441        assert!(rule.is_external_url("user@example.com"));
1442        assert!(rule.is_external_url("steering@kubernetes.io"));
1443        assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1444        assert!(rule.is_external_url("user_name@sub.domain.com"));
1445        assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1446
1447        // Template variables should be skipped (not checked as relative links)
1448        assert!(rule.is_external_url("{{URL}}")); // Handlebars/Mustache
1449        assert!(rule.is_external_url("{{#URL}}")); // Handlebars block helper
1450        assert!(rule.is_external_url("{{> partial}}")); // Handlebars partial
1451        assert!(rule.is_external_url("{{ variable }}")); // Mustache with spaces
1452        assert!(rule.is_external_url("{{% include %}}")); // Jinja2/Hugo shortcode
1453        assert!(rule.is_external_url("{{")); // Even partial matches (regex edge case)
1454
1455        // Absolute paths are NOT external (handled separately via is_absolute_path)
1456        // By default they are ignored, but can be configured to warn
1457        assert!(!rule.is_external_url("/api/v1/users"));
1458        assert!(!rule.is_external_url("/blog/2024/release.html"));
1459        assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1460        assert!(!rule.is_external_url("/pkg/runtime"));
1461        assert!(!rule.is_external_url("/doc/go1compat"));
1462        assert!(!rule.is_external_url("/index.html"));
1463        assert!(!rule.is_external_url("/assets/logo.png"));
1464
1465        // But is_absolute_path should detect them
1466        assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1467        assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1468        assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1469        assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1470        assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1471
1472        // Framework path aliases should be skipped (resolved by build tools)
1473        // Tilde prefix (common in Vite, Nuxt, Astro for project root)
1474        assert!(rule.is_external_url("~/assets/image.png"));
1475        assert!(rule.is_external_url("~/components/Button.vue"));
1476        assert!(rule.is_external_url("~assets/logo.svg")); // Nuxt style without /
1477
1478        // @ prefix (common in Vue, webpack, Vite aliases)
1479        assert!(rule.is_external_url("@/components/Header.vue"));
1480        assert!(rule.is_external_url("@images/photo.jpg"));
1481        assert!(rule.is_external_url("@assets/styles.css"));
1482
1483        // Relative paths should NOT be external (should be validated)
1484        assert!(!rule.is_external_url("./relative/path.md"));
1485        assert!(!rule.is_external_url("relative/path.md"));
1486        assert!(!rule.is_external_url("../parent/path.md"));
1487    }
1488
1489    #[test]
1490    fn test_framework_path_aliases() {
1491        // Create a temporary directory for test files
1492        let temp_dir = tempdir().unwrap();
1493        let base_path = temp_dir.path();
1494
1495        // Test content with framework path aliases (should all be skipped)
1496        let content = r#"
1497# Framework Path Aliases
1498
1499![Image 1](~/assets/penguin.jpg)
1500![Image 2](~assets/logo.svg)
1501![Image 3](@images/photo.jpg)
1502![Image 4](@/components/icon.svg)
1503[Link](@/pages/about.md)
1504
1505This is a [real missing link](missing.md) that should be flagged.
1506"#;
1507
1508        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1509
1510        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511        let result = rule.check(&ctx).unwrap();
1512
1513        // Should only have one warning for the real missing link
1514        assert_eq!(
1515            result.len(),
1516            1,
1517            "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1518        );
1519        assert!(
1520            result[0].message.contains("missing.md"),
1521            "Warning should be for missing.md"
1522        );
1523    }
1524
1525    #[test]
1526    fn test_url_decode_security_path_traversal() {
1527        // Ensure URL decoding doesn't enable path traversal attacks
1528        // The decoded path is still validated against the base path
1529        let temp_dir = tempdir().unwrap();
1530        let base_path = temp_dir.path();
1531
1532        // Create a file in the temp directory
1533        let file_in_base = base_path.join("safe.md");
1534        File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1535
1536        // Test with encoded path traversal attempt
1537        // Use a path that definitely won't exist on any platform (not /etc/passwd which exists on Linux)
1538        // %2F = /, so ..%2F..%2Fnonexistent%2Ffile = ../../nonexistent/file
1539        // %252F = %2F (double encoded), so ..%252F..%252F = ..%2F..%2F (literal, won't decode to ..)
1540        let content = r#"
1541[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1542[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1543[Safe link](safe.md)
1544"#;
1545
1546        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1547
1548        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1549        let result = rule.check(&ctx).unwrap();
1550
1551        // The traversal attempts should still be flagged as missing
1552        // (they don't exist relative to base_path after decoding)
1553        assert_eq!(
1554            result.len(),
1555            2,
1556            "Should have warnings for traversal attempts. Got: {result:?}"
1557        );
1558    }
1559
1560    #[test]
1561    fn test_url_encoded_utf8_filenames() {
1562        // Test with actual UTF-8 encoded filenames
1563        let temp_dir = tempdir().unwrap();
1564        let base_path = temp_dir.path();
1565
1566        // Create files with unicode names
1567        let cafe_file = base_path.join("café.md");
1568        File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1569
1570        let content = r#"
1571[Café link](caf%C3%A9.md)
1572[Missing unicode](r%C3%A9sum%C3%A9.md)
1573"#;
1574
1575        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1576
1577        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1578        let result = rule.check(&ctx).unwrap();
1579
1580        // Should only warn about the missing file
1581        assert_eq!(
1582            result.len(),
1583            1,
1584            "Should only warn about missing résumé.md. Got: {result:?}"
1585        );
1586        assert!(
1587            result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1588            "Warning should mention the URL-encoded filename"
1589        );
1590    }
1591
1592    #[test]
1593    fn test_url_encoded_emoji_filenames() {
1594        // URL-encoded emoji paths should be correctly resolved
1595        // 👤 = U+1F464 = F0 9F 91 A4 in UTF-8
1596        let temp_dir = tempdir().unwrap();
1597        let base_path = temp_dir.path();
1598
1599        // Create directory with emoji in name: 👤 Personal
1600        let emoji_dir = base_path.join("👤 Personal");
1601        std::fs::create_dir(&emoji_dir).unwrap();
1602
1603        // Create file in that directory: TV Shows.md
1604        let file_path = emoji_dir.join("TV Shows.md");
1605        File::create(&file_path)
1606            .unwrap()
1607            .write_all(b"# TV Shows\n\nContent here.")
1608            .unwrap();
1609
1610        // Test content with URL-encoded emoji link
1611        // %F0%9F%91%A4 = 👤, %20 = space
1612        let content = r#"
1613# Test Document
1614
1615[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1616[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1617"#;
1618
1619        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1620
1621        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622        let result = rule.check(&ctx).unwrap();
1623
1624        // Should only warn about the missing file, not the valid emoji path
1625        assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1626        assert!(
1627            result[0].message.contains("Missing.md"),
1628            "Warning should be for Missing.md, got: {}",
1629            result[0].message
1630        );
1631    }
1632
1633    #[test]
1634    fn test_no_warnings_without_base_path() {
1635        let rule = MD057ExistingRelativeLinks::new();
1636        let content = "[Link](missing.md)";
1637
1638        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639        let result = rule.check(&ctx).unwrap();
1640        assert!(result.is_empty(), "Should have no warnings without base path");
1641    }
1642
1643    #[test]
1644    fn test_existing_and_missing_links() {
1645        // Create a temporary directory for test files
1646        let temp_dir = tempdir().unwrap();
1647        let base_path = temp_dir.path();
1648
1649        // Create an existing file
1650        let exists_path = base_path.join("exists.md");
1651        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1652
1653        // Verify the file exists
1654        assert!(exists_path.exists(), "exists.md should exist for this test");
1655
1656        // Create test content with both existing and missing links
1657        let content = r#"
1658# Test Document
1659
1660[Valid Link](exists.md)
1661[Invalid Link](missing.md)
1662[External Link](https://example.com)
1663[Media Link](image.jpg)
1664        "#;
1665
1666        // Initialize rule with the base path (default: check all files including media)
1667        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1668
1669        // Test the rule
1670        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1671        let result = rule.check(&ctx).unwrap();
1672
1673        // Should have two warnings: missing.md and image.jpg (both don't exist)
1674        assert_eq!(result.len(), 2);
1675        let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1676        assert!(messages.iter().any(|m| m.contains("missing.md")));
1677        assert!(messages.iter().any(|m| m.contains("image.jpg")));
1678    }
1679
1680    #[test]
1681    fn test_angle_bracket_links() {
1682        // Create a temporary directory for test files
1683        let temp_dir = tempdir().unwrap();
1684        let base_path = temp_dir.path();
1685
1686        // Create an existing file
1687        let exists_path = base_path.join("exists.md");
1688        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1689
1690        // Create test content with angle bracket links
1691        let content = r#"
1692# Test Document
1693
1694[Valid Link](<exists.md>)
1695[Invalid Link](<missing.md>)
1696[External Link](<https://example.com>)
1697    "#;
1698
1699        // Test with default settings
1700        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1701
1702        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703        let result = rule.check(&ctx).unwrap();
1704
1705        // Should have one warning for missing.md
1706        assert_eq!(result.len(), 1, "Should have exactly one warning");
1707        assert!(
1708            result[0].message.contains("missing.md"),
1709            "Warning should mention missing.md"
1710        );
1711    }
1712
1713    #[test]
1714    fn test_angle_bracket_links_with_parens() {
1715        // Create a temporary directory for test files
1716        let temp_dir = tempdir().unwrap();
1717        let base_path = temp_dir.path();
1718
1719        // Create directory structure with parentheses in path
1720        let app_dir = base_path.join("app");
1721        std::fs::create_dir(&app_dir).unwrap();
1722        let upload_dir = app_dir.join("(upload)");
1723        std::fs::create_dir(&upload_dir).unwrap();
1724        let page_file = upload_dir.join("page.tsx");
1725        File::create(&page_file)
1726            .unwrap()
1727            .write_all(b"export default function Page() {}")
1728            .unwrap();
1729
1730        // Create test content with angle bracket links containing parentheses
1731        let content = r#"
1732# Test Document with Paths Containing Parens
1733
1734[Upload Page](<app/(upload)/page.tsx>)
1735[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1736[Missing](<app/(missing)/file.md>)
1737"#;
1738
1739        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1740
1741        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1742        let result = rule.check(&ctx).unwrap();
1743
1744        // Should only have one warning for the missing file
1745        assert_eq!(
1746            result.len(),
1747            1,
1748            "Should have exactly one warning for missing file. Got: {result:?}"
1749        );
1750        assert!(
1751            result[0].message.contains("app/(missing)/file.md"),
1752            "Warning should mention app/(missing)/file.md"
1753        );
1754    }
1755
1756    #[test]
1757    fn test_all_file_types_checked() {
1758        // Create a temporary directory for test files
1759        let temp_dir = tempdir().unwrap();
1760        let base_path = temp_dir.path();
1761
1762        // Create a test with various file types - all should be checked
1763        let content = r#"
1764[Image Link](image.jpg)
1765[Video Link](video.mp4)
1766[Markdown Link](document.md)
1767[PDF Link](file.pdf)
1768"#;
1769
1770        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1771
1772        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773        let result = rule.check(&ctx).unwrap();
1774
1775        // Should warn about all missing files regardless of extension
1776        assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1777    }
1778
1779    #[test]
1780    fn test_code_span_detection() {
1781        let rule = MD057ExistingRelativeLinks::new();
1782
1783        // Create a temporary directory for test files
1784        let temp_dir = tempdir().unwrap();
1785        let base_path = temp_dir.path();
1786
1787        let rule = rule.with_path(base_path);
1788
1789        // Test with document structure
1790        let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1791
1792        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1793        let result = rule.check(&ctx).unwrap();
1794
1795        // Should only find the real link, not the one in code
1796        assert_eq!(result.len(), 1, "Should only flag the real link");
1797        assert!(result[0].message.contains("nonexistent.md"));
1798    }
1799
1800    #[test]
1801    fn test_inline_code_spans() {
1802        // Create a temporary directory for test files
1803        let temp_dir = tempdir().unwrap();
1804        let base_path = temp_dir.path();
1805
1806        // Create test content with links in inline code spans
1807        let content = r#"
1808# Test Document
1809
1810This is a normal link: [Link](missing.md)
1811
1812This is a code span with a link: `[Link](another-missing.md)`
1813
1814Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1815
1816    "#;
1817
1818        // Initialize rule with the base path
1819        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1820
1821        // Test the rule
1822        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1823        let result = rule.check(&ctx).unwrap();
1824
1825        // Should only have warning for the normal link, not for links in code spans
1826        assert_eq!(result.len(), 1, "Should have exactly one warning");
1827        assert!(
1828            result[0].message.contains("missing.md"),
1829            "Warning should be for missing.md"
1830        );
1831        assert!(
1832            !result.iter().any(|w| w.message.contains("another-missing.md")),
1833            "Should not warn about link in code span"
1834        );
1835        assert!(
1836            !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1837            "Should not warn about link in inline code"
1838        );
1839    }
1840
1841    #[test]
1842    fn test_extensionless_link_resolution() {
1843        // Create a temporary directory for test files
1844        let temp_dir = tempdir().unwrap();
1845        let base_path = temp_dir.path();
1846
1847        // Create a markdown file WITHOUT specifying .md extension in the link
1848        let page_path = base_path.join("page.md");
1849        File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1850
1851        // Test content with extensionless link that should resolve to page.md
1852        let content = r#"
1853# Test Document
1854
1855[Link without extension](page)
1856[Link with extension](page.md)
1857[Missing link](nonexistent)
1858"#;
1859
1860        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1861
1862        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1863        let result = rule.check(&ctx).unwrap();
1864
1865        // Should only have warning for nonexistent link
1866        // Both "page" and "page.md" should resolve to the same file
1867        assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1868        assert!(
1869            result[0].message.contains("nonexistent"),
1870            "Warning should be for 'nonexistent' not 'page'"
1871        );
1872    }
1873
1874    // Cross-file validation tests
1875    #[test]
1876    fn test_cross_file_scope() {
1877        let rule = MD057ExistingRelativeLinks::new();
1878        assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1879    }
1880
1881    #[test]
1882    fn test_contribute_to_index_extracts_markdown_links() {
1883        let rule = MD057ExistingRelativeLinks::new();
1884        let content = r#"
1885# Document
1886
1887[Link to docs](./docs/guide.md)
1888[Link with fragment](./other.md#section)
1889[External link](https://example.com)
1890[Image link](image.png)
1891[Media file](video.mp4)
1892"#;
1893
1894        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895        let mut index = FileIndex::new();
1896        rule.contribute_to_index(&ctx, &mut index);
1897
1898        // Should only index markdown file links
1899        assert_eq!(index.cross_file_links.len(), 2);
1900
1901        // Check first link
1902        assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1903        assert_eq!(index.cross_file_links[0].fragment, "");
1904
1905        // Check second link (with fragment)
1906        assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1907        assert_eq!(index.cross_file_links[1].fragment, "section");
1908    }
1909
1910    #[test]
1911    fn test_contribute_to_index_skips_external_and_anchors() {
1912        let rule = MD057ExistingRelativeLinks::new();
1913        let content = r#"
1914# Document
1915
1916[External](https://example.com)
1917[Another external](http://example.org)
1918[Fragment only](#section)
1919[FTP link](ftp://files.example.com)
1920[Mail link](mailto:test@example.com)
1921[WWW link](www.example.com)
1922"#;
1923
1924        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925        let mut index = FileIndex::new();
1926        rule.contribute_to_index(&ctx, &mut index);
1927
1928        // Should not index any of these
1929        assert_eq!(index.cross_file_links.len(), 0);
1930    }
1931
1932    #[test]
1933    fn test_cross_file_check_valid_link() {
1934        use crate::workspace_index::WorkspaceIndex;
1935
1936        let rule = MD057ExistingRelativeLinks::new();
1937
1938        // Create a workspace index with the target file
1939        let mut workspace_index = WorkspaceIndex::new();
1940        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1941
1942        // Create file index with a link to an existing file
1943        let mut file_index = FileIndex::new();
1944        file_index.add_cross_file_link(CrossFileLinkIndex {
1945            target_path: "guide.md".to_string(),
1946            fragment: "".to_string(),
1947            line: 5,
1948            column: 1,
1949        });
1950
1951        // Run cross-file check from docs/index.md
1952        let warnings = rule
1953            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1954            .unwrap();
1955
1956        // Should have no warnings - file exists
1957        assert!(warnings.is_empty());
1958    }
1959
1960    #[test]
1961    fn test_cross_file_check_missing_link() {
1962        use crate::workspace_index::WorkspaceIndex;
1963
1964        let rule = MD057ExistingRelativeLinks::new();
1965
1966        // Create an empty workspace index
1967        let workspace_index = WorkspaceIndex::new();
1968
1969        // Create file index with a link to a missing file
1970        let mut file_index = FileIndex::new();
1971        file_index.add_cross_file_link(CrossFileLinkIndex {
1972            target_path: "missing.md".to_string(),
1973            fragment: "".to_string(),
1974            line: 5,
1975            column: 1,
1976        });
1977
1978        // Run cross-file check
1979        let warnings = rule
1980            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1981            .unwrap();
1982
1983        // Should have one warning for the missing file
1984        assert_eq!(warnings.len(), 1);
1985        assert!(warnings[0].message.contains("missing.md"));
1986        assert!(warnings[0].message.contains("does not exist"));
1987    }
1988
1989    #[test]
1990    fn test_cross_file_check_parent_path() {
1991        use crate::workspace_index::WorkspaceIndex;
1992
1993        let rule = MD057ExistingRelativeLinks::new();
1994
1995        // Create a workspace index with the target file at the root
1996        let mut workspace_index = WorkspaceIndex::new();
1997        workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1998
1999        // Create file index with a parent path link
2000        let mut file_index = FileIndex::new();
2001        file_index.add_cross_file_link(CrossFileLinkIndex {
2002            target_path: "../readme.md".to_string(),
2003            fragment: "".to_string(),
2004            line: 5,
2005            column: 1,
2006        });
2007
2008        // Run cross-file check from docs/guide.md
2009        let warnings = rule
2010            .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2011            .unwrap();
2012
2013        // Should have no warnings - file exists at normalized path
2014        assert!(warnings.is_empty());
2015    }
2016
2017    #[test]
2018    fn test_cross_file_check_html_link_with_md_source() {
2019        // Test that .html links are accepted when corresponding .md source exists
2020        // This supports mdBook and similar doc generators that compile .md to .html
2021        use crate::workspace_index::WorkspaceIndex;
2022
2023        let rule = MD057ExistingRelativeLinks::new();
2024
2025        // Create a workspace index with the .md source file
2026        let mut workspace_index = WorkspaceIndex::new();
2027        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2028
2029        // Create file index with an .html link (from another rule like MD051)
2030        let mut file_index = FileIndex::new();
2031        file_index.add_cross_file_link(CrossFileLinkIndex {
2032            target_path: "guide.html".to_string(),
2033            fragment: "section".to_string(),
2034            line: 10,
2035            column: 5,
2036        });
2037
2038        // Run cross-file check from docs/index.md
2039        let warnings = rule
2040            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2041            .unwrap();
2042
2043        // Should have no warnings - .md source exists for the .html link
2044        assert!(
2045            warnings.is_empty(),
2046            "Expected no warnings for .html link with .md source, got: {warnings:?}"
2047        );
2048    }
2049
2050    #[test]
2051    fn test_cross_file_check_html_link_without_source() {
2052        // Test that .html links without corresponding .md source ARE flagged
2053        use crate::workspace_index::WorkspaceIndex;
2054
2055        let rule = MD057ExistingRelativeLinks::new();
2056
2057        // Create an empty workspace index
2058        let workspace_index = WorkspaceIndex::new();
2059
2060        // Create file index with an .html link to a non-existent file
2061        let mut file_index = FileIndex::new();
2062        file_index.add_cross_file_link(CrossFileLinkIndex {
2063            target_path: "missing.html".to_string(),
2064            fragment: "".to_string(),
2065            line: 10,
2066            column: 5,
2067        });
2068
2069        // Run cross-file check from docs/index.md
2070        let warnings = rule
2071            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2072            .unwrap();
2073
2074        // Should have one warning - no .md source exists
2075        assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
2076        assert!(warnings[0].message.contains("missing.html"));
2077    }
2078
2079    #[test]
2080    fn test_normalize_path_function() {
2081        // Test simple cases
2082        assert_eq!(
2083            normalize_path(Path::new("docs/guide.md")),
2084            PathBuf::from("docs/guide.md")
2085        );
2086
2087        // Test current directory removal
2088        assert_eq!(
2089            normalize_path(Path::new("./docs/guide.md")),
2090            PathBuf::from("docs/guide.md")
2091        );
2092
2093        // Test parent directory resolution
2094        assert_eq!(
2095            normalize_path(Path::new("docs/sub/../guide.md")),
2096            PathBuf::from("docs/guide.md")
2097        );
2098
2099        // Test multiple parent directories
2100        assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2101    }
2102
2103    #[test]
2104    fn test_html_link_with_md_source() {
2105        // Links to .html files should pass if corresponding .md source exists
2106        let temp_dir = tempdir().unwrap();
2107        let base_path = temp_dir.path();
2108
2109        // Create guide.md (source file)
2110        let md_file = base_path.join("guide.md");
2111        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2112
2113        let content = r#"
2114[Read the guide](guide.html)
2115[Also here](getting-started.html)
2116"#;
2117
2118        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2119        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2120        let result = rule.check(&ctx).unwrap();
2121
2122        // guide.html passes (guide.md exists), getting-started.html fails
2123        assert_eq!(
2124            result.len(),
2125            1,
2126            "Should only warn about missing source. Got: {result:?}"
2127        );
2128        assert!(result[0].message.contains("getting-started.html"));
2129    }
2130
2131    #[test]
2132    fn test_htm_link_with_md_source() {
2133        // .htm extension should also check for markdown source
2134        let temp_dir = tempdir().unwrap();
2135        let base_path = temp_dir.path();
2136
2137        let md_file = base_path.join("page.md");
2138        File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2139
2140        let content = "[Page](page.htm)";
2141
2142        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2143        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2144        let result = rule.check(&ctx).unwrap();
2145
2146        assert!(
2147            result.is_empty(),
2148            "Should not warn when .md source exists for .htm link"
2149        );
2150    }
2151
2152    #[test]
2153    fn test_html_link_finds_various_markdown_extensions() {
2154        // Should find .mdx, .markdown, etc. as source files
2155        let temp_dir = tempdir().unwrap();
2156        let base_path = temp_dir.path();
2157
2158        File::create(base_path.join("doc.md")).unwrap();
2159        File::create(base_path.join("tutorial.mdx")).unwrap();
2160        File::create(base_path.join("guide.markdown")).unwrap();
2161
2162        let content = r#"
2163[Doc](doc.html)
2164[Tutorial](tutorial.html)
2165[Guide](guide.html)
2166"#;
2167
2168        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2169        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2170        let result = rule.check(&ctx).unwrap();
2171
2172        assert!(
2173            result.is_empty(),
2174            "Should find all markdown variants as source files. Got: {result:?}"
2175        );
2176    }
2177
2178    #[test]
2179    fn test_html_link_in_subdirectory() {
2180        // Should find markdown source in subdirectories
2181        let temp_dir = tempdir().unwrap();
2182        let base_path = temp_dir.path();
2183
2184        let docs_dir = base_path.join("docs");
2185        std::fs::create_dir(&docs_dir).unwrap();
2186        File::create(docs_dir.join("guide.md"))
2187            .unwrap()
2188            .write_all(b"# Guide")
2189            .unwrap();
2190
2191        let content = "[Guide](docs/guide.html)";
2192
2193        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2194        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2195        let result = rule.check(&ctx).unwrap();
2196
2197        assert!(result.is_empty(), "Should find markdown source in subdirectory");
2198    }
2199
2200    #[test]
2201    fn test_absolute_path_skipped_in_check() {
2202        // Test that absolute paths are skipped during link validation
2203        // This fixes the bug where /pkg/runtime was being flagged
2204        let temp_dir = tempdir().unwrap();
2205        let base_path = temp_dir.path();
2206
2207        let content = r#"
2208# Test Document
2209
2210[Go Runtime](/pkg/runtime)
2211[Go Runtime with Fragment](/pkg/runtime#section)
2212[API Docs](/api/v1/users)
2213[Blog Post](/blog/2024/release.html)
2214[React Hook](/react/hooks/use-state.html)
2215"#;
2216
2217        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2218        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2219        let result = rule.check(&ctx).unwrap();
2220
2221        // Should have NO warnings - all absolute paths should be skipped
2222        assert!(
2223            result.is_empty(),
2224            "Absolute paths should be skipped. Got warnings: {result:?}"
2225        );
2226    }
2227
2228    #[test]
2229    fn test_absolute_path_skipped_in_cross_file_check() {
2230        // Test that absolute paths are skipped in cross_file_check()
2231        use crate::workspace_index::WorkspaceIndex;
2232
2233        let rule = MD057ExistingRelativeLinks::new();
2234
2235        // Create an empty workspace index (no files exist)
2236        let workspace_index = WorkspaceIndex::new();
2237
2238        // Create file index with absolute path links (should be skipped)
2239        let mut file_index = FileIndex::new();
2240        file_index.add_cross_file_link(CrossFileLinkIndex {
2241            target_path: "/pkg/runtime.md".to_string(),
2242            fragment: "".to_string(),
2243            line: 5,
2244            column: 1,
2245        });
2246        file_index.add_cross_file_link(CrossFileLinkIndex {
2247            target_path: "/api/v1/users.md".to_string(),
2248            fragment: "section".to_string(),
2249            line: 10,
2250            column: 1,
2251        });
2252
2253        // Run cross-file check
2254        let warnings = rule
2255            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2256            .unwrap();
2257
2258        // Should have NO warnings - absolute paths should be skipped
2259        assert!(
2260            warnings.is_empty(),
2261            "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2262        );
2263    }
2264
2265    #[test]
2266    fn test_protocol_relative_url_not_skipped() {
2267        // Test that protocol-relative URLs (//example.com) are NOT skipped as absolute paths
2268        // They should still be caught by is_external_url() though
2269        let temp_dir = tempdir().unwrap();
2270        let base_path = temp_dir.path();
2271
2272        let content = r#"
2273# Test Document
2274
2275[External](//example.com/page)
2276[Another](//cdn.example.com/asset.js)
2277"#;
2278
2279        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2280        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2281        let result = rule.check(&ctx).unwrap();
2282
2283        // Should have NO warnings - protocol-relative URLs are external and should be skipped
2284        assert!(
2285            result.is_empty(),
2286            "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2287        );
2288    }
2289
2290    #[test]
2291    fn test_email_addresses_skipped() {
2292        // Test that email addresses without mailto: are skipped
2293        // These are clearly not file links (the @ symbol is definitive)
2294        let temp_dir = tempdir().unwrap();
2295        let base_path = temp_dir.path();
2296
2297        let content = r#"
2298# Test Document
2299
2300[Contact](user@example.com)
2301[Steering](steering@kubernetes.io)
2302[Support](john.doe+filter@company.co.uk)
2303[User](user_name@sub.domain.com)
2304"#;
2305
2306        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2307        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2308        let result = rule.check(&ctx).unwrap();
2309
2310        // Should have NO warnings - email addresses are clearly not file links and should be skipped
2311        assert!(
2312            result.is_empty(),
2313            "Email addresses should be skipped. Got warnings: {result:?}"
2314        );
2315    }
2316
2317    #[test]
2318    fn test_email_addresses_vs_file_paths() {
2319        // Test that email addresses (anything with @) are skipped
2320        // Note: File paths with @ are extremely rare, so we treat anything with @ as an email
2321        let temp_dir = tempdir().unwrap();
2322        let base_path = temp_dir.path();
2323
2324        let content = r#"
2325# Test Document
2326
2327[Email](user@example.com)  <!-- Should be skipped (email) -->
2328[Email2](steering@kubernetes.io)  <!-- Should be skipped (email) -->
2329[Email3](user@file.md)  <!-- Should be skipped (has @, treated as email) -->
2330"#;
2331
2332        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2333        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2334        let result = rule.check(&ctx).unwrap();
2335
2336        // All should be skipped - anything with @ is treated as an email
2337        assert!(
2338            result.is_empty(),
2339            "All email addresses should be skipped. Got: {result:?}"
2340        );
2341    }
2342
2343    #[test]
2344    fn test_diagnostic_position_accuracy() {
2345        // Test that diagnostics point to the URL, not the link text
2346        let temp_dir = tempdir().unwrap();
2347        let base_path = temp_dir.path();
2348
2349        // Position markers:     0         1         2         3
2350        //                       0123456789012345678901234567890123456789
2351        let content = "prefix [text](missing.md) suffix";
2352        //             The URL "missing.md" starts at 0-indexed position 14
2353        //             which is 1-indexed column 15, and ends at 0-indexed 24 (1-indexed column 25)
2354
2355        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2356        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2357        let result = rule.check(&ctx).unwrap();
2358
2359        assert_eq!(result.len(), 1, "Should have exactly one warning");
2360        assert_eq!(result[0].line, 1, "Should be on line 1");
2361        assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2362        assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2363    }
2364
2365    #[test]
2366    fn test_diagnostic_position_angle_brackets() {
2367        // Test position accuracy with angle bracket links
2368        let temp_dir = tempdir().unwrap();
2369        let base_path = temp_dir.path();
2370
2371        // Position markers:     0         1         2
2372        //                       012345678901234567890
2373        let content = "[link](<missing.md>)";
2374        //             The URL "missing.md" starts at 0-indexed position 8 (1-indexed column 9)
2375
2376        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2377        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2378        let result = rule.check(&ctx).unwrap();
2379
2380        assert_eq!(result.len(), 1, "Should have exactly one warning");
2381        assert_eq!(result[0].line, 1, "Should be on line 1");
2382        assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2383    }
2384
2385    #[test]
2386    fn test_diagnostic_position_multiline() {
2387        // Test that line numbers are correct for links on different lines
2388        let temp_dir = tempdir().unwrap();
2389        let base_path = temp_dir.path();
2390
2391        let content = r#"# Title
2392Some text on line 2
2393[link on line 3](missing1.md)
2394More text
2395[link on line 5](missing2.md)"#;
2396
2397        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2398        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2399        let result = rule.check(&ctx).unwrap();
2400
2401        assert_eq!(result.len(), 2, "Should have two warnings");
2402
2403        // First warning should be on line 3
2404        assert_eq!(result[0].line, 3, "First warning should be on line 3");
2405        assert!(result[0].message.contains("missing1.md"));
2406
2407        // Second warning should be on line 5
2408        assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2409        assert!(result[1].message.contains("missing2.md"));
2410    }
2411
2412    #[test]
2413    fn test_diagnostic_position_with_spaces() {
2414        // Test position with URLs that have spaces in parentheses
2415        let temp_dir = tempdir().unwrap();
2416        let base_path = temp_dir.path();
2417
2418        let content = "[link]( missing.md )";
2419        //             0123456789012345678901
2420        //             0-indexed position 8 is 'm' in 'missing.md' (after space and paren)
2421        //             which is 1-indexed column 9
2422
2423        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2424        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2425        let result = rule.check(&ctx).unwrap();
2426
2427        assert_eq!(result.len(), 1, "Should have exactly one warning");
2428        // The regex captures the URL without leading/trailing spaces
2429        assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2430    }
2431
2432    #[test]
2433    fn test_diagnostic_position_image() {
2434        // Test that image diagnostics also have correct positions
2435        let temp_dir = tempdir().unwrap();
2436        let base_path = temp_dir.path();
2437
2438        let content = "![alt text](missing.jpg)";
2439
2440        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2441        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2442        let result = rule.check(&ctx).unwrap();
2443
2444        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2445        assert_eq!(result[0].line, 1);
2446        // Images use start_col from the parser, which should point to the URL
2447        assert!(result[0].column > 0, "Should have valid column position");
2448        assert!(result[0].message.contains("missing.jpg"));
2449    }
2450
2451    #[test]
2452    fn test_wikilinks_skipped() {
2453        // Wikilinks should not trigger MD057 warnings
2454        // They use a different linking system (e.g., Obsidian, wiki software)
2455        let temp_dir = tempdir().unwrap();
2456        let base_path = temp_dir.path();
2457
2458        let content = r#"# Test Document
2459
2460[[Microsoft#Windows OS]]
2461[[SomePage]]
2462[[Page With Spaces]]
2463[[path/to/page#section]]
2464[[page|Display Text]]
2465
2466This is a [real missing link](missing.md) that should be flagged.
2467"#;
2468
2469        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2470        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2471        let result = rule.check(&ctx).unwrap();
2472
2473        // Should only warn about the regular markdown link, not wikilinks
2474        assert_eq!(
2475            result.len(),
2476            1,
2477            "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2478        );
2479        assert!(
2480            result[0].message.contains("missing.md"),
2481            "Warning should be for missing.md, not wikilinks"
2482        );
2483    }
2484
2485    #[test]
2486    fn test_wikilinks_not_added_to_index() {
2487        // Wikilinks should not be added to the cross-file link index
2488        let temp_dir = tempdir().unwrap();
2489        let base_path = temp_dir.path();
2490
2491        let content = r#"# Test Document
2492
2493[[Microsoft#Windows OS]]
2494[[SomePage#section]]
2495[Regular Link](other.md)
2496"#;
2497
2498        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2499        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2500
2501        let mut file_index = FileIndex::new();
2502        rule.contribute_to_index(&ctx, &mut file_index);
2503
2504        // Should only have the regular markdown link (if it's a markdown file)
2505        // Wikilinks should not be added
2506        let cross_file_links = &file_index.cross_file_links;
2507        assert_eq!(
2508            cross_file_links.len(),
2509            1,
2510            "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2511        );
2512        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2513    }
2514
2515    #[test]
2516    fn test_reference_definition_missing_file() {
2517        // Reference definitions [ref]: ./path.md should be checked
2518        let temp_dir = tempdir().unwrap();
2519        let base_path = temp_dir.path();
2520
2521        let content = r#"# Test Document
2522
2523[test]: ./missing.md
2524[example]: ./nonexistent.html
2525
2526Use [test] and [example] here.
2527"#;
2528
2529        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2530        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2531        let result = rule.check(&ctx).unwrap();
2532
2533        // Should have warnings for both reference definitions
2534        assert_eq!(
2535            result.len(),
2536            2,
2537            "Should have warnings for missing reference definition targets. Got: {result:?}"
2538        );
2539        assert!(
2540            result.iter().any(|w| w.message.contains("missing.md")),
2541            "Should warn about missing.md"
2542        );
2543        assert!(
2544            result.iter().any(|w| w.message.contains("nonexistent.html")),
2545            "Should warn about nonexistent.html"
2546        );
2547    }
2548
2549    #[test]
2550    fn test_reference_definition_existing_file() {
2551        // Reference definitions to existing files should NOT trigger warnings
2552        let temp_dir = tempdir().unwrap();
2553        let base_path = temp_dir.path();
2554
2555        // Create an existing file
2556        let exists_path = base_path.join("exists.md");
2557        File::create(&exists_path)
2558            .unwrap()
2559            .write_all(b"# Existing file")
2560            .unwrap();
2561
2562        let content = r#"# Test Document
2563
2564[test]: ./exists.md
2565
2566Use [test] here.
2567"#;
2568
2569        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2570        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2571        let result = rule.check(&ctx).unwrap();
2572
2573        // Should have NO warnings since the file exists
2574        assert!(
2575            result.is_empty(),
2576            "Should not warn about existing file. Got: {result:?}"
2577        );
2578    }
2579
2580    #[test]
2581    fn test_reference_definition_external_url_skipped() {
2582        // Reference definitions with external URLs should be skipped
2583        let temp_dir = tempdir().unwrap();
2584        let base_path = temp_dir.path();
2585
2586        let content = r#"# Test Document
2587
2588[google]: https://google.com
2589[example]: http://example.org
2590[mail]: mailto:test@example.com
2591[ftp]: ftp://files.example.com
2592[local]: ./missing.md
2593
2594Use [google], [example], [mail], [ftp], [local] here.
2595"#;
2596
2597        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2598        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2599        let result = rule.check(&ctx).unwrap();
2600
2601        // Should only warn about the local missing file, not external URLs
2602        assert_eq!(
2603            result.len(),
2604            1,
2605            "Should only warn about local missing file. Got: {result:?}"
2606        );
2607        assert!(
2608            result[0].message.contains("missing.md"),
2609            "Warning should be for missing.md"
2610        );
2611    }
2612
2613    #[test]
2614    fn test_reference_definition_fragment_only_skipped() {
2615        // Reference definitions with fragment-only URLs should be skipped
2616        let temp_dir = tempdir().unwrap();
2617        let base_path = temp_dir.path();
2618
2619        let content = r#"# Test Document
2620
2621[section]: #my-section
2622
2623Use [section] here.
2624"#;
2625
2626        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2627        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2628        let result = rule.check(&ctx).unwrap();
2629
2630        // Should have NO warnings for fragment-only links
2631        assert!(
2632            result.is_empty(),
2633            "Should not warn about fragment-only reference. Got: {result:?}"
2634        );
2635    }
2636
2637    #[test]
2638    fn test_reference_definition_column_position() {
2639        // Test that column position points to the URL in the reference definition
2640        let temp_dir = tempdir().unwrap();
2641        let base_path = temp_dir.path();
2642
2643        // Position markers:     0         1         2
2644        //                       0123456789012345678901
2645        let content = "[ref]: ./missing.md";
2646        //             The URL "./missing.md" starts at 0-indexed position 7
2647        //             which is 1-indexed column 8
2648
2649        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2650        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2651        let result = rule.check(&ctx).unwrap();
2652
2653        assert_eq!(result.len(), 1, "Should have exactly one warning");
2654        assert_eq!(result[0].line, 1, "Should be on line 1");
2655        assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2656    }
2657
2658    #[test]
2659    fn test_reference_definition_html_with_md_source() {
2660        // Reference definitions to .html files should pass if corresponding .md source exists
2661        let temp_dir = tempdir().unwrap();
2662        let base_path = temp_dir.path();
2663
2664        // Create guide.md (source file)
2665        let md_file = base_path.join("guide.md");
2666        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2667
2668        let content = r#"# Test Document
2669
2670[guide]: ./guide.html
2671[missing]: ./missing.html
2672
2673Use [guide] and [missing] here.
2674"#;
2675
2676        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2677        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2678        let result = rule.check(&ctx).unwrap();
2679
2680        // guide.html passes (guide.md exists), missing.html fails
2681        assert_eq!(
2682            result.len(),
2683            1,
2684            "Should only warn about missing source. Got: {result:?}"
2685        );
2686        assert!(result[0].message.contains("missing.html"));
2687    }
2688
2689    #[test]
2690    fn test_reference_definition_url_encoded() {
2691        // Reference definitions with URL-encoded paths should be decoded before checking
2692        let temp_dir = tempdir().unwrap();
2693        let base_path = temp_dir.path();
2694
2695        // Create a file with spaces in the name
2696        let file_with_spaces = base_path.join("file with spaces.md");
2697        File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2698
2699        let content = r#"# Test Document
2700
2701[spaces]: ./file%20with%20spaces.md
2702[missing]: ./missing%20file.md
2703
2704Use [spaces] and [missing] here.
2705"#;
2706
2707        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2708        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2709        let result = rule.check(&ctx).unwrap();
2710
2711        // Should only warn about the missing file
2712        assert_eq!(
2713            result.len(),
2714            1,
2715            "Should only warn about missing URL-encoded file. Got: {result:?}"
2716        );
2717        assert!(result[0].message.contains("missing%20file.md"));
2718    }
2719
2720    #[test]
2721    fn test_inline_and_reference_both_checked() {
2722        // Both inline links and reference definitions should be checked
2723        let temp_dir = tempdir().unwrap();
2724        let base_path = temp_dir.path();
2725
2726        let content = r#"# Test Document
2727
2728[inline link](./inline-missing.md)
2729[ref]: ./ref-missing.md
2730
2731Use [ref] here.
2732"#;
2733
2734        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2735        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2736        let result = rule.check(&ctx).unwrap();
2737
2738        // Should warn about both the inline link and the reference definition
2739        assert_eq!(
2740            result.len(),
2741            2,
2742            "Should warn about both inline and reference links. Got: {result:?}"
2743        );
2744        assert!(
2745            result.iter().any(|w| w.message.contains("inline-missing.md")),
2746            "Should warn about inline-missing.md"
2747        );
2748        assert!(
2749            result.iter().any(|w| w.message.contains("ref-missing.md")),
2750            "Should warn about ref-missing.md"
2751        );
2752    }
2753
2754    #[test]
2755    fn test_footnote_definitions_not_flagged() {
2756        // Regression test for issue #286: footnote definitions should not be
2757        // treated as reference definitions and flagged as broken links
2758        let rule = MD057ExistingRelativeLinks::default();
2759
2760        let content = r#"# Title
2761
2762A footnote[^1].
2763
2764[^1]: [link](https://www.google.com).
2765"#;
2766
2767        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2768        let result = rule.check(&ctx).unwrap();
2769
2770        assert!(
2771            result.is_empty(),
2772            "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2773        );
2774    }
2775
2776    #[test]
2777    fn test_footnote_with_relative_link_inside() {
2778        // Footnotes containing relative links should not be checked
2779        // (the footnote content is not a URL, it's content that may contain links)
2780        let rule = MD057ExistingRelativeLinks::default();
2781
2782        let content = r#"# Title
2783
2784See the footnote[^1].
2785
2786[^1]: Check out [this file](./existing.md) for more info.
2787[^2]: Also see [missing](./does-not-exist.md).
2788"#;
2789
2790        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2791        let result = rule.check(&ctx).unwrap();
2792
2793        // The inline links INSIDE footnotes should be checked (./existing.md, ./does-not-exist.md)
2794        // but the footnote definition itself should not be treated as a reference definition
2795        // Note: This test verifies that [^1]: and [^2]: are not parsed as ref defs with
2796        // URLs like "[this file](./existing.md)" or "[missing](./does-not-exist.md)"
2797        for warning in &result {
2798            assert!(
2799                !warning.message.contains("[this file]"),
2800                "Footnote content should not be treated as URL: {warning:?}"
2801            );
2802            assert!(
2803                !warning.message.contains("[missing]"),
2804                "Footnote content should not be treated as URL: {warning:?}"
2805            );
2806        }
2807    }
2808
2809    #[test]
2810    fn test_mixed_footnotes_and_reference_definitions() {
2811        // Ensure regular reference definitions are still checked while footnotes are skipped
2812        let temp_dir = tempdir().unwrap();
2813        let base_path = temp_dir.path();
2814
2815        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2816
2817        let content = r#"# Title
2818
2819A footnote[^1] and a [ref link][myref].
2820
2821[^1]: This is a footnote with [link](https://example.com).
2822
2823[myref]: ./missing-file.md "This should be checked"
2824"#;
2825
2826        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2827        let result = rule.check(&ctx).unwrap();
2828
2829        // Should only warn about the regular reference definition, not the footnote
2830        assert_eq!(
2831            result.len(),
2832            1,
2833            "Should only warn about the regular reference definition. Got: {result:?}"
2834        );
2835        assert!(
2836            result[0].message.contains("missing-file.md"),
2837            "Should warn about missing-file.md in reference definition"
2838        );
2839    }
2840
2841    #[test]
2842    fn test_absolute_links_ignore_by_default() {
2843        // By default, absolute links are ignored (not validated)
2844        let temp_dir = tempdir().unwrap();
2845        let base_path = temp_dir.path();
2846
2847        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2848
2849        let content = r#"# Links
2850
2851[API docs](/api/v1/users)
2852[Blog post](/blog/2024/release.html)
2853![Logo](/assets/logo.png)
2854
2855[ref]: /docs/reference.md
2856"#;
2857
2858        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2859        let result = rule.check(&ctx).unwrap();
2860
2861        // No warnings - absolute links are ignored by default
2862        assert!(
2863            result.is_empty(),
2864            "Absolute links should be ignored by default. Got: {result:?}"
2865        );
2866    }
2867
2868    #[test]
2869    fn test_absolute_links_warn_config() {
2870        // When configured to warn, absolute links should generate warnings
2871        let temp_dir = tempdir().unwrap();
2872        let base_path = temp_dir.path();
2873
2874        let config = MD057Config {
2875            absolute_links: AbsoluteLinksOption::Warn,
2876            ..Default::default()
2877        };
2878        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2879
2880        let content = r#"# Links
2881
2882[API docs](/api/v1/users)
2883[Blog post](/blog/2024/release.html)
2884"#;
2885
2886        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2887        let result = rule.check(&ctx).unwrap();
2888
2889        // Should have 2 warnings for the 2 absolute links
2890        assert_eq!(
2891            result.len(),
2892            2,
2893            "Should warn about both absolute links. Got: {result:?}"
2894        );
2895        assert!(
2896            result[0].message.contains("cannot be validated locally"),
2897            "Warning should explain why: {}",
2898            result[0].message
2899        );
2900        assert!(
2901            result[0].message.contains("/api/v1/users"),
2902            "Warning should include the link path"
2903        );
2904    }
2905
2906    #[test]
2907    fn test_absolute_links_warn_images() {
2908        // Images with absolute paths should also warn when configured
2909        let temp_dir = tempdir().unwrap();
2910        let base_path = temp_dir.path();
2911
2912        let config = MD057Config {
2913            absolute_links: AbsoluteLinksOption::Warn,
2914            ..Default::default()
2915        };
2916        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2917
2918        let content = r#"# Images
2919
2920![Logo](/assets/logo.png)
2921"#;
2922
2923        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2924        let result = rule.check(&ctx).unwrap();
2925
2926        assert_eq!(
2927            result.len(),
2928            1,
2929            "Should warn about absolute image path. Got: {result:?}"
2930        );
2931        assert!(
2932            result[0].message.contains("/assets/logo.png"),
2933            "Warning should include the image path"
2934        );
2935    }
2936
2937    #[test]
2938    fn test_absolute_links_warn_reference_definitions() {
2939        // Reference definitions with absolute paths should also warn when configured
2940        let temp_dir = tempdir().unwrap();
2941        let base_path = temp_dir.path();
2942
2943        let config = MD057Config {
2944            absolute_links: AbsoluteLinksOption::Warn,
2945            ..Default::default()
2946        };
2947        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2948
2949        let content = r#"# Reference
2950
2951See the [docs][ref].
2952
2953[ref]: /docs/reference.md
2954"#;
2955
2956        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2957        let result = rule.check(&ctx).unwrap();
2958
2959        assert_eq!(
2960            result.len(),
2961            1,
2962            "Should warn about absolute reference definition. Got: {result:?}"
2963        );
2964        assert!(
2965            result[0].message.contains("/docs/reference.md"),
2966            "Warning should include the reference path"
2967        );
2968    }
2969
2970    #[test]
2971    fn test_search_paths_inline_link() {
2972        let temp_dir = tempdir().unwrap();
2973        let base_path = temp_dir.path();
2974
2975        // Create an "assets" directory with an image
2976        let assets_dir = base_path.join("assets");
2977        std::fs::create_dir_all(&assets_dir).unwrap();
2978        std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
2979
2980        let config = MD057Config {
2981            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
2982            ..Default::default()
2983        };
2984        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2985
2986        let content = "# Test\n\n[Photo](photo.png)\n";
2987        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2988        let result = rule.check(&ctx).unwrap();
2989
2990        assert!(
2991            result.is_empty(),
2992            "Should find photo.png via search-paths. Got: {result:?}"
2993        );
2994    }
2995
2996    #[test]
2997    fn test_search_paths_image() {
2998        let temp_dir = tempdir().unwrap();
2999        let base_path = temp_dir.path();
3000
3001        let assets_dir = base_path.join("attachments");
3002        std::fs::create_dir_all(&assets_dir).unwrap();
3003        std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3004
3005        let config = MD057Config {
3006            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3007            ..Default::default()
3008        };
3009        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3010
3011        let content = "# Test\n\n![Diagram](diagram.svg)\n";
3012        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3013        let result = rule.check(&ctx).unwrap();
3014
3015        assert!(
3016            result.is_empty(),
3017            "Should find diagram.svg via search-paths. Got: {result:?}"
3018        );
3019    }
3020
3021    #[test]
3022    fn test_search_paths_reference_definition() {
3023        let temp_dir = tempdir().unwrap();
3024        let base_path = temp_dir.path();
3025
3026        let assets_dir = base_path.join("images");
3027        std::fs::create_dir_all(&assets_dir).unwrap();
3028        std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3029
3030        let config = MD057Config {
3031            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3032            ..Default::default()
3033        };
3034        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3035
3036        let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3037        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3038        let result = rule.check(&ctx).unwrap();
3039
3040        assert!(
3041            result.is_empty(),
3042            "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3043        );
3044    }
3045
3046    #[test]
3047    fn test_search_paths_still_warns_when_truly_missing() {
3048        let temp_dir = tempdir().unwrap();
3049        let base_path = temp_dir.path();
3050
3051        let assets_dir = base_path.join("assets");
3052        std::fs::create_dir_all(&assets_dir).unwrap();
3053
3054        let config = MD057Config {
3055            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3056            ..Default::default()
3057        };
3058        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3059
3060        let content = "# Test\n\n![Missing](nonexistent.png)\n";
3061        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3062        let result = rule.check(&ctx).unwrap();
3063
3064        assert_eq!(
3065            result.len(),
3066            1,
3067            "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3068        );
3069    }
3070
3071    #[test]
3072    fn test_search_paths_nonexistent_directory() {
3073        let temp_dir = tempdir().unwrap();
3074        let base_path = temp_dir.path();
3075
3076        let config = MD057Config {
3077            search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3078            ..Default::default()
3079        };
3080        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3081
3082        let content = "# Test\n\n![Missing](photo.png)\n";
3083        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3084        let result = rule.check(&ctx).unwrap();
3085
3086        assert_eq!(
3087            result.len(),
3088            1,
3089            "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3090        );
3091    }
3092
3093    #[test]
3094    fn test_obsidian_attachment_folder_named() {
3095        let temp_dir = tempdir().unwrap();
3096        let vault = temp_dir.path().join("vault");
3097        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3098        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3099        std::fs::create_dir_all(vault.join("notes")).unwrap();
3100
3101        std::fs::write(
3102            vault.join(".obsidian/app.json"),
3103            r#"{"attachmentFolderPath": "Attachments"}"#,
3104        )
3105        .unwrap();
3106        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3107
3108        let notes_dir = vault.join("notes");
3109        let source_file = notes_dir.join("test.md");
3110        std::fs::write(&source_file, "# Test\n\n![Photo](photo.png)\n").unwrap();
3111
3112        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3113
3114        let content = "# Test\n\n![Photo](photo.png)\n";
3115        let ctx =
3116            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3117        let result = rule.check(&ctx).unwrap();
3118
3119        assert!(
3120            result.is_empty(),
3121            "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3122        );
3123    }
3124
3125    #[test]
3126    fn test_obsidian_attachment_same_folder_as_file() {
3127        let temp_dir = tempdir().unwrap();
3128        let vault = temp_dir.path().join("vault-rf");
3129        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3130        std::fs::create_dir_all(vault.join("notes")).unwrap();
3131
3132        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3133
3134        // Image in the same directory as the file — default behavior, no extra search needed
3135        let notes_dir = vault.join("notes");
3136        let source_file = notes_dir.join("test.md");
3137        std::fs::write(&source_file, "placeholder").unwrap();
3138        std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3139
3140        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3141
3142        let content = "# Test\n\n![Photo](photo.png)\n";
3143        let ctx =
3144            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3145        let result = rule.check(&ctx).unwrap();
3146
3147        assert!(
3148            result.is_empty(),
3149            "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3150        );
3151    }
3152
3153    #[test]
3154    fn test_obsidian_not_triggered_without_obsidian_flavor() {
3155        let temp_dir = tempdir().unwrap();
3156        let vault = temp_dir.path().join("vault-nf");
3157        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3158        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3159        std::fs::create_dir_all(vault.join("notes")).unwrap();
3160
3161        std::fs::write(
3162            vault.join(".obsidian/app.json"),
3163            r#"{"attachmentFolderPath": "Attachments"}"#,
3164        )
3165        .unwrap();
3166        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3167
3168        let notes_dir = vault.join("notes");
3169        let source_file = notes_dir.join("test.md");
3170        std::fs::write(&source_file, "placeholder").unwrap();
3171
3172        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3173
3174        let content = "# Test\n\n![Photo](photo.png)\n";
3175        // Standard flavor — NOT Obsidian
3176        let ctx =
3177            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3178        let result = rule.check(&ctx).unwrap();
3179
3180        assert_eq!(
3181            result.len(),
3182            1,
3183            "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3184        );
3185    }
3186
3187    #[test]
3188    fn test_search_paths_combined_with_obsidian() {
3189        let temp_dir = tempdir().unwrap();
3190        let vault = temp_dir.path().join("vault-combo");
3191        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3192        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3193        std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3194        std::fs::create_dir_all(vault.join("notes")).unwrap();
3195
3196        std::fs::write(
3197            vault.join(".obsidian/app.json"),
3198            r#"{"attachmentFolderPath": "Attachments"}"#,
3199        )
3200        .unwrap();
3201        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3202        std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3203
3204        let notes_dir = vault.join("notes");
3205        let source_file = notes_dir.join("test.md");
3206        std::fs::write(&source_file, "placeholder").unwrap();
3207
3208        let extra_assets_dir = vault.join("extra-assets");
3209        let config = MD057Config {
3210            search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3211            ..Default::default()
3212        };
3213        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&notes_dir);
3214
3215        // Both links should resolve: photo.png via Obsidian, diagram.svg via search-paths
3216        let content = "# Test\n\n![Photo](photo.png)\n\n![Diagram](diagram.svg)\n";
3217        let ctx =
3218            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3219        let result = rule.check(&ctx).unwrap();
3220
3221        assert!(
3222            result.is_empty(),
3223            "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3224        );
3225    }
3226
3227    #[test]
3228    fn test_obsidian_attachment_subfolder_under_file() {
3229        let temp_dir = tempdir().unwrap();
3230        let vault = temp_dir.path().join("vault-sub");
3231        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3232        std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3233
3234        std::fs::write(
3235            vault.join(".obsidian/app.json"),
3236            r#"{"attachmentFolderPath": "./assets"}"#,
3237        )
3238        .unwrap();
3239        std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3240
3241        let notes_dir = vault.join("notes");
3242        let source_file = notes_dir.join("test.md");
3243        std::fs::write(&source_file, "placeholder").unwrap();
3244
3245        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3246
3247        let content = "# Test\n\n![Photo](photo.png)\n";
3248        let ctx =
3249            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3250        let result = rule.check(&ctx).unwrap();
3251
3252        assert!(
3253            result.is_empty(),
3254            "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3255        );
3256    }
3257
3258    #[test]
3259    fn test_obsidian_attachment_vault_root() {
3260        let temp_dir = tempdir().unwrap();
3261        let vault = temp_dir.path().join("vault-root");
3262        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3263        std::fs::create_dir_all(vault.join("notes")).unwrap();
3264
3265        // Empty string = vault root
3266        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3267        std::fs::write(vault.join("photo.png"), "fake").unwrap();
3268
3269        let notes_dir = vault.join("notes");
3270        let source_file = notes_dir.join("test.md");
3271        std::fs::write(&source_file, "placeholder").unwrap();
3272
3273        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3274
3275        let content = "# Test\n\n![Photo](photo.png)\n";
3276        let ctx =
3277            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3278        let result = rule.check(&ctx).unwrap();
3279
3280        assert!(
3281            result.is_empty(),
3282            "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3283        );
3284    }
3285
3286    #[test]
3287    fn test_search_paths_multiple_directories() {
3288        let temp_dir = tempdir().unwrap();
3289        let base_path = temp_dir.path();
3290
3291        let dir_a = base_path.join("dir-a");
3292        let dir_b = base_path.join("dir-b");
3293        std::fs::create_dir_all(&dir_a).unwrap();
3294        std::fs::create_dir_all(&dir_b).unwrap();
3295        std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3296        std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3297
3298        let config = MD057Config {
3299            search_paths: vec![
3300                dir_a.to_string_lossy().into_owned(),
3301                dir_b.to_string_lossy().into_owned(),
3302            ],
3303            ..Default::default()
3304        };
3305        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3306
3307        let content = "# Test\n\n![A](alpha.png)\n\n![B](beta.png)\n";
3308        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3309        let result = rule.check(&ctx).unwrap();
3310
3311        assert!(
3312            result.is_empty(),
3313            "Should find files across multiple search paths. Got: {result:?}"
3314        );
3315    }
3316
3317    #[test]
3318    fn test_cross_file_check_with_search_paths() {
3319        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3320
3321        let temp_dir = tempdir().unwrap();
3322        let base_path = temp_dir.path();
3323
3324        // Create docs directory with a markdown target in a search path
3325        let docs_dir = base_path.join("docs");
3326        std::fs::create_dir_all(&docs_dir).unwrap();
3327        std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3328
3329        let config = MD057Config {
3330            search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3331            ..Default::default()
3332        };
3333        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3334
3335        let file_path = base_path.join("README.md");
3336        std::fs::write(&file_path, "# Readme\n").unwrap();
3337
3338        let mut file_index = FileIndex::default();
3339        file_index.cross_file_links.push(CrossFileLinkIndex {
3340            target_path: "guide.md".to_string(),
3341            fragment: String::new(),
3342            line: 3,
3343            column: 1,
3344        });
3345
3346        let workspace_index = WorkspaceIndex::new();
3347
3348        let result = rule
3349            .cross_file_check(&file_path, &file_index, &workspace_index)
3350            .unwrap();
3351
3352        assert!(
3353            result.is_empty(),
3354            "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3355        );
3356    }
3357
3358    #[test]
3359    fn test_cross_file_check_with_obsidian_flavor() {
3360        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3361
3362        let temp_dir = tempdir().unwrap();
3363        let vault = temp_dir.path().join("vault-xf");
3364        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3365        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3366        std::fs::create_dir_all(vault.join("notes")).unwrap();
3367
3368        std::fs::write(
3369            vault.join(".obsidian/app.json"),
3370            r#"{"attachmentFolderPath": "Attachments"}"#,
3371        )
3372        .unwrap();
3373        std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3374
3375        let notes_dir = vault.join("notes");
3376        let file_path = notes_dir.join("test.md");
3377        std::fs::write(&file_path, "placeholder").unwrap();
3378
3379        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3380            .with_path(&notes_dir)
3381            .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3382
3383        let mut file_index = FileIndex::default();
3384        file_index.cross_file_links.push(CrossFileLinkIndex {
3385            target_path: "ref.md".to_string(),
3386            fragment: String::new(),
3387            line: 3,
3388            column: 1,
3389        });
3390
3391        let workspace_index = WorkspaceIndex::new();
3392
3393        let result = rule
3394            .cross_file_check(&file_path, &file_index, &workspace_index)
3395            .unwrap();
3396
3397        assert!(
3398            result.is_empty(),
3399            "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3400        );
3401    }
3402
3403    #[test]
3404    fn test_cross_file_check_clears_stale_cache() {
3405        // Verify that cross_file_check() resets the file existence cache so stale
3406        // entries from a previous lint cycle do not affect results.
3407        use crate::workspace_index::WorkspaceIndex;
3408
3409        let rule = MD057ExistingRelativeLinks::new();
3410
3411        // Seed the cache with a stale entry: pretend "docs/phantom.md" exists on disk.
3412        // In reality, neither the filesystem nor the workspace index has this file.
3413        {
3414            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3415            cache.insert(PathBuf::from("docs/phantom.md"), true);
3416        }
3417
3418        let workspace_index = WorkspaceIndex::new();
3419
3420        let mut file_index = FileIndex::new();
3421        file_index.add_cross_file_link(CrossFileLinkIndex {
3422            target_path: "phantom.md".to_string(),
3423            fragment: "".to_string(),
3424            line: 1,
3425            column: 1,
3426        });
3427
3428        let warnings = rule
3429            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3430            .unwrap();
3431
3432        // With cache reset, cross_file_check must detect that phantom.md does not exist
3433        assert_eq!(
3434            warnings.len(),
3435            1,
3436            "cross_file_check should report missing file after clearing stale cache. Got: {warnings:?}"
3437        );
3438        assert!(warnings[0].message.contains("phantom.md"));
3439    }
3440
3441    #[test]
3442    fn test_cross_file_check_does_not_carry_over_cache_between_runs() {
3443        // Two consecutive cross_file_check() calls should each start with a fresh cache.
3444        use crate::workspace_index::WorkspaceIndex;
3445
3446        let rule = MD057ExistingRelativeLinks::new();
3447        let workspace_index = WorkspaceIndex::new();
3448
3449        // First run: link to a file that doesn't exist
3450        let mut file_index_1 = FileIndex::new();
3451        file_index_1.add_cross_file_link(CrossFileLinkIndex {
3452            target_path: "nonexistent.md".to_string(),
3453            fragment: "".to_string(),
3454            line: 1,
3455            column: 1,
3456        });
3457
3458        let warnings_1 = rule
3459            .cross_file_check(Path::new("docs/a.md"), &file_index_1, &workspace_index)
3460            .unwrap();
3461        assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3462
3463        // Between runs, inject a stale "exists = true" entry for the same resolved path
3464        {
3465            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3466            cache.insert(PathBuf::from("docs/nonexistent.md"), true);
3467        }
3468
3469        // Second run: same link, but now cache says file exists (stale data)
3470        let warnings_2 = rule
3471            .cross_file_check(Path::new("docs/a.md"), &file_index_1, &workspace_index)
3472            .unwrap();
3473
3474        // The second run must also detect the missing file because the cache should be reset
3475        assert_eq!(
3476            warnings_2.len(),
3477            1,
3478            "Second run should still detect missing file after cache reset. Got: {warnings_2:?}"
3479        );
3480    }
3481}