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    fn setup_test() {
569        MD074MkDocsNav::clear_cache();
570    }
571
572    #[test]
573    fn test_find_mkdocs_yml() {
574        setup_test();
575        let temp_dir = tempdir().unwrap();
576        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
577        fs::write(&mkdocs_path, "site_name: Test").unwrap();
578
579        let subdir = temp_dir.path().join("docs");
580        fs::create_dir_all(&subdir).unwrap();
581        let file_in_subdir = subdir.join("test.md");
582
583        let found = find_mkdocs_yml(&file_in_subdir);
584        assert!(found.is_some());
585        // Canonicalized paths should match
586        assert_eq!(found.unwrap(), mkdocs_path.canonicalize().unwrap());
587    }
588
589    #[test]
590    fn test_find_mkdocs_yaml_extension() {
591        setup_test();
592        let temp_dir = tempdir().unwrap();
593        let mkdocs_path = temp_dir.path().join("mkdocs.yaml"); // .yaml extension
594        fs::write(&mkdocs_path, "site_name: Test").unwrap();
595
596        let docs_dir = temp_dir.path().join("docs");
597        fs::create_dir_all(&docs_dir).unwrap();
598        let file_in_docs = docs_dir.join("test.md");
599
600        let found = find_mkdocs_yml(&file_in_docs);
601        assert!(found.is_some(), "Should find mkdocs.yaml");
602        assert_eq!(found.unwrap(), mkdocs_path.canonicalize().unwrap());
603    }
604
605    #[test]
606    fn test_parse_simple_nav() {
607        setup_test();
608        let temp_dir = tempdir().unwrap();
609        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
610
611        let mkdocs_content = r#"
612site_name: Test
613docs_dir: docs
614nav:
615  - Home: index.md
616  - Guide: guide.md
617  - Section:
618    - Page 1: section/page1.md
619    - Page 2: section/page2.md
620"#;
621        fs::write(&mkdocs_path, mkdocs_content).unwrap();
622
623        let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
624        assert_eq!(config.docs_dir, "docs");
625        assert_eq!(config.nav.len(), 3);
626
627        let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
628        assert_eq!(paths.len(), 4);
629
630        // Check paths are extracted correctly
631        let path_strs: Vec<&str> = paths.iter().map(|(p, _)| p.as_str()).collect();
632        assert!(path_strs.contains(&"index.md"));
633        assert!(path_strs.contains(&"guide.md"));
634        assert!(path_strs.contains(&"section/page1.md"));
635        assert!(path_strs.contains(&"section/page2.md"));
636    }
637
638    #[test]
639    fn test_parse_deeply_nested_nav() {
640        setup_test();
641        let temp_dir = tempdir().unwrap();
642        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
643
644        let mkdocs_content = r#"
645site_name: Test
646nav:
647  - Level 1:
648    - Level 2:
649      - Level 3:
650        - Deep Page: deep/nested/page.md
651"#;
652        fs::write(&mkdocs_path, mkdocs_content).unwrap();
653
654        let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
655        let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
656
657        assert_eq!(paths.len(), 1);
658        assert_eq!(paths[0].0, "deep/nested/page.md");
659        assert!(paths[0].1.contains("Level 1"));
660        assert!(paths[0].1.contains("Level 2"));
661        assert!(paths[0].1.contains("Level 3"));
662    }
663
664    #[test]
665    fn test_parse_nav_with_external_urls() {
666        setup_test();
667        let temp_dir = tempdir().unwrap();
668        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
669
670        let mkdocs_content = r#"
671site_name: Test
672docs_dir: docs
673nav:
674  - Home: index.md
675  - GitHub: https://github.com/example/repo
676  - External: http://example.com
677  - Protocol Relative: //example.com/path
678"#;
679        fs::write(&mkdocs_path, mkdocs_content).unwrap();
680
681        let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
682        let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
683
684        // All 4 entries are extracted
685        assert_eq!(paths.len(), 4);
686
687        // Verify external URL detection
688        assert!(!MD074MkDocsNav::is_external_url("index.md"));
689        assert!(MD074MkDocsNav::is_external_url("https://github.com/example/repo"));
690        assert!(MD074MkDocsNav::is_external_url("http://example.com"));
691        assert!(MD074MkDocsNav::is_external_url("//example.com/path"));
692    }
693
694    #[test]
695    fn test_parse_nav_with_empty_section() {
696        setup_test();
697        let temp_dir = tempdir().unwrap();
698        let mkdocs_path = temp_dir.path().join("mkdocs.yml");
699
700        // Empty section (null value)
701        let mkdocs_content = r#"
702site_name: Test
703nav:
704  - Empty Section:
705  - Home: index.md
706"#;
707        fs::write(&mkdocs_path, mkdocs_content).unwrap();
708
709        let result = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path);
710        assert!(result.is_ok(), "Should handle empty sections");
711    }
712
713    #[test]
714    fn test_nav_not_found_validation() {
715        setup_test();
716        let temp_dir = tempdir().unwrap();
717
718        // Create mkdocs.yml
719        let mkdocs_content = r#"
720site_name: Test
721docs_dir: docs
722nav:
723  - Home: index.md
724  - Missing: missing.md
725"#;
726        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
727
728        // Create docs directory with only index.md
729        let docs_dir = temp_dir.path().join("docs");
730        fs::create_dir_all(&docs_dir).unwrap();
731        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
732
733        // Create a test markdown file
734        let test_file = docs_dir.join("test.md");
735        fs::write(&test_file, "# Test").unwrap();
736
737        let rule = MD074MkDocsNav::new();
738        let ctx =
739            crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
740
741        let result = rule.check(&ctx).unwrap();
742
743        // Should have 1 warning for missing.md
744        assert_eq!(result.len(), 1, "Should warn about missing nav entry. Got: {result:?}");
745        assert!(result[0].message.contains("missing.md"));
746    }
747
748    #[test]
749    fn test_absolute_links_validation() {
750        setup_test();
751        let temp_dir = tempdir().unwrap();
752
753        let mkdocs_content = r#"
754site_name: Test
755docs_dir: docs
756nav:
757  - Absolute: /absolute/path.md
758"#;
759        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
760
761        let docs_dir = temp_dir.path().join("docs");
762        fs::create_dir_all(&docs_dir).unwrap();
763        let test_file = docs_dir.join("test.md");
764        fs::write(&test_file, "# Test").unwrap();
765
766        let config = MD074Config {
767            not_found: NavValidation::Ignore,
768            omitted_files: NavValidation::Ignore,
769            absolute_links: NavValidation::Warn,
770        };
771        let rule = MD074MkDocsNav::from_config_struct(config);
772
773        let ctx =
774            crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
775
776        let result = rule.check(&ctx).unwrap();
777
778        assert_eq!(result.len(), 1, "Should warn about absolute path. Got: {result:?}");
779        assert!(result[0].message.contains("Absolute path"));
780    }
781
782    #[test]
783    fn test_omitted_files_validation() {
784        setup_test();
785        let temp_dir = tempdir().unwrap();
786
787        let mkdocs_content = r#"
788site_name: Test
789docs_dir: docs
790nav:
791  - Home: index.md
792"#;
793        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
794
795        let docs_dir = temp_dir.path().join("docs");
796        fs::create_dir_all(&docs_dir).unwrap();
797        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
798        fs::write(docs_dir.join("unlisted.md"), "# Unlisted").unwrap();
799
800        // Create subdirectory with file
801        let subdir = docs_dir.join("subdir");
802        fs::create_dir_all(&subdir).unwrap();
803        fs::write(subdir.join("nested.md"), "# Nested").unwrap();
804
805        let test_file = docs_dir.join("test.md");
806        fs::write(&test_file, "# Test").unwrap();
807
808        let config = MD074Config {
809            not_found: NavValidation::Ignore,
810            omitted_files: NavValidation::Warn,
811            absolute_links: NavValidation::Ignore,
812        };
813        let rule = MD074MkDocsNav::from_config_struct(config);
814
815        let ctx =
816            crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
817
818        let result = rule.check(&ctx).unwrap();
819
820        // Should warn about unlisted.md, test.md, and subdir/nested.md
821        // (index.md in root is skipped)
822        assert!(result.len() >= 2, "Should warn about omitted files. Got: {result:?}");
823
824        let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
825        assert!(
826            messages.iter().any(|m| m.contains("unlisted.md")),
827            "Should mention unlisted.md"
828        );
829    }
830
831    #[test]
832    fn test_omitted_files_with_subdirectories() {
833        setup_test();
834        let temp_dir = tempdir().unwrap();
835
836        let mkdocs_content = r#"
837site_name: Test
838docs_dir: docs
839nav:
840  - Home: index.md
841  - API:
842    - Overview: api/overview.md
843"#;
844        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
845
846        let docs_dir = temp_dir.path().join("docs");
847        fs::create_dir_all(&docs_dir).unwrap();
848        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
849
850        let api_dir = docs_dir.join("api");
851        fs::create_dir_all(&api_dir).unwrap();
852        fs::write(api_dir.join("overview.md"), "# Overview").unwrap();
853        fs::write(api_dir.join("unlisted.md"), "# Unlisted API").unwrap();
854
855        let test_file = docs_dir.join("index.md");
856
857        let config = MD074Config {
858            not_found: NavValidation::Warn,
859            omitted_files: NavValidation::Warn,
860            absolute_links: NavValidation::Ignore,
861        };
862        let rule = MD074MkDocsNav::from_config_struct(config);
863
864        let ctx =
865            crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
866
867        let result = rule.check(&ctx).unwrap();
868
869        // Should only warn about api/unlisted.md, not api/overview.md
870        let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
871
872        // api/overview.md should NOT be reported (it's in nav)
873        assert!(
874            !messages.iter().any(|m| m.contains("overview.md")),
875            "Should NOT warn about api/overview.md (it's in nav). Got: {messages:?}"
876        );
877
878        // api/unlisted.md SHOULD be reported
879        assert!(
880            messages.iter().any(|m| m.contains("unlisted.md")),
881            "Should warn about api/unlisted.md. Got: {messages:?}"
882        );
883    }
884
885    #[test]
886    fn test_skips_non_mkdocs_flavor() {
887        setup_test();
888        let rule = MD074MkDocsNav::new();
889        let ctx = crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::Standard, None);
890
891        let result = rule.check(&ctx).unwrap();
892        assert!(result.is_empty(), "Should skip non-MkDocs flavor");
893    }
894
895    #[test]
896    fn test_skips_external_urls_in_validation() {
897        setup_test();
898        let temp_dir = tempdir().unwrap();
899
900        let mkdocs_content = r#"
901site_name: Test
902docs_dir: docs
903nav:
904  - Home: index.md
905  - GitHub: https://github.com/example
906  - Docs: http://docs.example.com
907"#;
908        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
909
910        let docs_dir = temp_dir.path().join("docs");
911        fs::create_dir_all(&docs_dir).unwrap();
912        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
913
914        let test_file = docs_dir.join("index.md");
915
916        let rule = MD074MkDocsNav::new();
917        let ctx =
918            crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
919
920        let result = rule.check(&ctx).unwrap();
921
922        // Should NOT warn about external URLs as missing files
923        assert!(
924            result.is_empty(),
925            "Should not warn about external URLs. Got: {result:?}"
926        );
927    }
928
929    #[test]
930    fn test_cache_prevents_duplicate_validation() {
931        setup_test();
932        let temp_dir = tempdir().unwrap();
933
934        let mkdocs_content = r#"
935site_name: Test
936docs_dir: docs
937nav:
938  - Home: index.md
939  - Missing: missing.md
940"#;
941        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
942
943        let docs_dir = temp_dir.path().join("docs");
944        fs::create_dir_all(&docs_dir).unwrap();
945        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
946        fs::write(docs_dir.join("other.md"), "# Other").unwrap();
947
948        let rule = MD074MkDocsNav::new();
949
950        // First file check
951        let ctx1 = crate::lint_context::LintContext::new(
952            "# Home",
953            crate::config::MarkdownFlavor::MkDocs,
954            Some(docs_dir.join("index.md")),
955        );
956        let result1 = rule.check(&ctx1).unwrap();
957        assert_eq!(result1.len(), 1, "First check should return warnings");
958
959        // Second file check - same project
960        let ctx2 = crate::lint_context::LintContext::new(
961            "# Other",
962            crate::config::MarkdownFlavor::MkDocs,
963            Some(docs_dir.join("other.md")),
964        );
965        let result2 = rule.check(&ctx2).unwrap();
966        assert!(result2.is_empty(), "Second check should return no warnings (cached)");
967    }
968
969    #[test]
970    fn test_cache_invalidates_when_content_changes() {
971        setup_test();
972        let temp_dir = tempdir().unwrap();
973
974        let mkdocs_content_v1 = r#"
975site_name: Test
976docs_dir: docs
977nav:
978  - Home: index.md
979"#;
980        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content_v1).unwrap();
981
982        let docs_dir = temp_dir.path().join("docs");
983        fs::create_dir_all(&docs_dir).unwrap();
984        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
985
986        let rule = MD074MkDocsNav::new();
987
988        // First check - valid config, no warnings
989        let ctx1 = crate::lint_context::LintContext::new(
990            "# Home",
991            crate::config::MarkdownFlavor::MkDocs,
992            Some(docs_dir.join("index.md")),
993        );
994        let result1 = rule.check(&ctx1).unwrap();
995        assert!(
996            result1.is_empty(),
997            "First check: valid config should produce no warnings"
998        );
999
1000        // Now modify mkdocs.yml to add a missing file reference
1001        let mkdocs_content_v2 = r#"
1002site_name: Test
1003docs_dir: docs
1004nav:
1005  - Home: index.md
1006  - Missing: missing.md
1007"#;
1008        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content_v2).unwrap();
1009
1010        // Second check - content changed, cache should invalidate
1011        let ctx2 = crate::lint_context::LintContext::new(
1012            "# Home",
1013            crate::config::MarkdownFlavor::MkDocs,
1014            Some(docs_dir.join("index.md")),
1015        );
1016        let result2 = rule.check(&ctx2).unwrap();
1017        assert_eq!(
1018            result2.len(),
1019            1,
1020            "Second check: changed mkdocs.yml should re-validate and find missing.md"
1021        );
1022        assert!(result2[0].message.contains("missing.md"));
1023    }
1024
1025    #[test]
1026    fn test_invalid_mkdocs_yml_returns_warning() {
1027        setup_test();
1028        let temp_dir = tempdir().unwrap();
1029
1030        // Invalid YAML
1031        let mkdocs_content = "site_name: Test\nnav: [[[invalid yaml";
1032        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1033
1034        let docs_dir = temp_dir.path().join("docs");
1035        fs::create_dir_all(&docs_dir).unwrap();
1036        let test_file = docs_dir.join("test.md");
1037        fs::write(&test_file, "# Test").unwrap();
1038
1039        let rule = MD074MkDocsNav::new();
1040        let ctx =
1041            crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1042
1043        let result = rule.check(&ctx).unwrap();
1044
1045        assert_eq!(result.len(), 1, "Should return parse error warning");
1046        assert!(
1047            result[0].message.contains("Failed to parse"),
1048            "Should mention parse failure"
1049        );
1050    }
1051
1052    #[test]
1053    fn test_missing_docs_dir_returns_warning() {
1054        setup_test();
1055        let temp_dir = tempdir().unwrap();
1056
1057        let mkdocs_content = r#"
1058site_name: Test
1059docs_dir: nonexistent
1060nav:
1061  - Home: index.md
1062"#;
1063        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1064
1065        // Create a file but not in the docs_dir
1066        let other_dir = temp_dir.path().join("other");
1067        fs::create_dir_all(&other_dir).unwrap();
1068        let test_file = other_dir.join("test.md");
1069        fs::write(&test_file, "# Test").unwrap();
1070
1071        let rule = MD074MkDocsNav::new();
1072        let ctx =
1073            crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1074
1075        let result = rule.check(&ctx).unwrap();
1076
1077        assert_eq!(result.len(), 1, "Should warn about missing docs_dir");
1078        assert!(
1079            result[0].message.contains("does not exist"),
1080            "Should mention docs_dir doesn't exist"
1081        );
1082    }
1083
1084    #[test]
1085    fn test_default_docs_dir() {
1086        setup_test();
1087        let temp_dir = tempdir().unwrap();
1088
1089        // mkdocs.yml without docs_dir specified - should default to "docs"
1090        let mkdocs_content = r#"
1091site_name: Test
1092nav:
1093  - Home: index.md
1094"#;
1095        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1096
1097        let config = MD074MkDocsNav::parse_mkdocs_yml(&temp_dir.path().join("mkdocs.yml")).unwrap();
1098        assert_eq!(config.docs_dir, "docs", "Should default to 'docs'");
1099    }
1100
1101    #[test]
1102    fn test_path_normalization() {
1103        // Test that paths are normalized consistently
1104        let path1 = MD074MkDocsNav::normalize_path(Path::new("api/overview.md"));
1105        let path2 = MD074MkDocsNav::normalize_nav_path("api/overview.md");
1106        assert_eq!(path1, path2);
1107
1108        // Windows-style paths should be normalized
1109        let win_path = MD074MkDocsNav::normalize_nav_path("api\\overview.md");
1110        assert_eq!(win_path, PathBuf::from("api/overview.md"));
1111    }
1112
1113    #[test]
1114    fn test_skips_hidden_files_and_directories() {
1115        setup_test();
1116        let temp_dir = tempdir().unwrap();
1117
1118        let mkdocs_content = r#"
1119site_name: Test
1120docs_dir: docs
1121nav:
1122  - Home: index.md
1123"#;
1124        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1125
1126        let docs_dir = temp_dir.path().join("docs");
1127        fs::create_dir_all(&docs_dir).unwrap();
1128        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1129
1130        // Create hidden file and directory
1131        fs::write(docs_dir.join(".hidden.md"), "# Hidden").unwrap();
1132        let hidden_dir = docs_dir.join(".hidden_dir");
1133        fs::create_dir_all(&hidden_dir).unwrap();
1134        fs::write(hidden_dir.join("secret.md"), "# Secret").unwrap();
1135
1136        let collected = MD074MkDocsNav::collect_docs_files(&docs_dir);
1137
1138        assert_eq!(collected.len(), 1, "Should only find index.md");
1139        assert!(
1140            !collected.iter().any(|p| p.to_string_lossy().contains("hidden")),
1141            "Should not include hidden files"
1142        );
1143    }
1144
1145    #[test]
1146    fn test_is_external_url() {
1147        assert!(MD074MkDocsNav::is_external_url("https://example.com"));
1148        assert!(MD074MkDocsNav::is_external_url("http://example.com"));
1149        assert!(MD074MkDocsNav::is_external_url("//example.com"));
1150        assert!(MD074MkDocsNav::is_external_url("ftp://files.example.com"));
1151        assert!(!MD074MkDocsNav::is_external_url("index.md"));
1152        assert!(!MD074MkDocsNav::is_external_url("path/to/file.md"));
1153        assert!(!MD074MkDocsNav::is_external_url("/absolute/path.md"));
1154    }
1155
1156    #[test]
1157    fn test_is_absolute_path() {
1158        assert!(MD074MkDocsNav::is_absolute_path("/absolute/path.md"));
1159        assert!(MD074MkDocsNav::is_absolute_path("/index.md"));
1160        assert!(!MD074MkDocsNav::is_absolute_path("relative/path.md"));
1161        assert!(!MD074MkDocsNav::is_absolute_path("index.md"));
1162        assert!(!MD074MkDocsNav::is_absolute_path("https://example.com"));
1163    }
1164
1165    #[test]
1166    fn test_directory_nav_entries() {
1167        setup_test();
1168        let temp_dir = tempdir().unwrap();
1169
1170        // Nav with directory entry (trailing slash)
1171        let mkdocs_content = r#"
1172site_name: Test
1173docs_dir: docs
1174nav:
1175  - Home: index.md
1176  - API: api/
1177"#;
1178        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1179
1180        let docs_dir = temp_dir.path().join("docs");
1181        fs::create_dir_all(&docs_dir).unwrap();
1182        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1183
1184        // Create api directory WITHOUT index.md
1185        let api_dir = docs_dir.join("api");
1186        fs::create_dir_all(&api_dir).unwrap();
1187
1188        let test_file = docs_dir.join("index.md");
1189
1190        let rule = MD074MkDocsNav::new();
1191        let ctx =
1192            crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1193
1194        let result = rule.check(&ctx).unwrap();
1195
1196        // Should warn that api/index.md doesn't exist
1197        assert_eq!(
1198            result.len(),
1199            1,
1200            "Should warn about missing api/index.md. Got: {result:?}"
1201        );
1202        assert!(result[0].message.contains("api/"), "Should mention api/ in warning");
1203        assert!(
1204            result[0].message.contains("index.md"),
1205            "Should mention index.md in warning"
1206        );
1207    }
1208
1209    #[test]
1210    fn test_directory_nav_entries_with_index() {
1211        setup_test();
1212        let temp_dir = tempdir().unwrap();
1213
1214        // Nav with directory entry (trailing slash)
1215        let mkdocs_content = r#"
1216site_name: Test
1217docs_dir: docs
1218nav:
1219  - Home: index.md
1220  - API: api/
1221"#;
1222        fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1223
1224        let docs_dir = temp_dir.path().join("docs");
1225        fs::create_dir_all(&docs_dir).unwrap();
1226        fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1227
1228        // Create api directory WITH index.md
1229        let api_dir = docs_dir.join("api");
1230        fs::create_dir_all(&api_dir).unwrap();
1231        fs::write(api_dir.join("index.md"), "# API").unwrap();
1232
1233        let test_file = docs_dir.join("index.md");
1234
1235        let rule = MD074MkDocsNav::new();
1236        let ctx =
1237            crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1238
1239        let result = rule.check(&ctx).unwrap();
1240
1241        // Should not warn - api/index.md exists
1242        assert!(
1243            result.is_empty(),
1244            "Should not warn when api/index.md exists. Got: {result:?}"
1245        );
1246    }
1247}