use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::RuleConfig;
use crate::utils::mkdocs_config::find_mkdocs_yml;
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
mod md074_config;
pub use md074_config::{MD074Config, NavValidation};
static VALIDATED_PROJECTS: LazyLock<Mutex<HashMap<PathBuf, u64>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone)]
pub struct MD074MkDocsNav {
config: MD074Config,
}
impl Default for MD074MkDocsNav {
fn default() -> Self {
Self::new()
}
}
impl MD074MkDocsNav {
pub fn new() -> Self {
Self {
config: MD074Config::default(),
}
}
pub fn from_config_struct(config: MD074Config) -> Self {
Self { config }
}
#[cfg(test)]
pub fn clear_cache() {
if let Ok(mut cache) = VALIDATED_PROJECTS.lock() {
cache.clear();
}
}
#[cfg(test)]
fn parse_mkdocs_yml(path: &Path) -> Result<MkDocsConfig, String> {
let content = std::fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
Self::parse_mkdocs_yml_from_str(&content, path)
}
fn parse_mkdocs_yml_from_str(content: &str, path: &Path) -> Result<MkDocsConfig, String> {
serde_yml::from_str(content).map_err(|e| format!("Failed to parse {}: {e}", path.display()))
}
fn extract_nav_paths(nav: &[NavItem], prefix: &str) -> Vec<(String, String)> {
let mut paths = Vec::new();
for item in nav {
match item {
NavItem::Path(path) => {
let nav_path = if prefix.is_empty() {
path.clone()
} else {
format!("{prefix} > {path}")
};
paths.push((path.clone(), nav_path));
}
NavItem::Section { name, children } => {
let new_prefix = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix} > {name}")
};
paths.extend(Self::extract_nav_paths(children, &new_prefix));
}
NavItem::NamedPath { name, path } => {
let nav_path = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix} > {name}")
};
paths.push((path.clone(), nav_path));
}
}
}
paths
}
fn collect_docs_files(docs_dir: &Path) -> HashSet<PathBuf> {
Self::collect_docs_files_recursive(docs_dir, docs_dir)
}
fn collect_docs_files_recursive(current_dir: &Path, root_docs_dir: &Path) -> HashSet<PathBuf> {
let mut files = HashSet::new();
let entries = match std::fs::read_dir(current_dir) {
Ok(entries) => entries,
Err(_) => return files,
};
for entry in entries.flatten() {
let path = entry.path();
if path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
continue;
}
if path.is_dir() {
files.extend(Self::collect_docs_files_recursive(&path, root_docs_dir));
} else if path.is_file()
&& let Some(ext) = path.extension()
{
let ext_lower = ext.to_string_lossy().to_lowercase();
if ext_lower == "md" || ext_lower == "markdown" {
if let Ok(relative) = path.strip_prefix(root_docs_dir) {
let normalized = Self::normalize_path(relative);
files.insert(normalized);
}
}
}
}
files
}
fn normalize_path(path: &Path) -> PathBuf {
let path_str = path.to_string_lossy();
PathBuf::from(path_str.replace('\\', "/"))
}
fn normalize_nav_path(path: &str) -> PathBuf {
PathBuf::from(path.replace('\\', "/"))
}
fn is_external_url(path: &str) -> bool {
path.starts_with("http://") || path.starts_with("https://") || path.starts_with("//") || path.contains("://")
}
fn is_absolute_path(path: &str) -> bool {
path.starts_with('/')
}
fn strip_yaml_quotes(s: &str) -> &str {
if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
&s[1..s.len() - 1]
} else {
s
}
}
fn find_nav_line_in_yaml(yaml_content: &str, file_path: &str) -> Option<usize> {
for (idx, line) in yaml_content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
if let Some(rest) = trimmed.strip_prefix("- ")
&& Self::strip_yaml_quotes(rest.trim()) == file_path
{
return Some(idx + 1);
}
if let Some(colon_pos) = trimmed.find(": ") {
let value = trimmed[colon_pos + 2..].trim();
if Self::strip_yaml_quotes(value) == file_path {
return Some(idx + 1);
}
}
}
None
}
fn validate_nav(&self, mkdocs_path: &Path, mkdocs_config: &MkDocsConfig, yaml_content: &str) -> Vec<LintWarning> {
let mut warnings = Vec::new();
let mkdocs_file = mkdocs_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "mkdocs.yml".to_string());
let mkdocs_dir = mkdocs_path.parent().unwrap_or(Path::new("."));
let docs_dir = if Path::new(&mkdocs_config.docs_dir).is_absolute() {
PathBuf::from(&mkdocs_config.docs_dir)
} else {
mkdocs_dir.join(&mkdocs_config.docs_dir)
};
if !docs_dir.exists() {
let yaml_line = Self::find_nav_line_in_yaml(yaml_content, &mkdocs_config.docs_dir);
let line_info = yaml_line.map_or(String::new(), |l| format!(" (line {l})"));
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: 1,
column: 1,
end_line: 1,
end_column: 1,
message: format!(
"docs_dir '{}' does not exist (from {}{})",
mkdocs_config.docs_dir,
mkdocs_path.display(),
line_info,
),
severity: Severity::Warning,
fix: None,
});
return warnings;
}
let nav_paths = Self::extract_nav_paths(&mkdocs_config.nav, "");
let mut referenced_files: HashSet<PathBuf> = HashSet::new();
for (file_path, nav_location) in &nav_paths {
if Self::is_external_url(file_path) {
continue;
}
if Self::is_absolute_path(file_path) {
if self.config.absolute_links == NavValidation::Warn {
let yaml_line = Self::find_nav_line_in_yaml(yaml_content, file_path);
let line_info = yaml_line.map_or(String::new(), |l| format!(", line {l}"));
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: 1,
column: 1,
end_line: 1,
end_column: 1,
message: format!(
"Absolute path in nav '{nav_location}': {file_path} (in {mkdocs_file}{line_info})"
),
severity: Severity::Warning,
fix: None,
});
}
continue;
}
let normalized_path = Self::normalize_nav_path(file_path);
if self.config.not_found == NavValidation::Warn {
let full_path = docs_dir.join(&normalized_path);
let (actual_path, is_dir_entry) = if file_path.ends_with('/') || full_path.is_dir() {
let index_path = normalized_path.join("index.md");
(docs_dir.join(&index_path), true)
} else {
(full_path, false)
};
if is_dir_entry {
referenced_files.insert(normalized_path.join("index.md"));
} else {
referenced_files.insert(normalized_path.clone());
}
if !actual_path.exists() {
let display_path = if is_dir_entry {
format!(
"{} (resolves to {}/index.md)",
file_path,
file_path.trim_end_matches('/')
)
} else {
file_path.to_string()
};
let yaml_line = Self::find_nav_line_in_yaml(yaml_content, file_path);
let line_info = yaml_line.map_or(String::new(), |l| format!(", line {l}"));
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: 1,
column: 1,
end_line: 1,
end_column: 1,
message: format!(
"Nav entry '{nav_location}' points to non-existent file: {display_path} (in {mkdocs_file}{line_info})"
),
severity: Severity::Warning,
fix: None,
});
}
} else {
if file_path.ends_with('/') {
referenced_files.insert(normalized_path.join("index.md"));
} else {
referenced_files.insert(normalized_path);
}
}
}
if self.config.omitted_files == NavValidation::Warn {
let all_docs = Self::collect_docs_files(&docs_dir);
for doc_file in all_docs {
if !referenced_files.contains(&doc_file) {
let file_name = doc_file.file_name().map(|n| n.to_string_lossy());
if let Some(name) = &file_name {
let name_lower = name.to_lowercase();
if (doc_file.parent().is_none() || doc_file.parent() == Some(Path::new("")))
&& (name_lower == "index.md" || name_lower == "readme.md")
{
continue;
}
}
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: 1,
column: 1,
end_line: 1,
end_column: 1,
message: format!("File not referenced in nav: {} (in {mkdocs_file})", doc_file.display()),
severity: Severity::Info,
fix: None,
});
}
}
}
warnings
}
}
#[derive(Debug)]
struct MkDocsConfig {
docs_dir: String,
nav: Vec<NavItem>,
}
fn default_docs_dir() -> String {
"docs".to_string()
}
#[derive(Debug)]
enum NavItem {
Path(String),
Section { name: String, children: Vec<NavItem> },
NamedPath { name: String, path: String },
}
impl NavItem {
fn from_yaml_value(value: &serde_yml::Value) -> Option<NavItem> {
match value {
serde_yml::Value::String(s) => Some(NavItem::Path(s.clone())),
serde_yml::Value::Mapping(map) => {
if map.len() != 1 {
return None;
}
let (key, val) = map.iter().next()?;
let name = key.as_str()?.to_string();
match val {
serde_yml::Value::String(path) => Some(NavItem::NamedPath {
name,
path: path.clone(),
}),
serde_yml::Value::Sequence(seq) => {
let children: Vec<NavItem> = seq.iter().filter_map(NavItem::from_yaml_value).collect();
Some(NavItem::Section { name, children })
}
serde_yml::Value::Null => {
Some(NavItem::Section {
name,
children: Vec::new(),
})
}
_ => None,
}
}
_ => None,
}
}
}
impl<'de> Deserialize<'de> for MkDocsConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
#[derive(Deserialize)]
struct RawMkDocsConfig {
#[serde(default = "default_docs_dir")]
docs_dir: String,
#[serde(default)]
nav: Option<serde_yml::Value>,
}
let raw = RawMkDocsConfig::deserialize(deserializer)?;
let nav = match raw.nav {
Some(serde_yml::Value::Sequence(seq)) => seq.iter().filter_map(NavItem::from_yaml_value).collect(),
_ => Vec::new(),
};
Ok(MkDocsConfig {
docs_dir: raw.docs_dir,
nav,
})
}
}
impl Rule for MD074MkDocsNav {
fn name(&self) -> &'static str {
"MD074"
}
fn description(&self) -> &'static str {
"MkDocs nav entries should point to existing files"
}
fn category(&self) -> RuleCategory {
RuleCategory::Other
}
fn fix_capability(&self) -> FixCapability {
FixCapability::Unfixable
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.flavor != crate::config::MarkdownFlavor::MkDocs
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
if ctx.flavor != crate::config::MarkdownFlavor::MkDocs {
return Ok(Vec::new());
}
let Some(source_file) = &ctx.source_file else {
return Ok(Vec::new());
};
let Some(mkdocs_path) = find_mkdocs_yml(source_file) else {
return Ok(Vec::new());
};
let mkdocs_content = match std::fs::read_to_string(&mkdocs_path) {
Ok(content) => content,
Err(e) => {
return Ok(vec![LintWarning {
rule_name: Some(self.name().to_string()),
line: 1,
column: 1,
end_line: 1,
end_column: 1,
message: format!("Failed to read {}: {e}", mkdocs_path.display()),
severity: Severity::Warning,
fix: None,
}]);
}
};
let mut hasher = DefaultHasher::new();
mkdocs_content.hash(&mut hasher);
let content_hash = hasher.finish();
if let Ok(mut cache) = VALIDATED_PROJECTS.lock() {
if let Some(&cached_hash) = cache.get(&mkdocs_path)
&& cached_hash == content_hash
{
return Ok(Vec::new());
}
cache.insert(mkdocs_path.clone(), content_hash);
}
let mkdocs_config = match Self::parse_mkdocs_yml_from_str(&mkdocs_content, &mkdocs_path) {
Ok(config) => config,
Err(e) => {
return Ok(vec![LintWarning {
rule_name: Some(self.name().to_string()),
line: 1,
column: 1,
end_line: 1,
end_column: 1,
message: e,
severity: Severity::Warning,
fix: None,
}]);
}
};
Ok(self.validate_nav(&mkdocs_path, &mkdocs_config, &mkdocs_content))
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
Ok(ctx.content.to_string())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let default_config = MD074Config::default();
let json_value = serde_json::to_value(&default_config).ok()?;
let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
if let toml::Value::Table(table) = toml_value {
if !table.is_empty() {
Some((MD074Config::RULE_NAME.to_string(), toml::Value::Table(table)))
} else {
None
}
} else {
None
}
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD074Config>(config);
Box::new(Self::from_config_struct(rule_config))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn setup_test() {
MD074MkDocsNav::clear_cache();
}
#[test]
fn test_find_mkdocs_yml() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_path = temp_dir.path().join("mkdocs.yml");
fs::write(&mkdocs_path, "site_name: Test").unwrap();
let subdir = temp_dir.path().join("docs");
fs::create_dir_all(&subdir).unwrap();
let file_in_subdir = subdir.join("test.md");
let found = find_mkdocs_yml(&file_in_subdir);
assert!(found.is_some());
assert_eq!(found.unwrap(), mkdocs_path.canonicalize().unwrap());
}
#[test]
fn test_find_mkdocs_yaml_extension() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_path = temp_dir.path().join("mkdocs.yaml"); fs::write(&mkdocs_path, "site_name: Test").unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
let file_in_docs = docs_dir.join("test.md");
let found = find_mkdocs_yml(&file_in_docs);
assert!(found.is_some(), "Should find mkdocs.yaml");
assert_eq!(found.unwrap(), mkdocs_path.canonicalize().unwrap());
}
#[test]
fn test_parse_simple_nav() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_path = temp_dir.path().join("mkdocs.yml");
let mkdocs_content = r#"
site_name: Test
docs_dir: docs
nav:
- Home: index.md
- Guide: guide.md
- Section:
- Page 1: section/page1.md
- Page 2: section/page2.md
"#;
fs::write(&mkdocs_path, mkdocs_content).unwrap();
let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
assert_eq!(config.docs_dir, "docs");
assert_eq!(config.nav.len(), 3);
let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
assert_eq!(paths.len(), 4);
let path_strs: Vec<&str> = paths.iter().map(|(p, _)| p.as_str()).collect();
assert!(path_strs.contains(&"index.md"));
assert!(path_strs.contains(&"guide.md"));
assert!(path_strs.contains(&"section/page1.md"));
assert!(path_strs.contains(&"section/page2.md"));
}
#[test]
fn test_parse_deeply_nested_nav() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_path = temp_dir.path().join("mkdocs.yml");
let mkdocs_content = r#"
site_name: Test
nav:
- Level 1:
- Level 2:
- Level 3:
- Deep Page: deep/nested/page.md
"#;
fs::write(&mkdocs_path, mkdocs_content).unwrap();
let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].0, "deep/nested/page.md");
assert!(paths[0].1.contains("Level 1"));
assert!(paths[0].1.contains("Level 2"));
assert!(paths[0].1.contains("Level 3"));
}
#[test]
fn test_parse_nav_with_external_urls() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_path = temp_dir.path().join("mkdocs.yml");
let mkdocs_content = r#"
site_name: Test
docs_dir: docs
nav:
- Home: index.md
- GitHub: https://github.com/example/repo
- External: http://example.com
- Protocol Relative: //example.com/path
"#;
fs::write(&mkdocs_path, mkdocs_content).unwrap();
let config = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path).unwrap();
let paths = MD074MkDocsNav::extract_nav_paths(&config.nav, "");
assert_eq!(paths.len(), 4);
assert!(!MD074MkDocsNav::is_external_url("index.md"));
assert!(MD074MkDocsNav::is_external_url("https://github.com/example/repo"));
assert!(MD074MkDocsNav::is_external_url("http://example.com"));
assert!(MD074MkDocsNav::is_external_url("//example.com/path"));
}
#[test]
fn test_parse_nav_with_empty_section() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_path = temp_dir.path().join("mkdocs.yml");
let mkdocs_content = r#"
site_name: Test
nav:
- Empty Section:
- Home: index.md
"#;
fs::write(&mkdocs_path, mkdocs_content).unwrap();
let result = MD074MkDocsNav::parse_mkdocs_yml(&mkdocs_path);
assert!(result.is_ok(), "Should handle empty sections");
}
#[test]
fn test_nav_not_found_validation() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content = r#"
site_name: Test
docs_dir: docs
nav:
- Home: index.md
- Missing: missing.md
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
fs::write(docs_dir.join("index.md"), "# Home").unwrap();
let test_file = docs_dir.join("test.md");
fs::write(&test_file, "# Test").unwrap();
let rule = MD074MkDocsNav::new();
let ctx =
crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should warn about missing nav entry. Got: {result:?}");
assert!(result[0].message.contains("missing.md"));
}
#[test]
fn test_absolute_links_validation() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content = r#"
site_name: Test
docs_dir: docs
nav:
- Absolute: /absolute/path.md
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
let test_file = docs_dir.join("test.md");
fs::write(&test_file, "# Test").unwrap();
let config = MD074Config {
not_found: NavValidation::Ignore,
omitted_files: NavValidation::Ignore,
absolute_links: NavValidation::Warn,
};
let rule = MD074MkDocsNav::from_config_struct(config);
let ctx =
crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should warn about absolute path. Got: {result:?}");
assert!(result[0].message.contains("Absolute path"));
}
#[test]
fn test_omitted_files_validation() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content = r#"
site_name: Test
docs_dir: docs
nav:
- Home: index.md
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
fs::write(docs_dir.join("index.md"), "# Home").unwrap();
fs::write(docs_dir.join("unlisted.md"), "# Unlisted").unwrap();
let subdir = docs_dir.join("subdir");
fs::create_dir_all(&subdir).unwrap();
fs::write(subdir.join("nested.md"), "# Nested").unwrap();
let test_file = docs_dir.join("test.md");
fs::write(&test_file, "# Test").unwrap();
let config = MD074Config {
not_found: NavValidation::Ignore,
omitted_files: NavValidation::Warn,
absolute_links: NavValidation::Ignore,
};
let rule = MD074MkDocsNav::from_config_struct(config);
let ctx =
crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
let result = rule.check(&ctx).unwrap();
assert!(result.len() >= 2, "Should warn about omitted files. Got: {result:?}");
let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
assert!(
messages.iter().any(|m| m.contains("unlisted.md")),
"Should mention unlisted.md"
);
}
#[test]
fn test_omitted_files_with_subdirectories() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content = r#"
site_name: Test
docs_dir: docs
nav:
- Home: index.md
- API:
- Overview: api/overview.md
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
fs::write(docs_dir.join("index.md"), "# Home").unwrap();
let api_dir = docs_dir.join("api");
fs::create_dir_all(&api_dir).unwrap();
fs::write(api_dir.join("overview.md"), "# Overview").unwrap();
fs::write(api_dir.join("unlisted.md"), "# Unlisted API").unwrap();
let test_file = docs_dir.join("index.md");
let config = MD074Config {
not_found: NavValidation::Warn,
omitted_files: NavValidation::Warn,
absolute_links: NavValidation::Ignore,
};
let rule = MD074MkDocsNav::from_config_struct(config);
let ctx =
crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
let result = rule.check(&ctx).unwrap();
let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
assert!(
!messages.iter().any(|m| m.contains("overview.md")),
"Should NOT warn about api/overview.md (it's in nav). Got: {messages:?}"
);
assert!(
messages.iter().any(|m| m.contains("unlisted.md")),
"Should warn about api/unlisted.md. Got: {messages:?}"
);
}
#[test]
fn test_skips_non_mkdocs_flavor() {
setup_test();
let rule = MD074MkDocsNav::new();
let ctx = crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should skip non-MkDocs flavor");
}
#[test]
fn test_skips_external_urls_in_validation() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content = r#"
site_name: Test
docs_dir: docs
nav:
- Home: index.md
- GitHub: https://github.com/example
- Docs: http://docs.example.com
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
fs::write(docs_dir.join("index.md"), "# Home").unwrap();
let test_file = docs_dir.join("index.md");
let rule = MD074MkDocsNav::new();
let ctx =
crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not warn about external URLs. Got: {result:?}"
);
}
#[test]
fn test_cache_prevents_duplicate_validation() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content = r#"
site_name: Test
docs_dir: docs
nav:
- Home: index.md
- Missing: missing.md
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
fs::write(docs_dir.join("index.md"), "# Home").unwrap();
fs::write(docs_dir.join("other.md"), "# Other").unwrap();
let rule = MD074MkDocsNav::new();
let ctx1 = crate::lint_context::LintContext::new(
"# Home",
crate::config::MarkdownFlavor::MkDocs,
Some(docs_dir.join("index.md")),
);
let result1 = rule.check(&ctx1).unwrap();
assert_eq!(result1.len(), 1, "First check should return warnings");
let ctx2 = crate::lint_context::LintContext::new(
"# Other",
crate::config::MarkdownFlavor::MkDocs,
Some(docs_dir.join("other.md")),
);
let result2 = rule.check(&ctx2).unwrap();
assert!(result2.is_empty(), "Second check should return no warnings (cached)");
}
#[test]
fn test_cache_invalidates_when_content_changes() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content_v1 = r#"
site_name: Test
docs_dir: docs
nav:
- Home: index.md
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content_v1).unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
fs::write(docs_dir.join("index.md"), "# Home").unwrap();
let rule = MD074MkDocsNav::new();
let ctx1 = crate::lint_context::LintContext::new(
"# Home",
crate::config::MarkdownFlavor::MkDocs,
Some(docs_dir.join("index.md")),
);
let result1 = rule.check(&ctx1).unwrap();
assert!(
result1.is_empty(),
"First check: valid config should produce no warnings"
);
let mkdocs_content_v2 = r#"
site_name: Test
docs_dir: docs
nav:
- Home: index.md
- Missing: missing.md
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content_v2).unwrap();
let ctx2 = crate::lint_context::LintContext::new(
"# Home",
crate::config::MarkdownFlavor::MkDocs,
Some(docs_dir.join("index.md")),
);
let result2 = rule.check(&ctx2).unwrap();
assert_eq!(
result2.len(),
1,
"Second check: changed mkdocs.yml should re-validate and find missing.md"
);
assert!(result2[0].message.contains("missing.md"));
}
#[test]
fn test_invalid_mkdocs_yml_returns_warning() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content = "site_name: Test\nnav: [[[invalid yaml";
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
let test_file = docs_dir.join("test.md");
fs::write(&test_file, "# Test").unwrap();
let rule = MD074MkDocsNav::new();
let ctx =
crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should return parse error warning");
assert!(
result[0].message.contains("Failed to parse"),
"Should mention parse failure"
);
}
#[test]
fn test_missing_docs_dir_returns_warning() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content = r#"
site_name: Test
docs_dir: nonexistent
nav:
- Home: index.md
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
let other_dir = temp_dir.path().join("other");
fs::create_dir_all(&other_dir).unwrap();
let test_file = other_dir.join("test.md");
fs::write(&test_file, "# Test").unwrap();
let rule = MD074MkDocsNav::new();
let ctx =
crate::lint_context::LintContext::new("# Test", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should warn about missing docs_dir");
assert!(
result[0].message.contains("does not exist"),
"Should mention docs_dir doesn't exist"
);
}
#[test]
fn test_default_docs_dir() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content = r#"
site_name: Test
nav:
- Home: index.md
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
let config = MD074MkDocsNav::parse_mkdocs_yml(&temp_dir.path().join("mkdocs.yml")).unwrap();
assert_eq!(config.docs_dir, "docs", "Should default to 'docs'");
}
#[test]
fn test_path_normalization() {
let path1 = MD074MkDocsNav::normalize_path(Path::new("api/overview.md"));
let path2 = MD074MkDocsNav::normalize_nav_path("api/overview.md");
assert_eq!(path1, path2);
let win_path = MD074MkDocsNav::normalize_nav_path("api\\overview.md");
assert_eq!(win_path, PathBuf::from("api/overview.md"));
}
#[test]
fn test_skips_hidden_files_and_directories() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content = r#"
site_name: Test
docs_dir: docs
nav:
- Home: index.md
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
fs::write(docs_dir.join("index.md"), "# Home").unwrap();
fs::write(docs_dir.join(".hidden.md"), "# Hidden").unwrap();
let hidden_dir = docs_dir.join(".hidden_dir");
fs::create_dir_all(&hidden_dir).unwrap();
fs::write(hidden_dir.join("secret.md"), "# Secret").unwrap();
let collected = MD074MkDocsNav::collect_docs_files(&docs_dir);
assert_eq!(collected.len(), 1, "Should only find index.md");
assert!(
!collected.iter().any(|p| p.to_string_lossy().contains("hidden")),
"Should not include hidden files"
);
}
#[test]
fn test_is_external_url() {
assert!(MD074MkDocsNav::is_external_url("https://example.com"));
assert!(MD074MkDocsNav::is_external_url("http://example.com"));
assert!(MD074MkDocsNav::is_external_url("//example.com"));
assert!(MD074MkDocsNav::is_external_url("ftp://files.example.com"));
assert!(!MD074MkDocsNav::is_external_url("index.md"));
assert!(!MD074MkDocsNav::is_external_url("path/to/file.md"));
assert!(!MD074MkDocsNav::is_external_url("/absolute/path.md"));
}
#[test]
fn test_is_absolute_path() {
assert!(MD074MkDocsNav::is_absolute_path("/absolute/path.md"));
assert!(MD074MkDocsNav::is_absolute_path("/index.md"));
assert!(!MD074MkDocsNav::is_absolute_path("relative/path.md"));
assert!(!MD074MkDocsNav::is_absolute_path("index.md"));
assert!(!MD074MkDocsNav::is_absolute_path("https://example.com"));
}
#[test]
fn test_directory_nav_entries() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content = r#"
site_name: Test
docs_dir: docs
nav:
- Home: index.md
- API: api/
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
fs::write(docs_dir.join("index.md"), "# Home").unwrap();
let api_dir = docs_dir.join("api");
fs::create_dir_all(&api_dir).unwrap();
let test_file = docs_dir.join("index.md");
let rule = MD074MkDocsNav::new();
let ctx =
crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should warn about missing api/index.md. Got: {result:?}"
);
assert!(result[0].message.contains("api/"), "Should mention api/ in warning");
assert!(
result[0].message.contains("index.md"),
"Should mention index.md in warning"
);
}
#[test]
fn test_directory_nav_entries_with_index() {
setup_test();
let temp_dir = tempdir().unwrap();
let mkdocs_content = r#"
site_name: Test
docs_dir: docs
nav:
- Home: index.md
- API: api/
"#;
fs::write(temp_dir.path().join("mkdocs.yml"), mkdocs_content).unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
fs::write(docs_dir.join("index.md"), "# Home").unwrap();
let api_dir = docs_dir.join("api");
fs::create_dir_all(&api_dir).unwrap();
fs::write(api_dir.join("index.md"), "# API").unwrap();
let test_file = docs_dir.join("index.md");
let rule = MD074MkDocsNav::new();
let ctx =
crate::lint_context::LintContext::new("# Home", crate::config::MarkdownFlavor::MkDocs, Some(test_file));
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not warn when api/index.md exists. Got: {result:?}"
);
}
}