1use 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
16static VALIDATED_PROJECTS: LazyLock<Mutex<HashSet<PathBuf>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
19
20#[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 #[cfg(test)]
48 pub fn clear_cache() {
49 if let Ok(mut cache) = VALIDATED_PROJECTS.lock() {
50 cache.clear();
51 }
52 }
53
54 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 for filename in &["mkdocs.yml", "mkdocs.yaml"] {
65 let mkdocs_path = current.join(filename);
66 if mkdocs_path.exists() {
67 return mkdocs_path.canonicalize().ok();
69 }
70 }
71
72 if !current.pop() {
73 break;
74 }
75 }
76
77 None
78 }
79
80 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 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 fn collect_docs_files(docs_dir: &Path) -> HashSet<PathBuf> {
126 Self::collect_docs_files_recursive(docs_dir, docs_dir)
127 }
128
129 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 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 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 fn normalize_path(path: &Path) -> PathBuf {
167 let path_str = path.to_string_lossy();
168 PathBuf::from(path_str.replace('\\', "/"))
169 }
170
171 fn normalize_nav_path(path: &str) -> PathBuf {
173 PathBuf::from(path.replace('\\', "/"))
174 }
175
176 fn is_external_url(path: &str) -> bool {
178 path.starts_with("http://") || path.starts_with("https://") || path.starts_with("//") || path.contains("://")
179 }
180
181 fn is_absolute_path(path: &str) -> bool {
183 path.starts_with('/')
184 }
185
186 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 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 let nav_paths = Self::extract_nav_paths(&mkdocs_config.nav, "");
222
223 let mut referenced_files: HashSet<PathBuf> = HashSet::new();
225
226 for (file_path, nav_location) in &nav_paths {
228 if Self::is_external_url(file_path) {
230 continue;
231 }
232
233 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 if self.config.not_found == NavValidation::Warn {
254 let full_path = docs_dir.join(&normalized_path);
255
256 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 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 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 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 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 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#[derive(Debug)]
342struct MkDocsConfig {
343 docs_dir: String,
345
346 nav: Vec<NavItem>,
348}
349
350fn default_docs_dir() -> String {
351 "docs".to_string()
352}
353
354#[derive(Debug)]
360enum NavItem {
361 Path(String),
363
364 Section { name: String, children: Vec<NavItem> },
366
367 NamedPath { name: String, path: String },
369}
370
371impl NavItem {
372 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 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 RuleCategory::Other
448 }
449
450 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
451 ctx.flavor != crate::config::MarkdownFlavor::MkDocs
453 }
454
455 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
456 if ctx.flavor != crate::config::MarkdownFlavor::MkDocs {
458 return Ok(Vec::new());
459 }
460
461 let Some(source_file) = &ctx.source_file else {
463 return Ok(Vec::new());
464 };
465
466 let Some(mkdocs_path) = Self::find_mkdocs_yml(source_file) else {
468 return Ok(Vec::new());
469 };
470
471 if let Ok(mut cache) = VALIDATED_PROJECTS.lock() {
474 if !cache.insert(mkdocs_path.clone()) {
476 return Ok(Vec::new());
478 }
479 }
480 let mkdocs_config = match Self::parse_mkdocs_yml(&mkdocs_path) {
484 Ok(config) => config,
485 Err(e) => {
486 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 Ok(self.validate_nav(&mkdocs_path, &mkdocs_config))
502 }
503
504 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
505 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 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"); 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 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 assert_eq!(paths.len(), 4);
662
663 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 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 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 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 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 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 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 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 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
847
848 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(
1163 result.is_empty(),
1164 "Should not warn when api/index.md exists. Got: {result:?}"
1165 );
1166 }
1167}