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        for link in extract_cross_file_links(ctx) {
1227            index.add_cross_file_link(link);
1228        }
1229    }
1230
1231    fn cross_file_check(
1232        &self,
1233        _file_path: &Path,
1234        _file_index: &FileIndex,
1235        _workspace_index: &crate::workspace_index::WorkspaceIndex,
1236    ) -> LintResult {
1237        // All link targets are already validated by check() on each per-file pass.
1238        // check() resolves relative links against the file's own directory, handles
1239        // configured search paths, and applies the absolute_links config.
1240        // Validating them here too would produce identical duplicate warnings for
1241        // every broken link. (#631)
1242        //
1243        // The cross_file_scope / contribute_to_index / workspace-index infrastructure
1244        // remains in place to support future cross-file analyses (e.g. heading-anchor
1245        // validation across files).
1246        Ok(Vec::new())
1247    }
1248}
1249
1250/// Compute the shortest relative path from `from_dir` to `to_path`.
1251///
1252/// Both paths must be normalized (no `.` or `..` components).
1253/// Returns a relative `PathBuf` that navigates from `from_dir` to `to_path`.
1254fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1255    let from_components: Vec<_> = from_dir.components().collect();
1256    let to_components: Vec<_> = to_path.components().collect();
1257
1258    // Find common prefix length
1259    let common_len = from_components
1260        .iter()
1261        .zip(to_components.iter())
1262        .take_while(|(a, b)| a == b)
1263        .count();
1264
1265    let mut result = PathBuf::new();
1266
1267    // Go up for each remaining component in from_dir
1268    for _ in common_len..from_components.len() {
1269        result.push("..");
1270    }
1271
1272    // Append remaining components from to_path
1273    for component in &to_components[common_len..] {
1274        result.push(component);
1275    }
1276
1277    result
1278}
1279
1280/// Check if a relative link path can be shortened.
1281///
1282/// Given the source directory and the raw link path, computes whether there's
1283/// a shorter equivalent path. Returns `Some(compact_path)` if the link can
1284/// be simplified, `None` if it's already optimal.
1285fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1286    let link_path = Path::new(raw_link_path);
1287
1288    // Only check paths that contain traversal (../ or ./)
1289    let has_traversal = link_path
1290        .components()
1291        .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1292
1293    if !has_traversal {
1294        return None;
1295    }
1296
1297    // Resolve: source_dir + raw_link_path, then normalize
1298    let combined = source_dir.join(link_path);
1299    let normalized_target = normalize_path(&combined);
1300
1301    // Compute shortest path from source_dir back to the normalized target
1302    let normalized_source = normalize_path(source_dir);
1303    let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1304
1305    // Compare against the raw link path — if it differs, the path can be compacted
1306    if shortest != link_path {
1307        let compact = shortest.to_string_lossy().to_string();
1308        // Avoid suggesting empty path
1309        if compact.is_empty() {
1310            return None;
1311        }
1312        // Markdown links always use forward slashes regardless of platform
1313        Some(compact.replace('\\', "/"))
1314    } else {
1315        None
1316    }
1317}
1318
1319/// Normalize a path by resolving . and .. components
1320fn normalize_path(path: &Path) -> PathBuf {
1321    let mut components = Vec::new();
1322
1323    for component in path.components() {
1324        match component {
1325            std::path::Component::ParentDir => {
1326                // Go up one level if possible
1327                if !components.is_empty() {
1328                    components.pop();
1329                }
1330            }
1331            std::path::Component::CurDir => {
1332                // Skip current directory markers
1333            }
1334            _ => {
1335                components.push(component);
1336            }
1337        }
1338    }
1339
1340    components.iter().collect()
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345    use super::*;
1346    use crate::workspace_index::CrossFileLinkIndex;
1347    use std::fs::File;
1348    use std::io::Write;
1349    use tempfile::tempdir;
1350
1351    #[test]
1352    fn test_strip_query_and_fragment() {
1353        // Test query parameter stripping
1354        assert_eq!(
1355            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1356            "file.png"
1357        );
1358        assert_eq!(
1359            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1360            "file.png"
1361        );
1362        assert_eq!(
1363            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1364            "file.png"
1365        );
1366
1367        // Test fragment stripping
1368        assert_eq!(
1369            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1370            "file.md"
1371        );
1372        assert_eq!(
1373            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1374            "file.md"
1375        );
1376
1377        // Test both query and fragment (query comes first, per RFC 3986)
1378        assert_eq!(
1379            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1380            "file.md"
1381        );
1382
1383        // Test no query or fragment
1384        assert_eq!(
1385            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1386            "file.png"
1387        );
1388
1389        // Test with path
1390        assert_eq!(
1391            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1392            "path/to/image.png"
1393        );
1394        assert_eq!(
1395            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1396            "path/to/image.png"
1397        );
1398
1399        // Edge case: fragment before query (non-standard but possible)
1400        assert_eq!(
1401            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1402            "file.md"
1403        );
1404    }
1405
1406    #[test]
1407    fn test_url_decode() {
1408        // Simple space encoding
1409        assert_eq!(
1410            MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1411            "penguin with space.jpg"
1412        );
1413
1414        // Path with encoded spaces
1415        assert_eq!(
1416            MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1417            "assets/my file name.png"
1418        );
1419
1420        // Multiple encoded characters
1421        assert_eq!(
1422            MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1423            "hello world!.md"
1424        );
1425
1426        // Lowercase hex
1427        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1428
1429        // Uppercase hex
1430        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1431
1432        // Mixed case hex
1433        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1434
1435        // No encoding - return as-is
1436        assert_eq!(
1437            MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1438            "normal-file.md"
1439        );
1440
1441        // Incomplete percent encoding - leave as-is
1442        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1443
1444        // Percent at end - leave as-is
1445        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1446
1447        // Invalid hex digits - leave as-is
1448        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1449
1450        // Plus sign (should NOT be decoded - that's form encoding, not URL encoding)
1451        assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1452
1453        // Empty string
1454        assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1455
1456        // UTF-8 multi-byte characters (é = C3 A9 in UTF-8)
1457        assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1458
1459        // Multiple consecutive encoded characters
1460        assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), "   ");
1461
1462        // Encoded path separators
1463        assert_eq!(
1464            MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1465            "path/to/file.md"
1466        );
1467
1468        // Mixed encoded and non-encoded
1469        assert_eq!(
1470            MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1471            "hello world/foo bar.md"
1472        );
1473
1474        // Special characters that are commonly encoded
1475        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1476
1477        // Percent at position that looks like encoding but isn't valid
1478        assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1479    }
1480
1481    #[test]
1482    fn test_url_encoded_filenames() {
1483        // Create a temporary directory for test files
1484        let temp_dir = tempdir().unwrap();
1485        let base_path = temp_dir.path();
1486
1487        // Create a file with spaces in the name
1488        let file_with_spaces = base_path.join("penguin with space.jpg");
1489        File::create(&file_with_spaces)
1490            .unwrap()
1491            .write_all(b"image data")
1492            .unwrap();
1493
1494        // Create a subdirectory with spaces
1495        let subdir = base_path.join("my images");
1496        std::fs::create_dir(&subdir).unwrap();
1497        let nested_file = subdir.join("photo 1.png");
1498        File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1499
1500        // Test content with URL-encoded links
1501        let content = r#"
1502# Test Document with URL-Encoded Links
1503
1504![Penguin](penguin%20with%20space.jpg)
1505![Photo](my%20images/photo%201.png)
1506![Missing](missing%20file.jpg)
1507"#;
1508
1509        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1510
1511        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512        let result = rule.check(&ctx).unwrap();
1513
1514        // Should only have one warning for the missing file
1515        assert_eq!(
1516            result.len(),
1517            1,
1518            "Should only warn about missing%20file.jpg. Got: {result:?}"
1519        );
1520        assert!(
1521            result[0].message.contains("missing%20file.jpg"),
1522            "Warning should mention the URL-encoded filename"
1523        );
1524    }
1525
1526    #[test]
1527    fn test_external_urls() {
1528        let rule = MD057ExistingRelativeLinks::new();
1529
1530        // Common web protocols
1531        assert!(rule.is_external_url("https://example.com"));
1532        assert!(rule.is_external_url("http://example.com"));
1533        assert!(rule.is_external_url("ftp://example.com"));
1534        assert!(rule.is_external_url("www.example.com"));
1535        assert!(rule.is_external_url("example.com"));
1536
1537        // Special URI schemes
1538        assert!(rule.is_external_url("file:///path/to/file"));
1539        assert!(rule.is_external_url("smb://server/share"));
1540        assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1541        assert!(rule.is_external_url("mailto:user@example.com"));
1542        assert!(rule.is_external_url("tel:+1234567890"));
1543        assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1544        assert!(rule.is_external_url("javascript:void(0)"));
1545        assert!(rule.is_external_url("ssh://git@github.com/repo"));
1546        assert!(rule.is_external_url("git://github.com/repo.git"));
1547
1548        // Email addresses without mailto: protocol
1549        // These are clearly not file links and should be skipped
1550        assert!(rule.is_external_url("user@example.com"));
1551        assert!(rule.is_external_url("steering@kubernetes.io"));
1552        assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1553        assert!(rule.is_external_url("user_name@sub.domain.com"));
1554        assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1555
1556        // Template variables should be skipped (not checked as relative links)
1557        assert!(rule.is_external_url("{{URL}}")); // Handlebars/Mustache
1558        assert!(rule.is_external_url("{{#URL}}")); // Handlebars block helper
1559        assert!(rule.is_external_url("{{> partial}}")); // Handlebars partial
1560        assert!(rule.is_external_url("{{ variable }}")); // Mustache with spaces
1561        assert!(rule.is_external_url("{{% include %}}")); // Jinja2/Hugo shortcode
1562        assert!(rule.is_external_url("{{")); // Even partial matches (regex edge case)
1563
1564        // Absolute paths are NOT external (handled separately via is_absolute_path)
1565        // By default they are ignored, but can be configured to warn
1566        assert!(!rule.is_external_url("/api/v1/users"));
1567        assert!(!rule.is_external_url("/blog/2024/release.html"));
1568        assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1569        assert!(!rule.is_external_url("/pkg/runtime"));
1570        assert!(!rule.is_external_url("/doc/go1compat"));
1571        assert!(!rule.is_external_url("/index.html"));
1572        assert!(!rule.is_external_url("/assets/logo.png"));
1573
1574        // But is_absolute_path should detect them
1575        assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1576        assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1577        assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1578        assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1579        assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1580
1581        // Framework path aliases should be skipped (resolved by build tools)
1582        // Tilde prefix (common in Vite, Nuxt, Astro for project root)
1583        assert!(rule.is_external_url("~/assets/image.png"));
1584        assert!(rule.is_external_url("~/components/Button.vue"));
1585        assert!(rule.is_external_url("~assets/logo.svg")); // Nuxt style without /
1586
1587        // @ prefix (common in Vue, webpack, Vite aliases)
1588        assert!(rule.is_external_url("@/components/Header.vue"));
1589        assert!(rule.is_external_url("@images/photo.jpg"));
1590        assert!(rule.is_external_url("@assets/styles.css"));
1591
1592        // Relative paths should NOT be external (should be validated)
1593        assert!(!rule.is_external_url("./relative/path.md"));
1594        assert!(!rule.is_external_url("relative/path.md"));
1595        assert!(!rule.is_external_url("../parent/path.md"));
1596    }
1597
1598    #[test]
1599    fn test_framework_path_aliases() {
1600        // Create a temporary directory for test files
1601        let temp_dir = tempdir().unwrap();
1602        let base_path = temp_dir.path();
1603
1604        // Test content with framework path aliases (should all be skipped)
1605        let content = r#"
1606# Framework Path Aliases
1607
1608![Image 1](~/assets/penguin.jpg)
1609![Image 2](~assets/logo.svg)
1610![Image 3](@images/photo.jpg)
1611![Image 4](@/components/icon.svg)
1612[Link](@/pages/about.md)
1613
1614This is a [real missing link](missing.md) that should be flagged.
1615"#;
1616
1617        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1618
1619        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620        let result = rule.check(&ctx).unwrap();
1621
1622        // Should only have one warning for the real missing link
1623        assert_eq!(
1624            result.len(),
1625            1,
1626            "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1627        );
1628        assert!(
1629            result[0].message.contains("missing.md"),
1630            "Warning should be for missing.md"
1631        );
1632    }
1633
1634    #[test]
1635    fn test_url_decode_security_path_traversal() {
1636        // Ensure URL decoding doesn't enable path traversal attacks
1637        // The decoded path is still validated against the base path
1638        let temp_dir = tempdir().unwrap();
1639        let base_path = temp_dir.path();
1640
1641        // Create a file in the temp directory
1642        let file_in_base = base_path.join("safe.md");
1643        File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1644
1645        // Test with encoded path traversal attempt
1646        // Use a path that definitely won't exist on any platform (not /etc/passwd which exists on Linux)
1647        // %2F = /, so ..%2F..%2Fnonexistent%2Ffile = ../../nonexistent/file
1648        // %252F = %2F (double encoded), so ..%252F..%252F = ..%2F..%2F (literal, won't decode to ..)
1649        let content = r#"
1650[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1651[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1652[Safe link](safe.md)
1653"#;
1654
1655        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1656
1657        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1658        let result = rule.check(&ctx).unwrap();
1659
1660        // The traversal attempts should still be flagged as missing
1661        // (they don't exist relative to base_path after decoding)
1662        assert_eq!(
1663            result.len(),
1664            2,
1665            "Should have warnings for traversal attempts. Got: {result:?}"
1666        );
1667    }
1668
1669    #[test]
1670    fn test_url_encoded_utf8_filenames() {
1671        // Test with actual UTF-8 encoded filenames
1672        let temp_dir = tempdir().unwrap();
1673        let base_path = temp_dir.path();
1674
1675        // Create files with unicode names
1676        let cafe_file = base_path.join("café.md");
1677        File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1678
1679        let content = r#"
1680[Café link](caf%C3%A9.md)
1681[Missing unicode](r%C3%A9sum%C3%A9.md)
1682"#;
1683
1684        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1685
1686        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1687        let result = rule.check(&ctx).unwrap();
1688
1689        // Should only warn about the missing file
1690        assert_eq!(
1691            result.len(),
1692            1,
1693            "Should only warn about missing résumé.md. Got: {result:?}"
1694        );
1695        assert!(
1696            result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1697            "Warning should mention the URL-encoded filename"
1698        );
1699    }
1700
1701    #[test]
1702    fn test_url_encoded_emoji_filenames() {
1703        // URL-encoded emoji paths should be correctly resolved
1704        // 👤 = U+1F464 = F0 9F 91 A4 in UTF-8
1705        let temp_dir = tempdir().unwrap();
1706        let base_path = temp_dir.path();
1707
1708        // Create directory with emoji in name: 👤 Personal
1709        let emoji_dir = base_path.join("👤 Personal");
1710        std::fs::create_dir(&emoji_dir).unwrap();
1711
1712        // Create file in that directory: TV Shows.md
1713        let file_path = emoji_dir.join("TV Shows.md");
1714        File::create(&file_path)
1715            .unwrap()
1716            .write_all(b"# TV Shows\n\nContent here.")
1717            .unwrap();
1718
1719        // Test content with URL-encoded emoji link
1720        // %F0%9F%91%A4 = 👤, %20 = space
1721        let content = r#"
1722# Test Document
1723
1724[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1725[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1726"#;
1727
1728        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1729
1730        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1731        let result = rule.check(&ctx).unwrap();
1732
1733        // Should only warn about the missing file, not the valid emoji path
1734        assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1735        assert!(
1736            result[0].message.contains("Missing.md"),
1737            "Warning should be for Missing.md, got: {}",
1738            result[0].message
1739        );
1740    }
1741
1742    #[test]
1743    fn test_no_warnings_without_base_path() {
1744        let rule = MD057ExistingRelativeLinks::new();
1745        let content = "[Link](missing.md)";
1746
1747        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1748        let result = rule.check(&ctx).unwrap();
1749        assert!(result.is_empty(), "Should have no warnings without base path");
1750    }
1751
1752    #[test]
1753    fn test_existing_and_missing_links() {
1754        // Create a temporary directory for test files
1755        let temp_dir = tempdir().unwrap();
1756        let base_path = temp_dir.path();
1757
1758        // Create an existing file
1759        let exists_path = base_path.join("exists.md");
1760        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1761
1762        // Verify the file exists
1763        assert!(exists_path.exists(), "exists.md should exist for this test");
1764
1765        // Create test content with both existing and missing links
1766        let content = r#"
1767# Test Document
1768
1769[Valid Link](exists.md)
1770[Invalid Link](missing.md)
1771[External Link](https://example.com)
1772[Media Link](image.jpg)
1773        "#;
1774
1775        // Initialize rule with the base path (default: check all files including media)
1776        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1777
1778        // Test the rule
1779        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1780        let result = rule.check(&ctx).unwrap();
1781
1782        // Should have two warnings: missing.md and image.jpg (both don't exist)
1783        assert_eq!(result.len(), 2);
1784        let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1785        assert!(messages.iter().any(|m| m.contains("missing.md")));
1786        assert!(messages.iter().any(|m| m.contains("image.jpg")));
1787    }
1788
1789    #[test]
1790    fn test_angle_bracket_links() {
1791        // Create a temporary directory for test files
1792        let temp_dir = tempdir().unwrap();
1793        let base_path = temp_dir.path();
1794
1795        // Create an existing file
1796        let exists_path = base_path.join("exists.md");
1797        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1798
1799        // Create test content with angle bracket links
1800        let content = r#"
1801# Test Document
1802
1803[Valid Link](<exists.md>)
1804[Invalid Link](<missing.md>)
1805[External Link](<https://example.com>)
1806    "#;
1807
1808        // Test with default settings
1809        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1810
1811        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1812        let result = rule.check(&ctx).unwrap();
1813
1814        // Should have one warning for missing.md
1815        assert_eq!(result.len(), 1, "Should have exactly one warning");
1816        assert!(
1817            result[0].message.contains("missing.md"),
1818            "Warning should mention missing.md"
1819        );
1820    }
1821
1822    #[test]
1823    fn test_angle_bracket_links_with_parens() {
1824        // Create a temporary directory for test files
1825        let temp_dir = tempdir().unwrap();
1826        let base_path = temp_dir.path();
1827
1828        // Create directory structure with parentheses in path
1829        let app_dir = base_path.join("app");
1830        std::fs::create_dir(&app_dir).unwrap();
1831        let upload_dir = app_dir.join("(upload)");
1832        std::fs::create_dir(&upload_dir).unwrap();
1833        let page_file = upload_dir.join("page.tsx");
1834        File::create(&page_file)
1835            .unwrap()
1836            .write_all(b"export default function Page() {}")
1837            .unwrap();
1838
1839        // Create test content with angle bracket links containing parentheses
1840        let content = r#"
1841# Test Document with Paths Containing Parens
1842
1843[Upload Page](<app/(upload)/page.tsx>)
1844[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1845[Missing](<app/(missing)/file.md>)
1846"#;
1847
1848        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1849
1850        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1851        let result = rule.check(&ctx).unwrap();
1852
1853        // Should only have one warning for the missing file
1854        assert_eq!(
1855            result.len(),
1856            1,
1857            "Should have exactly one warning for missing file. Got: {result:?}"
1858        );
1859        assert!(
1860            result[0].message.contains("app/(missing)/file.md"),
1861            "Warning should mention app/(missing)/file.md"
1862        );
1863    }
1864
1865    #[test]
1866    fn test_all_file_types_checked() {
1867        // Create a temporary directory for test files
1868        let temp_dir = tempdir().unwrap();
1869        let base_path = temp_dir.path();
1870
1871        // Create a test with various file types - all should be checked
1872        let content = r#"
1873[Image Link](image.jpg)
1874[Video Link](video.mp4)
1875[Markdown Link](document.md)
1876[PDF Link](file.pdf)
1877"#;
1878
1879        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1880
1881        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1882        let result = rule.check(&ctx).unwrap();
1883
1884        // Should warn about all missing files regardless of extension
1885        assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1886    }
1887
1888    #[test]
1889    fn test_code_span_detection() {
1890        let rule = MD057ExistingRelativeLinks::new();
1891
1892        // Create a temporary directory for test files
1893        let temp_dir = tempdir().unwrap();
1894        let base_path = temp_dir.path();
1895
1896        let rule = rule.with_path(base_path);
1897
1898        // Test with document structure
1899        let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1900
1901        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1902        let result = rule.check(&ctx).unwrap();
1903
1904        // Should only find the real link, not the one in code
1905        assert_eq!(result.len(), 1, "Should only flag the real link");
1906        assert!(result[0].message.contains("nonexistent.md"));
1907    }
1908
1909    #[test]
1910    fn test_inline_code_spans() {
1911        // Create a temporary directory for test files
1912        let temp_dir = tempdir().unwrap();
1913        let base_path = temp_dir.path();
1914
1915        // Create test content with links in inline code spans
1916        let content = r#"
1917# Test Document
1918
1919This is a normal link: [Link](missing.md)
1920
1921This is a code span with a link: `[Link](another-missing.md)`
1922
1923Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1924
1925    "#;
1926
1927        // Initialize rule with the base path
1928        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1929
1930        // Test the rule
1931        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1932        let result = rule.check(&ctx).unwrap();
1933
1934        // Should only have warning for the normal link, not for links in code spans
1935        assert_eq!(result.len(), 1, "Should have exactly one warning");
1936        assert!(
1937            result[0].message.contains("missing.md"),
1938            "Warning should be for missing.md"
1939        );
1940        assert!(
1941            !result.iter().any(|w| w.message.contains("another-missing.md")),
1942            "Should not warn about link in code span"
1943        );
1944        assert!(
1945            !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1946            "Should not warn about link in inline code"
1947        );
1948    }
1949
1950    #[test]
1951    fn test_extensionless_link_resolution() {
1952        // Create a temporary directory for test files
1953        let temp_dir = tempdir().unwrap();
1954        let base_path = temp_dir.path();
1955
1956        // Create a markdown file WITHOUT specifying .md extension in the link
1957        let page_path = base_path.join("page.md");
1958        File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1959
1960        // Test content with extensionless link that should resolve to page.md
1961        let content = r#"
1962# Test Document
1963
1964[Link without extension](page)
1965[Link with extension](page.md)
1966[Missing link](nonexistent)
1967"#;
1968
1969        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1970
1971        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1972        let result = rule.check(&ctx).unwrap();
1973
1974        // Should only have warning for nonexistent link
1975        // Both "page" and "page.md" should resolve to the same file
1976        assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1977        assert!(
1978            result[0].message.contains("nonexistent"),
1979            "Warning should be for 'nonexistent' not 'page'"
1980        );
1981    }
1982
1983    // Cross-file validation tests
1984    #[test]
1985    fn test_cross_file_scope() {
1986        let rule = MD057ExistingRelativeLinks::new();
1987        assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1988    }
1989
1990    #[test]
1991    fn test_contribute_to_index_extracts_markdown_links() {
1992        let rule = MD057ExistingRelativeLinks::new();
1993        let content = r#"
1994# Document
1995
1996[Link to docs](./docs/guide.md)
1997[Link with fragment](./other.md#section)
1998[External link](https://example.com)
1999[Image link](image.png)
2000[Media file](video.mp4)
2001"#;
2002
2003        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2004        let mut index = FileIndex::new();
2005        rule.contribute_to_index(&ctx, &mut index);
2006
2007        // Should only index markdown file links
2008        assert_eq!(index.cross_file_links.len(), 2);
2009
2010        // Check first link
2011        assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2012        assert_eq!(index.cross_file_links[0].fragment, "");
2013
2014        // Check second link (with fragment)
2015        assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2016        assert_eq!(index.cross_file_links[1].fragment, "section");
2017    }
2018
2019    #[test]
2020    fn test_contribute_to_index_skips_external_and_anchors() {
2021        let rule = MD057ExistingRelativeLinks::new();
2022        let content = r#"
2023# Document
2024
2025[External](https://example.com)
2026[Another external](http://example.org)
2027[Fragment only](#section)
2028[FTP link](ftp://files.example.com)
2029[Mail link](mailto:test@example.com)
2030[WWW link](www.example.com)
2031"#;
2032
2033        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2034        let mut index = FileIndex::new();
2035        rule.contribute_to_index(&ctx, &mut index);
2036
2037        // Should not index any of these
2038        assert_eq!(index.cross_file_links.len(), 0);
2039    }
2040
2041    #[test]
2042    fn test_cross_file_check_valid_link() {
2043        use crate::workspace_index::WorkspaceIndex;
2044
2045        let rule = MD057ExistingRelativeLinks::new();
2046
2047        // Create a workspace index with the target file
2048        let mut workspace_index = WorkspaceIndex::new();
2049        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2050
2051        // Create file index with a link to an existing file
2052        let mut file_index = FileIndex::new();
2053        file_index.add_cross_file_link(CrossFileLinkIndex {
2054            target_path: "guide.md".to_string(),
2055            fragment: "".to_string(),
2056            line: 5,
2057            column: 1,
2058        });
2059
2060        // Run cross-file check from docs/index.md
2061        let warnings = rule
2062            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2063            .unwrap();
2064
2065        // Should have no warnings - file exists
2066        assert!(warnings.is_empty());
2067    }
2068
2069    #[test]
2070    fn test_cross_file_check_missing_link() {
2071        // cross_file_check delegates all validation to check() to avoid duplicates.
2072        // It always returns empty — the per-file check() path is authoritative.
2073        use crate::workspace_index::WorkspaceIndex;
2074
2075        let rule = MD057ExistingRelativeLinks::new();
2076        let workspace_index = WorkspaceIndex::new();
2077
2078        let mut file_index = FileIndex::new();
2079        file_index.add_cross_file_link(CrossFileLinkIndex {
2080            target_path: "missing.md".to_string(),
2081            fragment: "".to_string(),
2082            line: 5,
2083            column: 1,
2084        });
2085
2086        let warnings = rule
2087            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2088            .unwrap();
2089
2090        // cross_file_check defers to check(); it produces no warnings of its own.
2091        assert!(
2092            warnings.is_empty(),
2093            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2094        );
2095    }
2096
2097    #[test]
2098    fn test_cross_file_check_parent_path() {
2099        use crate::workspace_index::WorkspaceIndex;
2100
2101        let rule = MD057ExistingRelativeLinks::new();
2102
2103        // Create a workspace index with the target file at the root
2104        let mut workspace_index = WorkspaceIndex::new();
2105        workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2106
2107        // Create file index with a parent path link
2108        let mut file_index = FileIndex::new();
2109        file_index.add_cross_file_link(CrossFileLinkIndex {
2110            target_path: "../readme.md".to_string(),
2111            fragment: "".to_string(),
2112            line: 5,
2113            column: 1,
2114        });
2115
2116        // Run cross-file check from docs/guide.md
2117        let warnings = rule
2118            .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2119            .unwrap();
2120
2121        // Should have no warnings - file exists at normalized path
2122        assert!(warnings.is_empty());
2123    }
2124
2125    #[test]
2126    fn test_cross_file_check_html_link_with_md_source() {
2127        // Test that .html links are accepted when corresponding .md source exists
2128        // This supports mdBook and similar doc generators that compile .md to .html
2129        use crate::workspace_index::WorkspaceIndex;
2130
2131        let rule = MD057ExistingRelativeLinks::new();
2132
2133        // Create a workspace index with the .md source file
2134        let mut workspace_index = WorkspaceIndex::new();
2135        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2136
2137        // Create file index with an .html link (from another rule like MD051)
2138        let mut file_index = FileIndex::new();
2139        file_index.add_cross_file_link(CrossFileLinkIndex {
2140            target_path: "guide.html".to_string(),
2141            fragment: "section".to_string(),
2142            line: 10,
2143            column: 5,
2144        });
2145
2146        // Run cross-file check from docs/index.md
2147        let warnings = rule
2148            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2149            .unwrap();
2150
2151        // Should have no warnings - .md source exists for the .html link
2152        assert!(
2153            warnings.is_empty(),
2154            "Expected no warnings for .html link with .md source, got: {warnings:?}"
2155        );
2156    }
2157
2158    #[test]
2159    fn test_cross_file_check_html_link_without_source() {
2160        // cross_file_check delegates all validation to check() to avoid duplicates.
2161        // Verifying that .html links without a matching .md source are caught is
2162        // already covered by test_html_link_with_md_source (check() path).
2163        use crate::workspace_index::WorkspaceIndex;
2164
2165        let rule = MD057ExistingRelativeLinks::new();
2166        let workspace_index = WorkspaceIndex::new();
2167
2168        let mut file_index = FileIndex::new();
2169        file_index.add_cross_file_link(CrossFileLinkIndex {
2170            target_path: "missing.html".to_string(),
2171            fragment: "".to_string(),
2172            line: 10,
2173            column: 5,
2174        });
2175
2176        let warnings = rule
2177            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2178            .unwrap();
2179
2180        // cross_file_check defers to check(); it produces no warnings of its own.
2181        assert!(
2182            warnings.is_empty(),
2183            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2184        );
2185    }
2186
2187    #[test]
2188    fn test_normalize_path_function() {
2189        // Test simple cases
2190        assert_eq!(
2191            normalize_path(Path::new("docs/guide.md")),
2192            PathBuf::from("docs/guide.md")
2193        );
2194
2195        // Test current directory removal
2196        assert_eq!(
2197            normalize_path(Path::new("./docs/guide.md")),
2198            PathBuf::from("docs/guide.md")
2199        );
2200
2201        // Test parent directory resolution
2202        assert_eq!(
2203            normalize_path(Path::new("docs/sub/../guide.md")),
2204            PathBuf::from("docs/guide.md")
2205        );
2206
2207        // Test multiple parent directories
2208        assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2209    }
2210
2211    #[test]
2212    fn test_html_link_with_md_source() {
2213        // Links to .html files should pass if corresponding .md source exists
2214        let temp_dir = tempdir().unwrap();
2215        let base_path = temp_dir.path();
2216
2217        // Create guide.md (source file)
2218        let md_file = base_path.join("guide.md");
2219        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2220
2221        let content = r#"
2222[Read the guide](guide.html)
2223[Also here](getting-started.html)
2224"#;
2225
2226        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2227        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2228        let result = rule.check(&ctx).unwrap();
2229
2230        // guide.html passes (guide.md exists), getting-started.html fails
2231        assert_eq!(
2232            result.len(),
2233            1,
2234            "Should only warn about missing source. Got: {result:?}"
2235        );
2236        assert!(result[0].message.contains("getting-started.html"));
2237    }
2238
2239    #[test]
2240    fn test_htm_link_with_md_source() {
2241        // .htm extension should also check for markdown source
2242        let temp_dir = tempdir().unwrap();
2243        let base_path = temp_dir.path();
2244
2245        let md_file = base_path.join("page.md");
2246        File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2247
2248        let content = "[Page](page.htm)";
2249
2250        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2251        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2252        let result = rule.check(&ctx).unwrap();
2253
2254        assert!(
2255            result.is_empty(),
2256            "Should not warn when .md source exists for .htm link"
2257        );
2258    }
2259
2260    #[test]
2261    fn test_html_link_finds_various_markdown_extensions() {
2262        // Should find .mdx, .markdown, etc. as source files
2263        let temp_dir = tempdir().unwrap();
2264        let base_path = temp_dir.path();
2265
2266        File::create(base_path.join("doc.md")).unwrap();
2267        File::create(base_path.join("tutorial.mdx")).unwrap();
2268        File::create(base_path.join("guide.markdown")).unwrap();
2269
2270        let content = r#"
2271[Doc](doc.html)
2272[Tutorial](tutorial.html)
2273[Guide](guide.html)
2274"#;
2275
2276        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2277        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2278        let result = rule.check(&ctx).unwrap();
2279
2280        assert!(
2281            result.is_empty(),
2282            "Should find all markdown variants as source files. Got: {result:?}"
2283        );
2284    }
2285
2286    #[test]
2287    fn test_html_link_in_subdirectory() {
2288        // Should find markdown source in subdirectories
2289        let temp_dir = tempdir().unwrap();
2290        let base_path = temp_dir.path();
2291
2292        let docs_dir = base_path.join("docs");
2293        std::fs::create_dir(&docs_dir).unwrap();
2294        File::create(docs_dir.join("guide.md"))
2295            .unwrap()
2296            .write_all(b"# Guide")
2297            .unwrap();
2298
2299        let content = "[Guide](docs/guide.html)";
2300
2301        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2302        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2303        let result = rule.check(&ctx).unwrap();
2304
2305        assert!(result.is_empty(), "Should find markdown source in subdirectory");
2306    }
2307
2308    #[test]
2309    fn test_absolute_path_skipped_in_check() {
2310        // Test that absolute paths are skipped during link validation
2311        // This fixes the bug where /pkg/runtime was being flagged
2312        let temp_dir = tempdir().unwrap();
2313        let base_path = temp_dir.path();
2314
2315        let content = r#"
2316# Test Document
2317
2318[Go Runtime](/pkg/runtime)
2319[Go Runtime with Fragment](/pkg/runtime#section)
2320[API Docs](/api/v1/users)
2321[Blog Post](/blog/2024/release.html)
2322[React Hook](/react/hooks/use-state.html)
2323"#;
2324
2325        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2326        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2327        let result = rule.check(&ctx).unwrap();
2328
2329        // Should have NO warnings - all absolute paths should be skipped
2330        assert!(
2331            result.is_empty(),
2332            "Absolute paths should be skipped. Got warnings: {result:?}"
2333        );
2334    }
2335
2336    #[test]
2337    fn test_absolute_path_skipped_in_cross_file_check() {
2338        // Test that absolute paths are skipped in cross_file_check()
2339        use crate::workspace_index::WorkspaceIndex;
2340
2341        let rule = MD057ExistingRelativeLinks::new();
2342
2343        // Create an empty workspace index (no files exist)
2344        let workspace_index = WorkspaceIndex::new();
2345
2346        // Create file index with absolute path links (should be skipped)
2347        let mut file_index = FileIndex::new();
2348        file_index.add_cross_file_link(CrossFileLinkIndex {
2349            target_path: "/pkg/runtime.md".to_string(),
2350            fragment: "".to_string(),
2351            line: 5,
2352            column: 1,
2353        });
2354        file_index.add_cross_file_link(CrossFileLinkIndex {
2355            target_path: "/api/v1/users.md".to_string(),
2356            fragment: "section".to_string(),
2357            line: 10,
2358            column: 1,
2359        });
2360
2361        // Run cross-file check
2362        let warnings = rule
2363            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2364            .unwrap();
2365
2366        // Should have NO warnings - absolute paths should be skipped
2367        assert!(
2368            warnings.is_empty(),
2369            "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2370        );
2371    }
2372
2373    #[test]
2374    fn test_protocol_relative_url_not_skipped() {
2375        // Test that protocol-relative URLs (//example.com) are NOT skipped as absolute paths
2376        // They should still be caught by is_external_url() though
2377        let temp_dir = tempdir().unwrap();
2378        let base_path = temp_dir.path();
2379
2380        let content = r#"
2381# Test Document
2382
2383[External](//example.com/page)
2384[Another](//cdn.example.com/asset.js)
2385"#;
2386
2387        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2388        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2389        let result = rule.check(&ctx).unwrap();
2390
2391        // Should have NO warnings - protocol-relative URLs are external and should be skipped
2392        assert!(
2393            result.is_empty(),
2394            "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2395        );
2396    }
2397
2398    #[test]
2399    fn test_email_addresses_skipped() {
2400        // Test that email addresses without mailto: are skipped
2401        // These are clearly not file links (the @ symbol is definitive)
2402        let temp_dir = tempdir().unwrap();
2403        let base_path = temp_dir.path();
2404
2405        let content = r#"
2406# Test Document
2407
2408[Contact](user@example.com)
2409[Steering](steering@kubernetes.io)
2410[Support](john.doe+filter@company.co.uk)
2411[User](user_name@sub.domain.com)
2412"#;
2413
2414        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2415        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2416        let result = rule.check(&ctx).unwrap();
2417
2418        // Should have NO warnings - email addresses are clearly not file links and should be skipped
2419        assert!(
2420            result.is_empty(),
2421            "Email addresses should be skipped. Got warnings: {result:?}"
2422        );
2423    }
2424
2425    #[test]
2426    fn test_email_addresses_vs_file_paths() {
2427        // Test that email addresses (anything with @) are skipped
2428        // Note: File paths with @ are extremely rare, so we treat anything with @ as an email
2429        let temp_dir = tempdir().unwrap();
2430        let base_path = temp_dir.path();
2431
2432        let content = r#"
2433# Test Document
2434
2435[Email](user@example.com)  <!-- Should be skipped (email) -->
2436[Email2](steering@kubernetes.io)  <!-- Should be skipped (email) -->
2437[Email3](user@file.md)  <!-- Should be skipped (has @, treated as email) -->
2438"#;
2439
2440        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2441        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2442        let result = rule.check(&ctx).unwrap();
2443
2444        // All should be skipped - anything with @ is treated as an email
2445        assert!(
2446            result.is_empty(),
2447            "All email addresses should be skipped. Got: {result:?}"
2448        );
2449    }
2450
2451    #[test]
2452    fn test_diagnostic_position_accuracy() {
2453        // Test that diagnostics point to the URL, not the link text
2454        let temp_dir = tempdir().unwrap();
2455        let base_path = temp_dir.path();
2456
2457        // Position markers:     0         1         2         3
2458        //                       0123456789012345678901234567890123456789
2459        let content = "prefix [text](missing.md) suffix";
2460        //             The URL "missing.md" starts at 0-indexed position 14
2461        //             which is 1-indexed column 15, and ends at 0-indexed 24 (1-indexed column 25)
2462
2463        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2464        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2465        let result = rule.check(&ctx).unwrap();
2466
2467        assert_eq!(result.len(), 1, "Should have exactly one warning");
2468        assert_eq!(result[0].line, 1, "Should be on line 1");
2469        assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2470        assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2471    }
2472
2473    #[test]
2474    fn test_diagnostic_position_angle_brackets() {
2475        // Test position accuracy with angle bracket links
2476        let temp_dir = tempdir().unwrap();
2477        let base_path = temp_dir.path();
2478
2479        // Position markers:     0         1         2
2480        //                       012345678901234567890
2481        let content = "[link](<missing.md>)";
2482        //             The URL "missing.md" starts at 0-indexed position 8 (1-indexed column 9)
2483
2484        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2485        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2486        let result = rule.check(&ctx).unwrap();
2487
2488        assert_eq!(result.len(), 1, "Should have exactly one warning");
2489        assert_eq!(result[0].line, 1, "Should be on line 1");
2490        assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2491    }
2492
2493    #[test]
2494    fn test_diagnostic_position_multiline() {
2495        // Test that line numbers are correct for links on different lines
2496        let temp_dir = tempdir().unwrap();
2497        let base_path = temp_dir.path();
2498
2499        let content = r#"# Title
2500Some text on line 2
2501[link on line 3](missing1.md)
2502More text
2503[link on line 5](missing2.md)"#;
2504
2505        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2506        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2507        let result = rule.check(&ctx).unwrap();
2508
2509        assert_eq!(result.len(), 2, "Should have two warnings");
2510
2511        // First warning should be on line 3
2512        assert_eq!(result[0].line, 3, "First warning should be on line 3");
2513        assert!(result[0].message.contains("missing1.md"));
2514
2515        // Second warning should be on line 5
2516        assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2517        assert!(result[1].message.contains("missing2.md"));
2518    }
2519
2520    #[test]
2521    fn test_diagnostic_position_with_spaces() {
2522        // Test position with URLs that have spaces in parentheses
2523        let temp_dir = tempdir().unwrap();
2524        let base_path = temp_dir.path();
2525
2526        let content = "[link]( missing.md )";
2527        //             0123456789012345678901
2528        //             0-indexed position 8 is 'm' in 'missing.md' (after space and paren)
2529        //             which is 1-indexed column 9
2530
2531        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2532        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2533        let result = rule.check(&ctx).unwrap();
2534
2535        assert_eq!(result.len(), 1, "Should have exactly one warning");
2536        // The regex captures the URL without leading/trailing spaces
2537        assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2538    }
2539
2540    #[test]
2541    fn test_diagnostic_position_image() {
2542        // Test that image diagnostics also have correct positions
2543        let temp_dir = tempdir().unwrap();
2544        let base_path = temp_dir.path();
2545
2546        let content = "![alt text](missing.jpg)";
2547
2548        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2549        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2550        let result = rule.check(&ctx).unwrap();
2551
2552        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2553        assert_eq!(result[0].line, 1);
2554        // Images use start_col from the parser, which should point to the URL
2555        assert!(result[0].column > 0, "Should have valid column position");
2556        assert!(result[0].message.contains("missing.jpg"));
2557    }
2558
2559    #[test]
2560    fn test_wikilinks_skipped() {
2561        // Wikilinks should not trigger MD057 warnings
2562        // They use a different linking system (e.g., Obsidian, wiki software)
2563        let temp_dir = tempdir().unwrap();
2564        let base_path = temp_dir.path();
2565
2566        let content = r#"# Test Document
2567
2568[[Microsoft#Windows OS]]
2569[[SomePage]]
2570[[Page With Spaces]]
2571[[path/to/page#section]]
2572[[page|Display Text]]
2573
2574This is a [real missing link](missing.md) that should be flagged.
2575"#;
2576
2577        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2578        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2579        let result = rule.check(&ctx).unwrap();
2580
2581        // Should only warn about the regular markdown link, not wikilinks
2582        assert_eq!(
2583            result.len(),
2584            1,
2585            "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2586        );
2587        assert!(
2588            result[0].message.contains("missing.md"),
2589            "Warning should be for missing.md, not wikilinks"
2590        );
2591    }
2592
2593    #[test]
2594    fn test_wikilinks_not_added_to_index() {
2595        // Wikilinks should not be added to the cross-file link index
2596        let temp_dir = tempdir().unwrap();
2597        let base_path = temp_dir.path();
2598
2599        let content = r#"# Test Document
2600
2601[[Microsoft#Windows OS]]
2602[[SomePage#section]]
2603[Regular Link](other.md)
2604"#;
2605
2606        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2607        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2608
2609        let mut file_index = FileIndex::new();
2610        rule.contribute_to_index(&ctx, &mut file_index);
2611
2612        // Should only have the regular markdown link (if it's a markdown file)
2613        // Wikilinks should not be added
2614        let cross_file_links = &file_index.cross_file_links;
2615        assert_eq!(
2616            cross_file_links.len(),
2617            1,
2618            "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2619        );
2620        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2621    }
2622
2623    #[test]
2624    fn test_reference_definition_missing_file() {
2625        // Reference definitions [ref]: ./path.md should be checked
2626        let temp_dir = tempdir().unwrap();
2627        let base_path = temp_dir.path();
2628
2629        let content = r#"# Test Document
2630
2631[test]: ./missing.md
2632[example]: ./nonexistent.html
2633
2634Use [test] and [example] here.
2635"#;
2636
2637        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2638        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2639        let result = rule.check(&ctx).unwrap();
2640
2641        // Should have warnings for both reference definitions
2642        assert_eq!(
2643            result.len(),
2644            2,
2645            "Should have warnings for missing reference definition targets. Got: {result:?}"
2646        );
2647        assert!(
2648            result.iter().any(|w| w.message.contains("missing.md")),
2649            "Should warn about missing.md"
2650        );
2651        assert!(
2652            result.iter().any(|w| w.message.contains("nonexistent.html")),
2653            "Should warn about nonexistent.html"
2654        );
2655    }
2656
2657    #[test]
2658    fn test_reference_definition_existing_file() {
2659        // Reference definitions to existing files should NOT trigger warnings
2660        let temp_dir = tempdir().unwrap();
2661        let base_path = temp_dir.path();
2662
2663        // Create an existing file
2664        let exists_path = base_path.join("exists.md");
2665        File::create(&exists_path)
2666            .unwrap()
2667            .write_all(b"# Existing file")
2668            .unwrap();
2669
2670        let content = r#"# Test Document
2671
2672[test]: ./exists.md
2673
2674Use [test] here.
2675"#;
2676
2677        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2678        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2679        let result = rule.check(&ctx).unwrap();
2680
2681        // Should have NO warnings since the file exists
2682        assert!(
2683            result.is_empty(),
2684            "Should not warn about existing file. Got: {result:?}"
2685        );
2686    }
2687
2688    #[test]
2689    fn test_reference_definition_external_url_skipped() {
2690        // Reference definitions with external URLs should be skipped
2691        let temp_dir = tempdir().unwrap();
2692        let base_path = temp_dir.path();
2693
2694        let content = r#"# Test Document
2695
2696[google]: https://google.com
2697[example]: http://example.org
2698[mail]: mailto:test@example.com
2699[ftp]: ftp://files.example.com
2700[local]: ./missing.md
2701
2702Use [google], [example], [mail], [ftp], [local] here.
2703"#;
2704
2705        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2706        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2707        let result = rule.check(&ctx).unwrap();
2708
2709        // Should only warn about the local missing file, not external URLs
2710        assert_eq!(
2711            result.len(),
2712            1,
2713            "Should only warn about local missing file. Got: {result:?}"
2714        );
2715        assert!(
2716            result[0].message.contains("missing.md"),
2717            "Warning should be for missing.md"
2718        );
2719    }
2720
2721    #[test]
2722    fn test_reference_definition_fragment_only_skipped() {
2723        // Reference definitions with fragment-only URLs should be skipped
2724        let temp_dir = tempdir().unwrap();
2725        let base_path = temp_dir.path();
2726
2727        let content = r#"# Test Document
2728
2729[section]: #my-section
2730
2731Use [section] here.
2732"#;
2733
2734        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2735        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2736        let result = rule.check(&ctx).unwrap();
2737
2738        // Should have NO warnings for fragment-only links
2739        assert!(
2740            result.is_empty(),
2741            "Should not warn about fragment-only reference. Got: {result:?}"
2742        );
2743    }
2744
2745    #[test]
2746    fn test_reference_definition_column_position() {
2747        // Test that column position points to the URL in the reference definition
2748        let temp_dir = tempdir().unwrap();
2749        let base_path = temp_dir.path();
2750
2751        // Position markers:     0         1         2
2752        //                       0123456789012345678901
2753        let content = "[ref]: ./missing.md";
2754        //             The URL "./missing.md" starts at 0-indexed position 7
2755        //             which is 1-indexed column 8
2756
2757        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2758        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2759        let result = rule.check(&ctx).unwrap();
2760
2761        assert_eq!(result.len(), 1, "Should have exactly one warning");
2762        assert_eq!(result[0].line, 1, "Should be on line 1");
2763        assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2764    }
2765
2766    #[test]
2767    fn test_reference_definition_html_with_md_source() {
2768        // Reference definitions to .html files should pass if corresponding .md source exists
2769        let temp_dir = tempdir().unwrap();
2770        let base_path = temp_dir.path();
2771
2772        // Create guide.md (source file)
2773        let md_file = base_path.join("guide.md");
2774        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2775
2776        let content = r#"# Test Document
2777
2778[guide]: ./guide.html
2779[missing]: ./missing.html
2780
2781Use [guide] and [missing] here.
2782"#;
2783
2784        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2785        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2786        let result = rule.check(&ctx).unwrap();
2787
2788        // guide.html passes (guide.md exists), missing.html fails
2789        assert_eq!(
2790            result.len(),
2791            1,
2792            "Should only warn about missing source. Got: {result:?}"
2793        );
2794        assert!(result[0].message.contains("missing.html"));
2795    }
2796
2797    #[test]
2798    fn test_reference_definition_url_encoded() {
2799        // Reference definitions with URL-encoded paths should be decoded before checking
2800        let temp_dir = tempdir().unwrap();
2801        let base_path = temp_dir.path();
2802
2803        // Create a file with spaces in the name
2804        let file_with_spaces = base_path.join("file with spaces.md");
2805        File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2806
2807        let content = r#"# Test Document
2808
2809[spaces]: ./file%20with%20spaces.md
2810[missing]: ./missing%20file.md
2811
2812Use [spaces] and [missing] here.
2813"#;
2814
2815        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2816        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2817        let result = rule.check(&ctx).unwrap();
2818
2819        // Should only warn about the missing file
2820        assert_eq!(
2821            result.len(),
2822            1,
2823            "Should only warn about missing URL-encoded file. Got: {result:?}"
2824        );
2825        assert!(result[0].message.contains("missing%20file.md"));
2826    }
2827
2828    #[test]
2829    fn test_inline_and_reference_both_checked() {
2830        // Both inline links and reference definitions should be checked
2831        let temp_dir = tempdir().unwrap();
2832        let base_path = temp_dir.path();
2833
2834        let content = r#"# Test Document
2835
2836[inline link](./inline-missing.md)
2837[ref]: ./ref-missing.md
2838
2839Use [ref] here.
2840"#;
2841
2842        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2843        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2844        let result = rule.check(&ctx).unwrap();
2845
2846        // Should warn about both the inline link and the reference definition
2847        assert_eq!(
2848            result.len(),
2849            2,
2850            "Should warn about both inline and reference links. Got: {result:?}"
2851        );
2852        assert!(
2853            result.iter().any(|w| w.message.contains("inline-missing.md")),
2854            "Should warn about inline-missing.md"
2855        );
2856        assert!(
2857            result.iter().any(|w| w.message.contains("ref-missing.md")),
2858            "Should warn about ref-missing.md"
2859        );
2860    }
2861
2862    #[test]
2863    fn test_footnote_definitions_not_flagged() {
2864        // Regression test for issue #286: footnote definitions should not be
2865        // treated as reference definitions and flagged as broken links
2866        let rule = MD057ExistingRelativeLinks::default();
2867
2868        let content = r#"# Title
2869
2870A footnote[^1].
2871
2872[^1]: [link](https://www.google.com).
2873"#;
2874
2875        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2876        let result = rule.check(&ctx).unwrap();
2877
2878        assert!(
2879            result.is_empty(),
2880            "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2881        );
2882    }
2883
2884    #[test]
2885    fn test_footnote_with_relative_link_inside() {
2886        // Footnotes containing relative links should not be checked
2887        // (the footnote content is not a URL, it's content that may contain links)
2888        let rule = MD057ExistingRelativeLinks::default();
2889
2890        let content = r#"# Title
2891
2892See the footnote[^1].
2893
2894[^1]: Check out [this file](./existing.md) for more info.
2895[^2]: Also see [missing](./does-not-exist.md).
2896"#;
2897
2898        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2899        let result = rule.check(&ctx).unwrap();
2900
2901        // The inline links INSIDE footnotes should be checked (./existing.md, ./does-not-exist.md)
2902        // but the footnote definition itself should not be treated as a reference definition
2903        // Note: This test verifies that [^1]: and [^2]: are not parsed as ref defs with
2904        // URLs like "[this file](./existing.md)" or "[missing](./does-not-exist.md)"
2905        for warning in &result {
2906            assert!(
2907                !warning.message.contains("[this file]"),
2908                "Footnote content should not be treated as URL: {warning:?}"
2909            );
2910            assert!(
2911                !warning.message.contains("[missing]"),
2912                "Footnote content should not be treated as URL: {warning:?}"
2913            );
2914        }
2915    }
2916
2917    #[test]
2918    fn test_mixed_footnotes_and_reference_definitions() {
2919        // Ensure regular reference definitions are still checked while footnotes are skipped
2920        let temp_dir = tempdir().unwrap();
2921        let base_path = temp_dir.path();
2922
2923        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2924
2925        let content = r#"# Title
2926
2927A footnote[^1] and a [ref link][myref].
2928
2929[^1]: This is a footnote with [link](https://example.com).
2930
2931[myref]: ./missing-file.md "This should be checked"
2932"#;
2933
2934        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2935        let result = rule.check(&ctx).unwrap();
2936
2937        // Should only warn about the regular reference definition, not the footnote
2938        assert_eq!(
2939            result.len(),
2940            1,
2941            "Should only warn about the regular reference definition. Got: {result:?}"
2942        );
2943        assert!(
2944            result[0].message.contains("missing-file.md"),
2945            "Should warn about missing-file.md in reference definition"
2946        );
2947    }
2948
2949    #[test]
2950    fn test_absolute_links_ignore_by_default() {
2951        // By default, absolute links are ignored (not validated)
2952        let temp_dir = tempdir().unwrap();
2953        let base_path = temp_dir.path();
2954
2955        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2956
2957        let content = r#"# Links
2958
2959[API docs](/api/v1/users)
2960[Blog post](/blog/2024/release.html)
2961![Logo](/assets/logo.png)
2962
2963[ref]: /docs/reference.md
2964"#;
2965
2966        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2967        let result = rule.check(&ctx).unwrap();
2968
2969        // No warnings - absolute links are ignored by default
2970        assert!(
2971            result.is_empty(),
2972            "Absolute links should be ignored by default. Got: {result:?}"
2973        );
2974    }
2975
2976    #[test]
2977    fn test_absolute_links_warn_config() {
2978        // When configured to warn, absolute links should generate warnings
2979        let temp_dir = tempdir().unwrap();
2980        let base_path = temp_dir.path();
2981
2982        let config = MD057Config {
2983            absolute_links: AbsoluteLinksOption::Warn,
2984            ..Default::default()
2985        };
2986        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2987
2988        let content = r#"# Links
2989
2990[API docs](/api/v1/users)
2991[Blog post](/blog/2024/release.html)
2992"#;
2993
2994        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2995        let result = rule.check(&ctx).unwrap();
2996
2997        // Should have 2 warnings for the 2 absolute links
2998        assert_eq!(
2999            result.len(),
3000            2,
3001            "Should warn about both absolute links. Got: {result:?}"
3002        );
3003        assert!(
3004            result[0].message.contains("cannot be validated locally"),
3005            "Warning should explain why: {}",
3006            result[0].message
3007        );
3008        assert!(
3009            result[0].message.contains("/api/v1/users"),
3010            "Warning should include the link path"
3011        );
3012    }
3013
3014    #[test]
3015    fn test_absolute_links_warn_images() {
3016        // Images with absolute paths should also warn when configured
3017        let temp_dir = tempdir().unwrap();
3018        let base_path = temp_dir.path();
3019
3020        let config = MD057Config {
3021            absolute_links: AbsoluteLinksOption::Warn,
3022            ..Default::default()
3023        };
3024        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3025
3026        let content = r#"# Images
3027
3028![Logo](/assets/logo.png)
3029"#;
3030
3031        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3032        let result = rule.check(&ctx).unwrap();
3033
3034        assert_eq!(
3035            result.len(),
3036            1,
3037            "Should warn about absolute image path. Got: {result:?}"
3038        );
3039        assert!(
3040            result[0].message.contains("/assets/logo.png"),
3041            "Warning should include the image path"
3042        );
3043    }
3044
3045    #[test]
3046    fn test_absolute_links_warn_reference_definitions() {
3047        // Reference definitions with absolute paths should also warn when configured
3048        let temp_dir = tempdir().unwrap();
3049        let base_path = temp_dir.path();
3050
3051        let config = MD057Config {
3052            absolute_links: AbsoluteLinksOption::Warn,
3053            ..Default::default()
3054        };
3055        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3056
3057        let content = r#"# Reference
3058
3059See the [docs][ref].
3060
3061[ref]: /docs/reference.md
3062"#;
3063
3064        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3065        let result = rule.check(&ctx).unwrap();
3066
3067        assert_eq!(
3068            result.len(),
3069            1,
3070            "Should warn about absolute reference definition. Got: {result:?}"
3071        );
3072        assert!(
3073            result[0].message.contains("/docs/reference.md"),
3074            "Warning should include the reference path"
3075        );
3076    }
3077
3078    #[test]
3079    fn test_search_paths_inline_link() {
3080        let temp_dir = tempdir().unwrap();
3081        let base_path = temp_dir.path();
3082
3083        // Create an "assets" directory with an image
3084        let assets_dir = base_path.join("assets");
3085        std::fs::create_dir_all(&assets_dir).unwrap();
3086        std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3087
3088        let config = MD057Config {
3089            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3090            ..Default::default()
3091        };
3092        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3093
3094        let content = "# Test\n\n[Photo](photo.png)\n";
3095        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3096        let result = rule.check(&ctx).unwrap();
3097
3098        assert!(
3099            result.is_empty(),
3100            "Should find photo.png via search-paths. Got: {result:?}"
3101        );
3102    }
3103
3104    #[test]
3105    fn test_search_paths_image() {
3106        let temp_dir = tempdir().unwrap();
3107        let base_path = temp_dir.path();
3108
3109        let assets_dir = base_path.join("attachments");
3110        std::fs::create_dir_all(&assets_dir).unwrap();
3111        std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3112
3113        let config = MD057Config {
3114            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3115            ..Default::default()
3116        };
3117        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3118
3119        let content = "# Test\n\n![Diagram](diagram.svg)\n";
3120        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3121        let result = rule.check(&ctx).unwrap();
3122
3123        assert!(
3124            result.is_empty(),
3125            "Should find diagram.svg via search-paths. Got: {result:?}"
3126        );
3127    }
3128
3129    #[test]
3130    fn test_search_paths_reference_definition() {
3131        let temp_dir = tempdir().unwrap();
3132        let base_path = temp_dir.path();
3133
3134        let assets_dir = base_path.join("images");
3135        std::fs::create_dir_all(&assets_dir).unwrap();
3136        std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3137
3138        let config = MD057Config {
3139            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3140            ..Default::default()
3141        };
3142        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3143
3144        let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3145        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3146        let result = rule.check(&ctx).unwrap();
3147
3148        assert!(
3149            result.is_empty(),
3150            "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3151        );
3152    }
3153
3154    #[test]
3155    fn test_search_paths_still_warns_when_truly_missing() {
3156        let temp_dir = tempdir().unwrap();
3157        let base_path = temp_dir.path();
3158
3159        let assets_dir = base_path.join("assets");
3160        std::fs::create_dir_all(&assets_dir).unwrap();
3161
3162        let config = MD057Config {
3163            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3164            ..Default::default()
3165        };
3166        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3167
3168        let content = "# Test\n\n![Missing](nonexistent.png)\n";
3169        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3170        let result = rule.check(&ctx).unwrap();
3171
3172        assert_eq!(
3173            result.len(),
3174            1,
3175            "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3176        );
3177    }
3178
3179    #[test]
3180    fn test_search_paths_nonexistent_directory() {
3181        let temp_dir = tempdir().unwrap();
3182        let base_path = temp_dir.path();
3183
3184        let config = MD057Config {
3185            search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3186            ..Default::default()
3187        };
3188        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3189
3190        let content = "# Test\n\n![Missing](photo.png)\n";
3191        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3192        let result = rule.check(&ctx).unwrap();
3193
3194        assert_eq!(
3195            result.len(),
3196            1,
3197            "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3198        );
3199    }
3200
3201    #[test]
3202    fn test_obsidian_attachment_folder_named() {
3203        let temp_dir = tempdir().unwrap();
3204        let vault = temp_dir.path().join("vault");
3205        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3206        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3207        std::fs::create_dir_all(vault.join("notes")).unwrap();
3208
3209        std::fs::write(
3210            vault.join(".obsidian/app.json"),
3211            r#"{"attachmentFolderPath": "Attachments"}"#,
3212        )
3213        .unwrap();
3214        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3215
3216        let notes_dir = vault.join("notes");
3217        let source_file = notes_dir.join("test.md");
3218        std::fs::write(&source_file, "# Test\n\n![Photo](photo.png)\n").unwrap();
3219
3220        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3221
3222        let content = "# Test\n\n![Photo](photo.png)\n";
3223        let ctx =
3224            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3225        let result = rule.check(&ctx).unwrap();
3226
3227        assert!(
3228            result.is_empty(),
3229            "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3230        );
3231    }
3232
3233    #[test]
3234    fn test_obsidian_attachment_same_folder_as_file() {
3235        let temp_dir = tempdir().unwrap();
3236        let vault = temp_dir.path().join("vault-rf");
3237        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3238        std::fs::create_dir_all(vault.join("notes")).unwrap();
3239
3240        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3241
3242        // Image in the same directory as the file — default behavior, no extra search needed
3243        let notes_dir = vault.join("notes");
3244        let source_file = notes_dir.join("test.md");
3245        std::fs::write(&source_file, "placeholder").unwrap();
3246        std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3247
3248        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3249
3250        let content = "# Test\n\n![Photo](photo.png)\n";
3251        let ctx =
3252            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3253        let result = rule.check(&ctx).unwrap();
3254
3255        assert!(
3256            result.is_empty(),
3257            "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3258        );
3259    }
3260
3261    #[test]
3262    fn test_obsidian_not_triggered_without_obsidian_flavor() {
3263        let temp_dir = tempdir().unwrap();
3264        let vault = temp_dir.path().join("vault-nf");
3265        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3266        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3267        std::fs::create_dir_all(vault.join("notes")).unwrap();
3268
3269        std::fs::write(
3270            vault.join(".obsidian/app.json"),
3271            r#"{"attachmentFolderPath": "Attachments"}"#,
3272        )
3273        .unwrap();
3274        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3275
3276        let notes_dir = vault.join("notes");
3277        let source_file = notes_dir.join("test.md");
3278        std::fs::write(&source_file, "placeholder").unwrap();
3279
3280        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3281
3282        let content = "# Test\n\n![Photo](photo.png)\n";
3283        // Standard flavor — NOT Obsidian
3284        let ctx =
3285            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3286        let result = rule.check(&ctx).unwrap();
3287
3288        assert_eq!(
3289            result.len(),
3290            1,
3291            "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3292        );
3293    }
3294
3295    #[test]
3296    fn test_search_paths_combined_with_obsidian() {
3297        let temp_dir = tempdir().unwrap();
3298        let vault = temp_dir.path().join("vault-combo");
3299        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3300        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3301        std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3302        std::fs::create_dir_all(vault.join("notes")).unwrap();
3303
3304        std::fs::write(
3305            vault.join(".obsidian/app.json"),
3306            r#"{"attachmentFolderPath": "Attachments"}"#,
3307        )
3308        .unwrap();
3309        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3310        std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3311
3312        let notes_dir = vault.join("notes");
3313        let source_file = notes_dir.join("test.md");
3314        std::fs::write(&source_file, "placeholder").unwrap();
3315
3316        let extra_assets_dir = vault.join("extra-assets");
3317        let config = MD057Config {
3318            search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3319            ..Default::default()
3320        };
3321        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&notes_dir);
3322
3323        // Both links should resolve: photo.png via Obsidian, diagram.svg via search-paths
3324        let content = "# Test\n\n![Photo](photo.png)\n\n![Diagram](diagram.svg)\n";
3325        let ctx =
3326            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3327        let result = rule.check(&ctx).unwrap();
3328
3329        assert!(
3330            result.is_empty(),
3331            "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3332        );
3333    }
3334
3335    #[test]
3336    fn test_obsidian_attachment_subfolder_under_file() {
3337        let temp_dir = tempdir().unwrap();
3338        let vault = temp_dir.path().join("vault-sub");
3339        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3340        std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3341
3342        std::fs::write(
3343            vault.join(".obsidian/app.json"),
3344            r#"{"attachmentFolderPath": "./assets"}"#,
3345        )
3346        .unwrap();
3347        std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3348
3349        let notes_dir = vault.join("notes");
3350        let source_file = notes_dir.join("test.md");
3351        std::fs::write(&source_file, "placeholder").unwrap();
3352
3353        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3354
3355        let content = "# Test\n\n![Photo](photo.png)\n";
3356        let ctx =
3357            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3358        let result = rule.check(&ctx).unwrap();
3359
3360        assert!(
3361            result.is_empty(),
3362            "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3363        );
3364    }
3365
3366    #[test]
3367    fn test_obsidian_attachment_vault_root() {
3368        let temp_dir = tempdir().unwrap();
3369        let vault = temp_dir.path().join("vault-root");
3370        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3371        std::fs::create_dir_all(vault.join("notes")).unwrap();
3372
3373        // Empty string = vault root
3374        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3375        std::fs::write(vault.join("photo.png"), "fake").unwrap();
3376
3377        let notes_dir = vault.join("notes");
3378        let source_file = notes_dir.join("test.md");
3379        std::fs::write(&source_file, "placeholder").unwrap();
3380
3381        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3382
3383        let content = "# Test\n\n![Photo](photo.png)\n";
3384        let ctx =
3385            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3386        let result = rule.check(&ctx).unwrap();
3387
3388        assert!(
3389            result.is_empty(),
3390            "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3391        );
3392    }
3393
3394    #[test]
3395    fn test_search_paths_multiple_directories() {
3396        let temp_dir = tempdir().unwrap();
3397        let base_path = temp_dir.path();
3398
3399        let dir_a = base_path.join("dir-a");
3400        let dir_b = base_path.join("dir-b");
3401        std::fs::create_dir_all(&dir_a).unwrap();
3402        std::fs::create_dir_all(&dir_b).unwrap();
3403        std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3404        std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3405
3406        let config = MD057Config {
3407            search_paths: vec![
3408                dir_a.to_string_lossy().into_owned(),
3409                dir_b.to_string_lossy().into_owned(),
3410            ],
3411            ..Default::default()
3412        };
3413        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3414
3415        let content = "# Test\n\n![A](alpha.png)\n\n![B](beta.png)\n";
3416        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3417        let result = rule.check(&ctx).unwrap();
3418
3419        assert!(
3420            result.is_empty(),
3421            "Should find files across multiple search paths. Got: {result:?}"
3422        );
3423    }
3424
3425    #[test]
3426    fn test_cross_file_check_with_search_paths() {
3427        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3428
3429        let temp_dir = tempdir().unwrap();
3430        let base_path = temp_dir.path();
3431
3432        // Create docs directory with a markdown target in a search path
3433        let docs_dir = base_path.join("docs");
3434        std::fs::create_dir_all(&docs_dir).unwrap();
3435        std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3436
3437        let config = MD057Config {
3438            search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3439            ..Default::default()
3440        };
3441        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3442
3443        let file_path = base_path.join("README.md");
3444        std::fs::write(&file_path, "# Readme\n").unwrap();
3445
3446        let mut file_index = FileIndex::default();
3447        file_index.cross_file_links.push(CrossFileLinkIndex {
3448            target_path: "guide.md".to_string(),
3449            fragment: String::new(),
3450            line: 3,
3451            column: 1,
3452        });
3453
3454        let workspace_index = WorkspaceIndex::new();
3455
3456        let result = rule
3457            .cross_file_check(&file_path, &file_index, &workspace_index)
3458            .unwrap();
3459
3460        assert!(
3461            result.is_empty(),
3462            "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3463        );
3464    }
3465
3466    #[test]
3467    fn test_cross_file_check_with_obsidian_flavor() {
3468        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3469
3470        let temp_dir = tempdir().unwrap();
3471        let vault = temp_dir.path().join("vault-xf");
3472        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3473        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3474        std::fs::create_dir_all(vault.join("notes")).unwrap();
3475
3476        std::fs::write(
3477            vault.join(".obsidian/app.json"),
3478            r#"{"attachmentFolderPath": "Attachments"}"#,
3479        )
3480        .unwrap();
3481        std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3482
3483        let notes_dir = vault.join("notes");
3484        let file_path = notes_dir.join("test.md");
3485        std::fs::write(&file_path, "placeholder").unwrap();
3486
3487        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3488            .with_path(&notes_dir)
3489            .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3490
3491        let mut file_index = FileIndex::default();
3492        file_index.cross_file_links.push(CrossFileLinkIndex {
3493            target_path: "ref.md".to_string(),
3494            fragment: String::new(),
3495            line: 3,
3496            column: 1,
3497        });
3498
3499        let workspace_index = WorkspaceIndex::new();
3500
3501        let result = rule
3502            .cross_file_check(&file_path, &file_index, &workspace_index)
3503            .unwrap();
3504
3505        assert!(
3506            result.is_empty(),
3507            "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3508        );
3509    }
3510
3511    #[test]
3512    fn test_check_clears_stale_cache() {
3513        // Verify that check() resets the file existence cache so stale entries from
3514        // a previous lint cycle do not suppress valid warnings.
3515        let temp_dir = tempdir().unwrap();
3516        let base_path = temp_dir.path();
3517
3518        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3519
3520        // Seed the cache with a stale "exists" entry for a file that is NOT on disk.
3521        let phantom_path = base_path.join("phantom.md");
3522        {
3523            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3524            cache.insert(phantom_path.clone(), true);
3525        }
3526
3527        let content = "[phantom](phantom.md)\n";
3528        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3529        let warnings = rule.check(&ctx).unwrap();
3530
3531        // check() must reset the cache; stale "exists=true" must not suppress the warning.
3532        assert_eq!(
3533            warnings.len(),
3534            1,
3535            "check() should report missing file after clearing stale cache. Got: {warnings:?}"
3536        );
3537        assert!(warnings[0].message.contains("phantom.md"));
3538    }
3539
3540    #[test]
3541    fn test_check_does_not_carry_over_cache_between_runs() {
3542        // Two consecutive check() calls should each start with a fresh cache.
3543        let temp_dir = tempdir().unwrap();
3544        let base_path = temp_dir.path();
3545
3546        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3547
3548        let content = "[missing](nonexistent.md)\n";
3549        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3550
3551        // First run: file doesn't exist — warning expected.
3552        let warnings_1 = rule.check(&ctx).unwrap();
3553        assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3554
3555        // Inject a stale "exists = true" entry for the resolved path.
3556        let nonexistent_path = base_path.join("nonexistent.md");
3557        {
3558            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3559            cache.insert(nonexistent_path.clone(), true);
3560        }
3561
3562        // Second run: cache says file exists, but check() should reset it first.
3563        let warnings_2 = rule.check(&ctx).unwrap();
3564        assert_eq!(
3565            warnings_2.len(),
3566            1,
3567            "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
3568        );
3569    }
3570
3571    // --- Bug #631: duplicate warnings for broken relative links ---
3572
3573    /// Regression test: a single broken relative link must produce exactly one
3574    /// warning across both check() and cross_file_check(). Previously, each
3575    /// code path emitted an identical warning independently, causing duplicates.
3576    #[test]
3577    fn test_no_duplicate_warnings_for_broken_relative_link() {
3578        use crate::workspace_index::WorkspaceIndex;
3579
3580        let temp_dir = tempdir().unwrap();
3581        let base_path = temp_dir.path();
3582
3583        // The broken link target does NOT exist on disk.
3584        let source_file = base_path.join("index.md");
3585        std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
3586
3587        let content = "[broken](does/not/exist.md)\n";
3588
3589        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3590
3591        // Collect warnings from check() (per-file path)
3592        let ctx = crate::lint_context::LintContext::new(
3593            content,
3594            crate::config::MarkdownFlavor::Standard,
3595            Some(source_file.clone()),
3596        );
3597        let check_warnings = rule.check(&ctx).unwrap();
3598
3599        // Collect warnings from cross_file_check() (workspace-index path)
3600        let mut file_index = FileIndex::new();
3601        rule.contribute_to_index(&ctx, &mut file_index);
3602        let workspace_index = WorkspaceIndex::new();
3603        let cross_warnings = rule
3604            .cross_file_check(&source_file, &file_index, &workspace_index)
3605            .unwrap();
3606
3607        let total = check_warnings.len() + cross_warnings.len();
3608        assert_eq!(
3609            total, 1,
3610            "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
3611             check={check_warnings:?}, cross={cross_warnings:?}"
3612        );
3613    }
3614
3615    // --- Bug #632: absolute directory links incorrectly flagged ---
3616
3617    /// With absolute-links = "relative_to_roots", links to existing targets must
3618    /// be accepted for all four cases: {relative, absolute} x {file, directory}.
3619    #[test]
3620    fn test_absolute_dir_link_accepted_relative_to_roots() {
3621        let temp_dir = tempdir().unwrap();
3622        let root = temp_dir.path();
3623
3624        // Create directory `d` with a file inside (but no index.md)
3625        let dir_d = root.join("d");
3626        std::fs::create_dir_all(&dir_d).unwrap();
3627        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3628
3629        // Content exercises all four matrix cells:
3630        //   relative file, relative dir, absolute file, absolute dir
3631        let content = "\
3632[absolute dir](/d)\n\
3633[relative dir](d)\n\
3634[absolute file](/d/foo.md)\n\
3635[relative file](d/foo.md)\n";
3636
3637        let config = MD057Config {
3638            absolute_links: AbsoluteLinksOption::RelativeToRoots,
3639            roots: vec![],
3640            ..Default::default()
3641        };
3642        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3643
3644        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3645        let result = rule.check(&ctx).unwrap();
3646
3647        assert!(
3648            result.is_empty(),
3649            "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
3650        );
3651    }
3652
3653    /// A directory link with a trailing slash and no index.md should be reported
3654    /// as invalid under relative_to_roots (docs-convention: trailing slash implies index.md).
3655    #[test]
3656    fn test_absolute_trailing_slash_dir_link_requires_index() {
3657        let temp_dir = tempdir().unwrap();
3658        let root = temp_dir.path();
3659
3660        // Create directory `d` WITHOUT index.md
3661        let dir_d = root.join("d");
3662        std::fs::create_dir_all(&dir_d).unwrap();
3663        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3664
3665        // Trailing slash signals "this is a directory index" — index.md must exist.
3666        let content = "[dir with slash](/d/)\n";
3667
3668        let config = MD057Config {
3669            absolute_links: AbsoluteLinksOption::RelativeToRoots,
3670            roots: vec![],
3671            ..Default::default()
3672        };
3673        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3674
3675        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3676        let result = rule.check(&ctx).unwrap();
3677
3678        assert_eq!(
3679            result.len(),
3680            1,
3681            "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
3682        );
3683    }
3684
3685    /// The docs_dir (MkDocs) variant must still flag a directory link when index.md
3686    /// is absent. This is tested via the full check() path with RelativeToDocs config
3687    /// and a real mkdocs.yml pointing at a docs dir that contains the directory target.
3688    #[test]
3689    fn test_docs_dir_variant_still_enforces_index_md() {
3690        let temp_dir = tempdir().unwrap();
3691        let root = temp_dir.path();
3692
3693        // Create a minimal mkdocs.yml pointing at a "docs" directory
3694        std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
3695
3696        // Create docs/section/ WITHOUT index.md
3697        let docs_dir = root.join("docs");
3698        std::fs::create_dir_all(&docs_dir).unwrap();
3699        let section_dir = docs_dir.join("section");
3700        std::fs::create_dir_all(&section_dir).unwrap();
3701        std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
3702
3703        // Create the source markdown file inside docs/
3704        let source_file = docs_dir.join("index.md");
3705        std::fs::write(&source_file, "[sec](/section)\n").unwrap();
3706
3707        let config = MD057Config {
3708            absolute_links: AbsoluteLinksOption::RelativeToDocs,
3709            ..Default::default()
3710        };
3711        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
3712
3713        let content = "[sec](/section)\n";
3714        let ctx = crate::lint_context::LintContext::new(
3715            content,
3716            crate::config::MarkdownFlavor::Standard,
3717            Some(source_file.clone()),
3718        );
3719        let result = rule.check(&ctx).unwrap();
3720
3721        // MkDocs enforces index.md for directory links, so this should be flagged.
3722        assert_eq!(
3723            result.len(),
3724            1,
3725            "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
3726        );
3727        assert!(
3728            result[0].message.contains("index.md") || result[0].message.contains("section"),
3729            "Message should mention the directory or missing index.md: {}",
3730            result[0].message
3731        );
3732    }
3733
3734    /// Regression test for the edge case where a trailing-slash directory URL has a
3735    /// fragment suffix (e.g. `/guide/#intro`). After stripping the fragment, the
3736    /// decoded path is `guide/` (ends with `/`), but `is_directory_link` was computed
3737    /// from `url.ends_with('/')` which is false when the URL ends with `#intro`.
3738    /// The fix must still treat such links as directory links and require index.md.
3739    #[test]
3740    fn test_trailing_slash_with_fragment_treated_as_directory_link() {
3741        let temp_dir = tempdir().unwrap();
3742        let root = temp_dir.path();
3743
3744        // Create directory `guide` WITHOUT index.md
3745        let guide_dir = root.join("guide");
3746        std::fs::create_dir_all(&guide_dir).unwrap();
3747        std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
3748
3749        // /guide/#intro has a trailing slash before the fragment — must require index.md
3750        let content = "[guide with fragment](/guide/#intro)\n";
3751
3752        let config = MD057Config {
3753            absolute_links: AbsoluteLinksOption::RelativeToRoots,
3754            roots: vec![],
3755            ..Default::default()
3756        };
3757        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3758        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3759        let result = rule.check(&ctx).unwrap();
3760
3761        assert_eq!(
3762            result.len(),
3763            1,
3764            "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
3765        );
3766    }
3767}