use rumdl_lib::config::SourcedConfig;
use rumdl_lib::config::types::ConfigError;
use std::fs;
use tempfile::tempdir;
fn load_config(path: &std::path::Path) -> Result<rumdl_lib::config::Config, ConfigError> {
SourcedConfig::load_with_discovery(Some(path.to_str().unwrap()), None, false)
.map(|s| s.into_validated_unchecked().into())
}
#[test]
fn test_extends_basic_inheritance() {
let dir = tempdir().unwrap();
let base = dir.path().join("base.rumdl.toml");
fs::write(
&base,
r#"
[global]
disable = ["MD033"]
[MD013]
line-length = 80
"#,
)
.unwrap();
let child = dir.path().join("child.rumdl.toml");
fs::write(
&child,
r#"extends = "base.rumdl.toml"
[MD013]
line-length = 120
"#,
)
.unwrap();
let config = load_config(&child).unwrap();
assert!(
config.global.disable.contains(&"MD033".to_string()),
"Child should inherit disable list from base"
);
let line_length = rumdl_lib::config::get_rule_config_value::<i64>(&config, "MD013", "line-length");
assert_eq!(line_length, Some(120), "Child should override line-length from base");
}
#[test]
fn test_extends_deep_chain() {
let dir = tempdir().unwrap();
let c = dir.path().join("c.rumdl.toml");
fs::write(
&c,
r#"
[global]
disable = ["MD041"]
"#,
)
.unwrap();
let b = dir.path().join("b.rumdl.toml");
fs::write(
&b,
r#"extends = "c.rumdl.toml"
[global]
disable = ["MD033"]
"#,
)
.unwrap();
let a = dir.path().join("a.rumdl.toml");
fs::write(
&a,
r#"extends = "b.rumdl.toml"
[global]
disable = ["MD013"]
"#,
)
.unwrap();
let config = load_config(&a).unwrap();
assert!(
config.global.disable.contains(&"MD013".to_string()),
"A's disable list should be applied"
);
}
#[test]
fn test_extends_child_overrides_all() {
let dir = tempdir().unwrap();
let base = dir.path().join("base.rumdl.toml");
fs::write(
&base,
r#"
[global]
disable = ["MD033"]
flavor = "mkdocs"
"#,
)
.unwrap();
let child = dir.path().join("child.rumdl.toml");
fs::write(
&child,
r#"extends = "base.rumdl.toml"
[global]
disable = ["MD013"]
flavor = "standard"
"#,
)
.unwrap();
let config = load_config(&child).unwrap();
assert!(
config.global.disable.contains(&"MD013".to_string()),
"Child's disable should be present"
);
assert!(
!config.global.disable.contains(&"MD033".to_string()),
"Base's disable should be replaced, not merged"
);
use rumdl_lib::config::MarkdownFlavor;
assert_eq!(
config.global.flavor,
MarkdownFlavor::Standard,
"Child flavor should override base flavor"
);
}
#[test]
fn test_extends_relative_path_resolution() {
let dir = tempdir().unwrap();
let sub_dir = dir.path().join("subdir");
fs::create_dir_all(&sub_dir).unwrap();
let base = dir.path().join("base.rumdl.toml");
fs::write(
&base,
r#"
[global]
disable = ["MD001"]
"#,
)
.unwrap();
let child = sub_dir.join("child.rumdl.toml");
fs::write(
&child,
r#"extends = "../base.rumdl.toml"
[global]
disable = ["MD002"]
"#,
)
.unwrap();
let config = load_config(&child).unwrap();
assert!(
config.global.disable.contains(&"MD002".to_string()),
"Child's disable should be applied"
);
}
#[test]
fn test_extends_absolute_path_resolution() {
let dir = tempdir().unwrap();
let base = dir.path().join("base.rumdl.toml");
fs::write(
&base,
r#"
[global]
disable = ["MD041"]
"#,
)
.unwrap();
let base_absolute = base.canonicalize().unwrap();
let child_content = format!(
r#"extends = '{}'
[global]
disable = ["MD013"]
"#,
base_absolute.display()
);
let child = dir.path().join("child.rumdl.toml");
fs::write(&child, &child_content).unwrap();
let config = load_config(&child).unwrap();
assert!(
config.global.disable.contains(&"MD013".to_string()),
"Child's config should load with absolute path extends"
);
}
#[test]
fn test_extends_missing_base_file_gives_clear_error() {
let dir = tempdir().unwrap();
let child = dir.path().join("child.rumdl.toml");
fs::write(
&child,
r#"extends = "nonexistent_base.rumdl.toml"
[global]
disable = ["MD013"]
"#,
)
.unwrap();
let result = load_config(&child);
assert!(result.is_err(), "Loading config with missing base should fail");
match result.unwrap_err() {
ConfigError::ExtendsNotFound { path, from } => {
assert!(
path.contains("nonexistent_base.rumdl.toml"),
"Error should mention missing file path, got path: {path}"
);
assert!(
from.contains("child.rumdl.toml"),
"Error should mention the referencing file, got from: {from}"
);
}
other => panic!("Expected ExtendsNotFound error, got: {other:?}"),
}
}
#[test]
fn test_extends_circular_reference_is_detected() {
let dir = tempdir().unwrap();
let a = dir.path().join("a.rumdl.toml");
let b = dir.path().join("b.rumdl.toml");
fs::write(&a, r#"extends = "b.rumdl.toml""#).unwrap();
fs::write(&b, r#"extends = "a.rumdl.toml""#).unwrap();
let result = load_config(&a);
assert!(result.is_err(), "Circular extends should produce an error");
match result.unwrap_err() {
ConfigError::CircularExtends { path, .. } => {
assert!(
path.contains("a.rumdl.toml") || path.contains("b.rumdl.toml"),
"Error should mention a file in the cycle, got: {path}"
);
}
other => panic!("Expected CircularExtends error, got: {other:?}"),
}
}
#[test]
fn test_extends_self_reference_is_detected() {
let dir = tempdir().unwrap();
let config = dir.path().join("self.rumdl.toml");
fs::write(&config, r#"extends = "self.rumdl.toml""#).unwrap();
let result = load_config(&config);
assert!(result.is_err(), "Self-referential extends should produce an error");
match result.unwrap_err() {
ConfigError::CircularExtends { .. } => {} other => panic!("Expected CircularExtends error, got: {other:?}"),
}
}
#[test]
fn test_extends_chain_propagation() {
let dir = tempdir().unwrap();
let c = dir.path().join("c.rumdl.toml");
fs::write(
&c,
r#"
[MD007]
indent = 4
"#,
)
.unwrap();
let b = dir.path().join("b.rumdl.toml");
fs::write(
&b,
r#"extends = "c.rumdl.toml"
[MD003]
style = "atx"
"#,
)
.unwrap();
let a = dir.path().join("a.rumdl.toml");
fs::write(&a, r#"extends = "b.rumdl.toml""#).unwrap();
let config = load_config(&a).unwrap();
let indent = rumdl_lib::config::get_rule_config_value::<i64>(&config, "MD007", "indent");
assert_eq!(indent, Some(4), "A should inherit MD007.indent from C via the chain");
let style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD003", "style");
assert_eq!(style, Some("atx".to_string()), "A should inherit MD003.style from B");
}
#[test]
fn test_extends_rule_config_child_overrides_base() {
let dir = tempdir().unwrap();
let base = dir.path().join("base.rumdl.toml");
fs::write(
&base,
r#"
[MD013]
line-length = 80
[MD003]
style = "atx"
"#,
)
.unwrap();
let child = dir.path().join("child.rumdl.toml");
fs::write(
&child,
r#"extends = "base.rumdl.toml"
[MD013]
line-length = 100
"#,
)
.unwrap();
let config = load_config(&child).unwrap();
let line_length = rumdl_lib::config::get_rule_config_value::<i64>(&config, "MD013", "line-length");
assert_eq!(line_length, Some(100), "Child should override MD013.line-length");
let style = rumdl_lib::config::get_rule_config_value::<String>(&config, "MD003", "style");
assert_eq!(
style,
Some("atx".to_string()),
"MD003.style should be inherited from base"
);
}
#[test]
fn test_extends_loaded_files_tracks_chain() {
let dir = tempdir().unwrap();
let base = dir.path().join("base.rumdl.toml");
fs::write(&base, "").unwrap();
let child = dir.path().join("child.rumdl.toml");
fs::write(&child, r#"extends = "base.rumdl.toml""#).unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(child.to_str().unwrap()), None, false).unwrap();
assert!(
sourced.loaded_files.len() >= 2,
"Both base and child should appear in loaded_files, got: {:?}",
sourced.loaded_files
);
let has_base = sourced.loaded_files.iter().any(|f| f.contains("base.rumdl.toml"));
let has_child = sourced.loaded_files.iter().any(|f| f.contains("child.rumdl.toml"));
assert!(has_base, "base.rumdl.toml should be in loaded_files");
assert!(has_child, "child.rumdl.toml should be in loaded_files");
}
#[test]
fn test_extends_extend_enable_union_semantics() {
let dir = tempdir().unwrap();
let base = dir.path().join("base.rumdl.toml");
fs::write(
&base,
r#"
[global]
extend-enable = ["MD060"]
"#,
)
.unwrap();
let child = dir.path().join("child.rumdl.toml");
fs::write(
&child,
r#"extends = "base.rumdl.toml"
[global]
extend-enable = ["MD063"]
"#,
)
.unwrap();
let config = load_config(&child).unwrap();
assert!(
config.global.extend_enable.contains(&"MD060".to_string()),
"Base's extend-enable should be present"
);
assert!(
config.global.extend_enable.contains(&"MD063".to_string()),
"Child's extend-enable should be present"
);
}
#[test]
fn test_extends_deep_chain_replace_semantics() {
let dir = tempdir().unwrap();
let c = dir.path().join("c.rumdl.toml");
fs::write(&c, "[global]\ndisable = [\"MD041\"]\n").unwrap();
let b = dir.path().join("b.rumdl.toml");
fs::write(&b, "extends = \"c.rumdl.toml\"\n[global]\ndisable = [\"MD033\"]\n").unwrap();
let a = dir.path().join("a.rumdl.toml");
fs::write(&a, "extends = \"b.rumdl.toml\"\n[global]\ndisable = [\"MD013\"]\n").unwrap();
let config = load_config(&a).unwrap();
assert_eq!(
config.global.disable,
vec!["MD013".to_string()],
"disable should contain only A's value (replace semantics)"
);
assert!(
!config.global.disable.contains(&"MD033".to_string()),
"B's disable should not leak into A"
);
assert!(
!config.global.disable.contains(&"MD041".to_string()),
"C's disable should not leak into A"
);
}
#[test]
fn test_extends_per_file_ignores_inherited() {
let dir = tempdir().unwrap();
let base = dir.path().join("base.rumdl.toml");
fs::write(
&base,
r#"
[per-file-ignores]
"docs/*.md" = ["MD013"]
"README.md" = ["MD041"]
"#,
)
.unwrap();
let child = dir.path().join("child.rumdl.toml");
fs::write(
&child,
r#"extends = "base.rumdl.toml"
[global]
line-length = 100
"#,
)
.unwrap();
let config = load_config(&child).unwrap();
assert!(
config.per_file_ignores.contains_key("docs/*.md"),
"Child should inherit per_file_ignores from base"
);
assert!(
config.per_file_ignores.contains_key("README.md"),
"Child should inherit all per_file_ignores patterns from base"
);
}
#[test]
fn test_extends_per_file_ignores_replaced_by_child() {
let dir = tempdir().unwrap();
let base = dir.path().join("base.rumdl.toml");
fs::write(
&base,
r#"
[per-file-ignores]
"docs/*.md" = ["MD013"]
"#,
)
.unwrap();
let child = dir.path().join("child.rumdl.toml");
fs::write(
&child,
r#"extends = "base.rumdl.toml"
[per-file-ignores]
"src/*.md" = ["MD041"]
"#,
)
.unwrap();
let config = load_config(&child).unwrap();
assert!(
config.per_file_ignores.contains_key("src/*.md"),
"Child's per_file_ignores should be present"
);
assert!(
!config.per_file_ignores.contains_key("docs/*.md"),
"Base's per_file_ignores should be replaced by child's"
);
}
#[test]
fn test_extends_origin_attribution() {
let dir = tempdir().unwrap();
let base = dir.path().join("base.rumdl.toml");
let child = dir.path().join(".rumdl.toml");
fs::write(
&base,
r#"exclude = ["drafts"]
[MD013]
line-length = 100
"#,
)
.unwrap();
fs::write(
&child,
r#"extends = "base.rumdl.toml"
enable = ["MD001", "MD013"]
[MD013]
line-length = 120
"#,
)
.unwrap();
let sourced = SourcedConfig::load_with_discovery(Some(child.to_str().unwrap()), None, false).unwrap();
let origin_of = |origin: &Option<String>| -> String {
origin
.as_deref()
.and_then(|f| std::path::Path::new(f).file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default()
};
assert_eq!(origin_of(&sourced.global.exclude.origin), "base.rumdl.toml");
assert_eq!(origin_of(&sourced.global.enable.origin), ".rumdl.toml");
let md013 = sourced.rules.get("MD013").expect("MD013 config present");
let line_length = md013.values.get("line-length").expect("line-length set");
assert_eq!(toml::Value::Integer(120), line_length.value);
assert_eq!(origin_of(&line_length.origin), ".rumdl.toml");
}
struct EnvVarGuard {
key: String,
}
impl EnvVarGuard {
fn set(key: &str, value: &std::path::Path) -> Self {
unsafe { std::env::set_var(key, value) };
Self { key: key.to_string() }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe { std::env::remove_var(&self.key) };
}
}
#[test]
#[serial_test::serial]
fn test_extends_env_var_expansion() {
let dir = tempdir().unwrap();
let base = dir.path().join("base.rumdl.toml");
fs::write(
&base,
r#"
[global]
disable = ["MD033"]
[MD013]
line-length = 80
"#,
)
.unwrap();
let _guard = EnvVarGuard::set("RUMDL_TEST_EXTENDS_BASE_DIR", dir.path());
let child = dir.path().join("child.rumdl.toml");
fs::write(
&child,
"extends = '$RUMDL_TEST_EXTENDS_BASE_DIR/base.rumdl.toml'\n\n[MD013]\nline-length = 120\n",
)
.unwrap();
let config = load_config(&child).unwrap();
assert!(
config.global.disable.contains(&"MD033".to_string()),
"child should inherit the base via an env-var-expanded extends path"
);
let line_length = rumdl_lib::config::get_rule_config_value::<i64>(&config, "MD013", "line-length");
assert_eq!(line_length, Some(120), "child override should still apply");
}
#[test]
#[serial_test::serial]
fn test_extends_env_var_braced_form() {
let dir = tempdir().unwrap();
let base = dir.path().join("base.rumdl.toml");
fs::write(&base, "[global]\ndisable = [\"MD041\"]\n").unwrap();
let _guard = EnvVarGuard::set("RUMDL_TEST_EXTENDS_BRACED_DIR", dir.path());
let child = dir.path().join("child.rumdl.toml");
fs::write(&child, "extends = '${RUMDL_TEST_EXTENDS_BRACED_DIR}/base.rumdl.toml'\n").unwrap();
let config = load_config(&child).unwrap();
assert!(
config.global.disable.contains(&"MD041".to_string()),
"braced ${{VAR}} form should resolve and inherit the base"
);
}
#[test]
#[serial_test::serial]
fn test_extends_undefined_env_var_gives_clear_error() {
let dir = tempdir().unwrap();
unsafe { std::env::remove_var("RUMDL_TEST_DEFINITELY_UNSET_667") };
let child = dir.path().join("child.rumdl.toml");
fs::write(&child, "extends = '$RUMDL_TEST_DEFINITELY_UNSET_667/base.rumdl.toml'\n").unwrap();
match load_config(&child).unwrap_err() {
ConfigError::ExtendsUndefinedVar { var, from } => {
assert_eq!(
var, "RUMDL_TEST_DEFINITELY_UNSET_667",
"error should name the missing variable"
);
assert!(
from.contains("child.rumdl.toml"),
"error should name the referencing file, got from: {from}"
);
}
other => panic!("Expected ExtendsUndefinedVar error, got: {other:?}"),
}
}
#[test]
#[serial_test::serial]
fn test_extends_env_var_cycle_is_detected() {
let dir = tempdir().unwrap();
let _guard = EnvVarGuard::set("RUMDL_TEST_EXTENDS_SELF_DIR", dir.path());
let cfg = dir.path().join("self.rumdl.toml");
fs::write(&cfg, "extends = '$RUMDL_TEST_EXTENDS_SELF_DIR/self.rumdl.toml'\n").unwrap();
match load_config(&cfg).unwrap_err() {
ConfigError::CircularExtends { .. } => {} other => panic!("Expected CircularExtends via env-expanded path, got: {other:?}"),
}
}