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