use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_flavor_loading() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
flavor = "mkdocs"
disable = ["MD001"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
assert!(config.is_mkdocs_flavor());
assert!(config.is_mkdocs_project()); assert_eq!(config.global.disable, vec!["MD001".to_string()]);
}
#[test]
fn test_pyproject_toml_root_level_config() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("pyproject.toml");
let content = r#"
[tool.rumdl]
line-length = 120
disable = ["MD033"]
enable = ["MD001", "MD004"]
include = ["docs/*.md"]
exclude = ["node_modules"]
respect-gitignore = true
"#;
fs::write(&config_path, content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.disable, vec!["MD033".to_string()]);
assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
assert!(config.global.respect_gitignore);
let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
assert_eq!(line_length, Some(120));
}
#[test]
fn test_pyproject_toml_snake_case_and_kebab_case() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("pyproject.toml");
let content = r#"
[tool.rumdl]
line-length = 150
respect_gitignore = true
"#;
fs::write(&config_path, content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.respect_gitignore);
let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
assert_eq!(line_length, Some(150));
}
#[test]
fn test_md013_key_normalization_in_rumdl_toml() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD013]
line_length = 111
line-length = 222
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
assert_eq!(keys, vec!["line-length"]);
let val = &rule_cfg.values["line-length"].value;
assert_eq!(val.as_integer(), Some(222));
let config: Config = sourced.clone().into_validated_unchecked().into();
let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
assert_eq!(v1, Some(222));
assert_eq!(v2, Some(222));
}
#[test]
fn test_md013_section_case_insensitivity() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[md013]
line-length = 101
[Md013]
line-length = 102
[MD013]
line-length = 103
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.clone().into_validated_unchecked().into();
let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
assert_eq!(keys, vec!["line-length"]);
let val = &rule_cfg.values["line-length"].value;
assert_eq!(val.as_integer(), Some(103));
let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
assert_eq!(v, Some(103));
}
#[test]
fn test_md013_key_snake_and_kebab_case() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD013]
line_length = 201
line-length = 202
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.clone().into_validated_unchecked().into();
let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
assert_eq!(keys, vec!["line-length"]);
let val = &rule_cfg.values["line-length"].value;
assert_eq!(val.as_integer(), Some(202));
let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
assert_eq!(v1, Some(202));
assert_eq!(v2, Some(202));
}
#[test]
fn test_unknown_rule_section_is_ignored() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD999]
foo = 1
bar = 2
[MD013]
line-length = 303
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.clone().into_validated_unchecked().into();
assert!(!sourced.rules.contains_key("MD999"));
let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
assert_eq!(v, Some(303));
}
#[test]
fn test_invalid_toml_syntax() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD013]
line-length = "unclosed string
"#;
fs::write(&config_path, config_content).unwrap();
let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
assert!(result.is_err());
match result.unwrap_err() {
ConfigError::ParseError(msg) => {
assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
}
_ => panic!("Expected ParseError"),
}
}
#[test]
fn test_wrong_type_for_config_value() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD013]
line-length = "not a number"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let rule_config = config.rules.get("MD013").unwrap();
let value = rule_config.values.get("line-length").unwrap();
assert!(matches!(value, toml::Value::String(_)));
}
#[test]
fn test_empty_config_file() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
fs::write(&config_path, "").unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.line_length.get(), 80);
assert!(config.global.respect_gitignore);
assert!(config.rules.is_empty());
}
#[test]
fn test_malformed_pyproject_toml() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("pyproject.toml");
let content = r#"
[tool.rumdl
line-length = 120
"#;
fs::write(&config_path, content).unwrap();
let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
assert!(result.is_err());
}
#[test]
fn test_conflicting_config_values() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
enable = ["MD013"]
disable = ["MD013"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.enable.contains(&"MD013".to_string()));
assert!(!config.global.disable.contains(&"MD013".to_string()));
}
#[test]
fn test_invalid_rule_names() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
disable = ["MD-001", "MD_002"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.enable.len(), 4);
assert_eq!(config.global.disable.len(), 2);
}
#[test]
fn test_deeply_nested_config() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD013]
line-length = 100
[MD013.nested]
value = 42
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let rule_config = config.rules.get("MD013").unwrap();
assert_eq!(
rule_config.values.get("line-length").unwrap(),
&toml::Value::Integer(100)
);
assert!(!rule_config.values.contains_key("nested"));
}
#[test]
fn test_unicode_in_config() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
include = ["文档/*.md", "ドã‚ュメント/*.md"]
exclude = ["测试/*", "🚀/*"]
[MD013]
line-length = 80
message = "行太长了 🚨"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.include.len(), 2);
assert_eq!(config.global.exclude.len(), 2);
assert!(config.global.include[0].contains("文档"));
assert!(config.global.exclude[1].contains("🚀"));
let rule_config = config.rules.get("MD013").unwrap();
let message = rule_config.values.get("message").unwrap();
if let toml::Value::String(s) = message {
assert!(s.contains("行太长了"));
assert!(s.contains("🚨"));
}
}
#[test]
fn test_extremely_long_values() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let long_string = "a".repeat(10000);
let config_content = format!(
r#"
[global]
exclude = ["{long_string}"]
[MD013]
line-length = 999999999
"#
);
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.exclude[0].len(), 10000);
let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
assert_eq!(line_length, Some(999999999));
}
#[test]
fn test_config_with_comments() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
# This is a comment
enable = ["MD001"] # Enable MD001
# disable = ["MD002"] # This is commented out
[MD013] # Line length rule
line-length = 100 # Set to 100 characters
# ignored = true # This setting is commented out
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.enable, vec!["MD001"]);
assert!(config.global.disable.is_empty());
let rule_config = config.rules.get("MD013").unwrap();
assert_eq!(rule_config.values.len(), 1); assert!(!rule_config.values.contains_key("ignored"));
}
#[test]
fn test_arrays_in_rule_config() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD003]
levels = [1, 2, 3]
tags = ["important", "critical"]
mixed = [1, "two", true]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let rule_config = config.rules.get("MD003").expect("MD003 config should exist");
assert!(rule_config.values.contains_key("levels"));
assert!(rule_config.values.contains_key("tags"));
assert!(rule_config.values.contains_key("mixed"));
if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
assert_eq!(levels.len(), 3);
assert_eq!(levels[0], toml::Value::Integer(1));
assert_eq!(levels[1], toml::Value::Integer(2));
assert_eq!(levels[2], toml::Value::Integer(3));
} else {
panic!("levels should be an array");
}
if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
assert_eq!(tags.len(), 2);
assert_eq!(tags[0], toml::Value::String("important".to_string()));
assert_eq!(tags[1], toml::Value::String("critical".to_string()));
} else {
panic!("tags should be an array");
}
if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
assert_eq!(mixed.len(), 3);
assert_eq!(mixed[0], toml::Value::Integer(1));
assert_eq!(mixed[1], toml::Value::String("two".to_string()));
assert_eq!(mixed[2], toml::Value::Boolean(true));
} else {
panic!("mixed should be an array");
}
}
#[test]
fn test_normalize_key_edge_cases() {
assert_eq!(normalize_key("MD001"), "MD001");
assert_eq!(normalize_key("md001"), "MD001");
assert_eq!(normalize_key("Md001"), "MD001");
assert_eq!(normalize_key("mD001"), "MD001");
assert_eq!(normalize_key("line_length"), "line-length");
assert_eq!(normalize_key("line-length"), "line-length");
assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
assert_eq!(normalize_key("MD"), "md"); assert_eq!(normalize_key("MD00"), "md00"); assert_eq!(normalize_key("MD0001"), "md0001"); assert_eq!(normalize_key("MDabc"), "mdabc"); assert_eq!(normalize_key("MD00a"), "md00a"); assert_eq!(normalize_key(""), "");
assert_eq!(normalize_key("_"), "-");
assert_eq!(normalize_key("___"), "---");
}
#[test]
fn test_missing_config_file() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("nonexistent.toml");
let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
assert!(result.is_err());
match result.unwrap_err() {
ConfigError::IoError { .. } => {}
_ => panic!("Expected IoError for missing file"),
}
}
#[test]
#[cfg(unix)]
fn test_permission_denied_config() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
let mut perms = fs::metadata(&config_path).unwrap().permissions();
perms.set_mode(0o000);
fs::set_permissions(&config_path, perms).unwrap();
let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
let mut perms = fs::metadata(&config_path).unwrap().permissions();
perms.set_mode(0o644);
fs::set_permissions(&config_path, perms).unwrap();
assert!(result.is_err());
match result.unwrap_err() {
ConfigError::IoError { .. } => {}
_ => panic!("Expected IoError for permission denied"),
}
}
#[test]
fn test_circular_reference_detection() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let mut config_content = String::from("[MD001]\n");
for i in 0..100 {
config_content.push_str(&format!("key{i} = {i}\n"));
}
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let rule_config = config.rules.get("MD001").unwrap();
assert_eq!(rule_config.values.len(), 100);
}
#[test]
fn test_special_toml_values() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD001]
infinity = inf
neg_infinity = -inf
not_a_number = nan
datetime = 1979-05-27T07:32:00Z
local_date = 1979-05-27
local_time = 07:32:00
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
if let Some(rule_config) = config.rules.get("MD001") {
if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
assert!(f.is_infinite() && f.is_sign_positive());
}
if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
assert!(f.is_infinite() && f.is_sign_negative());
}
if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
assert!(f.is_nan());
}
if let Some(val) = rule_config.values.get("datetime") {
assert!(matches!(val, toml::Value::Datetime(_)));
}
}
}
#[test]
fn test_default_config_passes_validation() {
use crate::rules;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_path_str = config_path.to_str().unwrap();
create_default_config(config_path_str).unwrap();
let sourced = SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
let all_rules = rules::all_rules(&Config::default());
let registry = RuleRegistry::from_rules(&all_rules);
let warnings = validate_config_sourced(&sourced, ®istry);
if !warnings.is_empty() {
for warning in &warnings {
eprintln!("Config validation warning: {}", warning.message);
if let Some(rule) = &warning.rule {
eprintln!(" Rule: {rule}");
}
if let Some(key) = &warning.key {
eprintln!(" Key: {key}");
}
}
}
assert!(
warnings.is_empty(),
"Default config from rumdl init should pass validation without warnings"
);
}
#[test]
fn test_enabled_key_valid_for_any_rule() {
use crate::rules;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
std::fs::write(&config_path, "[MD070]\nenabled = true\n").unwrap();
let sourced = SourcedConfig::load(Some(config_path.to_str().unwrap()), None).expect("Config should load");
let all_rules = rules::all_rules(&Config::default());
let registry = RuleRegistry::from_rules(&all_rules);
let warnings = validate_config_sourced(&sourced, ®istry);
let enabled_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.key.as_deref() == Some("enabled"))
.collect();
assert!(
enabled_warnings.is_empty(),
"'enabled' should be valid for any rule, got warnings: {enabled_warnings:?}"
);
}
#[test]
fn test_per_file_ignores_config_parsing() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-ignores]
"README.md" = ["MD033"]
"docs/**/*.md" = ["MD013", "MD033"]
"test/*.md" = ["MD041"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.per_file_ignores.len(), 3);
assert_eq!(
config.per_file_ignores.get("README.md"),
Some(&vec!["MD033".to_string()])
);
assert_eq!(
config.per_file_ignores.get("docs/**/*.md"),
Some(&vec!["MD013".to_string(), "MD033".to_string()])
);
assert_eq!(
config.per_file_ignores.get("test/*.md"),
Some(&vec!["MD041".to_string()])
);
}
#[test]
fn test_per_file_ignores_glob_matching() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-ignores]
"README.md" = ["MD033"]
"docs/**/*.md" = ["MD013"]
"**/test_*.md" = ["MD041"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
assert!(ignored.contains("MD033"));
assert_eq!(ignored.len(), 1);
let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
assert!(ignored.contains("MD013"));
assert_eq!(ignored.len(), 1);
let ignored = config.get_ignored_rules_for_file(&PathBuf::from("tests/fixtures/test_example.md"));
assert!(ignored.contains("MD041"));
assert_eq!(ignored.len(), 1);
let ignored = config.get_ignored_rules_for_file(&PathBuf::from("other/file.md"));
assert!(ignored.is_empty());
}
#[test]
fn test_per_file_ignores_pyproject_toml() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl]
[tool.rumdl.per-file-ignores]
"README.md" = ["MD033", "MD013"]
"generated/*.md" = ["MD041"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.per_file_ignores.len(), 2);
assert_eq!(
config.per_file_ignores.get("README.md"),
Some(&vec!["MD033".to_string(), "MD013".to_string()])
);
assert_eq!(
config.per_file_ignores.get("generated/*.md"),
Some(&vec!["MD041".to_string()])
);
}
#[test]
fn test_per_file_ignores_multiple_patterns_match() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-ignores]
"docs/**/*.md" = ["MD013"]
"**/api/*.md" = ["MD033"]
"docs/api/overview.md" = ["MD041"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
assert_eq!(ignored.len(), 3);
assert!(ignored.contains("MD013"));
assert!(ignored.contains("MD033"));
assert!(ignored.contains("MD041"));
}
#[test]
fn test_per_file_ignores_rule_name_normalization() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-ignores]
"README.md" = ["md033", "MD013", "Md041"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
assert_eq!(ignored.len(), 3);
assert!(ignored.contains("MD033"));
assert!(ignored.contains("MD013"));
assert!(ignored.contains("MD041"));
}
#[test]
fn test_per_file_ignores_invalid_glob_pattern() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-ignores]
"[invalid" = ["MD033"]
"valid/*.md" = ["MD013"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let ignored = config.get_ignored_rules_for_file(&PathBuf::from("valid/test.md"));
assert!(ignored.contains("MD013"));
let ignored2 = config.get_ignored_rules_for_file(&PathBuf::from("[invalid"));
assert!(ignored2.is_empty());
}
#[test]
fn test_per_file_ignores_empty_section() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
disable = ["MD001"]
[per-file-ignores]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.per_file_ignores.len(), 0);
let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
assert!(ignored.is_empty());
}
#[test]
fn test_per_file_ignores_with_underscores_in_pyproject() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl]
[tool.rumdl.per_file_ignores]
"README.md" = ["MD033"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.per_file_ignores.len(), 1);
assert_eq!(
config.per_file_ignores.get("README.md"),
Some(&vec!["MD033".to_string()])
);
}
#[test]
fn test_per_file_ignores_absolute_path_matching() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let github_dir = temp_dir.path().join(".github");
fs::create_dir_all(&github_dir).unwrap();
let test_file = github_dir.join("pull_request_template.md");
fs::write(&test_file, "Test content").unwrap();
let config_content = r#"
[per-file-ignores]
".github/pull_request_template.md" = ["MD041"]
"docs/**/*.md" = ["MD013"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let absolute_path = test_file.canonicalize().unwrap();
let ignored = config.get_ignored_rules_for_file(&absolute_path);
assert!(
ignored.contains("MD041"),
"Should match absolute path {absolute_path:?} against relative pattern"
);
assert_eq!(ignored.len(), 1);
let relative_path = PathBuf::from(".github/pull_request_template.md");
let ignored = config.get_ignored_rules_for_file(&relative_path);
assert!(ignored.contains("MD041"), "Should match relative path");
}
#[test]
fn test_per_file_flavor_config_parsing() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"docs/**/*.md" = "mkdocs"
"**/*.mdx" = "mdx"
"**/*.qmd" = "quarto"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.per_file_flavor.len(), 3);
assert_eq!(
config.per_file_flavor.get("docs/**/*.md"),
Some(&MarkdownFlavor::MkDocs)
);
assert_eq!(config.per_file_flavor.get("**/*.mdx"), Some(&MarkdownFlavor::MDX));
assert_eq!(config.per_file_flavor.get("**/*.qmd"), Some(&MarkdownFlavor::Quarto));
}
#[test]
fn test_per_file_flavor_glob_matching() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"docs/**/*.md" = "mkdocs"
"**/*.mdx" = "mdx"
"components/**/*.md" = "mdx"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/overview.md"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
let flavor = config.get_flavor_for_file(&PathBuf::from("src/components/Button.mdx"));
assert_eq!(flavor, MarkdownFlavor::MDX);
let flavor = config.get_flavor_for_file(&PathBuf::from("components/Button/README.md"));
assert_eq!(flavor, MarkdownFlavor::MDX);
let flavor = config.get_flavor_for_file(&PathBuf::from("README.md"));
assert_eq!(flavor, MarkdownFlavor::Standard);
}
#[test]
fn test_per_file_flavor_pyproject_toml() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl]
[tool.rumdl.per-file-flavor]
"docs/**/*.md" = "mkdocs"
"**/*.mdx" = "mdx"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.per_file_flavor.len(), 2);
assert_eq!(
config.per_file_flavor.get("docs/**/*.md"),
Some(&MarkdownFlavor::MkDocs)
);
assert_eq!(config.per_file_flavor.get("**/*.mdx"), Some(&MarkdownFlavor::MDX));
}
#[test]
fn test_per_file_flavor_first_match_wins() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"docs/internal/**/*.md" = "quarto"
"docs/**/*.md" = "mkdocs"
"**/*.md" = "standard"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/internal/secret.md"));
assert_eq!(flavor, MarkdownFlavor::Quarto);
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/public/readme.md"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
let flavor = config.get_flavor_for_file(&PathBuf::from("other/file.md"));
assert_eq!(flavor, MarkdownFlavor::Standard);
}
#[test]
fn test_per_file_flavor_overrides_global_flavor() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
flavor = "mkdocs"
[per-file-flavor]
"**/*.mdx" = "mdx"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("components/Button.mdx"));
assert_eq!(flavor, MarkdownFlavor::MDX);
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/readme.md"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
}
#[test]
fn test_per_file_flavor_empty_map() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
disable = ["MD001"]
[per-file-flavor]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("README.md"));
assert_eq!(flavor, MarkdownFlavor::Standard);
let flavor = config.get_flavor_for_file(&PathBuf::from("test.mdx"));
assert_eq!(flavor, MarkdownFlavor::MDX);
}
#[test]
fn test_per_file_flavor_with_underscores() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl]
[tool.rumdl.per_file_flavor]
"docs/**/*.md" = "mkdocs"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.per_file_flavor.len(), 1);
assert_eq!(
config.per_file_flavor.get("docs/**/*.md"),
Some(&MarkdownFlavor::MkDocs)
);
}
#[test]
fn test_per_file_flavor_absolute_path_matching() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let docs_dir = temp_dir.path().join("docs");
fs::create_dir_all(&docs_dir).unwrap();
let test_file = docs_dir.join("guide.md");
fs::write(&test_file, "Test content").unwrap();
let config_content = r#"
[per-file-flavor]
"docs/**/*.md" = "mkdocs"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let absolute_path = test_file.canonicalize().unwrap();
let flavor = config.get_flavor_for_file(&absolute_path);
assert_eq!(
flavor,
MarkdownFlavor::MkDocs,
"Should match absolute path {absolute_path:?} against relative pattern"
);
let relative_path = PathBuf::from("docs/guide.md");
let flavor = config.get_flavor_for_file(&relative_path);
assert_eq!(flavor, MarkdownFlavor::MkDocs, "Should match relative path");
}
#[test]
fn test_per_file_flavor_all_flavors() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"standard/**/*.md" = "standard"
"mkdocs/**/*.md" = "mkdocs"
"mdx/**/*.md" = "mdx"
"quarto/**/*.md" = "quarto"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.per_file_flavor.len(), 4);
assert_eq!(
config.per_file_flavor.get("standard/**/*.md"),
Some(&MarkdownFlavor::Standard)
);
assert_eq!(
config.per_file_flavor.get("mkdocs/**/*.md"),
Some(&MarkdownFlavor::MkDocs)
);
assert_eq!(config.per_file_flavor.get("mdx/**/*.md"), Some(&MarkdownFlavor::MDX));
assert_eq!(
config.per_file_flavor.get("quarto/**/*.md"),
Some(&MarkdownFlavor::Quarto)
);
}
#[test]
fn test_per_file_flavor_invalid_glob_pattern() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"[invalid" = "mkdocs"
"valid/**/*.md" = "mdx"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("valid/test.md"));
assert_eq!(flavor, MarkdownFlavor::MDX);
let flavor = config.get_flavor_for_file(&PathBuf::from("other/test.md"));
assert_eq!(flavor, MarkdownFlavor::Standard);
}
#[test]
fn test_per_file_flavor_paths_with_spaces() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"my docs/**/*.md" = "mkdocs"
"src/**/*.md" = "mdx"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("my docs/guide.md"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
assert_eq!(flavor, MarkdownFlavor::MDX);
}
#[test]
fn test_per_file_flavor_deeply_nested_paths() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"a/b/c/d/e/**/*.md" = "quarto"
"a/b/**/*.md" = "mkdocs"
"**/*.md" = "standard"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("a/b/c/d/e/f/deep.md"));
assert_eq!(flavor, MarkdownFlavor::Quarto);
let flavor = config.get_flavor_for_file(&PathBuf::from("a/b/c/test.md"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
let flavor = config.get_flavor_for_file(&PathBuf::from("root.md"));
assert_eq!(flavor, MarkdownFlavor::Standard);
}
#[test]
fn test_per_file_flavor_complex_overlapping_patterns() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"docs/api/*.md" = "mkdocs"
"docs/**/*.mdx" = "mdx"
"docs/**/*.md" = "quarto"
"**/*.md" = "standard"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/reference.md"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/nested/file.md"));
assert_eq!(flavor, MarkdownFlavor::Quarto);
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/components/Button.mdx"));
assert_eq!(flavor, MarkdownFlavor::MDX);
let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
assert_eq!(flavor, MarkdownFlavor::Standard);
}
#[test]
fn test_per_file_flavor_extension_detection_interaction() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"legacy/**/*.mdx" = "standard"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("legacy/old.mdx"));
assert_eq!(flavor, MarkdownFlavor::Standard);
let flavor = config.get_flavor_for_file(&PathBuf::from("src/component.mdx"));
assert_eq!(flavor, MarkdownFlavor::MDX);
}
#[test]
fn test_per_file_flavor_standard_alias_none() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"plain/**/*.md" = "none"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("plain/test.md"));
assert_eq!(flavor, MarkdownFlavor::Standard);
}
#[test]
fn test_per_file_flavor_brace_expansion() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"docs/**/*.{md,mdx}" = "mkdocs"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/guide.md"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/component.mdx"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
}
#[test]
fn test_per_file_flavor_single_star_vs_double_star() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"docs/*.md" = "mkdocs"
"src/**/*.md" = "mdx"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/README.md"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/index.md"));
assert_eq!(flavor, MarkdownFlavor::Standard);
let flavor = config.get_flavor_for_file(&PathBuf::from("src/components/Button.md"));
assert_eq!(flavor, MarkdownFlavor::MDX);
let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
assert_eq!(flavor, MarkdownFlavor::MDX);
}
#[test]
fn test_per_file_flavor_question_mark_wildcard() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"docs/v?.md" = "mkdocs"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v1.md"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v2.md"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v10.md"));
assert_eq!(flavor, MarkdownFlavor::Standard);
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v.md"));
assert_eq!(flavor, MarkdownFlavor::Standard);
}
#[test]
fn test_per_file_flavor_character_class() {
use std::path::PathBuf;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-flavor]
"docs/[abc].md" = "mkdocs"
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/a.md"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/b.md"));
assert_eq!(flavor, MarkdownFlavor::MkDocs);
let flavor = config.get_flavor_for_file(&PathBuf::from("docs/d.md"));
assert_eq!(flavor, MarkdownFlavor::Standard);
}
#[test]
fn test_generate_json_schema() {
use schemars::schema_for;
use std::env;
let schema = schema_for!(Config);
let schema_json = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema");
if env::var("RUMDL_UPDATE_SCHEMA").is_ok() {
let schema_path = env::current_dir().unwrap().join("rumdl.schema.json");
fs::write(&schema_path, &schema_json).expect("Failed to write schema file");
println!("Schema written to: {}", schema_path.display());
}
assert!(schema_json.contains("\"title\": \"Config\""));
assert!(schema_json.contains("\"global\""));
assert!(schema_json.contains("\"per-file-ignores\""));
}
#[test]
fn test_markdown_flavor_schema_matches_fromstr() {
use schemars::schema_for;
let schema = schema_for!(MarkdownFlavor);
let schema_json = serde_json::to_value(&schema).expect("Failed to serialize schema");
let enum_values = schema_json
.get("enum")
.expect("Schema should have 'enum' field")
.as_array()
.expect("enum should be an array");
assert!(!enum_values.is_empty(), "Schema enum should not be empty");
for value in enum_values {
let str_value = value.as_str().expect("enum value should be a string");
let result = str_value.parse::<MarkdownFlavor>();
assert!(
result.is_ok(),
"Schema value '{str_value}' should be parseable by FromStr but got: {:?}",
result.err()
);
}
for alias in ["", "none"] {
let result = alias.parse::<MarkdownFlavor>();
assert!(result.is_ok(), "FromStr alias '{alias}' should be parseable");
}
}
#[test]
fn test_project_config_is_standalone() {
let temp_dir = tempdir().unwrap();
let user_config_dir = temp_dir.path().join("user_config");
let rumdl_config_dir = user_config_dir.join("rumdl");
fs::create_dir_all(&rumdl_config_dir).unwrap();
let user_config_path = rumdl_config_dir.join("rumdl.toml");
let user_config_content = r#"
[global]
disable = ["MD013", "MD041"]
line-length = 100
"#;
fs::write(&user_config_path, user_config_content).unwrap();
let project_config_path = temp_dir.path().join("project").join("pyproject.toml");
fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
let project_config_content = r#"
[tool.rumdl]
enable = ["MD001"]
"#;
fs::write(&project_config_path, project_config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery_impl(
Some(project_config_path.to_str().unwrap()),
None,
false,
Some(&user_config_dir),
)
.unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert!(
!config.global.disable.contains(&"MD013".to_string()),
"User config should NOT be merged with project config"
);
assert!(
!config.global.disable.contains(&"MD041".to_string()),
"User config should NOT be merged with project config"
);
assert!(
config.global.enable.contains(&"MD001".to_string()),
"Project config enabled rules should be applied"
);
}
#[test]
fn test_user_config_as_fallback_when_no_project_config() {
use std::env;
let temp_dir = tempdir().unwrap();
let original_dir = env::current_dir().unwrap();
let user_config_dir = temp_dir.path().join("user_config");
let rumdl_config_dir = user_config_dir.join("rumdl");
fs::create_dir_all(&rumdl_config_dir).unwrap();
let user_config_path = rumdl_config_dir.join("rumdl.toml");
let user_config_content = r#"
[global]
disable = ["MD013", "MD041"]
line-length = 88
"#;
fs::write(&user_config_path, user_config_content).unwrap();
let project_dir = temp_dir.path().join("project_no_config");
fs::create_dir_all(&project_dir).unwrap();
env::set_current_dir(&project_dir).unwrap();
let sourced = SourcedConfig::load_with_discovery_impl(None, None, false, Some(&user_config_dir)).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert!(
config.global.disable.contains(&"MD013".to_string()),
"User config should be loaded as fallback when no project config"
);
assert!(
config.global.disable.contains(&"MD041".to_string()),
"User config should be loaded as fallback when no project config"
);
assert_eq!(
config.global.line_length.get(),
88,
"User config line-length should be loaded as fallback"
);
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_user_config_fallback_supports_extends() {
use std::env;
let temp_dir = tempdir().unwrap();
let original_dir = env::current_dir().unwrap();
let user_config_dir = temp_dir.path().join("user_config");
let rumdl_config_dir = user_config_dir.join("rumdl");
fs::create_dir_all(&rumdl_config_dir).unwrap();
let base_config_path = rumdl_config_dir.join("base.toml");
fs::write(
&base_config_path,
r#"
[global]
disable = ["MD013"]
line-length = 92
"#,
)
.unwrap();
let user_config_path = rumdl_config_dir.join("rumdl.toml");
fs::write(
&user_config_path,
r#"extends = "base.toml"
[global]
extend-disable = ["MD033"]
"#,
)
.unwrap();
let project_dir = temp_dir.path().join("project_no_config");
fs::create_dir_all(&project_dir).unwrap();
env::set_current_dir(&project_dir).unwrap();
let sourced = SourcedConfig::load_with_discovery_impl(None, None, false, Some(&user_config_dir)).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.disable.contains(&"MD013".to_string()));
assert_eq!(config.global.line_length.get(), 92);
assert!(config.global.extend_disable.contains(&"MD033".to_string()));
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_typestate_validate_method() {
use tempfile::tempdir;
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[global]
enable = ["MD001"]
[MD013]
line_length = 80
unknown_option = true
"#;
std::fs::write(&config_path, config_content).expect("Failed to write config");
let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let default_config = Config::default();
let all_rules = crate::rules::all_rules(&default_config);
let registry = RuleRegistry::from_rules(&all_rules);
let validated = loaded.validate(®istry).expect("Should validate config");
let has_unknown_option_warning = validated
.validation_warnings
.iter()
.any(|w| w.message.contains("unknown_option") || w.message.contains("Unknown option"));
if !has_unknown_option_warning {
for w in &validated.validation_warnings {
eprintln!("Warning: {}", w.message);
}
}
assert!(
has_unknown_option_warning,
"Should have warning for unknown option. Got {} warnings: {:?}",
validated.validation_warnings.len(),
validated
.validation_warnings
.iter()
.map(|w| &w.message)
.collect::<Vec<_>>()
);
let config: Config = validated.into();
assert!(config.global.enable.contains(&"MD001".to_string()));
}
#[test]
fn test_typestate_validate_into_convenience_method() {
use tempfile::tempdir;
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[global]
enable = ["MD022"]
[MD022]
lines_above = 2
"#;
std::fs::write(&config_path, config_content).expect("Failed to write config");
let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let default_config = Config::default();
let all_rules = crate::rules::all_rules(&default_config);
let registry = RuleRegistry::from_rules(&all_rules);
let (config, warnings) = loaded.validate_into(®istry).expect("Should validate and convert");
assert!(warnings.is_empty(), "Should have no warnings for valid config");
assert!(config.global.enable.contains(&"MD022".to_string()));
}
#[test]
fn test_resolve_rule_name_canonical() {
assert_eq!(resolve_rule_name("MD001"), "MD001");
assert_eq!(resolve_rule_name("MD013"), "MD013");
assert_eq!(resolve_rule_name("MD069"), "MD069");
}
#[test]
fn test_resolve_rule_name_aliases() {
assert_eq!(resolve_rule_name("heading-increment"), "MD001");
assert_eq!(resolve_rule_name("line-length"), "MD013");
assert_eq!(resolve_rule_name("no-bare-urls"), "MD034");
assert_eq!(resolve_rule_name("ul-style"), "MD004");
}
#[test]
fn test_resolve_rule_name_case_insensitive() {
assert_eq!(resolve_rule_name("HEADING-INCREMENT"), "MD001");
assert_eq!(resolve_rule_name("Heading-Increment"), "MD001");
assert_eq!(resolve_rule_name("md001"), "MD001");
assert_eq!(resolve_rule_name("MD001"), "MD001");
}
#[test]
fn test_resolve_rule_name_underscore_to_hyphen() {
assert_eq!(resolve_rule_name("heading_increment"), "MD001");
assert_eq!(resolve_rule_name("line_length"), "MD013");
assert_eq!(resolve_rule_name("no_bare_urls"), "MD034");
}
#[test]
fn test_resolve_rule_name_unknown() {
assert_eq!(resolve_rule_name("custom-rule"), "custom-rule");
assert_eq!(resolve_rule_name("CUSTOM_RULE"), "custom-rule");
assert_eq!(resolve_rule_name("md999"), "MD999"); }
#[test]
fn test_resolve_rule_names_basic() {
let result = resolve_rule_names("MD001,line-length,heading-increment");
assert!(result.contains("MD001"));
assert!(result.contains("MD013")); assert_eq!(result.len(), 2);
}
#[test]
fn test_resolve_rule_names_with_whitespace() {
let result = resolve_rule_names(" MD001 , line-length , MD034 ");
assert!(result.contains("MD001"));
assert!(result.contains("MD013"));
assert!(result.contains("MD034"));
assert_eq!(result.len(), 3);
}
#[test]
fn test_resolve_rule_names_empty_entries() {
let result = resolve_rule_names("MD001,,MD013,");
assert!(result.contains("MD001"));
assert!(result.contains("MD013"));
assert_eq!(result.len(), 2);
}
#[test]
fn test_resolve_rule_names_empty_string() {
let result = resolve_rule_names("");
assert!(result.is_empty());
}
#[test]
fn test_resolve_rule_names_mixed() {
let result = resolve_rule_names("MD001,line-length,custom-rule");
assert!(result.contains("MD001"));
assert!(result.contains("MD013"));
assert!(result.contains("custom-rule"));
assert_eq!(result.len(), 3);
}
#[test]
fn test_is_valid_rule_name_canonical() {
assert!(is_valid_rule_name("MD001"));
assert!(is_valid_rule_name("MD013"));
assert!(is_valid_rule_name("MD041"));
assert!(is_valid_rule_name("MD069"));
assert!(is_valid_rule_name("md001"));
assert!(is_valid_rule_name("Md001"));
assert!(is_valid_rule_name("mD001"));
}
#[test]
fn test_is_valid_rule_name_aliases() {
assert!(is_valid_rule_name("line-length"));
assert!(is_valid_rule_name("heading-increment"));
assert!(is_valid_rule_name("no-bare-urls"));
assert!(is_valid_rule_name("ul-style"));
assert!(is_valid_rule_name("LINE-LENGTH"));
assert!(is_valid_rule_name("Line-Length"));
assert!(is_valid_rule_name("line_length"));
assert!(is_valid_rule_name("ul_style"));
}
#[test]
fn test_is_valid_rule_name_special_all() {
assert!(is_valid_rule_name("all"));
assert!(is_valid_rule_name("ALL"));
assert!(is_valid_rule_name("All"));
assert!(is_valid_rule_name("aLl"));
}
#[test]
fn test_is_valid_rule_name_invalid() {
assert!(!is_valid_rule_name("MD000"));
assert!(!is_valid_rule_name("MD002")); assert!(!is_valid_rule_name("MD006")); assert!(!is_valid_rule_name("MD999"));
assert!(!is_valid_rule_name("MD100"));
assert!(!is_valid_rule_name(""));
assert!(!is_valid_rule_name("INVALID"));
assert!(!is_valid_rule_name("not-a-rule"));
assert!(!is_valid_rule_name("random-text"));
assert!(!is_valid_rule_name("abc"));
assert!(!is_valid_rule_name("MD"));
assert!(!is_valid_rule_name("MD1"));
assert!(!is_valid_rule_name("MD12"));
}
#[test]
fn test_validate_cli_rule_names_valid() {
let warnings = validate_cli_rule_names(
Some("MD001,MD013"),
Some("line-length"),
Some("heading-increment"),
Some("all"),
None,
None,
);
assert!(warnings.is_empty(), "Expected no warnings for valid rules");
}
#[test]
fn test_validate_cli_rule_names_invalid() {
let warnings = validate_cli_rule_names(Some("abc"), None, None, None, None, None);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Unknown rule in --enable: abc"));
let warnings = validate_cli_rule_names(None, Some("xyz"), None, None, None, None);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Unknown rule in --disable: xyz"));
let warnings = validate_cli_rule_names(None, None, Some("nonexistent"), None, None, None);
assert_eq!(warnings.len(), 1);
assert!(
warnings[0]
.message
.contains("Unknown rule in --extend-enable: nonexistent")
);
let warnings = validate_cli_rule_names(None, None, None, Some("fake-rule"), None, None);
assert_eq!(warnings.len(), 1);
assert!(
warnings[0]
.message
.contains("Unknown rule in --extend-disable: fake-rule")
);
let warnings = validate_cli_rule_names(None, None, None, None, Some("not-a-rule"), None);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Unknown rule in --fixable: not-a-rule"));
let warnings = validate_cli_rule_names(None, None, None, None, None, Some("bogus"));
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Unknown rule in --unfixable: bogus"));
}
#[test]
fn test_validate_cli_rule_names_mixed() {
let warnings = validate_cli_rule_names(Some("MD001,abc,MD003"), None, None, None, None, None);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("abc"));
}
#[test]
fn test_validate_cli_rule_names_suggestions() {
let warnings = validate_cli_rule_names(Some("line-lenght"), None, None, None, None, None);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("did you mean"));
assert!(warnings[0].message.contains("line-length"));
}
#[test]
fn test_validate_cli_rule_names_none() {
let warnings = validate_cli_rule_names(None, None, None, None, None, None);
assert!(warnings.is_empty());
}
#[test]
fn test_validate_cli_rule_names_empty_string() {
let warnings = validate_cli_rule_names(Some(""), Some(""), Some(""), Some(""), Some(""), Some(""));
assert!(warnings.is_empty());
}
#[test]
fn test_validate_cli_rule_names_whitespace() {
let warnings = validate_cli_rule_names(Some(" MD001 , MD013 "), None, None, None, None, None);
assert!(warnings.is_empty(), "Whitespace should be trimmed");
}
#[test]
fn test_validate_cli_rule_names_fixable_valid() {
let warnings = validate_cli_rule_names(None, None, None, None, Some("MD001,MD013"), Some("MD040"));
assert!(
warnings.is_empty(),
"Expected no warnings for valid fixable/unfixable rules"
);
}
#[test]
fn test_all_implemented_rules_have_aliases() {
let config = crate::config::Config::default();
let all_rules = crate::rules::all_rules(&config);
let mut missing_rules = Vec::new();
for rule in &all_rules {
let rule_name = rule.name();
if resolve_rule_name_alias(rule_name).is_none() {
missing_rules.push(rule_name.to_string());
}
}
assert!(
missing_rules.is_empty(),
"The following rules are missing from RULE_ALIAS_MAP: {:?}\n\
Add entries like:\n\
- Canonical: \"{}\" => \"{}\"\n\
- Alias: \"RULE-NAME-HERE\" => \"{}\"",
missing_rules,
missing_rules.first().unwrap_or(&"MDxxx".to_string()),
missing_rules.first().unwrap_or(&"MDxxx".to_string()),
missing_rules.first().unwrap_or(&"MDxxx".to_string()),
);
}
#[test]
fn test_relative_path_in_cwd() {
let cwd = std::env::current_dir().unwrap();
let test_path = cwd.join("test_file.md");
fs::write(&test_path, "test").unwrap();
let result = super::to_relative_display_path(test_path.to_str().unwrap());
assert_eq!(result, "test_file.md");
fs::remove_file(&test_path).unwrap();
}
#[test]
fn test_relative_path_in_subdirectory() {
let cwd = std::env::current_dir().unwrap();
let subdir = cwd.join("test_subdir_for_relative_path");
fs::create_dir_all(&subdir).unwrap();
let test_path = subdir.join("test_file.md");
fs::write(&test_path, "test").unwrap();
let result = super::to_relative_display_path(test_path.to_str().unwrap());
assert_eq!(result, "test_subdir_for_relative_path/test_file.md");
fs::remove_file(&test_path).unwrap();
fs::remove_dir(&subdir).unwrap();
}
#[test]
fn test_relative_path_outside_cwd_returns_original() {
let outside_path = "/tmp/definitely_not_in_cwd_test.md";
let result = super::to_relative_display_path(outside_path);
let cwd = std::env::current_dir().unwrap();
if !cwd.starts_with("/tmp") {
assert_eq!(result, outside_path);
}
}
#[test]
fn test_relative_path_already_relative() {
let relative_path = "some/relative/path.md";
let result = super::to_relative_display_path(relative_path);
assert_eq!(result, relative_path);
}
#[test]
fn test_relative_path_with_dot_components() {
let cwd = std::env::current_dir().unwrap();
let test_path = cwd.join("test_dot_component.md");
fs::write(&test_path, "test").unwrap();
let dotted_path = cwd.join(".").join("test_dot_component.md");
let result = super::to_relative_display_path(dotted_path.to_str().unwrap());
assert_eq!(result, "test_dot_component.md");
fs::remove_file(&test_path).unwrap();
}
#[test]
fn test_relative_path_empty_string() {
let result = super::to_relative_display_path("");
assert_eq!(result, "");
}
#[test]
fn test_empty_enable_list_is_explicit_rumdl_toml() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
enable = []
disable = ["MD013"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
assert_ne!(
sourced.global.enable.source,
ConfigSource::Default,
"Empty enable = [] should change source from Default (it was explicitly set)"
);
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.enable.is_empty());
assert!(config.global.enable_is_explicit);
assert_eq!(config.global.disable, vec!["MD013".to_string()]);
}
#[test]
fn test_empty_enable_list_is_explicit_pyproject() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl]
enable = []
disable = ["MD033"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
assert_ne!(
sourced.global.enable.source,
ConfigSource::Default,
"Empty enable = [] in pyproject.toml should change source from Default"
);
}
#[test]
fn test_enable_all_keyword_rumdl_toml() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
enable = ["ALL"]
disable = ["MD013"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.enable.iter().any(|s| s.eq_ignore_ascii_case("all")));
assert_eq!(config.global.disable, vec!["MD013".to_string()]);
}
#[test]
fn test_enable_all_keyword_pyproject() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl]
enable = ["ALL"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.enable.iter().any(|s| s.eq_ignore_ascii_case("all")));
}
#[test]
fn test_nonempty_enable_list_still_works_rumdl_toml() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
enable = ["MD001", "MD003"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
assert_ne!(
sourced.global.enable.source,
ConfigSource::Default,
"Non-empty enable list should override Default source"
);
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.enable.len(), 2);
assert!(config.global.enable.contains(&"MD001".to_string()));
assert!(config.global.enable.contains(&"MD003".to_string()));
}
#[test]
fn test_nonempty_enable_list_still_works_pyproject() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl]
enable = ["MD001", "MD003"]
"#;
fs::write(&config_path, config_content).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
assert_ne!(
sourced.global.enable.source,
ConfigSource::Default,
"Non-empty enable list in pyproject.toml should override Default source"
);
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.enable.len(), 2);
}
#[test]
fn test_extends_basic_inheritance() {
let temp_dir = tempdir().unwrap();
let parent_path = temp_dir.path().join("parent.toml");
fs::write(
&parent_path,
r#"
[global]
disable = ["MD013"]
line-length = 120
"#,
)
.unwrap();
let child_path = temp_dir.path().join(".rumdl.toml");
fs::write(
&child_path,
format!(
r#"extends = "{}"
[global]
extend-disable = ["MD036"]
"#,
parent_path.display()
),
)
.unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(child_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert!(
config.global.disable.contains(&"MD013".to_string()),
"Parent's disable should be inherited"
);
assert!(
config.global.extend_disable.contains(&"MD036".to_string()),
"Child's extend-disable should be present"
);
assert_eq!(config.global.line_length.get(), 120);
}
#[test]
fn test_extends_child_overrides_parent() {
let temp_dir = tempdir().unwrap();
let parent_path = temp_dir.path().join("parent.toml");
fs::write(
&parent_path,
r#"
[global]
disable = ["MD013", "MD033"]
"#,
)
.unwrap();
let child_path = temp_dir.path().join(".rumdl.toml");
fs::write(
&child_path,
format!(
r#"extends = "{}"
[global]
disable = ["MD041"]
"#,
parent_path.display()
),
)
.unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(child_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.disable, vec!["MD041".to_string()]);
}
#[test]
fn test_extends_additive_extend_enable() {
let temp_dir = tempdir().unwrap();
let parent_path = temp_dir.path().join("parent.toml");
fs::write(
&parent_path,
r#"
[global]
extend-enable = ["MD060"]
"#,
)
.unwrap();
let child_path = temp_dir.path().join(".rumdl.toml");
fs::write(
&child_path,
format!(
r#"extends = "{}"
[global]
extend-enable = ["MD063"]
"#,
parent_path.display()
),
)
.unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(child_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert!(
config.global.extend_enable.contains(&"MD060".to_string()),
"Parent's extend-enable should be preserved"
);
assert!(
config.global.extend_enable.contains(&"MD063".to_string()),
"Child's extend-enable should be added"
);
}
#[test]
fn test_extends_chain_three_levels() {
let temp_dir = tempdir().unwrap();
let grandparent_path = temp_dir.path().join("grandparent.toml");
fs::write(
&grandparent_path,
r#"
[global]
line-length = 80
extend-enable = ["MD060"]
"#,
)
.unwrap();
let parent_path = temp_dir.path().join("parent.toml");
fs::write(
&parent_path,
format!(
r#"extends = "{}"
[global]
extend-enable = ["MD063"]
"#,
grandparent_path.display()
),
)
.unwrap();
let child_path = temp_dir.path().join(".rumdl.toml");
fs::write(
&child_path,
format!(
r#"extends = "{}"
[global]
extend-disable = ["MD013"]
"#,
parent_path.display()
),
)
.unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(child_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.line_length.get(), 80);
assert!(config.global.extend_enable.contains(&"MD060".to_string()));
assert!(config.global.extend_enable.contains(&"MD063".to_string()));
assert!(config.global.extend_disable.contains(&"MD013".to_string()));
}
#[test]
fn test_extends_circular_detection() {
let temp_dir = tempdir().unwrap();
let a_path = temp_dir.path().join("a.toml");
let b_path = temp_dir.path().join("b.toml");
fs::write(
&a_path,
format!(
r#"extends = "{}"
[global]
disable = ["MD013"]
"#,
b_path.display()
),
)
.unwrap();
fs::write(
&b_path,
format!(
r#"extends = "{}"
[global]
disable = ["MD033"]
"#,
a_path.display()
),
)
.unwrap();
let result = SourcedConfig::load_with_discovery(Some(a_path.to_str().unwrap()), None, true);
assert!(result.is_err(), "Circular extends should produce an error");
let err = result.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("Circular extends") || err_msg.contains("circular"),
"Error should mention circular: {err_msg}"
);
}
#[test]
fn test_extends_self_reference() {
let temp_dir = tempdir().unwrap();
let a_path = temp_dir.path().join("a.toml");
fs::write(
&a_path,
format!(
r#"extends = "{}"
[global]
disable = ["MD013"]
"#,
a_path.display()
),
)
.unwrap();
let result = SourcedConfig::load_with_discovery(Some(a_path.to_str().unwrap()), None, true);
assert!(result.is_err(), "Self-referencing extends should produce an error");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Circular extends") || err_msg.contains("circular"),
"Error should mention circular: {err_msg}"
);
}
#[test]
fn test_extends_depth_limit() {
let temp_dir = tempdir().unwrap();
let mut paths = Vec::new();
for i in 0..12 {
paths.push(temp_dir.path().join(format!("config_{i}.toml")));
}
fs::write(
&paths[11],
r#"
[global]
disable = ["MD013"]
"#,
)
.unwrap();
for i in (0..11).rev() {
fs::write(
&paths[i],
format!(
r#"extends = "{}"
[global]
extend-disable = ["MD{:03}"]
"#,
paths[i + 1].display(),
i + 1
),
)
.unwrap();
}
let result = SourcedConfig::load_with_discovery(Some(paths[0].to_str().unwrap()), None, true);
assert!(result.is_err(), "Deep extends chain should produce an error");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("maximum depth") || err_msg.contains("depth"),
"Error should mention depth: {err_msg}"
);
}
#[test]
fn test_extends_relative_path() {
let temp_dir = tempdir().unwrap();
let sub_dir = temp_dir.path().join("subdir");
fs::create_dir(&sub_dir).unwrap();
let parent_path = temp_dir.path().join("parent.toml");
fs::write(
&parent_path,
r#"
[global]
disable = ["MD013"]
"#,
)
.unwrap();
let child_path = sub_dir.join(".rumdl.toml");
fs::write(
&child_path,
r#"extends = "../parent.toml"
[global]
extend-disable = ["MD033"]
"#,
)
.unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(child_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.disable.contains(&"MD013".to_string()));
assert!(config.global.extend_disable.contains(&"MD033".to_string()));
}
#[test]
fn test_extends_missing_file() {
let temp_dir = tempdir().unwrap();
let child_path = temp_dir.path().join(".rumdl.toml");
fs::write(
&child_path,
r#"extends = "nonexistent.toml"
[global]
disable = ["MD013"]
"#,
)
.unwrap();
let result = SourcedConfig::load_with_discovery(Some(child_path.to_str().unwrap()), None, true);
assert!(result.is_err(), "Missing extends target should produce an error");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("not found") || err_msg.contains("nonexistent"),
"Error should mention file not found: {err_msg}"
);
}
#[test]
fn test_extends_pyproject_toml() {
let temp_dir = tempdir().unwrap();
let parent_path = temp_dir.path().join("parent.toml");
fs::write(
&parent_path,
r#"
[global]
disable = ["MD013"]
"#,
)
.unwrap();
let child_path = temp_dir.path().join("pyproject.toml");
fs::write(
&child_path,
format!(
r#"
[tool.rumdl]
extends = "{}"
extend-disable = ["MD033"]
"#,
parent_path.display()
),
)
.unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(child_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.disable.contains(&"MD013".to_string()));
assert!(config.global.extend_disable.contains(&"MD033".to_string()));
}
#[test]
fn test_extends_pyproject_child_overrides_rumdl_parent() {
let temp_dir = tempdir().unwrap();
let parent_path = temp_dir.path().join("parent.toml");
fs::write(
&parent_path,
r#"
[global]
disable = ["MD013", "MD033"]
"#,
)
.unwrap();
let child_path = temp_dir.path().join("pyproject.toml");
fs::write(
&child_path,
format!(
r#"
[tool.rumdl]
extends = "{}"
disable = ["MD041"]
"#,
parent_path.display()
),
)
.unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(child_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.disable, vec!["MD041".to_string()]);
}
#[test]
fn test_extends_rule_specific_override() {
let temp_dir = tempdir().unwrap();
let parent_path = temp_dir.path().join("parent.toml");
fs::write(
&parent_path,
r#"
[MD007]
indent = 4
"#,
)
.unwrap();
let child_path = temp_dir.path().join(".rumdl.toml");
fs::write(
&child_path,
format!(
r#"extends = "{}"
[MD007]
indent = 2
"#,
parent_path.display()
),
)
.unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(child_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let indent_val = get_rule_config_value::<i64>(&config, "MD007", "indent");
assert_eq!(indent_val, Some(2), "Child should override parent's MD007 indent");
}
#[test]
fn test_extends_rule_inherited_when_not_overridden() {
let temp_dir = tempdir().unwrap();
let parent_path = temp_dir.path().join("parent.toml");
fs::write(
&parent_path,
r#"
[MD007]
indent = 4
"#,
)
.unwrap();
let child_path = temp_dir.path().join(".rumdl.toml");
fs::write(
&child_path,
format!(
r#"extends = "{}"
[global]
disable = ["MD013"]
"#,
parent_path.display()
),
)
.unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(child_path.to_str().unwrap()), None, true).unwrap();
let config: Config = sourced.into_validated_unchecked().into();
let indent_val = get_rule_config_value::<i64>(&config, "MD007", "indent");
assert_eq!(indent_val, Some(4), "Parent's MD007 indent should be inherited");
}
#[test]
fn test_extends_loaded_files_tracking() {
let temp_dir = tempdir().unwrap();
let parent_path = temp_dir.path().join("parent.toml");
fs::write(
&parent_path,
r#"
[global]
disable = ["MD013"]
"#,
)
.unwrap();
let child_path = temp_dir.path().join(".rumdl.toml");
fs::write(
&child_path,
format!(
r#"extends = "{}"
[global]
extend-disable = ["MD033"]
"#,
parent_path.display()
),
)
.unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(child_path.to_str().unwrap()), None, true).unwrap();
assert!(
sourced.loaded_files.len() >= 2,
"Both parent and child should be in loaded_files, got: {:?}",
sourced.loaded_files
);
assert!(
sourced.loaded_files.iter().any(|f| f.contains("parent.toml")),
"parent.toml should be in loaded_files"
);
assert!(
sourced.loaded_files.iter().any(|f| f.contains(".rumdl.toml")),
".rumdl.toml should be in loaded_files"
);
}