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