use rumdl_lib::config::Config; use rumdl_lib::config::RuleRegistry;
use rumdl_lib::config::SourcedConfig;
use rumdl_lib::rules::*;
use serial_test::serial;
use std::collections::HashSet;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_load_config_file() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
let config_path = temp_path.join("test_config.toml");
let config_content = r#"
[global]
disable = ["MD013"]
enable = ["MD001", "MD003"]
include = ["docs/*.md"]
exclude = [".git"]
[MD013]
line_length = 120
code_blocks = false
tables = true
"#;
fs::write(&config_path, config_content).expect("Failed to write test config file");
let config_path_str = config_path.to_str().expect("Path should be valid UTF-8");
let sourced_result = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path_str), None, true);
assert!(
sourced_result.is_ok(),
"SourcedConfig loading should succeed. Error: {:?}",
sourced_result.err()
);
let config: Config = sourced_result.unwrap().into_validated_unchecked().into();
assert_eq!(config.global.disable, vec!["MD013"]);
assert_eq!(config.global.enable, vec!["MD001", "MD003"]);
assert_eq!(config.global.include, vec!["docs/*.md"]);
assert_eq!(config.global.exclude, vec![".git"]);
assert!(config.global.respect_gitignore);
let line_length = rumdl_lib::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
assert_eq!(line_length, Some(120));
let code_blocks = rumdl_lib::config::get_rule_config_value::<bool>(&config, "MD013", "code_blocks");
assert_eq!(code_blocks, Some(false));
let tables = rumdl_lib::config::get_rule_config_value::<bool>(&config, "MD013", "tables");
assert_eq!(tables, Some(true));
}
#[test]
fn test_load_nonexistent_config() {
let sourced_result =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some("nonexistent_config.toml"), None, true);
assert!(sourced_result.is_err(), "Loading nonexistent config should fail");
if let Err(err) = sourced_result {
assert!(
err.to_string().contains("Failed to read config file"),
"Error message should indicate file reading failure"
);
}
}
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.global.include.is_empty(), "Default include should be empty");
assert!(config.global.exclude.is_empty(), "Default exclude should be empty");
assert!(config.global.enable.is_empty(), "Default enable should be empty");
assert!(config.global.disable.is_empty(), "Default disable should be empty");
assert!(
config.global.respect_gitignore,
"Default respect_gitignore should be true"
);
assert!(config.rules.is_empty(), "Default rules map should be empty");
}
#[test]
fn test_create_default_config() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
let config_path = temp_path.join("test_default_config.toml");
if config_path.exists() {
fs::remove_file(&config_path).expect("Failed to remove existing test file");
}
let config_path_str = config_path.to_str().expect("Path should be valid UTF-8");
let result = rumdl_lib::config::create_default_config(config_path_str);
assert!(
result.is_ok(),
"Creating default config should succeed: {:?}",
result.err()
);
assert!(config_path.exists(), "Default config file should exist in temp dir");
let sourced_result = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path_str), None, true);
assert!(
sourced_result.is_ok(),
"Loading created config should succeed: {:?}",
sourced_result.err()
);
}
#[test]
fn test_rule_configuration_application() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
let config_path = temp_path.join("test_rule_config.toml");
let config_content = r#"
[MD013]
line_length = 150
[MD004]
style = "asterisk"
"#;
fs::write(&config_path, config_content).expect("Failed to write test config file");
let config_path_str = config_path.to_str().expect("Path should be valid UTF-8");
let sourced_config = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path_str), None, true)
.expect("Failed to load sourced config");
let config: Config = sourced_config.into_validated_unchecked().into();
let mut rules: Vec<Box<dyn rumdl_lib::rule::Rule>> = vec![
Box::new(MD013LineLength::default()),
Box::new(MD004UnorderedListStyle::new(UnorderedListStyle::Consistent)),
];
if let Some(pos) = rules.iter().position(|r| r.name() == "MD013") {
let line_length =
rumdl_lib::config::get_rule_config_value::<usize>(&config, "MD013", "line_length").unwrap_or(80);
let code_blocks =
rumdl_lib::config::get_rule_config_value::<bool>(&config, "MD013", "code_blocks").unwrap_or(true);
let tables = rumdl_lib::config::get_rule_config_value::<bool>(&config, "MD013", "tables").unwrap_or(false);
let headings = rumdl_lib::config::get_rule_config_value::<bool>(&config, "MD013", "headings").unwrap_or(true);
let strict = rumdl_lib::config::get_rule_config_value::<bool>(&config, "MD013", "strict").unwrap_or(false);
rules[pos] = Box::new(MD013LineLength::new(line_length, code_blocks, tables, headings, strict));
}
let test_content = "# Test\n\nThis is a line that exceeds 80 characters but not 150 characters. It's specifically designed for our test case.";
let warnings = rumdl_lib::lint(
test_content,
&rules,
false,
rumdl_lib::config::MarkdownFlavor::Standard,
None,
None,
)
.expect("Linting should succeed");
let md013_warnings = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD013"))
.count();
assert_eq!(
md013_warnings, 0,
"No MD013 warnings should be generated with line_length 150"
);
}
#[test]
fn test_multiple_rules_configuration() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
let config_path = temp_path.join("test_multi_rule_config.toml");
let config_content = r#"
[global]
disable = []
[MD013]
line_length = 100
[MD046]
style = "fenced"
[MD048]
style = "backtick"
"#;
fs::write(&config_path, config_content).expect("Failed to write test config file");
let config_path_str = config_path.to_str().expect("Path should be valid UTF-8");
let sourced_config = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path_str), None, true)
.expect("Failed to load sourced config");
let config: Config = sourced_config.into_validated_unchecked().into();
let md013_line_length = rumdl_lib::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
assert_eq!(md013_line_length, Some(100));
let md046_style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD046", "style");
assert_eq!(md046_style, Some("fenced".to_string()));
let md048_style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD048", "style");
assert_eq!(md048_style, Some("backtick".to_string()));
}
#[test]
fn test_invalid_config_format() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
let config_path = temp_path.join("invalid_config.toml");
let invalid_config_content = r#"
[global]
disable = ["MD013" # Missing closing bracket
"#;
fs::write(&config_path, invalid_config_content).expect("Failed to write invalid config file");
let config_path_str = config_path.to_str().expect("Path should be valid UTF-8");
let sourced_result = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path_str), None, true);
assert!(sourced_result.is_err(), "Loading invalid config should fail");
if let Err(err) = sourced_result {
assert!(
err.to_string().contains("Failed to parse TOML"),
"Error message should indicate parsing failure: {err}"
);
}
}
#[test]
fn test_integration_rule_behavior() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
let config_path = temp_path.join("test_integration_config.toml");
let config_content = r#"
[MD013]
line_length = 60 # Override default
[MD004]
style = "dash"
"#;
fs::write(&config_path, config_content).expect("Failed to write integration config file");
let config_path_str = config_path.to_str().expect("Path should be valid UTF-8");
let sourced_config = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path_str), None, true)
.expect("Failed to load integration config");
let config: Config = sourced_config.into_validated_unchecked().into();
let mut rules_md013: Vec<Box<dyn rumdl_lib::rule::Rule>> = vec![Box::new(MD013LineLength::default())];
if let Some(pos) = rules_md013.iter().position(|r| r.name() == "MD013") {
let line_length =
rumdl_lib::config::get_rule_config_value::<usize>(&config, "MD013", "line_length").unwrap_or(80);
rules_md013[pos] = Box::new(MD013LineLength::new(line_length, true, false, true, false));
}
let short_content = "# Test\nThis line is short.";
let long_content = "# Test\nThis line is definitely longer than the sixty characters limit we set.";
let warnings_short = rumdl_lib::lint(
short_content,
&rules_md013,
false,
rumdl_lib::config::MarkdownFlavor::Standard,
None,
None,
)
.unwrap();
let warnings_long = rumdl_lib::lint(
long_content,
&rules_md013,
false,
rumdl_lib::config::MarkdownFlavor::Standard,
None,
None,
)
.unwrap();
assert!(
warnings_short.iter().all(|w| w.rule_name.as_deref() != Some("MD013")),
"MD013 should not trigger for short line with config"
);
assert!(
warnings_long.iter().any(|w| w.rule_name.as_deref() == Some("MD013")),
"MD013 should trigger for long line with config"
);
}
#[test]
fn test_config_validation_unknown_rule() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("unknown_rule.toml");
let config_content = r#"[UNKNOWN_RULE]"#;
fs::write(&config_path, config_content).unwrap();
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("config should load successfully"); let rules = rumdl_lib::all_rules(&rumdl_lib::config::Config::default()); let registry = RuleRegistry::from_rules(&rules);
let warnings = rumdl_lib::config::validate_config_sourced(&sourced, ®istry); assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Unknown rule"));
assert!(warnings[0].message.contains("UNKNOWN_RULE"));
}
#[test]
fn test_config_validation_unknown_option() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("unknown_option.toml");
let config_content = r#"[MD013]
unknown_opt = true"#;
fs::write(&config_path, config_content).unwrap();
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("config should load successfully"); let rules = rumdl_lib::all_rules(&rumdl_lib::config::Config::default()); let registry = RuleRegistry::from_rules(&rules);
let warnings = rumdl_lib::config::validate_config_sourced(&sourced, ®istry); assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Unknown option"));
}
#[test]
fn test_config_validation_type_mismatch() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("type_mismatch.toml");
let config_content = r#"[MD013]
line_length = "not a number""#;
fs::write(&config_path, config_content).unwrap();
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("config should load successfully"); let rules = rumdl_lib::all_rules(&rumdl_lib::config::Config::default()); let registry = RuleRegistry::from_rules(&rules);
let warnings = rumdl_lib::config::validate_config_sourced(&sourced, ®istry); assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Type mismatch"));
}
#[test]
fn test_config_validation_unknown_global_option() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("unknown_global.toml");
let config_content = r#"[global]
unknown_global = true"#;
fs::write(&config_path, config_content).unwrap();
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("config should load successfully");
let rules = rumdl_lib::all_rules(&rumdl_lib::config::Config::default());
let registry = RuleRegistry::from_rules(&rules);
let warnings = rumdl_lib::config::validate_config_sourced(&sourced, ®istry);
let global_warnings = warnings.iter().filter(|w| w.rule.is_none()).count();
assert_eq!(
global_warnings, 1,
"Expected 1 unknown global option warning for 'unknown_global'"
);
let has_unknown_key_warning = warnings
.iter()
.any(|w| w.message.contains("unknown_global") || w.message.contains("unknown-global"));
assert!(
has_unknown_key_warning,
"Expected warning about unknown_global, got: {warnings:?}"
);
}
#[test]
fn test_pyproject_toml_root_level_config() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
let config_path = temp_path.join("pyproject.toml");
let config_content = r#"
[tool.rumdl]
line-length = 120
disable = ["MD033"]
enable = ["MD001", "MD004"]
include = ["docs/*.md"]
exclude = ["node_modules"]
respect-gitignore = true
# Rule-specific settings to ensure they are picked up too
[tool.rumdl.MD007]
indent = 2
"#;
fs::write(&config_path, config_content).expect("Failed to write test pyproject.toml");
let config_path_str = config_path.to_str().expect("Path should be valid UTF-8");
let sourced_config = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path_str), None, true)
.expect("Failed to load sourced config from explicit path");
let config: Config = sourced_config.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 = rumdl_lib::config::get_rule_config_value::<usize>(&config, "MD013", "line-length");
assert_eq!(line_length, Some(120));
let indent = rumdl_lib::config::get_rule_config_value::<usize>(&config, "MD007", "indent");
assert_eq!(indent, Some(2));
}
#[cfg(test)]
mod config_file_parsing_tests {
use rumdl_lib::config::SourcedConfig;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_json_file_detection_and_parsing() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("config.json");
let config_content = r#"{
"MD004": { "style": "dash" },
"MD013": { "line_length": 100 }
}"#;
fs::write(&config_path, config_content).unwrap();
let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
assert!(result.is_ok(), "Valid JSON config should load successfully");
let config: rumdl_lib::config::Config = result.unwrap().into_validated_unchecked().into();
let md004_style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD004", "style");
assert_eq!(md004_style, Some("dash".to_string()));
}
#[test]
fn test_invalid_json_syntax_error() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("invalid.json");
let config_content = r#"{ MD004: { "style": "dash" } }"#;
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(), "Invalid JSON should fail to parse");
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("Failed to parse JSON"),
"Error should mention JSON parsing: {error_msg}"
);
assert!(
error_msg.contains("key must be a string"),
"Error should be specific about the issue: {error_msg}"
);
}
#[test]
fn test_yaml_file_detection_and_parsing() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("config.yaml");
let config_content = r#"
MD004:
style: dash
MD013:
line_length: 100
"#;
fs::write(&config_path, config_content).unwrap();
let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
assert!(result.is_ok(), "Valid YAML config should load successfully");
let config: rumdl_lib::config::Config = result.unwrap().into_validated_unchecked().into();
let md004_style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD004", "style");
assert_eq!(md004_style, Some("dash".to_string()));
}
#[test]
fn test_invalid_yaml_syntax_error() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("invalid.yaml");
let config_content = r#"
MD004:
style: dash
invalid: - syntax
"#;
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(), "Invalid YAML should fail to parse");
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("Failed to parse YAML"),
"Error should mention YAML parsing: {error_msg}"
);
}
#[test]
fn test_toml_file_detection_and_parsing() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("config.toml");
let config_content = r#"
[MD004]
style = "dash"
[MD013]
line_length = 100
"#;
fs::write(&config_path, config_content).unwrap();
let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
assert!(result.is_ok(), "Valid TOML config should load successfully");
let config: rumdl_lib::config::Config = result.unwrap().into_validated_unchecked().into();
let md004_style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD004", "style");
assert_eq!(md004_style, Some("dash".to_string()));
}
#[test]
fn test_invalid_toml_syntax_error() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("invalid.toml");
let config_content = r#"
[MD004]
style = "dash"
invalid_key =
"#;
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(), "Invalid TOML should fail to parse");
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("Failed to parse TOML"),
"Error should mention TOML parsing: {error_msg}"
);
assert!(
error_msg.contains("string values must be quoted") || error_msg.contains("invalid string"),
"Error should describe the specific issue: {error_msg}"
);
}
#[test]
fn test_markdownlint_json_file_detection() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".markdownlint.json");
let config_content = r#"{
"MD004": { "style": "asterisk" },
"line-length": { "line_length": 120 }
}"#;
fs::write(&config_path, config_content).unwrap();
let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
assert!(result.is_ok(), "Valid markdownlint JSON should load successfully");
let config: rumdl_lib::config::Config = result.unwrap().into_validated_unchecked().into();
let md004_style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD004", "style");
assert_eq!(md004_style, Some("asterisk".to_string()));
}
#[test]
fn test_markdownlint_yaml_file_detection() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".markdownlint.yml");
let config_content = r#"
MD004:
style: plus
line-length:
line_length: 90
"#;
fs::write(&config_path, config_content).unwrap();
let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
assert!(result.is_ok(), "Valid markdownlint YAML should load successfully");
let config: rumdl_lib::config::Config = result.unwrap().into_validated_unchecked().into();
let md004_style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD004", "style");
assert_eq!(md004_style, Some("plus".to_string()));
}
#[test]
fn test_file_not_found_error() {
let result = SourcedConfig::load_with_discovery(Some("/nonexistent/config.json"), None, true);
assert!(result.is_err(), "Nonexistent file should fail to load");
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("Failed to read config file"),
"Error should mention file reading failure: {error_msg}"
);
assert!(
error_msg.contains("os error"),
"Error should mention specific I/O error: {error_msg}"
);
}
#[test]
fn test_different_file_extensions_use_correct_parsers() {
let temp_dir = tempdir().unwrap();
let json_path = temp_dir.path().join("test.json");
fs::write(&json_path, r#"{ invalid: json }"#).unwrap();
let json_result = SourcedConfig::load_with_discovery(Some(json_path.to_str().unwrap()), None, true);
assert!(json_result.is_err());
assert!(json_result.unwrap_err().to_string().contains("Failed to parse JSON"));
let yaml_path = temp_dir.path().join("test.yaml");
fs::write(&yaml_path, "invalid: - yaml").unwrap();
let yaml_result = SourcedConfig::load_with_discovery(Some(yaml_path.to_str().unwrap()), None, true);
assert!(yaml_result.is_err());
assert!(yaml_result.unwrap_err().to_string().contains("Failed to parse YAML"));
let toml_path = temp_dir.path().join("test.toml");
fs::write(&toml_path, "invalid = ").unwrap();
let toml_result = SourcedConfig::load_with_discovery(Some(toml_path.to_str().unwrap()), None, true);
assert!(toml_result.is_err());
assert!(toml_result.unwrap_err().to_string().contains("Failed to parse TOML"));
let unknown_path = temp_dir.path().join("test.config");
fs::write(&unknown_path, "invalid = ").unwrap();
let unknown_result = SourcedConfig::load_with_discovery(Some(unknown_path.to_str().unwrap()), None, true);
assert!(unknown_result.is_err());
assert!(unknown_result.unwrap_err().to_string().contains("Failed to parse TOML"));
}
#[test]
fn test_jsonc_file_support() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("config.jsonc");
let config_content = r#"{
// This is a comment
"MD004": { "style": "dash" }
}"#;
fs::write(&config_path, config_content).unwrap();
let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
assert!(result.is_ok(), "JSONC config with comments should load successfully");
}
#[test]
fn test_mixed_valid_and_invalid_config_values() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("mixed.json");
let config_content = r#"{
"MD004": { "style": "valid_dash_style", "invalid_option": "should_be_ignored" },
"MD013": { "line_length": "not_a_number" },
"UNKNOWN_RULE": { "some_option": "value" }
}"#;
fs::write(&config_path, config_content).unwrap();
let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
assert!(result.is_ok(), "Config with invalid values should still load");
}
#[test]
fn test_cli_integration_config_error_messages() {
use std::process::Command;
let temp_dir = tempdir().unwrap();
let binary_path = env!("CARGO_BIN_EXE_rumdl");
let json_path = temp_dir.path().join("invalid.json");
fs::write(&json_path, r#"{ invalid: "json" }"#).unwrap();
let output = Command::new(binary_path)
.args(["check", "--config", json_path.to_str().unwrap(), "README.md"])
.output()
.expect("Failed to execute command");
assert_eq!(
output.status.code(),
Some(2),
"Expected exit code 2 for invalid JSON config"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined_output = format!("{stderr}{stdout}");
assert!(
combined_output.contains("Failed to parse JSON") || combined_output.contains("Config error"),
"CLI should show JSON parsing error: stderr='{stderr}' stdout='{stdout}'"
);
let yaml_path = temp_dir.path().join("invalid.yaml");
fs::write(&yaml_path, "invalid: - yaml").unwrap();
let output = Command::new(binary_path)
.args(["check", "--config", yaml_path.to_str().unwrap(), "README.md"])
.output()
.expect("Failed to execute command");
assert_eq!(
output.status.code(),
Some(2),
"Expected exit code 2 for invalid YAML config"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined_output = format!("{stderr}{stdout}");
assert!(
combined_output.contains("Failed to parse YAML") || combined_output.contains("Config error"),
"CLI should show YAML parsing error: stderr='{stderr}' stdout='{stdout}'"
);
let output = Command::new(binary_path)
.args(["check", "--config", "/nonexistent/config.json", "README.md"])
.output()
.expect("Failed to execute command");
assert_eq!(
output.status.code(),
Some(2),
"Expected exit code 2 for nonexistent config file"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined_output = format!("{stderr}{stdout}");
assert!(
combined_output.contains("config file not found")
|| combined_output.contains("Failed to read config file")
|| combined_output.contains("Config error"),
"CLI should show file reading error: stderr='{stderr}' stdout='{stdout}'"
);
}
#[test]
fn test_no_config_flag_bypasses_config_loading() {
use std::process::Command;
let temp_dir = tempdir().unwrap();
let binary_path = env!("CARGO_BIN_EXE_rumdl");
let invalid_config_path = temp_dir.path().join(".rumdl.toml");
fs::write(&invalid_config_path, "invalid = [toml syntax").unwrap();
let md_path = temp_dir.path().join("test.md");
fs::write(&md_path, "# Test\n\nSome content.\n").unwrap();
let output = Command::new(binary_path)
.args(["check", "--no-config", md_path.to_str().unwrap()])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(
output.status.success(),
"Command with --no-config should succeed even with invalid config in directory. stderr='{}' stdout='{}'",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
}
#[test]
fn test_config_and_no_config_flags_conflict() {
use std::process::Command;
let temp_dir = tempdir().unwrap();
let binary_path = env!("CARGO_BIN_EXE_rumdl");
let config_path = temp_dir.path().join(".rumdl.toml");
fs::write(&config_path, "[global]\n").unwrap();
let md_path = temp_dir.path().join("test.md");
fs::write(&md_path, "# Test\n").unwrap();
let output = Command::new(binary_path)
.args([
"check",
"--config",
config_path.to_str().unwrap(),
"--no-config",
md_path.to_str().unwrap(),
])
.output()
.expect("Failed to execute command");
assert!(
!output.status.success(),
"Command with both --config and --no-config should fail"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cannot be used with"),
"Error should mention flag conflict. stderr='{stderr}'"
);
}
#[test]
fn test_auto_discovery_vs_explicit_config() {
let temp_dir = tempdir().unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
let auto_config_content = r#"{ "MD004": { "style": "asterisk" } }"#;
fs::write(".markdownlint.json", auto_config_content).unwrap();
let auto_result = SourcedConfig::load_with_discovery(None, None, false);
assert!(auto_result.is_ok(), "Auto-discovery should find .markdownlint.json");
let auto_config: rumdl_lib::config::Config = auto_result.unwrap().into_validated_unchecked().into();
let auto_style = rumdl_lib::config::get_rule_config_value::<String>(&auto_config, "MD004", "style");
assert_eq!(auto_style, Some("asterisk".to_string()));
let explicit_path = temp_dir.path().join("explicit.json");
let explicit_config_content = r#"{ "MD004": { "style": "dash" } }"#;
fs::write(&explicit_path, explicit_config_content).unwrap();
let explicit_result = SourcedConfig::load_with_discovery(Some(explicit_path.to_str().unwrap()), None, false);
assert!(explicit_result.is_ok(), "Explicit config should load successfully");
let explicit_config: rumdl_lib::config::Config = explicit_result.unwrap().into_validated_unchecked().into();
let explicit_style = rumdl_lib::config::get_rule_config_value::<String>(&explicit_config, "MD004", "style");
assert_eq!(explicit_style, Some("dash".to_string()));
let skip_result = SourcedConfig::load_with_discovery(None, None, true);
assert!(skip_result.is_ok(), "Skip auto-discovery should succeed");
let skip_config: rumdl_lib::config::Config = skip_result.unwrap().into_validated_unchecked().into();
let skip_style = rumdl_lib::config::get_rule_config_value::<String>(&skip_config, "MD004", "style");
assert_eq!(skip_style, None, "Skip auto-discovery should not load any config");
std::env::set_current_dir(original_dir).unwrap();
}
}
#[test]
#[serial(cwd)]
fn test_user_configuration_discovery() {
use std::env;
let original_dir = env::current_dir().unwrap();
let temp_dir = tempdir().unwrap();
let project_dir = temp_dir.path().join("project");
let config_dir = temp_dir.path().join("config");
let rumdl_config_dir = config_dir.join("rumdl");
fs::create_dir_all(&project_dir).unwrap();
fs::create_dir_all(&rumdl_config_dir).unwrap();
let user_config_path = rumdl_config_dir.join("rumdl.toml");
let user_config_content = r#"
[global]
line-length = 88
disable = ["MD041"]
[MD007]
indent = 4
"#;
fs::write(&user_config_path, user_config_content).unwrap();
env::set_current_dir(&project_dir).unwrap();
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery_impl(None, None, false, Some(&config_dir), None)
.expect("Should load user config");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(
config.global.line_length.get(),
88,
"Should load line-length from user config"
);
assert_eq!(
config.global.disable,
vec!["MD041"],
"Should load disabled rules from user config"
);
let indent = rumdl_lib::config::get_rule_config_value::<usize>(&config, "MD007", "indent");
assert_eq!(indent, Some(4), "Should load MD007 indent from user config");
let project_config_path = project_dir.join(".rumdl.toml");
let project_config_content = r#"
[global]
line-length = 100
[MD007]
indent = 2
"#;
fs::write(&project_config_path, project_config_content).unwrap();
let sourced_with_project =
rumdl_lib::config::SourcedConfig::load_with_discovery_impl(None, None, false, Some(&config_dir), None)
.expect("Should load project config");
let config_with_project: Config = sourced_with_project.into_validated_unchecked().into();
assert_eq!(
config_with_project.global.line_length.get(),
100,
"Project config should override user config"
);
let project_indent = rumdl_lib::config::get_rule_config_value::<usize>(&config_with_project, "MD007", "indent");
assert_eq!(
project_indent,
Some(2),
"Project MD007 config should override user config"
);
env::set_current_dir(original_dir).unwrap();
}
#[test]
#[serial(cwd)]
fn test_user_configuration_file_precedence() {
use std::env;
let original_dir = env::current_dir().unwrap();
let temp_dir = tempdir().unwrap();
let project_dir = temp_dir.path().join("project");
let config_dir = temp_dir.path().join("config");
let rumdl_config_dir = config_dir.join("rumdl");
fs::create_dir_all(&project_dir).unwrap();
fs::create_dir_all(&rumdl_config_dir).unwrap();
let dot_rumdl_path = rumdl_config_dir.join(".rumdl.toml");
fs::write(
&dot_rumdl_path,
r#"[global]
line-length = 77"#,
)
.unwrap();
let rumdl_path = rumdl_config_dir.join("rumdl.toml");
fs::write(
&rumdl_path,
r#"[global]
line-length = 88"#,
)
.unwrap();
let pyproject_path = rumdl_config_dir.join("pyproject.toml");
fs::write(
&pyproject_path,
r#"[tool.rumdl.global]
line-length = 99"#,
)
.unwrap();
env::set_current_dir(&project_dir).unwrap();
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery_impl(None, None, false, Some(&config_dir), None)
.expect("Should load user config");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(
config.global.line_length.get(),
77,
".rumdl.toml should have highest precedence"
);
fs::remove_file(&dot_rumdl_path).unwrap();
let sourced2 =
rumdl_lib::config::SourcedConfig::load_with_discovery_impl(None, None, false, Some(&config_dir), None)
.expect("Should load user config");
let config2: Config = sourced2.into_validated_unchecked().into();
assert_eq!(
config2.global.line_length.get(),
88,
"rumdl.toml should be loaded when .rumdl.toml is absent"
);
fs::remove_file(&rumdl_path).unwrap();
let sourced3 =
rumdl_lib::config::SourcedConfig::load_with_discovery_impl(None, None, false, Some(&config_dir), None)
.expect("Should load user config");
let config3: Config = sourced3.into_validated_unchecked().into();
assert_eq!(
config3.global.line_length.get(),
99,
"pyproject.toml should be loaded when other configs are absent"
);
env::set_current_dir(original_dir).unwrap();
}
#[test]
fn test_cache_dir_config() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
let config_path = temp_path.join("test_cache_dir.toml");
let config_content = r#"
[global]
cache-dir = "/custom/cache/path"
"#;
fs::write(&config_path, config_content).expect("Failed to write test config file");
let config_path_str = config_path.to_str().expect("Path should be valid UTF-8");
let sourced = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path_str), None, true)
.expect("Should load config successfully");
let config: rumdl_lib::config::Config = sourced.into_validated_unchecked().into();
assert!(config.global.cache_dir.is_some(), "cache_dir should be set from config");
assert_eq!(
config.global.cache_dir.as_ref().unwrap(),
"/custom/cache/path",
"cache_dir should match the configured value"
);
let config_path2 = temp_path.join("test_cache_dir_snake.toml");
let config_content2 = r#"
[global]
cache_dir = "/another/cache/path"
"#;
fs::write(&config_path2, config_content2).expect("Failed to write test config file");
let config_path2_str = config_path2.to_str().expect("Path should be valid UTF-8");
let sourced2 = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path2_str), None, true)
.expect("Should load config successfully");
let config2: rumdl_lib::config::Config = sourced2.into_validated_unchecked().into();
assert!(
config2.global.cache_dir.is_some(),
"cache_dir should be set from config with snake_case"
);
assert_eq!(
config2.global.cache_dir.as_ref().unwrap(),
"/another/cache/path",
"cache_dir should match the configured value with snake_case"
);
let config_path3 = temp_path.join("test_no_cache_dir.toml");
let config_content3 = r#"
[global]
line-length = 100
"#;
fs::write(&config_path3, config_content3).expect("Failed to write test config file");
let config_path3_str = config_path3.to_str().expect("Path should be valid UTF-8");
let sourced3 = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path3_str), None, true)
.expect("Should load config successfully");
let config3: rumdl_lib::config::Config = sourced3.into_validated_unchecked().into();
assert!(
config3.global.cache_dir.is_none(),
"cache_dir should be None when not configured"
);
}
#[test]
fn test_cache_enabled_config() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
let config_path = temp_path.join("test_cache_disabled.toml");
let config_content = r#"
[global]
cache = false
"#;
fs::write(&config_path, config_content).expect("Failed to write test config file");
let config_path_str = config_path.to_str().expect("Path should be valid UTF-8");
let sourced = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path_str), None, true)
.expect("Should load config successfully");
let config: rumdl_lib::config::Config = sourced.into_validated_unchecked().into();
assert!(!config.global.cache, "cache should be false when configured as false");
let config_path2 = temp_path.join("test_cache_enabled.toml");
let config_content2 = r#"
[global]
cache = true
"#;
fs::write(&config_path2, config_content2).expect("Failed to write test config file");
let config_path2_str = config_path2.to_str().expect("Path should be valid UTF-8");
let sourced2 = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path2_str), None, true)
.expect("Should load config successfully");
let config2: rumdl_lib::config::Config = sourced2.into_validated_unchecked().into();
assert!(config2.global.cache, "cache should be true when configured as true");
let config_path3 = temp_path.join("test_no_cache_setting.toml");
let config_content3 = r#"
[global]
line-length = 100
"#;
fs::write(&config_path3, config_content3).expect("Failed to write test config file");
let config_path3_str = config_path3.to_str().expect("Path should be valid UTF-8");
let sourced3 = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path3_str), None, true)
.expect("Should load config successfully");
let config3: rumdl_lib::config::Config = sourced3.into_validated_unchecked().into();
assert!(config3.global.cache, "cache should default to true when not configured");
}
mod project_root_tests {
use std::fs;
use tempfile::tempdir;
#[test]
fn test_project_root_with_git_at_root() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
fs::create_dir(temp_path.join(".git")).expect("Failed to create .git");
fs::write(temp_path.join(".rumdl.toml"), "[global]").expect("Failed to write config");
fs::create_dir(temp_path.join("docs")).expect("Failed to create docs");
fs::write(temp_path.join("docs/test.md"), "# Test").expect("Failed to write test.md");
let config_path = temp_path.join(".rumdl.toml");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
assert!(sourced.project_root.is_some(), "project_root should be set");
let project_root = sourced.project_root.unwrap();
assert_eq!(
project_root.canonicalize().unwrap(),
temp_path.canonicalize().unwrap(),
"project_root should be at .git location"
);
}
#[test]
fn test_project_root_with_config_in_subdirectory() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
fs::create_dir(temp_path.join(".git")).expect("Failed to create .git");
fs::create_dir(temp_path.join(".config")).expect("Failed to create .config");
fs::write(temp_path.join(".config/.rumdl.toml"), "[global]").expect("Failed to write config");
fs::create_dir(temp_path.join("docs")).expect("Failed to create docs");
fs::write(temp_path.join("docs/test.md"), "# Test").expect("Failed to write test.md");
let config_path = temp_path.join(".config/.rumdl.toml");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
assert!(sourced.project_root.is_some(), "project_root should be set");
let project_root = sourced.project_root.unwrap();
assert_eq!(
project_root.canonicalize().unwrap(),
temp_path.canonicalize().unwrap(),
"project_root should be at .git location, not config location"
);
}
#[test]
fn test_project_root_without_git() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
fs::create_dir(temp_path.join(".config")).expect("Failed to create .config");
fs::write(temp_path.join(".config/.rumdl.toml"), "[global]").expect("Failed to write config");
fs::create_dir(temp_path.join("docs")).expect("Failed to create docs");
fs::write(temp_path.join("docs/test.md"), "# Test").expect("Failed to write test.md");
let config_path = temp_path.join(".config/.rumdl.toml");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
assert!(sourced.project_root.is_some(), "project_root should be set");
let project_root = sourced.project_root.unwrap();
assert_eq!(
project_root.canonicalize().unwrap(),
temp_path.join(".config").canonicalize().unwrap(),
"project_root should be at config location when no .git found"
);
}
#[test]
fn test_project_root_with_auto_discovery() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
fs::create_dir(temp_path.join(".git")).expect("Failed to create .git");
fs::write(temp_path.join(".rumdl.toml"), "[global]").expect("Failed to write config");
fs::create_dir_all(temp_path.join("docs/deep/nested")).expect("Failed to create nested dirs");
fs::write(temp_path.join("docs/deep/nested/test.md"), "# Test").expect("Failed to write test.md");
let original_dir = std::env::current_dir().expect("Failed to get current dir");
std::env::set_current_dir(temp_path.join("docs/deep/nested")).expect("Failed to change dir");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(None, None, false).expect("Should discover config");
std::env::set_current_dir(original_dir).expect("Failed to restore dir");
assert!(
sourced.project_root.is_some(),
"project_root should be set with auto-discovery"
);
let project_root = sourced.project_root.unwrap();
assert_eq!(
project_root.canonicalize().unwrap(),
temp_path.canonicalize().unwrap(),
"project_root should be at .git location even from nested directory"
);
}
#[test]
fn test_cache_dir_resolves_to_project_root() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
fs::create_dir(temp_path.join(".git")).expect("Failed to create .git");
fs::write(temp_path.join(".rumdl.toml"), "[global]").expect("Failed to write config");
let config_path = temp_path.join(".rumdl.toml");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let cache_dir_from_config = sourced
.global
.cache_dir
.as_ref()
.map(|sv| std::path::PathBuf::from(&sv.value));
let project_root = sourced.project_root.clone();
let mut cache_dir = cache_dir_from_config.unwrap_or_else(|| std::path::PathBuf::from(".rumdl_cache"));
if cache_dir.is_relative()
&& let Some(root) = project_root
{
cache_dir = root.join(cache_dir);
}
assert_eq!(
cache_dir.parent().unwrap().canonicalize().unwrap(),
temp_path.canonicalize().unwrap(),
"cache directory should be anchored to project root"
);
}
#[test]
fn test_config_dir_discovery() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
fs::create_dir(temp_path.join(".git")).expect("Failed to create .git");
fs::create_dir(temp_path.join(".config")).expect("Failed to create .config");
fs::write(
temp_path.join(".config/rumdl.toml"),
r#"
[global]
line-length = 42
"#,
)
.expect("Failed to write config");
let original_dir = std::env::current_dir().expect("Failed to get current dir");
std::env::set_current_dir(temp_path).expect("Failed to change dir");
let sourced = rumdl_lib::config::SourcedConfig::load_with_discovery(None, None, false)
.expect("Should discover .config/rumdl.toml");
std::env::set_current_dir(original_dir).expect("Failed to restore dir");
let config: rumdl_lib::config::Config = sourced.into_validated_unchecked().into();
assert_eq!(
config.global.line_length.get(),
42,
".config/rumdl.toml should be discovered"
);
}
#[test]
fn test_config_dir_precedence() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let temp_path = temp_dir.path();
fs::create_dir(temp_path.join(".git")).expect("Failed to create .git");
fs::write(
temp_path.join(".rumdl.toml"),
r#"
[global]
line-length = 100
"#,
)
.expect("Failed to write root config");
fs::create_dir(temp_path.join(".config")).expect("Failed to create .config");
fs::write(
temp_path.join(".config/rumdl.toml"),
r#"
[global]
line-length = 42
"#,
)
.expect("Failed to write .config config");
let original_dir = std::env::current_dir().expect("Failed to get current dir");
std::env::set_current_dir(temp_path).expect("Failed to change dir");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(None, None, false).expect("Should discover config");
std::env::set_current_dir(original_dir).expect("Failed to restore dir");
let config: rumdl_lib::config::Config = sourced.into_validated_unchecked().into();
assert_eq!(
config.global.line_length.get(),
100,
".rumdl.toml should take precedence over .config/rumdl.toml"
);
}
}
#[test]
fn test_rumdl_toml_rule_section_with_aliases() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[ul-style]
style = "dash"
[ol-prefix]
style = "ordered"
[line-length]
line-length = 100
code-blocks = false
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config with aliases");
let config: Config = sourced.into_validated_unchecked().into();
let ul_style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD004", "style");
assert_eq!(
ul_style,
Some("dash".to_string()),
"ul-style alias should resolve to MD004"
);
let ol_style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD029", "style");
assert_eq!(
ol_style,
Some("ordered".to_string()),
"ol-prefix alias should resolve to MD029"
);
let line_length = rumdl_lib::config::get_rule_config_value::<usize>(&config, "MD013", "line-length");
assert_eq!(line_length, Some(100), "line-length alias should resolve to MD013");
let code_blocks = rumdl_lib::config::get_rule_config_value::<bool>(&config, "MD013", "code-blocks");
assert_eq!(code_blocks, Some(false), "code-blocks config should work with alias");
}
#[test]
fn test_rumdl_toml_enable_disable_with_aliases() {
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 = ["ul-style", "ol-prefix", "line-length"]
disable = ["no-bare-urls", "hr-style"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(
config.global.enable.contains(&"MD004".to_string()),
"ul-style should be resolved to MD004 in enable"
);
assert!(
config.global.enable.contains(&"MD029".to_string()),
"ol-prefix should be resolved to MD029 in enable"
);
assert!(
config.global.enable.contains(&"MD013".to_string()),
"line-length should be resolved to MD013 in enable"
);
assert!(
config.global.disable.contains(&"MD034".to_string()),
"no-bare-urls should be resolved to MD034 in disable"
);
assert!(
config.global.disable.contains(&"MD035".to_string()),
"hr-style should be resolved to MD035 in disable"
);
}
#[test]
fn test_rumdl_toml_per_file_ignores_with_aliases() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[per-file-ignores]
"docs/*.md" = ["ul-style", "line-length"]
"README.md" = ["no-bare-urls"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let docs_rules = config.per_file_ignores.get("docs/*.md");
assert!(docs_rules.is_some(), "docs/*.md pattern should exist");
assert!(
docs_rules.unwrap().contains(&"MD004".to_string()),
"ul-style should be resolved to MD004"
);
assert!(
docs_rules.unwrap().contains(&"MD013".to_string()),
"line-length should be resolved to MD013"
);
let readme_rules = config.per_file_ignores.get("README.md");
assert!(readme_rules.is_some(), "README.md pattern should exist");
assert!(
readme_rules.unwrap().contains(&"MD034".to_string()),
"no-bare-urls should be resolved to MD034"
);
}
#[test]
fn test_pyproject_toml_rule_section_with_aliases() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl.ul-style]
style = "dash"
[tool.rumdl.ol-prefix]
style = "ordered"
[tool.rumdl.line-length]
line-length = 100
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load pyproject.toml with aliases");
let config: Config = sourced.into_validated_unchecked().into();
let ul_style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD004", "style");
assert_eq!(
ul_style,
Some("dash".to_string()),
"ul-style alias should resolve to MD004 in pyproject.toml"
);
let ol_style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD029", "style");
assert_eq!(
ol_style,
Some("ordered".to_string()),
"ol-prefix alias should resolve to MD029 in pyproject.toml"
);
let line_length = rumdl_lib::config::get_rule_config_value::<usize>(&config, "MD013", "line-length");
assert_eq!(
line_length,
Some(100),
"line-length alias should resolve to MD013 in pyproject.toml (section 3)"
);
}
#[test]
fn test_pyproject_toml_enable_disable_with_aliases() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl]
enable = ["ul-style", "ol-prefix"]
disable = ["no-bare-urls", "line-length"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(
config.global.enable.contains(&"MD004".to_string()),
"ul-style should be resolved to MD004"
);
assert!(
config.global.enable.contains(&"MD029".to_string()),
"ol-prefix should be resolved to MD029"
);
assert!(
config.global.disable.contains(&"MD034".to_string()),
"no-bare-urls should be resolved to MD034"
);
assert!(
config.global.disable.contains(&"MD013".to_string()),
"line-length should be resolved to MD013"
);
}
#[test]
fn test_mixed_canonical_and_alias_names() {
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", "ul-style", "MD013", "ol-prefix"]
[MD004]
style = "asterisk"
[line-length]
line-length = 120
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.enable.contains(&"MD001".to_string()));
assert!(config.global.enable.contains(&"MD004".to_string()));
assert!(config.global.enable.contains(&"MD013".to_string()));
assert!(config.global.enable.contains(&"MD029".to_string()));
let ul_style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD004", "style");
assert_eq!(ul_style, Some("asterisk".to_string()));
let line_length = rumdl_lib::config::get_rule_config_value::<usize>(&config, "MD013", "line-length");
assert_eq!(line_length, Some(120));
}
#[test]
fn test_fuzzy_matching_suggests_aliases() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[ul-sytle]
style = "dash"
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let rules = rumdl_lib::all_rules(&rumdl_lib::config::Config::default());
let registry = RuleRegistry::from_rules(&rules);
let warnings = rumdl_lib::config::validate_config_sourced(&sourced, ®istry);
for (i, warning) in warnings.iter().enumerate() {
println!("Warning {}: {}", i, warning.message);
}
assert_eq!(warnings.len(), 1, "Should have 1 validation warning");
assert!(
warnings[0].message.contains("ul-sytle"),
"Warning should mention the typo: {}",
warnings[0].message
);
assert!(
warnings[0].message.contains("ul-style"),
"Warning should suggest the correct alias in lowercase: {}",
warnings[0].message
);
}
#[test]
fn test_md007_style_explicit_from_config_file() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[MD007]
indent = 4
style = "fixed"
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let indent = rumdl_lib::config::get_rule_config_value::<u8>(&config, "MD007", "indent");
assert_eq!(indent, Some(4), "indent should be 4");
let style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD007", "style");
assert_eq!(style, Some("fixed".to_string()), "style should be fixed");
}
#[test]
fn test_md007_indent_only_config() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[MD007]
indent = 4
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let indent = rumdl_lib::config::get_rule_config_value::<u8>(&config, "MD007", "indent");
assert_eq!(indent, Some(4), "indent should be 4");
let style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD007", "style");
assert!(
style.is_none(),
"style should not be set when only indent is configured"
);
}
#[test]
fn test_md073_reads_indent_from_md007_config() {
use rumdl_lib::rule::Rule;
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[MD007]
indent = 4
[MD073]
enabled = true
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let rule = MD073TocValidation::from_config(&config);
let rule = rule
.as_any()
.downcast_ref::<MD073TocValidation>()
.expect("Should downcast to MD073TocValidation");
assert_eq!(rule.indent, 4, "MD073 should read indent from MD007 config");
}
#[test]
fn test_md073_explicit_indent_overrides_md007() {
use rumdl_lib::rule::Rule;
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[MD007]
indent = 4
[MD073]
enabled = true
indent = 3
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let rule = MD073TocValidation::from_config(&config);
let rule = rule
.as_any()
.downcast_ref::<MD073TocValidation>()
.expect("Should downcast to MD073TocValidation");
assert_eq!(rule.indent, 3, "MD073 explicit indent should override MD007");
}
#[test]
fn test_severity_config_toml() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[MD001]
severity = "warning"
[MD013]
severity = "error"
line_length = 120
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(
config.get_rule_severity("MD001"),
Some(rumdl_lib::rule::Severity::Warning),
"MD001 should have Warning severity"
);
assert_eq!(
config.get_rule_severity("MD013"),
Some(rumdl_lib::rule::Severity::Error),
"MD013 should have Error severity"
);
let line_length = rumdl_lib::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
assert_eq!(line_length, Some(120));
}
#[test]
fn test_severity_case_insensitive() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[MD001]
severity = "ERROR"
[MD003]
severity = "Warning"
[MD004]
severity = "error"
[MD005]
severity = "warning"
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(
config.get_rule_severity("MD001"),
Some(rumdl_lib::rule::Severity::Error)
);
assert_eq!(
config.get_rule_severity("MD003"),
Some(rumdl_lib::rule::Severity::Warning)
);
assert_eq!(
config.get_rule_severity("MD004"),
Some(rumdl_lib::rule::Severity::Error)
);
assert_eq!(
config.get_rule_severity("MD005"),
Some(rumdl_lib::rule::Severity::Warning)
);
}
#[test]
fn test_severity_pyproject_toml() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl]
MD001 = { severity = "warning" }
MD013 = { severity = "error", line_length = 100 }
[tool.rumdl.MD003]
severity = "error"
style = "atx"
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(
config.get_rule_severity("MD001"),
Some(rumdl_lib::rule::Severity::Warning)
);
assert_eq!(
config.get_rule_severity("MD013"),
Some(rumdl_lib::rule::Severity::Error)
);
assert_eq!(
config.get_rule_severity("MD003"),
Some(rumdl_lib::rule::Severity::Error)
);
let line_length = rumdl_lib::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
assert_eq!(line_length, Some(100));
let style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD003", "style");
assert_eq!(style.as_deref(), Some("atx"));
}
#[test]
fn test_severity_unknown_rule_validation() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[MD999]
severity = "error"
[MD001]
severity = "warning"
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let all_rules = rumdl_lib::rules::all_rules(&rumdl_lib::config::Config::default());
let registry = RuleRegistry::from_rules(&all_rules);
let validated = sourced.validate(®istry).expect("Validation should succeed");
assert!(
validated
.validation_warnings
.iter()
.any(|w| w.message.contains("MD999") && w.message.contains("nknown")),
"Should warn about unknown rule MD999"
);
let config: Config = validated.into();
assert_eq!(
config.get_rule_severity("MD001"),
Some(rumdl_lib::rule::Severity::Warning)
);
}
#[test]
fn test_severity_with_rule_aliases() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[heading-increment]
severity = "error"
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(
config.get_rule_severity("MD001"),
Some(rumdl_lib::rule::Severity::Error),
"Severity set via alias should be stored under canonical name"
);
}
#[test]
fn test_md007_indent_explicit_do_what_i_mean() {
use rumdl_lib::lint_context::LintContext;
use rumdl_lib::rule::Rule;
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[MD007]
indent = 4
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let rule = MD007ULIndent::from_config(&config);
let valid_content = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(valid_content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).expect("Rule check should succeed");
assert!(
result.is_empty(),
"With indent=4 explicit, 4-space indentation should be valid. Got: {result:?}"
);
let invalid_content = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(invalid_content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).expect("Rule check should succeed");
assert!(
!result.is_empty(),
"With indent=4 explicit, 2-space indentation should be flagged"
);
assert!(
result[0].message.contains("Expected 4 spaces"),
"Warning should say expected 4 spaces, got: {}",
result[0].message
);
}
#[test]
fn test_md007_explicit_text_aligned_overrides_indent() {
use rumdl_lib::lint_context::LintContext;
use rumdl_lib::rule::Rule;
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("test.toml");
let config_content = r#"
[MD007]
indent = 4
style = "text-aligned"
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let rule = MD007ULIndent::from_config(&config);
let content = "* Item 1\n * Item 2\n * Item 3";
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).expect("Rule check should succeed");
assert!(
result.is_empty(),
"With explicit text-aligned style, 2-space indentation should be valid. Got: {result:?}"
);
}
#[test]
#[allow(deprecated)]
fn test_global_config_all_fields_roundtrip() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("complete_config.toml");
let config_content = r#"
[global]
# Vec fields - non-empty
enable = ["MD001", "MD003"]
disable = ["MD013", "MD041"]
include = ["docs/**/*.md", "README.md"]
exclude = ["node_modules/**", "vendor/**"]
fixable = ["MD009", "MD010"]
unfixable = ["MD033"]
# Boolean fields - opposite of default
respect-gitignore = false
cache = false
force-exclude = true
# Option/scalar fields - set to non-default values
line-length = 120
output-format = "json"
cache-dir = "/custom/cache/path"
flavor = "mkdocs"
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let config_path_str = config_path.to_str().expect("Path should be valid UTF-8");
let sourced = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path_str), None, true)
.expect("Should load config successfully");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(
config.global.enable,
vec!["MD001", "MD003"],
"enable field should be populated"
);
assert_eq!(
config.global.disable,
vec!["MD013", "MD041"],
"disable field should be populated"
);
assert_eq!(
config.global.include,
vec!["docs/**/*.md", "README.md"],
"include field should be populated"
);
assert_eq!(
config.global.exclude,
vec!["node_modules/**", "vendor/**"],
"exclude field should be populated"
);
assert_eq!(
config.global.fixable,
vec!["MD009", "MD010"],
"fixable field should be populated"
);
assert_eq!(
config.global.unfixable,
vec!["MD033"],
"unfixable field should be populated"
);
assert!(
!config.global.respect_gitignore,
"respect_gitignore should be false (non-default)"
);
assert!(!config.global.cache, "cache should be false (non-default)");
assert!(
config.global.force_exclude,
"force_exclude should be true (non-default)"
);
assert_eq!(config.global.line_length.get(), 120, "line_length should be 120");
assert_eq!(
config.global.output_format.as_deref(),
Some("json"),
"output_format should be 'json'"
);
assert_eq!(
config.global.cache_dir.as_deref(),
Some("/custom/cache/path"),
"cache_dir should be '/custom/cache/path'"
);
assert_eq!(
config.global.flavor,
rumdl_lib::config::MarkdownFlavor::MkDocs,
"flavor should be MkDocs"
);
}
#[test]
#[allow(deprecated)]
fn test_global_config_all_fields_pyproject_toml() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl]
enable = ["MD002", "MD004"]
disable = ["MD014", "MD042"]
include = ["src/**/*.md"]
exclude = [".git/**"]
fixable = ["MD011"]
unfixable = ["MD034"]
respect-gitignore = false
cache = false
force-exclude = true
line-length = 100
output-format = "pylint"
cache-dir = "/pyproject/cache"
flavor = "quarto"
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let config_path_str = config_path.to_str().expect("Path should be valid UTF-8");
let sourced = rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path_str), None, true)
.expect("Should load pyproject.toml successfully");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.enable, vec!["MD002", "MD004"]);
assert_eq!(config.global.disable, vec!["MD014", "MD042"]);
assert_eq!(config.global.include, vec!["src/**/*.md"]);
assert_eq!(config.global.exclude, vec![".git/**"]);
assert_eq!(config.global.fixable, vec!["MD011"]);
assert_eq!(config.global.unfixable, vec!["MD034"]);
assert!(!config.global.respect_gitignore);
assert!(!config.global.cache);
assert!(config.global.force_exclude);
assert_eq!(config.global.line_length.get(), 100);
assert_eq!(config.global.output_format.as_deref(), Some("pylint"));
assert_eq!(config.global.cache_dir.as_deref(), Some("/pyproject/cache"));
assert_eq!(config.global.flavor, rumdl_lib::config::MarkdownFlavor::Quarto);
}
#[test]
fn test_per_file_ignores_brace_expansion_required() {
use std::path::PathBuf;
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-ignores]
"AGENTS.md,README.md" = ["MD033"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let ignored_agents = config.get_ignored_rules_for_file(&PathBuf::from("AGENTS.md"));
assert!(
ignored_agents.is_empty(),
"Pattern 'AGENTS.md,README.md' should NOT match 'AGENTS.md' (commas are literal in glob patterns)"
);
let ignored_readme = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
assert!(
ignored_readme.is_empty(),
"Pattern 'AGENTS.md,README.md' should NOT match 'README.md' (commas are literal in glob patterns)"
);
let ignored_literal = config.get_ignored_rules_for_file(&PathBuf::from("AGENTS.md,README.md"));
assert!(
ignored_literal.contains("MD033"),
"Pattern 'AGENTS.md,README.md' should match literal filename 'AGENTS.md,README.md'"
);
let config_content = r#"
[per-file-ignores]
"{AGENTS.md,README.md}" = ["MD033"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let ignored_agents = config.get_ignored_rules_for_file(&PathBuf::from("AGENTS.md"));
assert!(
ignored_agents.contains("MD033"),
"Pattern '{{AGENTS.md,README.md}}' should match 'AGENTS.md'"
);
let ignored_readme = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
assert!(
ignored_readme.contains("MD033"),
"Pattern '{{AGENTS.md,README.md}}' should match 'README.md'"
);
let ignored_literal = config.get_ignored_rules_for_file(&PathBuf::from("AGENTS.md,README.md"));
assert!(
ignored_literal.is_empty(),
"Brace pattern should NOT match literal 'AGENTS.md,README.md'"
);
}
#[test]
fn test_per_file_ignores_brace_expansion_edge_cases() {
use std::path::PathBuf;
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-ignores]
"a.md,b.md,c.md" = ["MD033"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.get_ignored_rules_for_file(&PathBuf::from("a.md")).is_empty());
assert!(config.get_ignored_rules_for_file(&PathBuf::from("b.md")).is_empty());
assert!(config.get_ignored_rules_for_file(&PathBuf::from("c.md")).is_empty());
let config_content = r#"
[per-file-ignores]
"{*.md,*.txt}" = ["MD013"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(
config
.get_ignored_rules_for_file(&PathBuf::from("test.md"))
.contains("MD013")
);
assert!(
config
.get_ignored_rules_for_file(&PathBuf::from("test.txt"))
.contains("MD013")
);
assert!(config.get_ignored_rules_for_file(&PathBuf::from("test.rs")).is_empty());
let config_content = r#"
[per-file-ignores]
"path/with,comma/file.md" = ["MD033"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(
config
.get_ignored_rules_for_file(&PathBuf::from("path/with,comma/file.md"))
.contains("MD033")
);
assert!(
config
.get_ignored_rules_for_file(&PathBuf::from("path/with/file.md"))
.is_empty()
);
let config_content = r#"
[per-file-ignores]
"README.{md,txt}" = ["MD041"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(
config
.get_ignored_rules_for_file(&PathBuf::from("README.md"))
.contains("MD041")
);
assert!(
config
.get_ignored_rules_for_file(&PathBuf::from("README.txt"))
.contains("MD041")
);
assert!(
config
.get_ignored_rules_for_file(&PathBuf::from("README.rst"))
.is_empty()
);
}
#[test]
fn test_per_file_ignores_brace_expansion_no_false_warning() {
use std::path::PathBuf;
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[per-file-ignores]
"{docs,guides}/**/*.md" = ["MD013"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let ignored_docs = config.get_ignored_rules_for_file(&PathBuf::from("docs/file.md"));
assert!(ignored_docs.contains("MD013"), "Should match docs/file.md");
let ignored_guides = config.get_ignored_rules_for_file(&PathBuf::from("guides/file.md"));
assert!(ignored_guides.contains("MD013"), "Should match guides/file.md");
let ignored_other = config.get_ignored_rules_for_file(&PathBuf::from("other/file.md"));
assert!(ignored_other.is_empty(), "Should NOT match other/file.md");
}
#[test]
fn test_per_file_ignores_brace_expansion_pyproject() {
use std::path::PathBuf;
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl.per-file-ignores]
"{AGENTS.md,README.md}" = ["MD033"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let ignored_agents = config.get_ignored_rules_for_file(&PathBuf::from("AGENTS.md"));
assert!(
ignored_agents.contains("MD033"),
"Brace expansion should work in pyproject.toml for AGENTS.md"
);
let ignored_readme = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
assert!(
ignored_readme.contains("MD033"),
"Brace expansion should work in pyproject.toml for README.md"
);
let config_content = r#"
[tool.rumdl.per-file-ignores]
"AGENTS.md,README.md" = ["MD033"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced =
rumdl_lib::config::SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(
config
.get_ignored_rules_for_file(&PathBuf::from("AGENTS.md"))
.is_empty(),
"Comma pattern in pyproject.toml should NOT match AGENTS.md"
);
assert!(
config
.get_ignored_rules_for_file(&PathBuf::from("README.md"))
.is_empty(),
"Comma pattern in pyproject.toml should NOT match README.md"
);
}
#[test]
fn test_extend_enable_config_rumdl_toml() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
extend-enable = ["MD060", "MD063"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.extend_enable.len(), 2);
assert!(config.global.extend_enable.contains(&"MD060".to_string()));
assert!(config.global.extend_enable.contains(&"MD063".to_string()));
}
#[test]
fn test_extend_disable_config_rumdl_toml() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
extend-disable = ["MD013", "MD033"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.extend_disable.len(), 2);
assert!(config.global.extend_disable.contains(&"MD013".to_string()));
assert!(config.global.extend_disable.contains(&"MD033".to_string()));
}
#[test]
fn test_extend_enable_config_pyproject_toml() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("pyproject.toml");
let config_content = r#"
[tool.rumdl]
extend-enable = ["MD060"]
extend-disable = ["MD013"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.extend_enable.contains(&"MD060".to_string()));
assert!(config.global.extend_disable.contains(&"MD013".to_string()));
}
#[test]
fn test_extend_enable_with_aliases() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
extend-enable = ["table-format", "heading-capitalization"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(
config.global.extend_enable.contains(&"MD060".to_string()),
"table-format should resolve to MD060, got: {:?}",
config.global.extend_enable
);
assert!(
config.global.extend_enable.contains(&"MD063".to_string()),
"heading-capitalization should resolve to MD063, got: {:?}",
config.global.extend_enable
);
}
#[test]
fn test_extend_enable_snake_case_key() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
extend_enable = ["MD060"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.extend_enable.contains(&"MD060".to_string()));
}
#[test]
fn test_opt_in_rules_excluded_by_default() {
let config = Config::default();
let all = all_rules(&config);
let filtered = filter_rules(&all, &config.global);
let filtered_names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
let opt_in_set = opt_in_rules();
for name in &opt_in_set {
assert!(
!filtered_names.contains(*name),
"Opt-in rule {name} should not be in default filtered rules"
);
}
assert!(filtered_names.contains("MD001"));
assert!(filtered_names.contains("MD013"));
assert!(filtered_names.contains("MD058"));
}
#[test]
fn test_enable_all_includes_opt_in() {
let config = Config::default();
let all = all_rules(&config);
let mut global = config.global.clone();
global.enable = vec!["ALL".to_string()];
let filtered = filter_rules(&all, &global);
assert_eq!(filtered.len(), all.len());
let filtered_names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(filtered_names.contains("MD060"));
assert!(filtered_names.contains("MD063"));
assert!(filtered_names.contains("MD072"));
}
#[test]
fn test_extend_enable_adds_opt_in_to_defaults() {
let config = Config::default();
let all = all_rules(&config);
let num_opt_in = opt_in_rules().len();
let mut global = config.global.clone();
global.extend_enable = vec!["MD060".to_string(), "MD063".to_string()];
let filtered = filter_rules(&all, &global);
assert_eq!(filtered.len(), all.len() - num_opt_in + 2);
let filtered_names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(filtered_names.contains("MD060"));
assert!(filtered_names.contains("MD063"));
assert!(!filtered_names.contains("MD072"));
}
#[test]
fn test_disable_overrides_extend_enable() {
let config = Config::default();
let all = all_rules(&config);
let mut global = config.global.clone();
global.extend_enable = vec!["MD060".to_string()];
global.disable = vec!["MD060".to_string()];
let filtered = filter_rules(&all, &global);
let filtered_names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(!filtered_names.contains("MD060"));
}
#[test]
fn test_extend_disable_removes_from_defaults() {
let config = Config::default();
let all = all_rules(&config);
let mut global = config.global.clone();
global.extend_disable = vec!["MD001".to_string(), "MD013".to_string()];
let filtered = filter_rules(&all, &global);
let filtered_names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(!filtered_names.contains("MD001"));
assert!(!filtered_names.contains("MD013"));
}
#[test]
fn test_enable_empty_means_no_rules() {
let config = Config::default();
let all = all_rules(&config);
let mut global = config.global.clone();
global.enable_is_explicit = true;
let filtered = filter_rules(&all, &global);
assert_eq!(filtered.len(), 0);
}
#[test]
fn test_enable_empty_plus_extend_enable() {
let config = Config::default();
let all = all_rules(&config);
let mut global = config.global.clone();
global.enable_is_explicit = true;
global.extend_enable = vec!["MD001".to_string()];
let filtered = filter_rules(&all, &global);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name(), "MD001");
}
#[test]
fn test_enable_specific_plus_extend_enable() {
let config = Config::default();
let all = all_rules(&config);
let mut global = config.global.clone();
global.enable = vec!["MD001".to_string()];
global.extend_enable = vec!["MD013".to_string()];
let filtered = filter_rules(&all, &global);
assert_eq!(filtered.len(), 2);
let names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(names.contains("MD001"));
assert!(names.contains("MD013"));
}
#[test]
fn test_enable_all_plus_disable_specific() {
let config = Config::default();
let all = all_rules(&config);
let mut global = config.global.clone();
global.enable = vec!["ALL".to_string()];
global.disable = vec!["MD060".to_string(), "MD013".to_string()];
let filtered = filter_rules(&all, &global);
assert_eq!(filtered.len(), all.len() - 2);
let names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(!names.contains("MD060"));
assert!(!names.contains("MD013"));
}
#[test]
fn test_backward_compat_enabled_true_bridge() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD060]
enabled = true
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(
config.global.extend_enable.contains(&"MD060".to_string()),
"Backward compat bridge should add MD060 to extend_enable, got: {:?}",
config.global.extend_enable
);
}
#[test]
fn test_per_rule_enabled_true_overrides_global_disable() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
disable = ["MD060"]
[MD060]
enabled = true
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let all = all_rules(&config);
let filtered = filter_rules(&all, &config.global);
let names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(
names.contains("MD060"),
"per-rule enabled=true should override global disable"
);
assert!(
!config.global.disable.contains(&"MD060".to_string()),
"MD060 should be removed from disable list"
);
}
#[test]
fn test_per_rule_enabled_false_disables_rule() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD041]
enabled = false
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(
!config.global.extend_enable.contains(&"MD041".to_string()),
"enabled=false should not add to extend_enable"
);
assert!(
config.global.disable.contains(&"MD041".to_string()),
"enabled=false should add to disable list"
);
let all = all_rules(&config);
let filtered = filter_rules(&all, &config.global);
let names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(!names.contains("MD041"), "MD041 should be excluded when enabled=false");
}
#[test]
fn test_extend_enable_all_keyword() {
let config = Config::default();
let all = all_rules(&config);
let total = all.len();
let mut global = config.global.clone();
global.extend_enable = vec!["ALL".to_string()];
let filtered = filter_rules(&all, &global);
assert_eq!(
filtered.len(),
total,
"extend-enable = [\"ALL\"] should enable all {total} rules",
);
}
#[test]
fn test_extend_enable_all_with_specific_enable() {
let config = Config::default();
let all = all_rules(&config);
let total = all.len();
let mut global = config.global.clone();
global.enable = vec!["MD001".to_string()];
global.extend_enable = vec!["ALL".to_string()];
let filtered = filter_rules(&all, &global);
assert_eq!(
filtered.len(),
total,
"enable + extend-enable=[\"ALL\"] should enable all rules"
);
}
#[test]
fn test_extend_disable_all_keyword() {
let config = Config::default();
let all = all_rules(&config);
let mut global = config.global.clone();
global.extend_disable = vec!["all".to_string()];
let filtered = filter_rules(&all, &global);
assert_eq!(filtered.len(), 0, "extend-disable = [\"all\"] should disable all rules");
}
#[test]
fn test_extend_disable_all_case_insensitive() {
let config = Config::default();
let all = all_rules(&config);
let mut global = config.global.clone();
global.extend_disable = vec!["ALL".to_string()];
let filtered = filter_rules(&all, &global);
assert_eq!(
filtered.len(),
0,
"extend-disable = [\"ALL\"] should disable all rules (case-insensitive)"
);
}
#[test]
fn test_extend_enable_all_with_extend_disable_specific() {
let config = Config::default();
let all = all_rules(&config);
let total = all.len();
let mut global = config.global.clone();
global.extend_enable = vec!["ALL".to_string()];
global.extend_disable = vec!["MD013".to_string()];
let filtered = filter_rules(&all, &global);
assert_eq!(filtered.len(), total - 1);
let names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(!names.contains("MD013"));
}
#[test]
fn test_md072_key_order_not_flagged_as_unknown() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD072]
key-order = ["description", "title"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let rules = all_rules(&config);
let registry = RuleRegistry::from_rules(&rules);
let valid_keys = registry
.config_keys_for("MD072")
.expect("MD072 should exist in registry");
assert!(
valid_keys.contains("key-order") || valid_keys.contains("key_order"),
"key-order/key_order should be a valid config key for MD072, got: {valid_keys:?}",
);
}
#[test]
fn test_md072_key_order_snake_case_not_flagged() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD072]
key_order = ["description", "title"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let rules = all_rules(&config);
let registry = RuleRegistry::from_rules(&rules);
let valid_keys = registry
.config_keys_for("MD072")
.expect("MD072 should exist in registry");
assert!(
valid_keys.contains("key_order"),
"key_order should be a valid config key for MD072, got: {valid_keys:?}",
);
}
#[test]
fn test_md072_required_keys_loads_from_config_and_warns() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[MD072]
enabled = true
required-keys = ["title", "date"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
let rules = all_rules(&config);
let registry = RuleRegistry::from_rules(&rules);
let valid_keys = registry
.config_keys_for("MD072")
.expect("MD072 should exist in registry");
assert!(
valid_keys.contains("required-keys") || valid_keys.contains("required_keys"),
"required-keys/required_keys should be a valid config key for MD072, got: {valid_keys:?}",
);
let md072 = rules
.iter()
.find(|r| r.name() == "MD072")
.expect("MD072 should be in the configured rule set");
let content = "---\ntitle: Test\n---\n\n# Heading\n";
let ctx = rumdl_lib::lint_context::LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let warnings = md072.check(&ctx).expect("check should succeed");
assert_eq!(warnings.len(), 1, "exactly the missing 'date' key should be reported");
assert!(warnings[0].message.contains("missing required key 'date'"));
}
#[test]
fn test_md072_unknown_option_still_detected() {
let config = Config::default();
let rules = all_rules(&config);
let registry = RuleRegistry::from_rules(&rules);
let valid_keys = registry
.config_keys_for("MD072")
.expect("MD072 should exist in registry");
assert!(
!valid_keys.contains("nonexistent-option"),
"nonexistent-option should NOT be a valid config key for MD072"
);
}
#[test]
fn test_md072_key_order_no_type_mismatch_warning() {
let config = Config::default();
let rules = all_rules(&config);
let registry = RuleRegistry::from_rules(&rules);
let expected = registry.expected_value_for("MD072", "key_order");
assert!(
expected.is_none(),
"expected_value_for should return None for nullable key_order, got: {expected:?}",
);
}
#[test]
fn test_top_level_line_length() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("rumdl.toml");
let config_content = r#"
line-length = 120
[MD004]
style = "dash"
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.line_length.get(), 120);
}
#[test]
fn test_top_level_disable() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("rumdl.toml");
let config_content = r#"
disable = ["MD013", "MD033"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.disable.contains(&"MD013".to_string()));
assert!(config.global.disable.contains(&"MD033".to_string()));
}
#[test]
fn test_top_level_exclude() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("rumdl.toml");
let config_content = r#"
exclude = ["node_modules", "build/**"]
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.exclude.contains(&"node_modules".to_string()));
assert!(config.global.exclude.contains(&"build/**".to_string()));
}
#[test]
fn test_top_level_respect_gitignore() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("rumdl.toml");
let config_content = r#"
respect-gitignore = false
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert!(!config.global.respect_gitignore);
}
#[test]
fn test_top_level_snake_case_keys() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("rumdl.toml");
let config_content = r#"
line_length = 100
respect_gitignore = false
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.line_length.get(), 100);
assert!(!config.global.respect_gitignore);
}
#[test]
fn test_global_section_overrides_top_level() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("rumdl.toml");
let config_content = r#"
line-length = 120
[global]
line-length = 80
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(
config.global.line_length.get(),
80,
"[global] section should override top-level key"
);
}
#[test]
fn test_top_level_keys_coexist_with_rule_sections() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("rumdl.toml");
let config_content = r#"
line-length = 88
disable = []
exclude = []
respect-gitignore = true
[MD004]
style = "dash"
[MD013]
line_length = 88
code_blocks = false
tables = true
headings = true
strict = false
[MD029]
style = "ordered"
[MD035]
style = "---"
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let rules = rumdl_lib::all_rules(&Config::default());
let registry = RuleRegistry::from_rules(&rules);
let warnings = rumdl_lib::config::validate_config_sourced(&sourced, ®istry);
let unknown_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("Unknown")).collect();
assert!(
unknown_warnings.is_empty(),
"Top-level global keys should not produce unknown key warnings, got: {unknown_warnings:?}"
);
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.line_length.get(), 88);
assert!(config.global.respect_gitignore);
}
#[test]
fn test_top_level_global_value_with_rule_table() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("rumdl.toml");
let config_content = r#"
line-length = 88
exclude = ["vendor/**"]
[MD013]
code-blocks = false
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.line_length.get(), 88);
assert!(config.global.exclude.contains(&"vendor/**".to_string()));
assert!(config.rules.contains_key("MD013"), "MD013 rule config should exist");
}
#[test]
fn test_top_level_all_global_keys() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("rumdl.toml");
let config_content = r#"
enable = ["MD001", "MD003"]
disable = ["MD033"]
include = ["docs/**"]
exclude = ["vendor/**"]
extend-enable = ["MD041"]
extend-disable = ["MD010"]
respect-gitignore = false
force-exclude = true
line-length = 100
output-format = "json"
cache-dir = "/tmp/rumdl-cache"
cache = false
fixable = ["MD009"]
unfixable = ["MD013"]
flavor = "mkdocs"
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let rules = rumdl_lib::all_rules(&Config::default());
let registry = RuleRegistry::from_rules(&rules);
let warnings = rumdl_lib::config::validate_config_sourced(&sourced, ®istry);
let unknown_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("Unknown")).collect();
assert!(
unknown_warnings.is_empty(),
"All top-level global keys should be recognized, got: {unknown_warnings:?}"
);
let config: Config = sourced.into_validated_unchecked().into();
assert!(config.global.enable.contains(&"MD001".to_string()));
assert!(config.global.enable.contains(&"MD003".to_string()));
assert!(config.global.disable.contains(&"MD033".to_string()));
assert!(config.global.include.contains(&"docs/**".to_string()));
assert!(config.global.exclude.contains(&"vendor/**".to_string()));
assert!(config.global.extend_enable.contains(&"MD041".to_string()));
assert!(config.global.extend_disable.contains(&"MD010".to_string()));
assert!(!config.global.respect_gitignore);
#[allow(deprecated)]
{
assert!(config.global.force_exclude);
}
assert_eq!(config.global.line_length.get(), 100);
assert_eq!(config.global.output_format.as_deref(), Some("json"));
assert_eq!(config.global.cache_dir.as_deref(), Some("/tmp/rumdl-cache"));
assert!(!config.global.cache);
assert!(config.global.fixable.contains(&"MD009".to_string()));
assert!(config.global.unfixable.contains(&"MD013".to_string()));
assert_eq!(config.global.flavor, rumdl_lib::config::MarkdownFlavor::MkDocs);
}
#[test]
fn test_unknown_top_level_value_key_produces_warning() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let config_path = temp_dir.path().join("rumdl.toml");
let config_content = r#"
line-length = 100
typo-key = true
"#;
fs::write(&config_path, config_content).expect("Failed to write config");
let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
.expect("Should load config");
let rules = rumdl_lib::all_rules(&Config::default());
let registry = RuleRegistry::from_rules(&rules);
let warnings = rumdl_lib::config::validate_config_sourced(&sourced, ®istry);
let line_length_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("line-length")).collect();
assert!(
line_length_warnings.is_empty(),
"Known global key 'line-length' should not produce warnings"
);
let typo_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("typo-key")).collect();
assert!(
!typo_warnings.is_empty(),
"Unknown top-level value key 'typo-key' should produce a warning"
);
let config: Config = sourced.into_validated_unchecked().into();
assert_eq!(config.global.line_length.get(), 100);
}