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