use rumdl_lib::config::{Config, GlobalConfig, RuleConfig, RuleRegistry};
use rumdl_lib::rules::{all_rules, filter_rules, opt_in_rules};
use std::collections::{BTreeMap, HashSet};
#[test]
fn test_all_rules_returns_all_rules() {
let config = Config::default();
let rules = all_rules(&config);
assert_eq!(rules.len(), 71);
let rule_names: HashSet<String> = rules.iter().map(|r| r.name().to_string()).collect();
assert!(rule_names.contains("MD001"));
assert!(rule_names.contains("MD058"));
assert!(rule_names.contains("MD025"));
assert!(rule_names.contains("MD071"));
assert!(rule_names.contains("MD072"));
assert!(rule_names.contains("MD073"));
assert!(rule_names.contains("MD074"));
assert!(rule_names.contains("MD076"));
}
#[test]
fn test_filter_rules_with_empty_config() {
let config = Config::default();
let all = all_rules(&config);
let global_config = GlobalConfig::default();
let filtered = filter_rules(&all, &global_config);
let num_opt_in = opt_in_rules().len();
assert_eq!(filtered.len(), all.len() - num_opt_in);
let filtered_names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
for opt_in_name in opt_in_rules() {
assert!(
!filtered_names.contains(opt_in_name),
"Opt-in rule {opt_in_name} should not be in default filter_rules output"
);
}
}
#[test]
fn test_filter_rules_disable_specific_rules() {
let config = Config::default();
let all = all_rules(&config);
let num_opt_in = opt_in_rules().len();
let global_config = GlobalConfig {
disable: vec!["MD001".to_string(), "MD004".to_string(), "MD003".to_string()],
..Default::default()
};
let filtered = filter_rules(&all, &global_config);
assert_eq!(filtered.len(), all.len() - num_opt_in - 3);
let rule_names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(!rule_names.contains("MD001"));
assert!(!rule_names.contains("MD004"));
assert!(!rule_names.contains("MD003"));
assert!(rule_names.contains("MD005"));
assert!(rule_names.contains("MD058"));
}
#[test]
fn test_filter_rules_disable_all() {
let config = Config::default();
let all = all_rules(&config);
let global_config = GlobalConfig {
disable: vec!["all".to_string()],
..Default::default()
};
let filtered = filter_rules(&all, &global_config);
assert_eq!(filtered.len(), 0);
}
#[test]
fn test_filter_rules_disable_all_but_enable_specific() {
let config = Config::default();
let all = all_rules(&config);
let global_config = GlobalConfig {
disable: vec!["all".to_string()],
enable: vec!["MD001".to_string(), "MD005".to_string(), "MD010".to_string()],
..Default::default()
};
let filtered = filter_rules(&all, &global_config);
assert_eq!(filtered.len(), 3);
let rule_names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(rule_names.contains("MD001"));
assert!(rule_names.contains("MD005"));
assert!(rule_names.contains("MD010"));
assert!(!rule_names.contains("MD003"));
assert!(!rule_names.contains("MD004"));
}
#[test]
fn test_filter_rules_enable_only_specific() {
let config = Config::default();
let all = all_rules(&config);
let global_config = GlobalConfig {
enable: vec!["MD001".to_string(), "MD004".to_string()],
..Default::default()
};
let filtered = filter_rules(&all, &global_config);
assert_eq!(filtered.len(), 2);
let rule_names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(rule_names.contains("MD001"));
assert!(rule_names.contains("MD004"));
assert!(!rule_names.contains("MD003"));
}
#[test]
fn test_filter_rules_enable_with_disable_override() {
let config = Config::default();
let all = all_rules(&config);
let global_config = GlobalConfig {
enable: vec!["MD001".to_string(), "MD004".to_string(), "MD003".to_string()],
disable: vec!["MD004".to_string()],
..Default::default()
};
let filtered = filter_rules(&all, &global_config);
assert_eq!(filtered.len(), 2);
let rule_names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(rule_names.contains("MD001"));
assert!(!rule_names.contains("MD004")); assert!(rule_names.contains("MD003"));
}
#[test]
fn test_filter_rules_complex_scenario() {
let config = Config::default();
let all = all_rules(&config);
let global_config = GlobalConfig {
disable: vec![
"MD001".to_string(),
"MD003".to_string(),
"MD004".to_string(),
"MD005".to_string(),
],
..Default::default()
};
let filtered = filter_rules(&all, &global_config);
let num_opt_in = opt_in_rules().len();
assert_eq!(filtered.len(), all.len() - num_opt_in - 4);
let rule_names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(!rule_names.contains("MD001"));
assert!(!rule_names.contains("MD003"));
assert!(!rule_names.contains("MD004"));
assert!(!rule_names.contains("MD005"));
assert!(rule_names.contains("MD007"));
assert!(rule_names.contains("MD010"));
assert!(rule_names.contains("MD058"));
}
#[test]
fn test_all_rules_consistency() {
let config = Config::default();
let rules1 = all_rules(&config);
let rules2 = all_rules(&config);
assert_eq!(rules1.len(), rules2.len());
let mut seen_names = HashSet::new();
for rule in &rules1 {
let name = rule.name();
assert!(seen_names.insert(name.to_string()), "Duplicate rule name: {name}");
}
}
#[test]
fn test_filter_rules_preserves_rule_order() {
let config = Config::default();
let all = all_rules(&config);
let opt_in_set = opt_in_rules();
let global_config = GlobalConfig {
disable: vec!["MD010".to_string(), "MD020".to_string(), "MD030".to_string()],
..Default::default()
};
let filtered = filter_rules(&all, &global_config);
let all_names: Vec<String> = all
.iter()
.map(|r| r.name().to_string())
.filter(|name| !global_config.disable.contains(name) && !opt_in_set.contains(name.as_str()))
.collect();
let filtered_names: Vec<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert_eq!(all_names, filtered_names);
}
#[test]
fn test_filter_rules_enable_all_keyword() {
let config = Config::default();
let all = all_rules(&config);
let total = all.len();
let global_config = GlobalConfig {
enable: vec!["ALL".to_string()],
..Default::default()
};
let filtered = filter_rules(&all, &global_config);
assert_eq!(filtered.len(), total);
}
#[test]
fn test_filter_rules_enable_all_with_disable() {
let config = Config::default();
let all = all_rules(&config);
let total = all.len();
let global_config = GlobalConfig {
enable: vec!["ALL".to_string()],
disable: vec!["MD013".to_string()],
..Default::default()
};
let filtered = filter_rules(&all, &global_config);
assert_eq!(filtered.len(), total - 1);
let rule_names: HashSet<String> = filtered.iter().map(|r| r.name().to_string()).collect();
assert!(!rule_names.contains("MD013"));
assert!(rule_names.contains("MD001"));
}
#[test]
fn test_filter_rules_enable_all_case_insensitive() {
let config = Config::default();
let all = all_rules(&config);
let total = all.len();
let global_config = GlobalConfig {
enable: vec!["all".to_string()],
..Default::default()
};
let filtered = filter_rules(&all, &global_config);
assert_eq!(filtered.len(), total);
let global_config = GlobalConfig {
enable: vec!["All".to_string()],
..Default::default()
};
let filtered = filter_rules(&all, &global_config);
assert_eq!(filtered.len(), total);
}
#[test]
fn test_filter_rules_enable_all_overrides_disable_all() {
let config = Config::default();
let all = all_rules(&config);
let total = all.len();
let global_config = GlobalConfig {
enable: vec!["ALL".to_string()],
disable: vec!["all".to_string()],
..Default::default()
};
let filtered = filter_rules(&all, &global_config);
assert_eq!(filtered.len(), total);
}
#[test]
fn test_filter_rules_empty_enable_returns_non_opt_in() {
let config = Config::default();
let all = all_rules(&config);
let num_opt_in = opt_in_rules().len();
let global_config = GlobalConfig::default();
let filtered = filter_rules(&all, &global_config);
assert_eq!(filtered.len(), all.len() - num_opt_in);
}
#[test]
fn test_all_configurable_rules_expose_config_schema() {
let config = Config::default();
let rules = all_rules(&config);
let registry = RuleRegistry::from_rules(&rules);
let mut rules_with_config = Vec::new();
let mut rules_without_config = Vec::new();
for rule in &rules {
let name = rule.name().to_string();
if rule.default_config_section().is_some() {
rules_with_config.push(name);
} else {
rules_without_config.push(name);
}
}
for name in &rules_with_config {
assert!(
registry.rule_schemas.contains_key(name.as_str()),
"Registry missing schema for configurable rule {name}"
);
}
assert_eq!(
rules_with_config.len(),
46,
"Expected 46 rules with config sections. If you added config to a rule, \
implement default_config_section(). Rules with config: {rules_with_config:?}"
);
}
#[test]
fn test_promote_opt_in_enabled_adds_to_extend_enable() {
let mut config = Config::default();
let mut values = BTreeMap::new();
values.insert("enabled".to_string(), toml::Value::Boolean(true));
values.insert("style".to_string(), toml::Value::String("aligned".to_string()));
config
.rules
.insert("MD060".to_string(), RuleConfig { severity: None, values });
assert!(
!config.global.extend_enable.contains(&"MD060".to_string()),
"MD060 should not be in extend_enable before promotion"
);
config.apply_per_rule_enabled();
assert!(
config.global.extend_enable.contains(&"MD060".to_string()),
"MD060 should be in extend_enable after promotion"
);
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"),
"MD060 should be included by filter_rules after promotion"
);
}
#[test]
fn test_per_rule_enabled_false_adds_to_disable() {
let mut config = Config::default();
let mut values = BTreeMap::new();
values.insert("enabled".to_string(), toml::Value::Boolean(false));
config
.rules
.insert("MD060".to_string(), RuleConfig { severity: None, values });
config.apply_per_rule_enabled();
assert!(
!config.global.extend_enable.contains(&"MD060".to_string()),
"MD060 should NOT be in extend_enable when enabled=false"
);
assert!(
config.global.disable.contains(&"MD060".to_string()),
"MD060 should be added to disable when enabled=false"
);
}
#[test]
fn test_per_rule_enabled_false_disables_non_opt_in_rule() {
let mut config = Config::default();
let mut values = BTreeMap::new();
values.insert("enabled".to_string(), toml::Value::Boolean(false));
config
.rules
.insert("MD041".to_string(), RuleConfig { severity: None, values });
config.apply_per_rule_enabled();
assert!(
config.global.disable.contains(&"MD041".to_string()),
"MD041 should be in 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 by filter_rules when enabled=false"
);
}
#[test]
fn test_per_rule_enabled_true_overrides_global_disable() {
let mut config = Config::default();
config.global.disable.push("MD001".to_string());
let mut values = BTreeMap::new();
values.insert("enabled".to_string(), toml::Value::Boolean(true));
config
.rules
.insert("MD001".to_string(), RuleConfig { severity: None, values });
config.apply_per_rule_enabled();
assert!(
!config.global.disable.contains(&"MD001".to_string()),
"MD001 should be removed from disable when enabled=true"
);
assert!(
config.global.extend_enable.contains(&"MD001".to_string()),
"MD001 should be in extend_enable when enabled=true"
);
}
#[test]
fn test_per_rule_enabled_false_overrides_extend_enable() {
let mut config = Config::default();
config.global.extend_enable.push("MD060".to_string());
let mut values = BTreeMap::new();
values.insert("enabled".to_string(), toml::Value::Boolean(false));
config
.rules
.insert("MD060".to_string(), RuleConfig { severity: None, values });
config.apply_per_rule_enabled();
assert!(
!config.global.extend_enable.contains(&"MD060".to_string()),
"MD060 should be removed from extend_enable when enabled=false"
);
assert!(
config.global.disable.contains(&"MD060".to_string()),
"MD060 should be in disable when enabled=false"
);
}
#[test]
fn test_per_rule_enabled_true_overrides_extend_disable() {
let mut config = Config::default();
config.global.extend_disable.push("MD001".to_string());
let mut values = BTreeMap::new();
values.insert("enabled".to_string(), toml::Value::Boolean(true));
config
.rules
.insert("MD001".to_string(), RuleConfig { severity: None, values });
config.apply_per_rule_enabled();
assert!(
!config.global.extend_disable.contains(&"MD001".to_string()),
"MD001 should be removed from extend_disable when enabled=true"
);
assert!(
config.global.extend_enable.contains(&"MD001".to_string()),
"MD001 should be in extend_enable when enabled=true"
);
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("MD001"),
"MD001 should be included when per-rule enabled=true overrides extend-disable"
);
}
#[test]
fn test_promote_opt_in_enabled_no_duplicate_when_already_extended() {
let mut config = Config::default();
config.global.extend_enable.push("MD060".to_string());
let mut values = BTreeMap::new();
values.insert("enabled".to_string(), toml::Value::Boolean(true));
config
.rules
.insert("MD060".to_string(), RuleConfig { severity: None, values });
config.apply_per_rule_enabled();
let count = config.global.extend_enable.iter().filter(|s| *s == "MD060").count();
assert_eq!(count, 1, "MD060 should not be duplicated in extend_enable");
}
#[test]
fn test_promote_enabled_harmless_for_non_opt_in_rules() {
let mut config = Config::default();
let mut values = BTreeMap::new();
values.insert("enabled".to_string(), toml::Value::Boolean(true));
config
.rules
.insert("MD001".to_string(), RuleConfig { severity: None, values });
config.apply_per_rule_enabled();
assert!(config.global.extend_enable.contains(&"MD001".to_string()));
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("MD001"),
"MD001 should be included (non-opt-in, always active)"
);
}
#[test]
fn test_promote_opt_in_md060_fix_produces_aligned_table() {
let mut config = Config::default();
config.global.disable.push("MD041".to_string());
let mut values = BTreeMap::new();
values.insert("enabled".to_string(), toml::Value::Boolean(true));
values.insert("style".to_string(), toml::Value::String("aligned".to_string()));
config
.rules
.insert("MD060".to_string(), RuleConfig { severity: None, values });
config.apply_per_rule_enabled();
let all = all_rules(&config);
let rules = filter_rules(&all, &config.global);
let content = "|Column 1 |Column 2|\n|:--|--:|\n|Test|Val |\n|New|Val|\n";
let warnings = rumdl_lib::lint(
content,
&rules,
false,
rumdl_lib::config::MarkdownFlavor::Obsidian,
None,
Some(&config),
)
.unwrap();
let has_md060 = warnings
.iter()
.any(|w| w.rule_name.as_ref().is_some_and(|name| name == "MD060"));
assert!(has_md060, "Should detect MD060 warnings for unaligned table");
}
#[test]
fn test_extend_enable_includes_opt_in_rules_in_filter() {
let mut config = Config::default();
config.global.extend_enable.push("MD060".to_string());
let mut values = BTreeMap::new();
values.insert("style".to_string(), toml::Value::String("aligned".to_string()));
config
.rules
.insert("MD060".to_string(), RuleConfig { severity: None, values });
let all = all_rules(&config);
let rules = filter_rules(&all, &config.global);
let names: HashSet<String> = rules.iter().map(|r| r.name().to_string()).collect();
assert!(
names.contains("MD060"),
"MD060 should be included when in extend_enable"
);
}
#[test]
fn test_fixable_field_populates_config() {
let mut config = Config::default();
config.global.fixable = vec!["MD009".to_string(), "MD047".to_string()];
config.global.unfixable = vec!["MD013".to_string()];
assert_eq!(config.global.fixable.len(), 2);
assert!(config.global.fixable.contains(&"MD009".to_string()));
assert!(config.global.fixable.contains(&"MD047".to_string()));
assert_eq!(config.global.unfixable.len(), 1);
assert!(config.global.unfixable.contains(&"MD013".to_string()));
}
#[test]
fn test_unfixable_field_populates_config() {
let mut config = Config::default();
config.global.unfixable = vec!["MD009".to_string()];
assert_eq!(config.global.unfixable.len(), 1);
assert!(config.global.unfixable.contains(&"MD009".to_string()));
assert!(config.global.fixable.is_empty());
}
#[test]
fn test_enable_is_explicit_empty_means_no_rules() {
let mut config = Config::default();
config.global.enable = Vec::new();
config.global.enable_is_explicit = true;
let all = all_rules(&config);
let rules = filter_rules(&all, &config.global);
assert!(
rules.is_empty(),
"With enable_is_explicit=true and empty enable, no rules should be active"
);
}
#[test]
fn test_enable_is_explicit_with_extend_enable() {
let mut config = Config::default();
config.global.enable = Vec::new();
config.global.enable_is_explicit = true;
config.global.extend_enable = vec!["MD001".to_string(), "MD009".to_string()];
let all = all_rules(&config);
let rules = filter_rules(&all, &config.global);
let names: HashSet<String> = rules.iter().map(|r| r.name().to_string()).collect();
assert_eq!(names.len(), 2, "Only the 2 extend-enable rules should be active");
assert!(names.contains("MD001"));
assert!(names.contains("MD009"));
}
#[test]
fn test_enable_not_explicit_empty_means_all_defaults() {
let config = Config::default();
assert!(!config.global.enable_is_explicit);
let all = all_rules(&config);
let rules = filter_rules(&all, &config.global);
let num_opt_in = opt_in_rules().len();
assert_eq!(
rules.len(),
all.len() - num_opt_in,
"Without enable_is_explicit, all default (non-opt-in) rules should run"
);
}
#[test]
fn test_flavor_alias_qmd_maps_to_quarto() {
let flavor: rumdl_lib::config::MarkdownFlavor = "qmd".parse().unwrap();
assert_eq!(flavor, rumdl_lib::config::MarkdownFlavor::Quarto);
}
#[test]
fn test_flavor_alias_rmd_maps_to_quarto() {
let flavor: rumdl_lib::config::MarkdownFlavor = "rmd".parse().unwrap();
assert_eq!(flavor, rumdl_lib::config::MarkdownFlavor::Quarto);
}
#[test]
fn test_flavor_alias_rmarkdown_maps_to_quarto() {
let flavor: rumdl_lib::config::MarkdownFlavor = "rmarkdown".parse().unwrap();
assert_eq!(flavor, rumdl_lib::config::MarkdownFlavor::Quarto);
}
#[test]
fn test_flavor_alias_gfm_maps_to_standard() {
let flavor: rumdl_lib::config::MarkdownFlavor = "gfm".parse().unwrap();
assert_eq!(flavor, rumdl_lib::config::MarkdownFlavor::Standard);
}
#[test]
fn test_flavor_alias_commonmark_maps_to_standard() {
let flavor: rumdl_lib::config::MarkdownFlavor = "commonmark".parse().unwrap();
assert_eq!(flavor, rumdl_lib::config::MarkdownFlavor::Standard);
}
#[test]
fn test_flavor_alias_github_maps_to_standard() {
let flavor: rumdl_lib::config::MarkdownFlavor = "github".parse().unwrap();
assert_eq!(flavor, rumdl_lib::config::MarkdownFlavor::Standard);
}
#[test]
fn test_flavor_alias_jekyll_maps_to_kramdown() {
let flavor: rumdl_lib::config::MarkdownFlavor = "jekyll".parse().unwrap();
assert_eq!(flavor, rumdl_lib::config::MarkdownFlavor::Kramdown);
}
#[allow(deprecated)]
#[test]
fn test_wasm_config_parity_all_global_fields_wired() {
let gc = GlobalConfig::default();
let GlobalConfig {
enable,
disable,
extend_enable,
extend_disable,
line_length,
flavor,
fixable,
unfixable,
enable_is_explicit,
exclude: _,
include: _,
respect_gitignore: _,
output_format: _,
force_exclude: _,
cache_dir: _,
cache: _,
} = gc;
assert!(enable.is_empty());
assert!(disable.is_empty());
assert!(extend_enable.is_empty());
assert!(extend_disable.is_empty());
assert_eq!(line_length.get(), 80);
assert_eq!(flavor, rumdl_lib::config::MarkdownFlavor::Standard);
assert!(fixable.is_empty());
assert!(unfixable.is_empty());
assert!(!enable_is_explicit);
let mut config = Config::default();
config.global.disable = vec!["MD041".to_string()];
config.global.enable = vec!["MD001".to_string(), "MD009".to_string()];
config.global.enable_is_explicit = true;
config.global.extend_enable = vec!["MD060".to_string()];
config.global.extend_disable = vec!["MD013".to_string()];
config.global.line_length = rumdl_lib::types::LineLength::new(120);
config.global.flavor = rumdl_lib::config::MarkdownFlavor::MkDocs;
config.global.fixable = vec!["MD009".to_string()];
config.global.unfixable = vec!["MD033".to_string()];
assert_eq!(config.global.disable, vec!["MD041".to_string()], "disable");
assert_eq!(
config.global.enable,
vec!["MD001".to_string(), "MD009".to_string()],
"enable"
);
assert!(config.global.enable_is_explicit, "enable_is_explicit");
assert_eq!(config.global.extend_enable, vec!["MD060".to_string()], "extend_enable");
assert_eq!(
config.global.extend_disable,
vec!["MD013".to_string()],
"extend_disable"
);
assert_eq!(config.global.line_length.get(), 120, "line_length");
assert_eq!(
config.global.flavor,
rumdl_lib::config::MarkdownFlavor::MkDocs,
"flavor"
);
assert_eq!(config.global.fixable, vec!["MD009".to_string()], "fixable");
assert_eq!(config.global.unfixable, vec!["MD033".to_string()], "unfixable");
let all = all_rules(&config);
let rules = filter_rules(&all, &config.global);
let names: HashSet<String> = rules.iter().map(|r| r.name().to_string()).collect();
assert!(names.contains("MD001"), "MD001 should be in enabled set");
assert!(names.contains("MD009"), "MD009 should be in enabled set");
assert!(names.contains("MD060"), "MD060 should be included via extend_enable");
assert!(!names.contains("MD041"), "MD041 should be disabled");
assert!(!names.contains("MD013"), "MD013 should be disabled via extend_disable");
}