1use crate::rule::{FixCapability, 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(super) 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_yaml::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 Ok(entries) = std::fs::read_dir(current_dir) else {
116 return files;
117 };
118
119 for entry in entries.flatten() {
120 let path = entry.path();
121
122 if path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
124 continue;
125 }
126
127 if path.is_dir() {
128 files.extend(Self::collect_docs_files_recursive(&path, root_docs_dir));
129 } else if path.is_file()
130 && let Some(ext) = path.extension()
131 {
132 let ext_lower = ext.to_string_lossy().to_lowercase();
133 if ext_lower == "md" || ext_lower == "markdown" {
134 if let Ok(relative) = path.strip_prefix(root_docs_dir) {
136 let normalized = Self::normalize_path(relative);
137 files.insert(normalized);
138 }
139 }
140 }
141 }
142
143 files
144 }
145
146 fn normalize_path(path: &Path) -> PathBuf {
148 let path_str = path.to_string_lossy();
149 PathBuf::from(path_str.replace('\\', "/"))
150 }
151
152 fn normalize_nav_path(path: &str) -> PathBuf {
154 PathBuf::from(path.replace('\\', "/"))
155 }
156
157 fn is_external_url(path: &str) -> bool {
159 path.starts_with("http://") || path.starts_with("https://") || path.starts_with("//") || path.contains("://")
160 }
161
162 fn is_absolute_path(path: &str) -> bool {
164 path.starts_with('/')
165 }
166
167 fn strip_yaml_quotes(s: &str) -> &str {
172 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
173 &s[1..s.len() - 1]
174 } else {
175 s
176 }
177 }
178
179 fn find_nav_line_in_yaml(yaml_content: &str, file_path: &str) -> Option<usize> {
180 for (idx, line) in yaml_content.lines().enumerate() {
181 let trimmed = line.trim();
182 if trimmed.starts_with('#') {
184 continue;
185 }
186 if let Some(rest) = trimmed.strip_prefix("- ")
188 && Self::strip_yaml_quotes(rest.trim()) == file_path
189 {
190 return Some(idx + 1);
191 }
192 if let Some(colon_pos) = trimmed.find(": ") {
194 let value = trimmed[colon_pos + 2..].trim();
195 if Self::strip_yaml_quotes(value) == file_path {
196 return Some(idx + 1);
197 }
198 }
199 }
200 None
201 }
202
203 fn validate_nav(&self, mkdocs_path: &Path, mkdocs_config: &MkDocsConfig, yaml_content: &str) -> Vec<LintWarning> {
205 let mut warnings = Vec::new();
206 let mkdocs_file = mkdocs_path
207 .file_name()
208 .map_or_else(|| "mkdocs.yml".to_string(), |n| n.to_string_lossy().to_string());
209
210 let mkdocs_dir = mkdocs_path.parent().unwrap_or(Path::new("."));
212 let docs_dir = if Path::new(&mkdocs_config.docs_dir).is_absolute() {
213 PathBuf::from(&mkdocs_config.docs_dir)
214 } else {
215 mkdocs_dir.join(&mkdocs_config.docs_dir)
216 };
217
218 if !docs_dir.exists() {
219 let yaml_line = Self::find_nav_line_in_yaml(yaml_content, &mkdocs_config.docs_dir);
220 let line_info = yaml_line.map_or(String::new(), |l| format!(" (line {l})"));
221 warnings.push(LintWarning {
222 rule_name: Some(self.name().to_string()),
223 line: 1,
224 column: 1,
225 end_line: 1,
226 end_column: 1,
227 message: format!(
228 "docs_dir '{}' does not exist (from {}{})",
229 mkdocs_config.docs_dir,
230 mkdocs_path.display(),
231 line_info,
232 ),
233 severity: Severity::Warning,
234 fix: None,
235 });
236 return warnings;
237 }
238
239 let nav_paths = Self::extract_nav_paths(&mkdocs_config.nav, "");
241
242 let mut referenced_files: HashSet<PathBuf> = HashSet::new();
244
245 for (file_path, nav_location) in &nav_paths {
247 if Self::is_external_url(file_path) {
249 continue;
250 }
251
252 if Self::is_absolute_path(file_path) {
254 if self.config.absolute_links == NavValidation::Warn {
255 let yaml_line = Self::find_nav_line_in_yaml(yaml_content, file_path);
256 let line_info = yaml_line.map_or(String::new(), |l| format!(", line {l}"));
257 warnings.push(LintWarning {
258 rule_name: Some(self.name().to_string()),
259 line: 1,
260 column: 1,
261 end_line: 1,
262 end_column: 1,
263 message: format!(
264 "Absolute path in nav '{nav_location}': {file_path} (in {mkdocs_file}{line_info})"
265 ),
266 severity: Severity::Warning,
267 fix: None,
268 });
269 }
270 continue;
271 }
272
273 let normalized_path = Self::normalize_nav_path(file_path);
274
275 if self.config.not_found == NavValidation::Warn {
277 let full_path = docs_dir.join(&normalized_path);
278
279 let (actual_path, is_dir_entry) = if file_path.ends_with('/') || full_path.is_dir() {
281 let index_path = normalized_path.join("index.md");
282 (docs_dir.join(&index_path), true)
283 } else {
284 (full_path, false)
285 };
286
287 if is_dir_entry {
289 referenced_files.insert(normalized_path.join("index.md"));
290 } else {
291 referenced_files.insert(normalized_path.clone());
292 }
293
294 if !actual_path.exists() {
295 let display_path = if is_dir_entry {
296 format!(
297 "{} (resolves to {}/index.md)",
298 file_path,
299 file_path.trim_end_matches('/')
300 )
301 } else {
302 file_path.clone()
303 };
304 let yaml_line = Self::find_nav_line_in_yaml(yaml_content, file_path);
305 let line_info = yaml_line.map_or(String::new(), |l| format!(", line {l}"));
306 warnings.push(LintWarning {
307 rule_name: Some(self.name().to_string()),
308 line: 1,
309 column: 1,
310 end_line: 1,
311 end_column: 1,
312 message: format!(
313 "Nav entry '{nav_location}' points to non-existent file: {display_path} (in {mkdocs_file}{line_info})"
314 ),
315 severity: Severity::Warning,
316 fix: None,
317 });
318 }
319 } else {
320 if file_path.ends_with('/') {
322 referenced_files.insert(normalized_path.join("index.md"));
323 } else {
324 referenced_files.insert(normalized_path);
325 }
326 }
327 }
328
329 if self.config.omitted_files == NavValidation::Warn {
331 let all_docs = Self::collect_docs_files(&docs_dir);
332
333 for doc_file in all_docs {
334 if !referenced_files.contains(&doc_file) {
335 let file_name = doc_file.file_name().map(|n| n.to_string_lossy());
337 if let Some(name) = &file_name {
338 let name_lower = name.to_lowercase();
339 if (doc_file.parent().is_none() || doc_file.parent() == Some(Path::new("")))
341 && (name_lower == "index.md" || name_lower == "readme.md")
342 {
343 continue;
344 }
345 }
346
347 warnings.push(LintWarning {
348 rule_name: Some(self.name().to_string()),
349 line: 1,
350 column: 1,
351 end_line: 1,
352 end_column: 1,
353 message: format!("File not referenced in nav: {} (in {mkdocs_file})", doc_file.display()),
354 severity: Severity::Info,
355 fix: None,
356 });
357 }
358 }
359 }
360
361 warnings
362 }
363}
364
365#[derive(Debug)]
367struct MkDocsConfig {
368 docs_dir: String,
370
371 nav: Vec<NavItem>,
373}
374
375fn default_docs_dir() -> String {
376 "docs".to_string()
377}
378
379#[derive(Debug)]
385enum NavItem {
386 Path(String),
388
389 Section { name: String, children: Vec<NavItem> },
391
392 NamedPath { name: String, path: String },
394}
395
396impl NavItem {
397 fn from_yaml_value(value: &serde_yaml::Value) -> Option<NavItem> {
399 match value {
400 serde_yaml::Value::String(s) => Some(NavItem::Path(s.clone())),
401 serde_yaml::Value::Mapping(map) => {
402 if map.len() != 1 {
403 return None;
404 }
405
406 let (key, val) = map.iter().next()?;
407 let name = key.as_str()?.to_string();
408
409 match val {
410 serde_yaml::Value::String(path) => Some(NavItem::NamedPath {
411 name,
412 path: path.clone(),
413 }),
414 serde_yaml::Value::Sequence(seq) => {
415 let children: Vec<NavItem> = seq.iter().filter_map(NavItem::from_yaml_value).collect();
416 Some(NavItem::Section { name, children })
417 }
418 serde_yaml::Value::Null => {
419 Some(NavItem::Section {
421 name,
422 children: Vec::new(),
423 })
424 }
425 _ => None,
426 }
427 }
428 _ => None,
429 }
430 }
431}
432
433impl<'de> Deserialize<'de> for MkDocsConfig {
434 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
435 where
436 D: serde::de::Deserializer<'de>,
437 {
438 #[derive(Deserialize)]
439 struct RawMkDocsConfig {
440 #[serde(default = "default_docs_dir")]
441 docs_dir: String,
442 #[serde(default)]
443 nav: Option<serde_yaml::Value>,
444 }
445
446 let raw = RawMkDocsConfig::deserialize(deserializer)?;
447
448 let nav = match raw.nav {
449 Some(serde_yaml::Value::Sequence(seq)) => seq.iter().filter_map(NavItem::from_yaml_value).collect(),
450 _ => Vec::new(),
451 };
452
453 Ok(MkDocsConfig {
454 docs_dir: raw.docs_dir,
455 nav,
456 })
457 }
458}
459
460impl Rule for MD074MkDocsNav {
461 fn name(&self) -> &'static str {
462 "MD074"
463 }
464
465 fn description(&self) -> &'static str {
466 "MkDocs nav entries should point to existing files"
467 }
468
469 fn category(&self) -> RuleCategory {
470 RuleCategory::Other
471 }
472
473 fn fix_capability(&self) -> FixCapability {
474 FixCapability::Unfixable
475 }
476
477 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
478 ctx.flavor != crate::config::MarkdownFlavor::MkDocs
480 }
481
482 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
483 if ctx.flavor != crate::config::MarkdownFlavor::MkDocs {
485 return Ok(Vec::new());
486 }
487
488 let Some(source_file) = &ctx.source_file else {
490 return Ok(Vec::new());
491 };
492
493 let Some(mkdocs_path) = find_mkdocs_yml(source_file) else {
495 return Ok(Vec::new());
496 };
497
498 let mkdocs_content = match std::fs::read_to_string(&mkdocs_path) {
500 Ok(content) => content,
501 Err(e) => {
502 return Ok(vec![LintWarning {
503 rule_name: Some(self.name().to_string()),
504 line: 1,
505 column: 1,
506 end_line: 1,
507 end_column: 1,
508 message: format!("Failed to read {}: {e}", mkdocs_path.display()),
509 severity: Severity::Warning,
510 fix: None,
511 }]);
512 }
513 };
514
515 let mut hasher = DefaultHasher::new();
516 mkdocs_content.hash(&mut hasher);
517 let content_hash = hasher.finish();
518
519 if let Ok(mut cache) = VALIDATED_PROJECTS.lock() {
521 if let Some(&cached_hash) = cache.get(&mkdocs_path)
522 && cached_hash == content_hash
523 {
524 return Ok(Vec::new());
525 }
526 cache.insert(mkdocs_path.clone(), content_hash);
527 }
528 let mkdocs_config = match Self::parse_mkdocs_yml_from_str(&mkdocs_content, &mkdocs_path) {
532 Ok(config) => config,
533 Err(e) => {
534 return Ok(vec![LintWarning {
535 rule_name: Some(self.name().to_string()),
536 line: 1,
537 column: 1,
538 end_line: 1,
539 end_column: 1,
540 message: e,
541 severity: Severity::Warning,
542 fix: None,
543 }]);
544 }
545 };
546
547 Ok(self.validate_nav(&mkdocs_path, &mkdocs_config, &mkdocs_content))
549 }
550
551 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
552 Ok(ctx.content.to_string())
554 }
555
556 fn as_any(&self) -> &dyn std::any::Any {
557 self
558 }
559
560 fn default_config_section(&self) -> Option<(String, toml::Value)> {
561 let default_config = MD074Config::default();
562 let json_value = serde_json::to_value(&default_config).ok()?;
563 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
564
565 if let toml::Value::Table(table) = toml_value {
566 if !table.is_empty() {
567 Some((MD074Config::RULE_NAME.to_string(), toml::Value::Table(table)))
568 } else {
569 None
570 }
571 } else {
572 None
573 }
574 }
575
576 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
577 where
578 Self: Sized,
579 {
580 let rule_config = crate::rule_config_serde::load_rule_config::<MD074Config>(config);
581 Box::new(Self::from_config_struct(rule_config))
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use std::fs;
589 use tempfile::tempdir;
590
591 fn setup_test() {
592 MD074MkDocsNav::clear_cache();
593 }
594
595 #[test]
596 fn test_find_mkdocs_yml() {
597 setup_test();
598 let temp_dir = tempdir().unwrap();
599 let mkdocs_path = temp_dir.path().join("mkdocs.yml");
600 fs::write(&mkdocs_path, "site_name: Test").unwrap();
601
602 let subdir = temp_dir.path().join("docs");
603 fs::create_dir_all(&subdir).unwrap();
604 let file_in_subdir = subdir.join("test.md");
605
606 let found = find_mkdocs_yml(&file_in_subdir);
607 assert!(found.is_some());
608 assert_eq!(found.unwrap(), mkdocs_path.canonicalize().unwrap());
610 }
611
612 #[test]
613 fn test_find_mkdocs_yaml_extension() {
614 setup_test();
615 let temp_dir = tempdir().unwrap();
616 let mkdocs_path = temp_dir.path().join("mkdocs.yaml"); fs::write(&mkdocs_path, "site_name: Test").unwrap();
618
619 let docs_dir = temp_dir.path().join("docs");
620 fs::create_dir_all(&docs_dir).unwrap();
621 let file_in_docs = docs_dir.join("test.md");
622
623 let found = find_mkdocs_yml(&file_in_docs);
624 assert!(found.is_some(), "Should find mkdocs.yaml");
625 assert_eq!(found.unwrap(), mkdocs_path.canonicalize().unwrap());
626 }
627
628 #[test]
629 fn test_parse_simple_nav() {
630 setup_test();
631 let temp_dir = tempdir().unwrap();
632 let mkdocs_path = temp_dir.path().join("mkdocs.yml");
633
634 let mkdocs_content = r#"
635site_name: Test
636docs_dir: docs
637nav:
638 - Home: index.md
639 - Guide: guide.md
640 - Section:
641 - Page 1: section/page1.md
642 - Page 2: section/page2.md
643"#;
644 fs::write(&mkdocs_path, mkdocs_content).unwrap();
645
646 let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
647 assert_eq!(config.docs_dir, "docs");
648 assert_eq!(config.nav.len(), 3);
649
650 let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
651 assert_eq!(paths.len(), 4);
652
653 let path_strs: Vec<&str> = paths.iter().map(|(p, _)| p.as_str()).collect();
655 assert!(path_strs.contains(&"index.md"));
656 assert!(path_strs.contains(&"guide.md"));
657 assert!(path_strs.contains(&"section/page1.md"));
658 assert!(path_strs.contains(&"section/page2.md"));
659 }
660
661 #[test]
662 fn test_parse_deeply_nested_nav() {
663 setup_test();
664 let temp_dir = tempdir().unwrap();
665 let mkdocs_path = temp_dir.path().join("mkdocs.yml");
666
667 let mkdocs_content = r#"
668site_name: Test
669nav:
670 - Level 1:
671 - Level 2:
672 - Level 3:
673 - Deep Page: deep/nested/page.md
674"#;
675 fs::write(&mkdocs_path, mkdocs_content).unwrap();
676
677 let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
678 let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
679
680 assert_eq!(paths.len(), 1);
681 assert_eq!(paths[0].0, "deep/nested/page.md");
682 assert!(paths[0].1.contains("Level 1"));
683 assert!(paths[0].1.contains("Level 2"));
684 assert!(paths[0].1.contains("Level 3"));
685 }
686
687 #[test]
688 fn test_parse_nav_with_external_urls() {
689 setup_test();
690 let temp_dir = tempdir().unwrap();
691 let mkdocs_path = temp_dir.path().join("mkdocs.yml");
692
693 let mkdocs_content = r#"
694site_name: Test
695docs_dir: docs
696nav:
697 - Home: index.md
698 - GitHub: https://github.com/example/repo
699 - External: http://example.com
700 - Protocol Relative: //example.com/path
701"#;
702 fs::write(&mkdocs_path, mkdocs_content).unwrap();
703
704 let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
705 let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
706
707 assert_eq!(paths.len(), 4);
709
710 assert!(!MD074MkDocsNav::is_external_url("index.md"));
712 assert!(MD074MkDocsNav::is_external_url("https://github.com/example/repo"));
713 assert!(MD074MkDocsNav::is_external_url("http://example.com"));
714 assert!(MD074MkDocsNav::is_external_url("//example.com/path"));
715 }
716
717 #[test]
718 fn test_parse_nav_with_empty_section() {
719 setup_test();
720 let temp_dir = tempdir().unwrap();
721 let mkdocs_path = temp_dir.path().join("mkdocs.yml");
722
723 let mkdocs_content = r#"
725site_name: Test
726nav:
727 - Empty Section:
728 - Home: index.md
729"#;
730 fs::write(&mkdocs_path, mkdocs_content).unwrap();
731
732 let result = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path);
733 assert!(result.is_ok(), "Should handle empty sections");
734 }
735
736 #[test]
737 fn test_nav_not_found_validation() {
738 setup_test();
739 let temp_dir = tempdir().unwrap();
740
741 let mkdocs_content = r#"
743site_name: Test
744docs_dir: docs
745nav:
746 - Home: index.md
747 - Missing: missing.md
748"#;
749 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
750
751 let docs_dir = temp_dir.path().join("docs");
753 fs::create_dir_all(&docs_dir).unwrap();
754 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
755
756 let test_file = docs_dir.join("test.md");
758 fs::write(&test_file, "# Test").unwrap();
759
760 let rule = MD074MkDocsNav::new();
761 let ctx =
762 crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
763
764 let result = rule.check(&ctx).unwrap();
765
766 assert_eq!(result.len(), 1, "Should warn about missing nav entry. Got: {result:?}");
768 assert!(result[0].message.contains("missing.md"));
769 }
770
771 #[test]
772 fn test_absolute_links_validation() {
773 setup_test();
774 let temp_dir = tempdir().unwrap();
775
776 let mkdocs_content = r#"
777site_name: Test
778docs_dir: docs
779nav:
780 - Absolute: /absolute/path.md
781"#;
782 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
783
784 let docs_dir = temp_dir.path().join("docs");
785 fs::create_dir_all(&docs_dir).unwrap();
786 let test_file = docs_dir.join("test.md");
787 fs::write(&test_file, "# Test").unwrap();
788
789 let config = MD074Config {
790 not_found: NavValidation::Ignore,
791 omitted_files: NavValidation::Ignore,
792 absolute_links: NavValidation::Warn,
793 };
794 let rule = MD074MkDocsNav::from_config_struct(config);
795
796 let ctx =
797 crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
798
799 let result = rule.check(&ctx).unwrap();
800
801 assert_eq!(result.len(), 1, "Should warn about absolute path. Got: {result:?}");
802 assert!(result[0].message.contains("Absolute path"));
803 }
804
805 #[test]
806 fn test_omitted_files_validation() {
807 setup_test();
808 let temp_dir = tempdir().unwrap();
809
810 let mkdocs_content = r#"
811site_name: Test
812docs_dir: docs
813nav:
814 - Home: index.md
815"#;
816 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
817
818 let docs_dir = temp_dir.path().join("docs");
819 fs::create_dir_all(&docs_dir).unwrap();
820 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
821 fs::write(docs_dir.join("unlisted.md"), "# Unlisted").unwrap();
822
823 let subdir = docs_dir.join("subdir");
825 fs::create_dir_all(&subdir).unwrap();
826 fs::write(subdir.join("nested.md"), "# Nested").unwrap();
827
828 let test_file = docs_dir.join("test.md");
829 fs::write(&test_file, "# Test").unwrap();
830
831 let config = MD074Config {
832 not_found: NavValidation::Ignore,
833 omitted_files: NavValidation::Warn,
834 absolute_links: NavValidation::Ignore,
835 };
836 let rule = MD074MkDocsNav::from_config_struct(config);
837
838 let ctx =
839 crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
840
841 let result = rule.check(&ctx).unwrap();
842
843 assert!(result.len() >= 2, "Should warn about omitted files. Got: {result:?}");
846
847 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
848 assert!(
849 messages.iter().any(|m| m.contains("unlisted.md")),
850 "Should mention unlisted.md"
851 );
852 }
853
854 #[test]
855 fn test_omitted_files_with_subdirectories() {
856 setup_test();
857 let temp_dir = tempdir().unwrap();
858
859 let mkdocs_content = r#"
860site_name: Test
861docs_dir: docs
862nav:
863 - Home: index.md
864 - API:
865 - Overview: api/overview.md
866"#;
867 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
868
869 let docs_dir = temp_dir.path().join("docs");
870 fs::create_dir_all(&docs_dir).unwrap();
871 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
872
873 let api_dir = docs_dir.join("api");
874 fs::create_dir_all(&api_dir).unwrap();
875 fs::write(api_dir.join("overview.md"), "# Overview").unwrap();
876 fs::write(api_dir.join("unlisted.md"), "# Unlisted API").unwrap();
877
878 let test_file = docs_dir.join("index.md");
879
880 let config = MD074Config {
881 not_found: NavValidation::Warn,
882 omitted_files: NavValidation::Warn,
883 absolute_links: NavValidation::Ignore,
884 };
885 let rule = MD074MkDocsNav::from_config_struct(config);
886
887 let ctx =
888 crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
889
890 let result = rule.check(&ctx).unwrap();
891
892 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
894
895 assert!(
897 !messages.iter().any(|m| m.contains("overview.md")),
898 "Should NOT warn about api/overview.md (it's in nav). Got: {messages:?}"
899 );
900
901 assert!(
903 messages.iter().any(|m| m.contains("unlisted.md")),
904 "Should warn about api/unlisted.md. Got: {messages:?}"
905 );
906 }
907
908 #[test]
909 fn test_skips_non_mkdocs_flavor() {
910 setup_test();
911 let rule = MD074MkDocsNav::new();
912 let ctx = crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::Standard, None);
913
914 let result = rule.check(&ctx).unwrap();
915 assert!(result.is_empty(), "Should skip non-MkDocs flavor");
916 }
917
918 #[test]
919 fn test_skips_external_urls_in_validation() {
920 setup_test();
921 let temp_dir = tempdir().unwrap();
922
923 let mkdocs_content = r#"
924site_name: Test
925docs_dir: docs
926nav:
927 - Home: index.md
928 - GitHub: https://github.com/example
929 - Docs: http://docs.example.com
930"#;
931 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
932
933 let docs_dir = temp_dir.path().join("docs");
934 fs::create_dir_all(&docs_dir).unwrap();
935 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
936
937 let test_file = docs_dir.join("index.md");
938
939 let rule = MD074MkDocsNav::new();
940 let ctx =
941 crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
942
943 let result = rule.check(&ctx).unwrap();
944
945 assert!(
947 result.is_empty(),
948 "Should not warn about external URLs. Got: {result:?}"
949 );
950 }
951
952 #[test]
953 fn test_cache_prevents_duplicate_validation() {
954 setup_test();
955 let temp_dir = tempdir().unwrap();
956
957 let mkdocs_content = r#"
958site_name: Test
959docs_dir: docs
960nav:
961 - Home: index.md
962 - Missing: missing.md
963"#;
964 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
965
966 let docs_dir = temp_dir.path().join("docs");
967 fs::create_dir_all(&docs_dir).unwrap();
968 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
969 fs::write(docs_dir.join("other.md"), "# Other").unwrap();
970
971 let rule = MD074MkDocsNav::new();
972
973 let ctx1 = crate::lint_context::LintContext::new(
975 "# Home",
976 crate::config::MarkdownFlavor::MkDocs,
977 Some(docs_dir.join("index.md")),
978 );
979 let result1 = rule.check(&ctx1).unwrap();
980 assert_eq!(result1.len(), 1, "First check should return warnings");
981
982 let ctx2 = crate::lint_context::LintContext::new(
984 "# Other",
985 crate::config::MarkdownFlavor::MkDocs,
986 Some(docs_dir.join("other.md")),
987 );
988 let result2 = rule.check(&ctx2).unwrap();
989 assert!(result2.is_empty(), "Second check should return no warnings (cached)");
990 }
991
992 #[test]
993 fn test_cache_invalidates_when_content_changes() {
994 setup_test();
995 let temp_dir = tempdir().unwrap();
996
997 let mkdocs_content_v1 = r#"
998site_name: Test
999docs_dir: docs
1000nav:
1001 - Home: index.md
1002"#;
1003 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content_v1).unwrap();
1004
1005 let docs_dir = temp_dir.path().join("docs");
1006 fs::create_dir_all(&docs_dir).unwrap();
1007 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1008
1009 let rule = MD074MkDocsNav::new();
1010
1011 let ctx1 = crate::lint_context::LintContext::new(
1013 "# Home",
1014 crate::config::MarkdownFlavor::MkDocs,
1015 Some(docs_dir.join("index.md")),
1016 );
1017 let result1 = rule.check(&ctx1).unwrap();
1018 assert!(
1019 result1.is_empty(),
1020 "First check: valid config should produce no warnings"
1021 );
1022
1023 let mkdocs_content_v2 = r#"
1025site_name: Test
1026docs_dir: docs
1027nav:
1028 - Home: index.md
1029 - Missing: missing.md
1030"#;
1031 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content_v2).unwrap();
1032
1033 let ctx2 = crate::lint_context::LintContext::new(
1035 "# Home",
1036 crate::config::MarkdownFlavor::MkDocs,
1037 Some(docs_dir.join("index.md")),
1038 );
1039 let result2 = rule.check(&ctx2).unwrap();
1040 assert_eq!(
1041 result2.len(),
1042 1,
1043 "Second check: changed mkdocs.yml should re-validate and find missing.md"
1044 );
1045 assert!(result2[0].message.contains("missing.md"));
1046 }
1047
1048 #[test]
1049 fn test_invalid_mkdocs_yml_returns_warning() {
1050 setup_test();
1051 let temp_dir = tempdir().unwrap();
1052
1053 let mkdocs_content = "site_name: Test\nnav: [[[invalid yaml";
1055 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1056
1057 let docs_dir = temp_dir.path().join("docs");
1058 fs::create_dir_all(&docs_dir).unwrap();
1059 let test_file = docs_dir.join("test.md");
1060 fs::write(&test_file, "# Test").unwrap();
1061
1062 let rule = MD074MkDocsNav::new();
1063 let ctx =
1064 crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1065
1066 let result = rule.check(&ctx).unwrap();
1067
1068 assert_eq!(result.len(), 1, "Should return parse error warning");
1069 assert!(
1070 result[0].message.contains("Failed to parse"),
1071 "Should mention parse failure"
1072 );
1073 }
1074
1075 #[test]
1076 fn test_missing_docs_dir_returns_warning() {
1077 setup_test();
1078 let temp_dir = tempdir().unwrap();
1079
1080 let mkdocs_content = r#"
1081site_name: Test
1082docs_dir: nonexistent
1083nav:
1084 - Home: index.md
1085"#;
1086 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1087
1088 let other_dir = temp_dir.path().join("other");
1090 fs::create_dir_all(&other_dir).unwrap();
1091 let test_file = other_dir.join("test.md");
1092 fs::write(&test_file, "# Test").unwrap();
1093
1094 let rule = MD074MkDocsNav::new();
1095 let ctx =
1096 crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1097
1098 let result = rule.check(&ctx).unwrap();
1099
1100 assert_eq!(result.len(), 1, "Should warn about missing docs_dir");
1101 assert!(
1102 result[0].message.contains("does not exist"),
1103 "Should mention docs_dir doesn't exist"
1104 );
1105 }
1106
1107 #[test]
1108 fn test_default_docs_dir() {
1109 setup_test();
1110 let temp_dir = tempdir().unwrap();
1111
1112 let mkdocs_content = r#"
1114site_name: Test
1115nav:
1116 - Home: index.md
1117"#;
1118 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1119
1120 let config = MD074MkDocsNav::parse_mkdocs_yml(&temp_dir.path().join("mkdocs.yml")).unwrap();
1121 assert_eq!(config.docs_dir, "docs", "Should default to 'docs'");
1122 }
1123
1124 #[test]
1125 fn test_path_normalization() {
1126 let path1 = MD074MkDocsNav::normalize_path(Path::new("api/overview.md"));
1128 let path2 = MD074MkDocsNav::normalize_nav_path("api/overview.md");
1129 assert_eq!(path1, path2);
1130
1131 let win_path = MD074MkDocsNav::normalize_nav_path("api\\overview.md");
1133 assert_eq!(win_path, PathBuf::from("api/overview.md"));
1134 }
1135
1136 #[test]
1137 fn test_skips_hidden_files_and_directories() {
1138 setup_test();
1139 let temp_dir = tempdir().unwrap();
1140
1141 let mkdocs_content = r#"
1142site_name: Test
1143docs_dir: docs
1144nav:
1145 - Home: index.md
1146"#;
1147 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1148
1149 let docs_dir = temp_dir.path().join("docs");
1150 fs::create_dir_all(&docs_dir).unwrap();
1151 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1152
1153 fs::write(docs_dir.join(".hidden.md"), "# Hidden").unwrap();
1155 let hidden_dir = docs_dir.join(".hidden_dir");
1156 fs::create_dir_all(&hidden_dir).unwrap();
1157 fs::write(hidden_dir.join("secret.md"), "# Secret").unwrap();
1158
1159 let collected = MD074MkDocsNav::collect_docs_files(&docs_dir);
1160
1161 assert_eq!(collected.len(), 1, "Should only find index.md");
1162 assert!(
1163 !collected.iter().any(|p| p.to_string_lossy().contains("hidden")),
1164 "Should not include hidden files"
1165 );
1166 }
1167
1168 #[test]
1169 fn test_is_external_url() {
1170 assert!(MD074MkDocsNav::is_external_url("https://example.com"));
1171 assert!(MD074MkDocsNav::is_external_url("http://example.com"));
1172 assert!(MD074MkDocsNav::is_external_url("//example.com"));
1173 assert!(MD074MkDocsNav::is_external_url("ftp://files.example.com"));
1174 assert!(!MD074MkDocsNav::is_external_url("index.md"));
1175 assert!(!MD074MkDocsNav::is_external_url("path/to/file.md"));
1176 assert!(!MD074MkDocsNav::is_external_url("/absolute/path.md"));
1177 }
1178
1179 #[test]
1180 fn test_is_absolute_path() {
1181 assert!(MD074MkDocsNav::is_absolute_path("/absolute/path.md"));
1182 assert!(MD074MkDocsNav::is_absolute_path("/index.md"));
1183 assert!(!MD074MkDocsNav::is_absolute_path("relative/path.md"));
1184 assert!(!MD074MkDocsNav::is_absolute_path("index.md"));
1185 assert!(!MD074MkDocsNav::is_absolute_path("https://example.com"));
1186 }
1187
1188 #[test]
1189 fn test_directory_nav_entries() {
1190 setup_test();
1191 let temp_dir = tempdir().unwrap();
1192
1193 let mkdocs_content = r#"
1195site_name: Test
1196docs_dir: docs
1197nav:
1198 - Home: index.md
1199 - API: api/
1200"#;
1201 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1202
1203 let docs_dir = temp_dir.path().join("docs");
1204 fs::create_dir_all(&docs_dir).unwrap();
1205 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1206
1207 let api_dir = docs_dir.join("api");
1209 fs::create_dir_all(&api_dir).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_eq!(
1221 result.len(),
1222 1,
1223 "Should warn about missing api/index.md. Got: {result:?}"
1224 );
1225 assert!(result[0].message.contains("api/"), "Should mention api/ in warning");
1226 assert!(
1227 result[0].message.contains("index.md"),
1228 "Should mention index.md in warning"
1229 );
1230 }
1231
1232 #[test]
1233 fn test_directory_nav_entries_with_index() {
1234 setup_test();
1235 let temp_dir = tempdir().unwrap();
1236
1237 let mkdocs_content = r#"
1239site_name: Test
1240docs_dir: docs
1241nav:
1242 - Home: index.md
1243 - API: api/
1244"#;
1245 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1246
1247 let docs_dir = temp_dir.path().join("docs");
1248 fs::create_dir_all(&docs_dir).unwrap();
1249 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1250
1251 let api_dir = docs_dir.join("api");
1253 fs::create_dir_all(&api_dir).unwrap();
1254 fs::write(api_dir.join("index.md"), "# API").unwrap();
1255
1256 let test_file = docs_dir.join("index.md");
1257
1258 let rule = MD074MkDocsNav::new();
1259 let ctx =
1260 crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1261
1262 let result = rule.check(&ctx).unwrap();
1263
1264 assert!(
1266 result.is_empty(),
1267 "Should not warn when api/index.md exists. Got: {result:?}"
1268 );
1269 }
1270}