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 fn setup_test() {
569 MD074MkDocsNav::clear_cache();
570 }
571
572 #[test]
573 fn test_find_mkdocs_yml() {
574 setup_test();
575 let temp_dir = tempdir().unwrap();
576 let mkdocs_path = temp_dir.path().join("mkdocs.yml");
577 fs::write(&mkdocs_path, "site_name: Test").unwrap();
578
579 let subdir = temp_dir.path().join("docs");
580 fs::create_dir_all(&subdir).unwrap();
581 let file_in_subdir = subdir.join("test.md");
582
583 let found = find_mkdocs_yml(&file_in_subdir);
584 assert!(found.is_some());
585 assert_eq!(found.unwrap(), mkdocs_path.canonicalize().unwrap());
587 }
588
589 #[test]
590 fn test_find_mkdocs_yaml_extension() {
591 setup_test();
592 let temp_dir = tempdir().unwrap();
593 let mkdocs_path = temp_dir.path().join("mkdocs.yaml"); fs::write(&mkdocs_path, "site_name: Test").unwrap();
595
596 let docs_dir = temp_dir.path().join("docs");
597 fs::create_dir_all(&docs_dir).unwrap();
598 let file_in_docs = docs_dir.join("test.md");
599
600 let found = find_mkdocs_yml(&file_in_docs);
601 assert!(found.is_some(), "Should find mkdocs.yaml");
602 assert_eq!(found.unwrap(), mkdocs_path.canonicalize().unwrap());
603 }
604
605 #[test]
606 fn test_parse_simple_nav() {
607 setup_test();
608 let temp_dir = tempdir().unwrap();
609 let mkdocs_path = temp_dir.path().join("mkdocs.yml");
610
611 let mkdocs_content = r#"
612site_name: Test
613docs_dir: docs
614nav:
615 - Home: index.md
616 - Guide: guide.md
617 - Section:
618 - Page 1: section/page1.md
619 - Page 2: section/page2.md
620"#;
621 fs::write(&mkdocs_path, mkdocs_content).unwrap();
622
623 let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
624 assert_eq!(config.docs_dir, "docs");
625 assert_eq!(config.nav.len(), 3);
626
627 let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
628 assert_eq!(paths.len(), 4);
629
630 let path_strs: Vec<&str> = paths.iter().map(|(p, _)| p.as_str()).collect();
632 assert!(path_strs.contains(&"index.md"));
633 assert!(path_strs.contains(&"guide.md"));
634 assert!(path_strs.contains(&"section/page1.md"));
635 assert!(path_strs.contains(&"section/page2.md"));
636 }
637
638 #[test]
639 fn test_parse_deeply_nested_nav() {
640 setup_test();
641 let temp_dir = tempdir().unwrap();
642 let mkdocs_path = temp_dir.path().join("mkdocs.yml");
643
644 let mkdocs_content = r#"
645site_name: Test
646nav:
647 - Level 1:
648 - Level 2:
649 - Level 3:
650 - Deep Page: deep/nested/page.md
651"#;
652 fs::write(&mkdocs_path, mkdocs_content).unwrap();
653
654 let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
655 let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
656
657 assert_eq!(paths.len(), 1);
658 assert_eq!(paths[0].0, "deep/nested/page.md");
659 assert!(paths[0].1.contains("Level 1"));
660 assert!(paths[0].1.contains("Level 2"));
661 assert!(paths[0].1.contains("Level 3"));
662 }
663
664 #[test]
665 fn test_parse_nav_with_external_urls() {
666 setup_test();
667 let temp_dir = tempdir().unwrap();
668 let mkdocs_path = temp_dir.path().join("mkdocs.yml");
669
670 let mkdocs_content = r#"
671site_name: Test
672docs_dir: docs
673nav:
674 - Home: index.md
675 - GitHub: https://github.com/example/repo
676 - External: http://example.com
677 - Protocol Relative: //example.com/path
678"#;
679 fs::write(&mkdocs_path, mkdocs_content).unwrap();
680
681 let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
682 let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
683
684 assert_eq!(paths.len(), 4);
686
687 assert!(!MD074MkDocsNav::is_external_url("index.md"));
689 assert!(MD074MkDocsNav::is_external_url("https://github.com/example/repo"));
690 assert!(MD074MkDocsNav::is_external_url("http://example.com"));
691 assert!(MD074MkDocsNav::is_external_url("//example.com/path"));
692 }
693
694 #[test]
695 fn test_parse_nav_with_empty_section() {
696 setup_test();
697 let temp_dir = tempdir().unwrap();
698 let mkdocs_path = temp_dir.path().join("mkdocs.yml");
699
700 let mkdocs_content = r#"
702site_name: Test
703nav:
704 - Empty Section:
705 - Home: index.md
706"#;
707 fs::write(&mkdocs_path, mkdocs_content).unwrap();
708
709 let result = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path);
710 assert!(result.is_ok(), "Should handle empty sections");
711 }
712
713 #[test]
714 fn test_nav_not_found_validation() {
715 setup_test();
716 let temp_dir = tempdir().unwrap();
717
718 let mkdocs_content = r#"
720site_name: Test
721docs_dir: docs
722nav:
723 - Home: index.md
724 - Missing: missing.md
725"#;
726 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
727
728 let docs_dir = temp_dir.path().join("docs");
730 fs::create_dir_all(&docs_dir).unwrap();
731 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
732
733 let test_file = docs_dir.join("test.md");
735 fs::write(&test_file, "# Test").unwrap();
736
737 let rule = MD074MkDocsNav::new();
738 let ctx =
739 crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
740
741 let result = rule.check(&ctx).unwrap();
742
743 assert_eq!(result.len(), 1, "Should warn about missing nav entry. Got: {result:?}");
745 assert!(result[0].message.contains("missing.md"));
746 }
747
748 #[test]
749 fn test_absolute_links_validation() {
750 setup_test();
751 let temp_dir = tempdir().unwrap();
752
753 let mkdocs_content = r#"
754site_name: Test
755docs_dir: docs
756nav:
757 - Absolute: /absolute/path.md
758"#;
759 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
760
761 let docs_dir = temp_dir.path().join("docs");
762 fs::create_dir_all(&docs_dir).unwrap();
763 let test_file = docs_dir.join("test.md");
764 fs::write(&test_file, "# Test").unwrap();
765
766 let config = MD074Config {
767 not_found: NavValidation::Ignore,
768 omitted_files: NavValidation::Ignore,
769 absolute_links: NavValidation::Warn,
770 };
771 let rule = MD074MkDocsNav::from_config_struct(config);
772
773 let ctx =
774 crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
775
776 let result = rule.check(&ctx).unwrap();
777
778 assert_eq!(result.len(), 1, "Should warn about absolute path. Got: {result:?}");
779 assert!(result[0].message.contains("Absolute path"));
780 }
781
782 #[test]
783 fn test_omitted_files_validation() {
784 setup_test();
785 let temp_dir = tempdir().unwrap();
786
787 let mkdocs_content = r#"
788site_name: Test
789docs_dir: docs
790nav:
791 - Home: index.md
792"#;
793 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
794
795 let docs_dir = temp_dir.path().join("docs");
796 fs::create_dir_all(&docs_dir).unwrap();
797 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
798 fs::write(docs_dir.join("unlisted.md"), "# Unlisted").unwrap();
799
800 let subdir = docs_dir.join("subdir");
802 fs::create_dir_all(&subdir).unwrap();
803 fs::write(subdir.join("nested.md"), "# Nested").unwrap();
804
805 let test_file = docs_dir.join("test.md");
806 fs::write(&test_file, "# Test").unwrap();
807
808 let config = MD074Config {
809 not_found: NavValidation::Ignore,
810 omitted_files: NavValidation::Warn,
811 absolute_links: NavValidation::Ignore,
812 };
813 let rule = MD074MkDocsNav::from_config_struct(config);
814
815 let ctx =
816 crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
817
818 let result = rule.check(&ctx).unwrap();
819
820 assert!(result.len() >= 2, "Should warn about omitted files. Got: {result:?}");
823
824 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
825 assert!(
826 messages.iter().any(|m| m.contains("unlisted.md")),
827 "Should mention unlisted.md"
828 );
829 }
830
831 #[test]
832 fn test_omitted_files_with_subdirectories() {
833 setup_test();
834 let temp_dir = tempdir().unwrap();
835
836 let mkdocs_content = r#"
837site_name: Test
838docs_dir: docs
839nav:
840 - Home: index.md
841 - API:
842 - Overview: api/overview.md
843"#;
844 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
845
846 let docs_dir = temp_dir.path().join("docs");
847 fs::create_dir_all(&docs_dir).unwrap();
848 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
849
850 let api_dir = docs_dir.join("api");
851 fs::create_dir_all(&api_dir).unwrap();
852 fs::write(api_dir.join("overview.md"), "# Overview").unwrap();
853 fs::write(api_dir.join("unlisted.md"), "# Unlisted API").unwrap();
854
855 let test_file = docs_dir.join("index.md");
856
857 let config = MD074Config {
858 not_found: NavValidation::Warn,
859 omitted_files: NavValidation::Warn,
860 absolute_links: NavValidation::Ignore,
861 };
862 let rule = MD074MkDocsNav::from_config_struct(config);
863
864 let ctx =
865 crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
866
867 let result = rule.check(&ctx).unwrap();
868
869 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
871
872 assert!(
874 !messages.iter().any(|m| m.contains("overview.md")),
875 "Should NOT warn about api/overview.md (it's in nav). Got: {messages:?}"
876 );
877
878 assert!(
880 messages.iter().any(|m| m.contains("unlisted.md")),
881 "Should warn about api/unlisted.md. Got: {messages:?}"
882 );
883 }
884
885 #[test]
886 fn test_skips_non_mkdocs_flavor() {
887 setup_test();
888 let rule = MD074MkDocsNav::new();
889 let ctx = crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::Standard, None);
890
891 let result = rule.check(&ctx).unwrap();
892 assert!(result.is_empty(), "Should skip non-MkDocs flavor");
893 }
894
895 #[test]
896 fn test_skips_external_urls_in_validation() {
897 setup_test();
898 let temp_dir = tempdir().unwrap();
899
900 let mkdocs_content = r#"
901site_name: Test
902docs_dir: docs
903nav:
904 - Home: index.md
905 - GitHub: https://github.com/example
906 - Docs: http://docs.example.com
907"#;
908 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
909
910 let docs_dir = temp_dir.path().join("docs");
911 fs::create_dir_all(&docs_dir).unwrap();
912 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
913
914 let test_file = docs_dir.join("index.md");
915
916 let rule = MD074MkDocsNav::new();
917 let ctx =
918 crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
919
920 let result = rule.check(&ctx).unwrap();
921
922 assert!(
924 result.is_empty(),
925 "Should not warn about external URLs. Got: {result:?}"
926 );
927 }
928
929 #[test]
930 fn test_cache_prevents_duplicate_validation() {
931 setup_test();
932 let temp_dir = tempdir().unwrap();
933
934 let mkdocs_content = r#"
935site_name: Test
936docs_dir: docs
937nav:
938 - Home: index.md
939 - Missing: missing.md
940"#;
941 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
942
943 let docs_dir = temp_dir.path().join("docs");
944 fs::create_dir_all(&docs_dir).unwrap();
945 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
946 fs::write(docs_dir.join("other.md"), "# Other").unwrap();
947
948 let rule = MD074MkDocsNav::new();
949
950 let ctx1 = crate::lint_context::LintContext::new(
952 "# Home",
953 crate::config::MarkdownFlavor::MkDocs,
954 Some(docs_dir.join("index.md")),
955 );
956 let result1 = rule.check(&ctx1).unwrap();
957 assert_eq!(result1.len(), 1, "First check should return warnings");
958
959 let ctx2 = crate::lint_context::LintContext::new(
961 "# Other",
962 crate::config::MarkdownFlavor::MkDocs,
963 Some(docs_dir.join("other.md")),
964 );
965 let result2 = rule.check(&ctx2).unwrap();
966 assert!(result2.is_empty(), "Second check should return no warnings (cached)");
967 }
968
969 #[test]
970 fn test_cache_invalidates_when_content_changes() {
971 setup_test();
972 let temp_dir = tempdir().unwrap();
973
974 let mkdocs_content_v1 = r#"
975site_name: Test
976docs_dir: docs
977nav:
978 - Home: index.md
979"#;
980 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content_v1).unwrap();
981
982 let docs_dir = temp_dir.path().join("docs");
983 fs::create_dir_all(&docs_dir).unwrap();
984 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
985
986 let rule = MD074MkDocsNav::new();
987
988 let ctx1 = crate::lint_context::LintContext::new(
990 "# Home",
991 crate::config::MarkdownFlavor::MkDocs,
992 Some(docs_dir.join("index.md")),
993 );
994 let result1 = rule.check(&ctx1).unwrap();
995 assert!(
996 result1.is_empty(),
997 "First check: valid config should produce no warnings"
998 );
999
1000 let mkdocs_content_v2 = r#"
1002site_name: Test
1003docs_dir: docs
1004nav:
1005 - Home: index.md
1006 - Missing: missing.md
1007"#;
1008 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content_v2).unwrap();
1009
1010 let ctx2 = crate::lint_context::LintContext::new(
1012 "# Home",
1013 crate::config::MarkdownFlavor::MkDocs,
1014 Some(docs_dir.join("index.md")),
1015 );
1016 let result2 = rule.check(&ctx2).unwrap();
1017 assert_eq!(
1018 result2.len(),
1019 1,
1020 "Second check: changed mkdocs.yml should re-validate and find missing.md"
1021 );
1022 assert!(result2[0].message.contains("missing.md"));
1023 }
1024
1025 #[test]
1026 fn test_invalid_mkdocs_yml_returns_warning() {
1027 setup_test();
1028 let temp_dir = tempdir().unwrap();
1029
1030 let mkdocs_content = "site_name: Test\nnav: [[[invalid yaml";
1032 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1033
1034 let docs_dir = temp_dir.path().join("docs");
1035 fs::create_dir_all(&docs_dir).unwrap();
1036 let test_file = docs_dir.join("test.md");
1037 fs::write(&test_file, "# Test").unwrap();
1038
1039 let rule = MD074MkDocsNav::new();
1040 let ctx =
1041 crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1042
1043 let result = rule.check(&ctx).unwrap();
1044
1045 assert_eq!(result.len(), 1, "Should return parse error warning");
1046 assert!(
1047 result[0].message.contains("Failed to parse"),
1048 "Should mention parse failure"
1049 );
1050 }
1051
1052 #[test]
1053 fn test_missing_docs_dir_returns_warning() {
1054 setup_test();
1055 let temp_dir = tempdir().unwrap();
1056
1057 let mkdocs_content = r#"
1058site_name: Test
1059docs_dir: nonexistent
1060nav:
1061 - Home: index.md
1062"#;
1063 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1064
1065 let other_dir = temp_dir.path().join("other");
1067 fs::create_dir_all(&other_dir).unwrap();
1068 let test_file = other_dir.join("test.md");
1069 fs::write(&test_file, "# Test").unwrap();
1070
1071 let rule = MD074MkDocsNav::new();
1072 let ctx =
1073 crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1074
1075 let result = rule.check(&ctx).unwrap();
1076
1077 assert_eq!(result.len(), 1, "Should warn about missing docs_dir");
1078 assert!(
1079 result[0].message.contains("does not exist"),
1080 "Should mention docs_dir doesn't exist"
1081 );
1082 }
1083
1084 #[test]
1085 fn test_default_docs_dir() {
1086 setup_test();
1087 let temp_dir = tempdir().unwrap();
1088
1089 let mkdocs_content = r#"
1091site_name: Test
1092nav:
1093 - Home: index.md
1094"#;
1095 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1096
1097 let config = MD074MkDocsNav::parse_mkdocs_yml(&temp_dir.path().join("mkdocs.yml")).unwrap();
1098 assert_eq!(config.docs_dir, "docs", "Should default to 'docs'");
1099 }
1100
1101 #[test]
1102 fn test_path_normalization() {
1103 let path1 = MD074MkDocsNav::normalize_path(Path::new("api/overview.md"));
1105 let path2 = MD074MkDocsNav::normalize_nav_path("api/overview.md");
1106 assert_eq!(path1, path2);
1107
1108 let win_path = MD074MkDocsNav::normalize_nav_path("api\\overview.md");
1110 assert_eq!(win_path, PathBuf::from("api/overview.md"));
1111 }
1112
1113 #[test]
1114 fn test_skips_hidden_files_and_directories() {
1115 setup_test();
1116 let temp_dir = tempdir().unwrap();
1117
1118 let mkdocs_content = r#"
1119site_name: Test
1120docs_dir: docs
1121nav:
1122 - Home: index.md
1123"#;
1124 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1125
1126 let docs_dir = temp_dir.path().join("docs");
1127 fs::create_dir_all(&docs_dir).unwrap();
1128 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1129
1130 fs::write(docs_dir.join(".hidden.md"), "# Hidden").unwrap();
1132 let hidden_dir = docs_dir.join(".hidden_dir");
1133 fs::create_dir_all(&hidden_dir).unwrap();
1134 fs::write(hidden_dir.join("secret.md"), "# Secret").unwrap();
1135
1136 let collected = MD074MkDocsNav::collect_docs_files(&docs_dir);
1137
1138 assert_eq!(collected.len(), 1, "Should only find index.md");
1139 assert!(
1140 !collected.iter().any(|p| p.to_string_lossy().contains("hidden")),
1141 "Should not include hidden files"
1142 );
1143 }
1144
1145 #[test]
1146 fn test_is_external_url() {
1147 assert!(MD074MkDocsNav::is_external_url("https://example.com"));
1148 assert!(MD074MkDocsNav::is_external_url("http://example.com"));
1149 assert!(MD074MkDocsNav::is_external_url("//example.com"));
1150 assert!(MD074MkDocsNav::is_external_url("ftp://files.example.com"));
1151 assert!(!MD074MkDocsNav::is_external_url("index.md"));
1152 assert!(!MD074MkDocsNav::is_external_url("path/to/file.md"));
1153 assert!(!MD074MkDocsNav::is_external_url("/absolute/path.md"));
1154 }
1155
1156 #[test]
1157 fn test_is_absolute_path() {
1158 assert!(MD074MkDocsNav::is_absolute_path("/absolute/path.md"));
1159 assert!(MD074MkDocsNav::is_absolute_path("/index.md"));
1160 assert!(!MD074MkDocsNav::is_absolute_path("relative/path.md"));
1161 assert!(!MD074MkDocsNav::is_absolute_path("index.md"));
1162 assert!(!MD074MkDocsNav::is_absolute_path("https://example.com"));
1163 }
1164
1165 #[test]
1166 fn test_directory_nav_entries() {
1167 setup_test();
1168 let temp_dir = tempdir().unwrap();
1169
1170 let mkdocs_content = r#"
1172site_name: Test
1173docs_dir: docs
1174nav:
1175 - Home: index.md
1176 - API: api/
1177"#;
1178 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1179
1180 let docs_dir = temp_dir.path().join("docs");
1181 fs::create_dir_all(&docs_dir).unwrap();
1182 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1183
1184 let api_dir = docs_dir.join("api");
1186 fs::create_dir_all(&api_dir).unwrap();
1187
1188 let test_file = docs_dir.join("index.md");
1189
1190 let rule = MD074MkDocsNav::new();
1191 let ctx =
1192 crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1193
1194 let result = rule.check(&ctx).unwrap();
1195
1196 assert_eq!(
1198 result.len(),
1199 1,
1200 "Should warn about missing api/index.md. Got: {result:?}"
1201 );
1202 assert!(result[0].message.contains("api/"), "Should mention api/ in warning");
1203 assert!(
1204 result[0].message.contains("index.md"),
1205 "Should mention index.md in warning"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_directory_nav_entries_with_index() {
1211 setup_test();
1212 let temp_dir = tempdir().unwrap();
1213
1214 let mkdocs_content = r#"
1216site_name: Test
1217docs_dir: docs
1218nav:
1219 - Home: index.md
1220 - API: api/
1221"#;
1222 fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
1223
1224 let docs_dir = temp_dir.path().join("docs");
1225 fs::create_dir_all(&docs_dir).unwrap();
1226 fs::write(docs_dir.join("index.md"), "# Home").unwrap();
1227
1228 let api_dir = docs_dir.join("api");
1230 fs::create_dir_all(&api_dir).unwrap();
1231 fs::write(api_dir.join("index.md"), "# API").unwrap();
1232
1233 let test_file = docs_dir.join("index.md");
1234
1235 let rule = MD074MkDocsNav::new();
1236 let ctx =
1237 crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
1238
1239 let result = rule.check(&ctx).unwrap();
1240
1241 assert!(
1243 result.is_empty(),
1244 "Should not warn when api/index.md exists. Got: {result:?}"
1245 );
1246 }
1247}