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