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