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