Skip to main content

rumdl_lib/rules/
md057_existing_relative_links.rs

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