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