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