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