1use 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
18static VALIDATED_PROJECTS: LazyLock<Mutex<HashMap<PathBuf, u64>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
21
22#[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 #[cfg(test)]
50 pub fn clear_cache() {
51 if let Ok(mut cache) = VALIDATED_PROJECTS.lock() {
52 cache.clear();
53 }
54 }
55
56 #[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 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 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 fn collect_docs_files(docs_dir: &Path) -> HashSet<PathBuf> {
108 Self::collect_docs_files_recursive(docs_dir, docs_dir)
109 }
110
111 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 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 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 fn normalize_path(path: &Path) -> PathBuf {
149 let path_str = path.to_string_lossy();
150 PathBuf::from(path_str.replace('\\', "/"))
151 }
152
153 fn normalize_nav_path(path: &str) -> PathBuf {
155 PathBuf::from(path.replace('\\', "/"))
156 }
157
158 fn is_external_url(path: &str) -> bool {
160 path.starts_with("http://") || path.starts_with("https://") || path.starts_with("//") || path.contains("://")
161 }
162
163 fn is_absolute_path(path: &str) -> bool {
165 path.starts_with('/')
166 }
167
168 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 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 let nav_paths = Self::extract_nav_paths(&mkdocs_config.nav, "");
204
205 let mut referenced_files: HashSet<PathBuf> = HashSet::new();
207
208 for (file_path, nav_location) in &nav_paths {
210 if Self::is_external_url(file_path) {
212 continue;
213 }
214
215 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 if self.config.not_found == NavValidation::Warn {
236 let full_path = docs_dir.join(&normalized_path);
237
238 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 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 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 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 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 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#[derive(Debug)]
324struct MkDocsConfig {
325 docs_dir: String,
327
328 nav: Vec<NavItem>,
330}
331
332fn default_docs_dir() -> String {
333 "docs".to_string()
334}
335
336#[derive(Debug)]
342enum NavItem {
343 Path(String),
345
346 Section { name: String, children: Vec<NavItem> },
348
349 NamedPath { name: String, path: String },
351}
352
353impl NavItem {
354 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 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 RuleCategory::Other
430 }
431
432 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
433 ctx.flavor != crate::config::MarkdownFlavor::MkDocs
435 }
436
437 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
438 if ctx.flavor != crate::config::MarkdownFlavor::MkDocs {
440 return Ok(Vec::new());
441 }
442
443 let Some(source_file) = &ctx.source_file else {
445 return Ok(Vec::new());
446 };
447
448 let Some(mkdocs_path) = find_mkdocs_yml(source_file) else {
450 return Ok(Vec::new());
451 };
452
453 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 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 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 Ok(self.validate_nav(&mkdocs_path, &mkdocs_config))
504 }
505
506 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
507 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 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"); 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 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 assert_eq!(paths.len(), 4);
664
665 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 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 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 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 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 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 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 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 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
849
850 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(
1221 result.is_empty(),
1222 "Should not warn when api/index.md exists. Got: {result:?}"
1223 );
1224 }
1225}