Skip to main content

rumdl_lib/rules/
md074_mkdocs_nav.rs

1//!
2//! Rule MD074: MkDocs nav validation
3//!
4//! See [docs/md074.md](../../docs/md074.md) for full documentation, configuration, and examples.
5
6use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::mkdocs_config::find_mkdocs_yml;
8use serde::Deserialize;
9use std::collections::{HashMap, HashSet};
10use std::hash::{DefaultHasher, Hash, Hasher};
11use std::path::{Path, PathBuf};
12use std::sync::{LazyLock, Mutex};
13
14mod md074_config;
15pub(super) use md074_config::{MD074Config, NavValidation};
16
17/// Cache mapping mkdocs.yml paths to content hashes.
18/// Re-validates when file content changes (self-invalidating for LSP mode).
19static VALIDATED_PROJECTS: LazyLock<Mutex<HashMap<PathBuf, u64>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
20
21/// Rule MD074: MkDocs nav validation
22///
23/// Validates that MkDocs nav entries in mkdocs.yml point to existing files.
24/// Only active when the markdown flavor is set to "mkdocs".
25#[derive(Debug, Clone)]
26pub struct MD074MkDocsNav {
27    config: MD074Config,
28}
29
30impl Default for MD074MkDocsNav {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl MD074MkDocsNav {
37    pub fn new() -> Self {
38        Self {
39            config: MD074Config::default(),
40        }
41    }
42
43    pub fn from_config_struct(config: MD074Config) -> Self {
44        Self { config }
45    }
46
47    /// Clear the validation cache.
48    #[cfg(test)]
49    pub fn clear_cache() {
50        if let Ok(mut cache) = VALIDATED_PROJECTS.lock() {
51            cache.clear();
52        }
53    }
54
55    /// Parse mkdocs.yml and extract configuration (reads from disk).
56    /// Used by tests that need to parse without going through `check()`.
57    #[cfg(test)]
58    fn parse_mkdocs_yml(path: &Path) -> Result<MkDocsConfig, String> {
59        let content = std::fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
60        Self::parse_mkdocs_yml_from_str(&content, path)
61    }
62
63    /// Parse mkdocs.yml from already-read content
64    fn parse_mkdocs_yml_from_str(content: &str, path: &Path) -> Result<MkDocsConfig, String> {
65        serde_yaml::from_str(content).map_err(|e| format!("Failed to parse {}: {e}", path.display()))
66    }
67
68    /// Recursively extract all file paths from nav structure
69    /// Returns tuples of (file_path, nav_location_description)
70    fn extract_nav_paths(nav: &[NavItem], prefix: &str) -> Vec<(String, String)> {
71        let mut paths = Vec::new();
72
73        for item in nav {
74            match item {
75                NavItem::Path(path) => {
76                    let nav_path = if prefix.is_empty() {
77                        path.clone()
78                    } else {
79                        format!("{prefix} > {path}")
80                    };
81                    paths.push((path.clone(), nav_path));
82                }
83                NavItem::Section { name, children } => {
84                    let new_prefix = if prefix.is_empty() {
85                        name.clone()
86                    } else {
87                        format!("{prefix} > {name}")
88                    };
89                    paths.extend(Self::extract_nav_paths(children, &new_prefix));
90                }
91                NavItem::NamedPath { name, path } => {
92                    let nav_path = if prefix.is_empty() {
93                        name.clone()
94                    } else {
95                        format!("{prefix} > {name}")
96                    };
97                    paths.push((path.clone(), nav_path));
98                }
99            }
100        }
101
102        paths
103    }
104
105    /// Collect all markdown files in docs_dir recursively
106    fn collect_docs_files(docs_dir: &Path) -> HashSet<PathBuf> {
107        Self::collect_docs_files_recursive(docs_dir, docs_dir)
108    }
109
110    /// Recursive helper that preserves the original docs_dir for relative path calculation
111    fn collect_docs_files_recursive(current_dir: &Path, root_docs_dir: &Path) -> HashSet<PathBuf> {
112        let mut files = HashSet::new();
113
114        let Ok(entries) = std::fs::read_dir(current_dir) else {
115            return files;
116        };
117
118        for entry in entries.flatten() {
119            let path = entry.path();
120
121            // Skip hidden directories and files
122            if path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
123                continue;
124            }
125
126            if path.is_dir() {
127                files.extend(Self::collect_docs_files_recursive(&path, root_docs_dir));
128            } else if path.is_file()
129                && let Some(ext) = path.extension()
130            {
131                let ext_lower = ext.to_string_lossy().to_lowercase();
132                if ext_lower == "md" || ext_lower == "markdown" {
133                    // Get path relative to docs_dir, normalized with forward slashes
134                    if let Ok(relative) = path.strip_prefix(root_docs_dir) {
135                        let normalized = Self::normalize_path(relative);
136                        files.insert(normalized);
137                    }
138                }
139            }
140        }
141
142        files
143    }
144
145    /// Normalize a path to use forward slashes (for cross-platform consistency)
146    fn normalize_path(path: &Path) -> PathBuf {
147        let path_str = path.to_string_lossy();
148        PathBuf::from(path_str.replace('\\', "/"))
149    }
150
151    /// Normalize a nav path string for comparison
152    fn normalize_nav_path(path: &str) -> PathBuf {
153        PathBuf::from(path.replace('\\', "/"))
154    }
155
156    /// Check if a path looks like an external URL
157    fn is_external_url(path: &str) -> bool {
158        path.starts_with("http://") || path.starts_with("https://") || path.starts_with("//") || path.contains("://")
159    }
160
161    /// Check if a path is absolute (starts with /)
162    fn is_absolute_path(path: &str) -> bool {
163        path.starts_with('/')
164    }
165
166    /// Find the 1-indexed line number in the raw YAML content where a nav path appears.
167    /// Checks for the path as a YAML value (after `:` or `- `), not just substring.
168    /// Returns None if not found.
169    /// Strip surrounding YAML quotes (single or double) from a value
170    fn strip_yaml_quotes(s: &str) -> &str {
171        if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
172            &s[1..s.len() - 1]
173        } else {
174            s
175        }
176    }
177
178    fn find_nav_line_in_yaml(yaml_content: &str, file_path: &str) -> Option<usize> {
179        for (idx, line) in yaml_content.lines().enumerate() {
180            let trimmed = line.trim();
181            // Skip comments
182            if trimmed.starts_with('#') {
183                continue;
184            }
185            // Match "- path.md" or "- 'path.md'" (bare list item)
186            if let Some(rest) = trimmed.strip_prefix("- ")
187                && Self::strip_yaml_quotes(rest.trim()) == file_path
188            {
189                return Some(idx + 1);
190            }
191            // Match "Title: path.md" or "- Title: 'path.md'" (named nav entry)
192            if let Some(colon_pos) = trimmed.find(": ") {
193                let value = trimmed[colon_pos + 2..].trim();
194                if Self::strip_yaml_quotes(value) == file_path {
195                    return Some(idx + 1);
196                }
197            }
198        }
199        None
200    }
201
202    /// Perform the actual validation of mkdocs.yml nav entries
203    fn validate_nav(&self, mkdocs_path: &Path, mkdocs_config: &MkDocsConfig, yaml_content: &str) -> Vec<LintWarning> {
204        let mut warnings = Vec::new();
205        let mkdocs_file = mkdocs_path
206            .file_name()
207            .map_or_else(|| "mkdocs.yml".to_string(), |n| n.to_string_lossy().to_string());
208
209        // Get docs_dir relative to mkdocs.yml location
210        let mkdocs_dir = mkdocs_path.parent().unwrap_or(Path::new("."));
211        let docs_dir = if Path::new(&mkdocs_config.docs_dir).is_absolute() {
212            PathBuf::from(&mkdocs_config.docs_dir)
213        } else {
214            mkdocs_dir.join(&mkdocs_config.docs_dir)
215        };
216
217        if !docs_dir.exists() {
218            let yaml_line = Self::find_nav_line_in_yaml(yaml_content, &mkdocs_config.docs_dir);
219            let line_info = yaml_line.map_or(String::new(), |l| format!(" (line {l})"));
220            warnings.push(LintWarning {
221                rule_name: Some(self.name().to_string()),
222                line: 1,
223                column: 1,
224                end_line: 1,
225                end_column: 1,
226                message: format!(
227                    "docs_dir '{}' does not exist (from {}{})",
228                    mkdocs_config.docs_dir,
229                    mkdocs_path.display(),
230                    line_info,
231                ),
232                severity: Severity::Warning,
233                fix: None,
234            });
235            return warnings;
236        }
237
238        // Extract all nav paths
239        let nav_paths = Self::extract_nav_paths(&mkdocs_config.nav, "");
240
241        // Track referenced files for omitted_files check (normalized paths)
242        let mut referenced_files: HashSet<PathBuf> = HashSet::new();
243
244        // Validate each nav entry
245        for (file_path, nav_location) in &nav_paths {
246            // Skip external URLs
247            if Self::is_external_url(file_path) {
248                continue;
249            }
250
251            // Check for absolute links
252            if Self::is_absolute_path(file_path) {
253                if self.config.absolute_links == NavValidation::Warn {
254                    let yaml_line = Self::find_nav_line_in_yaml(yaml_content, file_path);
255                    let line_info = yaml_line.map_or(String::new(), |l| format!(", line {l}"));
256                    warnings.push(LintWarning {
257                        rule_name: Some(self.name().to_string()),
258                        line: 1,
259                        column: 1,
260                        end_line: 1,
261                        end_column: 1,
262                        message: format!(
263                            "Absolute path in nav '{nav_location}': {file_path} (in {mkdocs_file}{line_info})"
264                        ),
265                        severity: Severity::Warning,
266                        fix: None,
267                    });
268                }
269                continue;
270            }
271
272            let normalized_path = Self::normalize_nav_path(file_path);
273
274            // Check if file exists
275            if self.config.not_found == NavValidation::Warn {
276                let full_path = docs_dir.join(&normalized_path);
277
278                // Handle directory entries (e.g., "api/" -> "api/index.md")
279                let (actual_path, is_dir_entry) = if file_path.ends_with('/') || full_path.is_dir() {
280                    let index_path = normalized_path.join("index.md");
281                    (docs_dir.join(&index_path), true)
282                } else {
283                    (full_path, false)
284                };
285
286                // Track the actual file that would be served
287                if is_dir_entry {
288                    referenced_files.insert(normalized_path.join("index.md"));
289                } else {
290                    referenced_files.insert(normalized_path.clone());
291                }
292
293                if !actual_path.exists() {
294                    let display_path = if is_dir_entry {
295                        format!(
296                            "{} (resolves to {}/index.md)",
297                            file_path,
298                            file_path.trim_end_matches('/')
299                        )
300                    } else {
301                        file_path.clone()
302                    };
303                    let yaml_line = Self::find_nav_line_in_yaml(yaml_content, file_path);
304                    let line_info = yaml_line.map_or(String::new(), |l| format!(", line {l}"));
305                    warnings.push(LintWarning {
306                        rule_name: Some(self.name().to_string()),
307                        line: 1,
308                        column: 1,
309                        end_line: 1,
310                        end_column: 1,
311                        message: format!(
312                            "Nav entry '{nav_location}' points to non-existent file: {display_path} (in {mkdocs_file}{line_info})"
313                        ),
314                        severity: Severity::Warning,
315                        fix: None,
316                    });
317                }
318            } else {
319                // Still track referenced files even if not validating
320                if file_path.ends_with('/') {
321                    referenced_files.insert(normalized_path.join("index.md"));
322                } else {
323                    referenced_files.insert(normalized_path);
324                }
325            }
326        }
327
328        // Check for omitted files
329        if self.config.omitted_files == NavValidation::Warn {
330            let all_docs = Self::collect_docs_files(&docs_dir);
331
332            for doc_file in all_docs {
333                if !referenced_files.contains(&doc_file) {
334                    // Skip common files that are often intentionally not in nav
335                    let file_name = doc_file.file_name().map(|n| n.to_string_lossy());
336                    if let Some(name) = &file_name {
337                        let name_lower = name.to_lowercase();
338                        // Skip index files in root, README files, and other common non-nav files
339                        if (doc_file.parent().is_none() || doc_file.parent() == Some(Path::new("")))
340                            && (name_lower == "index.md" || name_lower == "readme.md")
341                        {
342                            continue;
343                        }
344                    }
345
346                    warnings.push(LintWarning {
347                        rule_name: Some(self.name().to_string()),
348                        line: 1,
349                        column: 1,
350                        end_line: 1,
351                        end_column: 1,
352                        message: format!("File not referenced in nav: {} (in {mkdocs_file})", doc_file.display()),
353                        severity: Severity::Info,
354                        fix: None,
355                    });
356                }
357            }
358        }
359
360        warnings
361    }
362}
363
364/// MkDocs configuration structure (partial - only fields we need for validation)
365#[derive(Debug)]
366struct MkDocsConfig {
367    /// Documentation directory (default: "docs")
368    docs_dir: String,
369
370    /// Navigation structure
371    nav: Vec<NavItem>,
372}
373
374fn default_docs_dir() -> String {
375    "docs".to_string()
376}
377
378/// Navigation item in mkdocs.yml
379/// MkDocs nav can be:
380/// - A simple string: "index.md"
381/// - A named path: { "Home": "index.md" }
382/// - A section with children: { "Section": [...] }
383#[derive(Debug)]
384enum NavItem {
385    /// Simple path: "index.md"
386    Path(String),
387
388    /// Section with children: { "Section Name": [...] }
389    Section { name: String, children: Vec<NavItem> },
390
391    /// Named path: { "Page Title": "path/to/page.md" }
392    NamedPath { name: String, path: String },
393}
394
395impl NavItem {
396    /// Parse a NavItem from a serde_yaml::Value
397    fn from_yaml_value(value: &serde_yaml::Value) -> Option<NavItem> {
398        match value {
399            serde_yaml::Value::String(s) => Some(NavItem::Path(s.clone())),
400            serde_yaml::Value::Mapping(map) => {
401                if map.len() != 1 {
402                    return None;
403                }
404
405                let (key, val) = map.iter().next()?;
406                let name = key.as_str()?.to_string();
407
408                match val {
409                    serde_yaml::Value::String(path) => Some(NavItem::NamedPath {
410                        name,
411                        path: path.clone(),
412                    }),
413                    serde_yaml::Value::Sequence(seq) => {
414                        let children: Vec<NavItem> = seq.iter().filter_map(NavItem::from_yaml_value).collect();
415                        Some(NavItem::Section { name, children })
416                    }
417                    serde_yaml::Value::Null => {
418                        // Handle case like "- Section:" with no value (treated as empty section)
419                        Some(NavItem::Section {
420                            name,
421                            children: Vec::new(),
422                        })
423                    }
424                    _ => None,
425                }
426            }
427            _ => None,
428        }
429    }
430}
431
432impl<'de> Deserialize<'de> for MkDocsConfig {
433    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
434    where
435        D: serde::de::Deserializer<'de>,
436    {
437        #[derive(Deserialize)]
438        struct RawMkDocsConfig {
439            #[serde(default = "default_docs_dir")]
440            docs_dir: String,
441            #[serde(default)]
442            nav: Option<serde_yaml::Value>,
443        }
444
445        let raw = RawMkDocsConfig::deserialize(deserializer)?;
446
447        let nav = match raw.nav {
448            Some(serde_yaml::Value::Sequence(seq)) => seq.iter().filter_map(NavItem::from_yaml_value).collect(),
449            _ => Vec::new(),
450        };
451
452        Ok(MkDocsConfig {
453            docs_dir: raw.docs_dir,
454            nav,
455        })
456    }
457}
458
459impl Rule for MD074MkDocsNav {
460    fn name(&self) -> &'static str {
461        "MD074"
462    }
463
464    fn description(&self) -> &'static str {
465        "MkDocs nav entries should point to existing files"
466    }
467
468    fn category(&self) -> RuleCategory {
469        RuleCategory::Other
470    }
471
472    fn fix_capability(&self) -> FixCapability {
473        FixCapability::Unfixable
474    }
475
476    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
477        // Only run for MkDocs flavor
478        ctx.flavor != crate::config::MarkdownFlavor::MkDocs
479    }
480
481    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
482        // Only run for MkDocs flavor
483        if ctx.flavor != crate::config::MarkdownFlavor::MkDocs {
484            return Ok(Vec::new());
485        }
486
487        // Need source file path to find mkdocs.yml
488        let Some(source_file) = ctx.source_file() else {
489            return Ok(Vec::new());
490        };
491
492        // Find mkdocs.yml (returns canonicalized path for consistent caching)
493        let Some(mkdocs_path) = find_mkdocs_yml(source_file) else {
494            return Ok(Vec::new());
495        };
496
497        // Read mkdocs.yml content and compute hash for cache invalidation
498        let mkdocs_content = match std::fs::read_to_string(&mkdocs_path) {
499            Ok(content) => content,
500            Err(e) => {
501                return Ok(vec![LintWarning {
502                    rule_name: Some(self.name().to_string()),
503                    line: 1,
504                    column: 1,
505                    end_line: 1,
506                    end_column: 1,
507                    message: format!("Failed to read {}: {e}", mkdocs_path.display()),
508                    severity: Severity::Warning,
509                    fix: None,
510                }]);
511            }
512        };
513
514        let mut hasher = DefaultHasher::new();
515        mkdocs_content.hash(&mut hasher);
516        let content_hash = hasher.finish();
517
518        // Check if we've already validated this exact version of mkdocs.yml
519        if let Ok(mut cache) = VALIDATED_PROJECTS.lock() {
520            if let Some(&cached_hash) = cache.get(&mkdocs_path)
521                && cached_hash == content_hash
522            {
523                return Ok(Vec::new());
524            }
525            cache.insert(mkdocs_path.clone(), content_hash);
526        }
527        // If lock is poisoned, continue with validation (just without caching)
528
529        // Parse mkdocs.yml from already-read content
530        let mkdocs_config = match Self::parse_mkdocs_yml_from_str(&mkdocs_content, &mkdocs_path) {
531            Ok(config) => config,
532            Err(e) => {
533                return Ok(vec![LintWarning {
534                    rule_name: Some(self.name().to_string()),
535                    line: 1,
536                    column: 1,
537                    end_line: 1,
538                    end_column: 1,
539                    message: e,
540                    severity: Severity::Warning,
541                    fix: None,
542                }]);
543            }
544        };
545
546        // Perform validation
547        Ok(self.validate_nav(&mkdocs_path, &mkdocs_config, &mkdocs_content))
548    }
549
550    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
551        // This rule doesn't provide automatic fixes
552        Ok(ctx.content.to_string())
553    }
554
555    fn as_any(&self) -> &dyn std::any::Any {
556        self
557    }
558
559    crate::impl_rule_config_methods!(MD074Config);
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use std::fs;
566    use tempfile::tempdir;
567
568    /// Serializes the tests that share the process-global validation cache.
569    ///
570    /// [`MD074MkDocsNav::clear_cache`] wipes state a test depends on for its whole
571    /// body, so two of them running at once in one process reset each other
572    /// mid-test. Each test holds this for its duration.
573    static TEST_LOCK: Mutex<()> = Mutex::new(());
574
575    /// Takes the lock, then clears the cache. Hold the guard for the whole test:
576    /// dropping it early re-opens the race, which the guard's own `must_use` catches.
577    fn setup_test() -> std::sync::MutexGuard<'static, ()> {
578        let guard = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
579        MD074MkDocsNav::clear_cache();
580        guard
581    }
582
583    #[test]
584    fn test_find_mkdocs_yml() {
585        let _guard = setup_test();
586        let temp_dir = tempdir().unwrap();
587        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
588        fs::write(&mkdocs_path, "site_name: Test").unwrap();
589
590        let subdir = temp_dir.path().join("docs");
591        fs::create_dir_all(&subdir).unwrap();
592        let file_in_subdir = subdir.join("test.md");
593
594        let found = find_mkdocs_yml(&file_in_subdir);
595        assert!(found.is_some());
596        // Canonicalized paths should match
597        assert_eq!(found.unwrap(), mkdocs_path.canonicalize().unwrap());
598    }
599
600    #[test]
601    fn test_find_mkdocs_yaml_extension() {
602        let _guard = setup_test();
603        let temp_dir = tempdir().unwrap();
604        let mkdocs_path = temp_dir.path().join("mkdocs.yaml"); // .yaml extension
605        fs::write(&mkdocs_path, "site_name: Test").unwrap();
606
607        let docs_dir = temp_dir.path().join("docs");
608        fs::create_dir_all(&docs_dir).unwrap();
609        let file_in_docs = docs_dir.join("test.md");
610
611        let found = find_mkdocs_yml(&file_in_docs);
612        assert!(found.is_some(), "Should find mkdocs.yaml");
613        assert_eq!(found.unwrap(), mkdocs_path.canonicalize().unwrap());
614    }
615
616    #[test]
617    fn test_parse_simple_nav() {
618        let _guard = setup_test();
619        let temp_dir = tempdir().unwrap();
620        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
621
622        let mkdocs_content = r#"
623site_name: Test
624docs_dir: docs
625nav:
626  - Home: index.md
627  - Guide: guide.md
628  - Section:
629    - Page 1: section/page1.md
630    - Page 2: section/page2.md
631"#;
632        fs::write(&mkdocs_path, mkdocs_content).unwrap();
633
634        let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
635        assert_eq!(config.docs_dir, "docs");
636        assert_eq!(config.nav.len(), 3);
637
638        let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
639        assert_eq!(paths.len(), 4);
640
641        // Check paths are extracted correctly
642        let path_strs: Vec<&str> = paths.iter().map(|(p, _)| p.as_str()).collect();
643        assert!(path_strs.contains(&"index.md"));
644        assert!(path_strs.contains(&"guide.md"));
645        assert!(path_strs.contains(&"section/page1.md"));
646        assert!(path_strs.contains(&"section/page2.md"));
647    }
648
649    #[test]
650    fn test_parse_deeply_nested_nav() {
651        let _guard = setup_test();
652        let temp_dir = tempdir().unwrap();
653        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
654
655        let mkdocs_content = r#"
656site_name: Test
657nav:
658  - Level 1:
659    - Level 2:
660      - Level 3:
661        - Deep Page: deep/nested/page.md
662"#;
663        fs::write(&mkdocs_path, mkdocs_content).unwrap();
664
665        let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
666        let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
667
668        assert_eq!(paths.len(), 1);
669        assert_eq!(paths[0].0, "deep/nested/page.md");
670        assert!(paths[0].1.contains("Level 1"));
671        assert!(paths[0].1.contains("Level 2"));
672        assert!(paths[0].1.contains("Level 3"));
673    }
674
675    #[test]
676    fn test_parse_nav_with_external_urls() {
677        let _guard = setup_test();
678        let temp_dir = tempdir().unwrap();
679        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
680
681        let mkdocs_content = r#"
682site_name: Test
683docs_dir: docs
684nav:
685  - Home: index.md
686  - GitHub: https://github.com/example/repo
687  - External: http://example.com
688  - Protocol Relative: //example.com/path
689"#;
690        fs::write(&mkdocs_path, mkdocs_content).unwrap();
691
692        let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
693        let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
694
695        // All 4 entries are extracted
696        assert_eq!(paths.len(), 4);
697
698        // Verify external URL detection
699        assert!(!MD074MkDocsNav::is_external_url("index.md"));
700        assert!(MD074MkDocsNav::is_external_url("https://github.com/example/repo"));
701        assert!(MD074MkDocsNav::is_external_url("http://example.com"));
702        assert!(MD074MkDocsNav::is_external_url("//example.com/path"));
703    }
704
705    #[test]
706    fn test_parse_nav_with_empty_section() {
707        let _guard = setup_test();
708        let temp_dir = tempdir().unwrap();
709        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
710
711        // Empty section (null value)
712        let mkdocs_content = r#"
713site_name: Test
714nav:
715  - Empty Section:
716  - Home: index.md
717"#;
718        fs::write(&mkdocs_path, mkdocs_content).unwrap();
719
720        let result = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path);
721        assert!(result.is_ok(), "Should handle empty sections");
722    }
723
724    #[test]
725    fn test_nav_not_found_validation() {
726        let _guard = setup_test();
727        let temp_dir = tempdir().unwrap();
728
729        // Create mkdocs.yml
730        let mkdocs_content = r#"
731site_name: Test
732docs_dir: docs
733nav:
734  - Home: index.md
735  - Missing: missing.md
736"#;
737        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
738
739        // Create docs directory with only index.md
740        let docs_dir = temp_dir.path().join("docs");
741        fs::create_dir_all(&docs_dir).unwrap();
742        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
743
744        // Create a test markdown file
745        let test_file = docs_dir.join("test.md");
746        fs::write(&test_file, "# Test").unwrap();
747
748        let rule = MD074MkDocsNav::new();
749        let ctx =
750            crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
751
752        let result = rule.check(&ctx).unwrap();
753
754        // Should have 1 warning for missing.md
755        assert_eq!(result.len(), 1, "Should warn about missing nav entry. Got: {result:?}");
756        assert!(result[0].message.contains("missing.md"));
757    }
758
759    #[test]
760    fn test_absolute_links_validation() {
761        let _guard = setup_test();
762        let temp_dir = tempdir().unwrap();
763
764        let mkdocs_content = r#"
765site_name: Test
766docs_dir: docs
767nav:
768  - Absolute: /absolute/path.md
769"#;
770        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
771
772        let docs_dir = temp_dir.path().join("docs");
773        fs::create_dir_all(&docs_dir).unwrap();
774        let test_file = docs_dir.join("test.md");
775        fs::write(&test_file, "# Test").unwrap();
776
777        let config = MD074Config {
778            not_found: NavValidation::Ignore,
779            omitted_files: NavValidation::Ignore,
780            absolute_links: NavValidation::Warn,
781        };
782        let rule = MD074MkDocsNav::from_config_struct(config);
783
784        let ctx =
785            crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
786
787        let result = rule.check(&ctx).unwrap();
788
789        assert_eq!(result.len(), 1, "Should warn about absolute path. Got: {result:?}");
790        assert!(result[0].message.contains("Absolute path"));
791    }
792
793    #[test]
794    fn test_omitted_files_validation() {
795        let _guard = setup_test();
796        let temp_dir = tempdir().unwrap();
797
798        let mkdocs_content = r#"
799site_name: Test
800docs_dir: docs
801nav:
802  - Home: index.md
803"#;
804        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
805
806        let docs_dir = temp_dir.path().join("docs");
807        fs::create_dir_all(&docs_dir).unwrap();
808        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
809        fs::write(docs_dir.join("unlisted.md"), "# Unlisted").unwrap();
810
811        // Create subdirectory with file
812        let subdir = docs_dir.join("subdir");
813        fs::create_dir_all(&subdir).unwrap();
814        fs::write(subdir.join("nested.md"), "# Nested").unwrap();
815
816        let test_file = docs_dir.join("test.md");
817        fs::write(&test_file, "# Test").unwrap();
818
819        let config = MD074Config {
820            not_found: NavValidation::Ignore,
821            omitted_files: NavValidation::Warn,
822            absolute_links: NavValidation::Ignore,
823        };
824        let rule = MD074MkDocsNav::from_config_struct(config);
825
826        let ctx =
827            crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
828
829        let result = rule.check(&ctx).unwrap();
830
831        // Should warn about unlisted.md, test.md, and subdir/nested.md
832        // (index.md in root is skipped)
833        assert!(result.len() >= 2, "Should warn about omitted files. Got: {result:?}");
834
835        let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
836        assert!(
837            messages.iter().any(|m| m.contains("unlisted.md")),
838            "Should mention unlisted.md"
839        );
840    }
841
842    #[test]
843    fn test_omitted_files_with_subdirectories() {
844        let _guard = setup_test();
845        let temp_dir = tempdir().unwrap();
846
847        let mkdocs_content = r#"
848site_name: Test
849docs_dir: docs
850nav:
851  - Home: index.md
852  - API:
853    - Overview: api/overview.md
854"#;
855        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
856
857        let docs_dir = temp_dir.path().join("docs");
858        fs::create_dir_all(&docs_dir).unwrap();
859        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
860
861        let api_dir = docs_dir.join("api");
862        fs::create_dir_all(&api_dir).unwrap();
863        fs::write(api_dir.join("overview.md"), "# Overview").unwrap();
864        fs::write(api_dir.join("unlisted.md"), "# Unlisted API").unwrap();
865
866        let test_file = docs_dir.join("index.md");
867
868        let config = MD074Config {
869            not_found: NavValidation::Warn,
870            omitted_files: NavValidation::Warn,
871            absolute_links: NavValidation::Ignore,
872        };
873        let rule = MD074MkDocsNav::from_config_struct(config);
874
875        let ctx =
876            crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
877
878        let result = rule.check(&ctx).unwrap();
879
880        // Should only warn about api/unlisted.md, not api/overview.md
881        let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
882
883        // api/overview.md should NOT be reported (it's in nav)
884        assert!(
885            !messages.iter().any(|m| m.contains("overview.md")),
886            "Should NOT warn about api/overview.md (it's in nav). Got: {messages:?}"
887        );
888
889        // api/unlisted.md SHOULD be reported
890        assert!(
891            messages.iter().any(|m| m.contains("unlisted.md")),
892            "Should warn about api/unlisted.md. Got: {messages:?}"
893        );
894    }
895
896    #[test]
897    fn test_skips_non_mkdocs_flavor() {
898        let _guard = setup_test();
899        let rule = MD074MkDocsNav::new();
900        let ctx = crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::Standard, None);
901
902        let result = rule.check(&ctx).unwrap();
903        assert!(result.is_empty(), "Should skip non-MkDocs flavor");
904    }
905
906    #[test]
907    fn test_skips_external_urls_in_validation() {
908        let _guard = setup_test();
909        let temp_dir = tempdir().unwrap();
910
911        let mkdocs_content = r#"
912site_name: Test
913docs_dir: docs
914nav:
915  - Home: index.md
916  - GitHub: https://github.com/example
917  - Docs: http://docs.example.com
918"#;
919        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
920
921        let docs_dir = temp_dir.path().join("docs");
922        fs::create_dir_all(&docs_dir).unwrap();
923        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
924
925        let test_file = docs_dir.join("index.md");
926
927        let rule = MD074MkDocsNav::new();
928        let ctx =
929            crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
930
931        let result = rule.check(&ctx).unwrap();
932
933        // Should NOT warn about external URLs as missing files
934        assert!(
935            result.is_empty(),
936            "Should not warn about external URLs. Got: {result:?}"
937        );
938    }
939
940    #[test]
941    fn test_cache_prevents_duplicate_validation() {
942        let _guard = setup_test();
943        let temp_dir = tempdir().unwrap();
944
945        let mkdocs_content = r#"
946site_name: Test
947docs_dir: docs
948nav:
949  - Home: index.md
950  - Missing: missing.md
951"#;
952        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
953
954        let docs_dir = temp_dir.path().join("docs");
955        fs::create_dir_all(&docs_dir).unwrap();
956        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
957        fs::write(docs_dir.join("other.md"), "# Other").unwrap();
958
959        let rule = MD074MkDocsNav::new();
960
961        // First file check
962        let ctx1 = crate::lint_context::LintContext::new(
963            "# Home",
964            crate::config::MarkdownFlavor::MkDocs,
965            Some(docs_dir.join("index.md")),
966        );
967        let result1 = rule.check(&ctx1).unwrap();
968        assert_eq!(result1.len(), 1, "First check should return warnings");
969
970        // Second file check - same project
971        let ctx2 = crate::lint_context::LintContext::new(
972            "# Other",
973            crate::config::MarkdownFlavor::MkDocs,
974            Some(docs_dir.join("other.md")),
975        );
976        let result2 = rule.check(&ctx2).unwrap();
977        assert!(result2.is_empty(), "Second check should return no warnings (cached)");
978    }
979
980    #[test]
981    fn test_cache_invalidates_when_content_changes() {
982        let _guard = setup_test();
983        let temp_dir = tempdir().unwrap();
984
985        let mkdocs_content_v1 = r#"
986site_name: Test
987docs_dir: docs
988nav:
989  - Home: index.md
990"#;
991        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content_v1).unwrap();
992
993        let docs_dir = temp_dir.path().join("docs");
994        fs::create_dir_all(&docs_dir).unwrap();
995        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
996
997        let rule = MD074MkDocsNav::new();
998
999        // First check - valid config, no warnings
1000        let ctx1 = crate::lint_context::LintContext::new(
1001            "# Home",
1002            crate::config::MarkdownFlavor::MkDocs,
1003            Some(docs_dir.join("index.md")),
1004        );
1005        let result1 = rule.check(&ctx1).unwrap();
1006        assert!(
1007            result1.is_empty(),
1008            "First check: valid config should produce no warnings"
1009        );
1010
1011        // Now modify mkdocs.yml to add a missing file reference
1012        let mkdocs_content_v2 = r#"
1013site_name: Test
1014docs_dir: docs
1015nav:
1016  - Home: index.md
1017  - Missing: missing.md
1018"#;
1019        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content_v2).unwrap();
1020
1021        // Second check - content changed, cache should invalidate
1022        let ctx2 = crate::lint_context::LintContext::new(
1023            "# Home",
1024            crate::config::MarkdownFlavor::MkDocs,
1025            Some(docs_dir.join("index.md")),
1026        );
1027        let result2 = rule.check(&ctx2).unwrap();
1028        assert_eq!(
1029            result2.len(),
1030            1,
1031            "Second check: changed mkdocs.yml should re-validate and find missing.md"
1032        );
1033        assert!(result2[0].message.contains("missing.md"));
1034    }
1035
1036    #[test]
1037    fn test_invalid_mkdocs_yml_returns_warning() {
1038        let _guard = setup_test();
1039        let temp_dir = tempdir().unwrap();
1040
1041        // Invalid YAML
1042        let mkdocs_content = "site_name: Test\nnav: [[[invalid yaml";
1043        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1044
1045        let docs_dir = temp_dir.path().join("docs");
1046        fs::create_dir_all(&docs_dir).unwrap();
1047        let test_file = docs_dir.join("test.md");
1048        fs::write(&test_file, "# Test").unwrap();
1049
1050        let rule = MD074MkDocsNav::new();
1051        let ctx =
1052            crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1053
1054        let result = rule.check(&ctx).unwrap();
1055
1056        assert_eq!(result.len(), 1, "Should return parse error warning");
1057        assert!(
1058            result[0].message.contains("Failed to parse"),
1059            "Should mention parse failure"
1060        );
1061    }
1062
1063    #[test]
1064    fn test_missing_docs_dir_returns_warning() {
1065        let _guard = setup_test();
1066        let temp_dir = tempdir().unwrap();
1067
1068        let mkdocs_content = r#"
1069site_name: Test
1070docs_dir: nonexistent
1071nav:
1072  - Home: index.md
1073"#;
1074        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1075
1076        // Create a file but not in the docs_dir
1077        let other_dir = temp_dir.path().join("other");
1078        fs::create_dir_all(&other_dir).unwrap();
1079        let test_file = other_dir.join("test.md");
1080        fs::write(&test_file, "# Test").unwrap();
1081
1082        let rule = MD074MkDocsNav::new();
1083        let ctx =
1084            crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1085
1086        let result = rule.check(&ctx).unwrap();
1087
1088        assert_eq!(result.len(), 1, "Should warn about missing docs_dir");
1089        assert!(
1090            result[0].message.contains("does not exist"),
1091            "Should mention docs_dir doesn't exist"
1092        );
1093    }
1094
1095    #[test]
1096    fn test_default_docs_dir() {
1097        let _guard = setup_test();
1098        let temp_dir = tempdir().unwrap();
1099
1100        // mkdocs.yml without docs_dir specified - should default to "docs"
1101        let mkdocs_content = r#"
1102site_name: Test
1103nav:
1104  - Home: index.md
1105"#;
1106        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1107
1108        let config = MD074MkDocsNav::parse_mkdocs_yml(&temp_dir.path().join("mkdocs.yml")).unwrap();
1109        assert_eq!(config.docs_dir, "docs", "Should default to 'docs'");
1110    }
1111
1112    #[test]
1113    fn test_path_normalization() {
1114        // Test that paths are normalized consistently
1115        let path1 = MD074MkDocsNav::normalize_path(Path::new("api/overview.md"));
1116        let path2 = MD074MkDocsNav::normalize_nav_path("api/overview.md");
1117        assert_eq!(path1, path2);
1118
1119        // Windows-style paths should be normalized
1120        let win_path = MD074MkDocsNav::normalize_nav_path("api\\overview.md");
1121        assert_eq!(win_path, PathBuf::from("api/overview.md"));
1122    }
1123
1124    #[test]
1125    fn test_skips_hidden_files_and_directories() {
1126        let _guard = setup_test();
1127        let temp_dir = tempdir().unwrap();
1128
1129        let mkdocs_content = r#"
1130site_name: Test
1131docs_dir: docs
1132nav:
1133  - Home: index.md
1134"#;
1135        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1136
1137        let docs_dir = temp_dir.path().join("docs");
1138        fs::create_dir_all(&docs_dir).unwrap();
1139        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1140
1141        // Create hidden file and directory
1142        fs::write(docs_dir.join(".hidden.md"), "# Hidden").unwrap();
1143        let hidden_dir = docs_dir.join(".hidden_dir");
1144        fs::create_dir_all(&hidden_dir).unwrap();
1145        fs::write(hidden_dir.join("secret.md"), "# Secret").unwrap();
1146
1147        let collected = MD074MkDocsNav::collect_docs_files(&docs_dir);
1148
1149        assert_eq!(collected.len(), 1, "Should only find index.md");
1150        assert!(
1151            !collected.iter().any(|p| p.to_string_lossy().contains("hidden")),
1152            "Should not include hidden files"
1153        );
1154    }
1155
1156    #[test]
1157    fn test_is_external_url() {
1158        assert!(MD074MkDocsNav::is_external_url("https://example.com"));
1159        assert!(MD074MkDocsNav::is_external_url("http://example.com"));
1160        assert!(MD074MkDocsNav::is_external_url("//example.com"));
1161        assert!(MD074MkDocsNav::is_external_url("ftp://files.example.com"));
1162        assert!(!MD074MkDocsNav::is_external_url("index.md"));
1163        assert!(!MD074MkDocsNav::is_external_url("path/to/file.md"));
1164        assert!(!MD074MkDocsNav::is_external_url("/absolute/path.md"));
1165    }
1166
1167    #[test]
1168    fn test_is_absolute_path() {
1169        assert!(MD074MkDocsNav::is_absolute_path("/absolute/path.md"));
1170        assert!(MD074MkDocsNav::is_absolute_path("/index.md"));
1171        assert!(!MD074MkDocsNav::is_absolute_path("relative/path.md"));
1172        assert!(!MD074MkDocsNav::is_absolute_path("index.md"));
1173        assert!(!MD074MkDocsNav::is_absolute_path("https://example.com"));
1174    }
1175
1176    #[test]
1177    fn test_directory_nav_entries() {
1178        let _guard = setup_test();
1179        let temp_dir = tempdir().unwrap();
1180
1181        // Nav with directory entry (trailing slash)
1182        let mkdocs_content = r#"
1183site_name: Test
1184docs_dir: docs
1185nav:
1186  - Home: index.md
1187  - API: api/
1188"#;
1189        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1190
1191        let docs_dir = temp_dir.path().join("docs");
1192        fs::create_dir_all(&docs_dir).unwrap();
1193        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1194
1195        // Create api directory WITHOUT index.md
1196        let api_dir = docs_dir.join("api");
1197        fs::create_dir_all(&api_dir).unwrap();
1198
1199        let test_file = docs_dir.join("index.md");
1200
1201        let rule = MD074MkDocsNav::new();
1202        let ctx =
1203            crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1204
1205        let result = rule.check(&ctx).unwrap();
1206
1207        // Should warn that api/index.md doesn't exist
1208        assert_eq!(
1209            result.len(),
1210            1,
1211            "Should warn about missing api/index.md. Got: {result:?}"
1212        );
1213        assert!(result[0].message.contains("api/"), "Should mention api/ in warning");
1214        assert!(
1215            result[0].message.contains("index.md"),
1216            "Should mention index.md in warning"
1217        );
1218    }
1219
1220    #[test]
1221    fn test_directory_nav_entries_with_index() {
1222        let _guard = setup_test();
1223        let temp_dir = tempdir().unwrap();
1224
1225        // Nav with directory entry (trailing slash)
1226        let mkdocs_content = r#"
1227site_name: Test
1228docs_dir: docs
1229nav:
1230  - Home: index.md
1231  - API: api/
1232"#;
1233        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1234
1235        let docs_dir = temp_dir.path().join("docs");
1236        fs::create_dir_all(&docs_dir).unwrap();
1237        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1238
1239        // Create api directory WITH index.md
1240        let api_dir = docs_dir.join("api");
1241        fs::create_dir_all(&api_dir).unwrap();
1242        fs::write(api_dir.join("index.md"), "# API").unwrap();
1243
1244        let test_file = docs_dir.join("index.md");
1245
1246        let rule = MD074MkDocsNav::new();
1247        let ctx =
1248            crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1249
1250        let result = rule.check(&ctx).unwrap();
1251
1252        // Should not warn - api/index.md exists
1253        assert!(
1254            result.is_empty(),
1255            "Should not warn when api/index.md exists. Got: {result:?}"
1256        );
1257    }
1258}