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::HashSet;
11use std::path::{Path, PathBuf};
12use std::sync::{LazyLock, Mutex};
13
14mod md074_config;
15pub use md074_config::{MD074Config, NavValidation};
16
17static VALIDATED_PROJECTS: LazyLock<Mutex<HashSet<PathBuf>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
20
21#[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 #[cfg(test)]
49 pub fn clear_cache() {
50 if let Ok(mut cache) = VALIDATED_PROJECTS.lock() {
51 cache.clear();
52 }
53 }
54
55 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 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 fn collect_docs_files(docs_dir: &Path) -> HashSet<PathBuf> {
101 Self::collect_docs_files_recursive(docs_dir, docs_dir)
102 }
103
104 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 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 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 fn normalize_path(path: &Path) -> PathBuf {
142 let path_str = path.to_string_lossy();
143 PathBuf::from(path_str.replace('\\', "/"))
144 }
145
146 fn normalize_nav_path(path: &str) -> PathBuf {
148 PathBuf::from(path.replace('\\', "/"))
149 }
150
151 fn is_external_url(path: &str) -> bool {
153 path.starts_with("http://") || path.starts_with("https://") || path.starts_with("//") || path.contains("://")
154 }
155
156 fn is_absolute_path(path: &str) -> bool {
158 path.starts_with('/')
159 }
160
161 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 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 let nav_paths = Self::extract_nav_paths(&mkdocs_config.nav, "");
197
198 let mut referenced_files: HashSet<PathBuf> = HashSet::new();
200
201 for (file_path, nav_location) in &nav_paths {
203 if Self::is_external_url(file_path) {
205 continue;
206 }
207
208 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 if self.config.not_found == NavValidation::Warn {
229 let full_path = docs_dir.join(&normalized_path);
230
231 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 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 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 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 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 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#[derive(Debug)]
317struct MkDocsConfig {
318 docs_dir: String,
320
321 nav: Vec<NavItem>,
323}
324
325fn default_docs_dir() -> String {
326 "docs".to_string()
327}
328
329#[derive(Debug)]
335enum NavItem {
336 Path(String),
338
339 Section { name: String, children: Vec<NavItem> },
341
342 NamedPath { name: String, path: String },
344}
345
346impl NavItem {
347 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 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 RuleCategory::Other
423 }
424
425 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
426 ctx.flavor != crate::config::MarkdownFlavor::MkDocs
428 }
429
430 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
431 if ctx.flavor != crate::config::MarkdownFlavor::MkDocs {
433 return Ok(Vec::new());
434 }
435
436 let Some(source_file) = &ctx.source_file else {
438 return Ok(Vec::new());
439 };
440
441 let Some(mkdocs_path) = find_mkdocs_yml(source_file) else {
443 return Ok(Vec::new());
444 };
445
446 if let Ok(mut cache) = VALIDATED_PROJECTS.lock() {
449 if !cache.insert(mkdocs_path.clone()) {
451 return Ok(Vec::new());
453 }
454 }
455 let mkdocs_config = match Self::parse_mkdocs_yml(&mkdocs_path) {
459 Ok(config) => config,
460 Err(e) => {
461 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 Ok(self.validate_nav(&mkdocs_path, &mkdocs_config))
477 }
478
479 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
480 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 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"); 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 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 assert_eq!(paths.len(), 4);
637
638 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 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 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 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 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 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 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 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 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
822
823 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(
1138 result.is_empty(),
1139 "Should not warn when api/index.md exists. Got: {result:?}"
1140 );
1141 }
1142}