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