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