use std::str::FromStr;
use super::flavor::{MarkdownFlavor, normalize_key};
use super::registry::RuleRegistry;
use super::source_tracking::{ConfigSource, SourcedGlobalConfig, SourcedValue};
use super::types::GlobalConfig;
use crate::types::LineLength;
pub const GLOBAL_VALUE_KEYS: &[&str] = &[
"enable",
"disable",
"include",
"exclude",
"extend-enable",
"extend-disable",
"respect-gitignore",
"force-exclude",
"line-length",
"output-format",
"cache-dir",
"cache",
"fixable",
"unfixable",
"flavor",
"editorconfig",
];
pub fn is_global_value_key(key: &str) -> bool {
GLOBAL_VALUE_KEYS.contains(&key)
}
#[derive(Debug)]
pub enum GlobalKeyValue {
Set(toml::Value, ConfigSource),
Unset,
}
pub fn read_global_key(
effective: &GlobalConfig,
sourced: &SourcedGlobalConfig,
norm_key: &str,
) -> Option<GlobalKeyValue> {
let strings = |value: &[String], sv: &SourcedValue<Vec<String>>| {
GlobalKeyValue::Set(
toml::Value::Array(value.iter().map(|s| toml::Value::String(s.clone())).collect()),
sv.source,
)
};
let boolean = |value: bool, sv: &SourcedValue<bool>| GlobalKeyValue::Set(toml::Value::Boolean(value), sv.source);
let optional_string = |value: &Option<String>, slot: &Option<SourcedValue<String>>| match value {
Some(value) => GlobalKeyValue::Set(
toml::Value::String(value.clone()),
slot.as_ref().map_or(ConfigSource::Default, |sv| sv.source),
),
None => GlobalKeyValue::Unset,
};
Some(match norm_key {
"enable" => strings(&effective.enable, &sourced.enable),
"disable" => strings(&effective.disable, &sourced.disable),
"include" => strings(&effective.include, &sourced.include),
"exclude" => strings(&effective.exclude, &sourced.exclude),
"extend-enable" => strings(&effective.extend_enable, &sourced.extend_enable),
"extend-disable" => strings(&effective.extend_disable, &sourced.extend_disable),
"fixable" => strings(&effective.fixable, &sourced.fixable),
"unfixable" => strings(&effective.unfixable, &sourced.unfixable),
"respect-gitignore" => boolean(effective.respect_gitignore, &sourced.respect_gitignore),
"force-exclude" => {
#[allow(deprecated)]
let value = effective.force_exclude;
boolean(value, &sourced.force_exclude)
}
"cache" => boolean(effective.cache, &sourced.cache),
"editorconfig" => boolean(effective.editorconfig, &sourced.editorconfig),
"line-length" => GlobalKeyValue::Set(
toml::Value::Integer(effective.line_length.get() as i64),
sourced.line_length.source,
),
"output-format" => optional_string(&effective.output_format, &sourced.output_format),
"cache-dir" => optional_string(&effective.cache_dir, &sourced.cache_dir),
"flavor" => GlobalKeyValue::Set(toml::Value::String(effective.flavor.to_string()), sourced.flavor.source),
_ => return None,
})
}
#[derive(Debug)]
pub enum ApplyOutcome {
Applied,
TypeMismatch { expected: &'static str },
InvalidValue { message: String },
Unrecognized,
}
pub fn apply_global_key(
global: &mut SourcedGlobalConfig,
norm_key: &str,
value: &toml::Value,
source: ConfigSource,
origin: Option<&str>,
registry: &RuleRegistry,
) -> ApplyOutcome {
let origin = origin.map(std::string::ToString::to_string);
let resolve_rule_list = |arr: &[toml::Value]| -> Vec<String> {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
.collect()
};
let to_strings =
|arr: &[toml::Value]| -> Vec<String> { arr.iter().filter_map(|v| v.as_str()).map(str::to_string).collect() };
match norm_key {
"enable" | "disable" | "extend-enable" | "extend-disable" | "fixable" | "unfixable" => {
let toml::Value::Array(arr) = value else {
return ApplyOutcome::TypeMismatch { expected: "array" };
};
let values = resolve_rule_list(arr);
match norm_key {
"enable" => global.enable.push_override(values, source, origin),
"disable" => global.disable.push_override(values, source, origin),
"extend-enable" => global.extend_enable.push_override(values, source, origin),
"extend-disable" => global.extend_disable.push_override(values, source, origin),
"fixable" => global.fixable.push_override(values, source, origin),
"unfixable" => global.unfixable.push_override(values, source, origin),
_ => unreachable!("outer match limits the keys"),
}
ApplyOutcome::Applied
}
"include" | "exclude" => {
let toml::Value::Array(arr) = value else {
return ApplyOutcome::TypeMismatch { expected: "array" };
};
let values = to_strings(arr);
match norm_key {
"include" => global.include.push_override(values, source, origin),
"exclude" => global.exclude.push_override(values, source, origin),
_ => unreachable!("outer match limits the keys"),
}
ApplyOutcome::Applied
}
"respect-gitignore" | "force-exclude" | "cache" | "editorconfig" => {
let Some(b) = value.as_bool() else {
return ApplyOutcome::TypeMismatch { expected: "boolean" };
};
match norm_key {
"respect-gitignore" => global.respect_gitignore.push_override(b, source, origin),
"force-exclude" => global.force_exclude.push_override(b, source, origin),
"cache" => global.cache.push_override(b, source, origin),
"editorconfig" => global.editorconfig.push_override(b, source, origin),
_ => unreachable!("outer match limits the keys"),
}
ApplyOutcome::Applied
}
"line-length" => {
let Some(n) = value.as_integer() else {
return ApplyOutcome::TypeMismatch { expected: "integer" };
};
global
.line_length
.push_override(LineLength::new(n.max(0) as usize), source, origin);
ApplyOutcome::Applied
}
"output-format" | "cache-dir" => {
let Some(s) = value.as_str() else {
return ApplyOutcome::TypeMismatch { expected: "string" };
};
let slot = match norm_key {
"output-format" => &mut global.output_format,
"cache-dir" => &mut global.cache_dir,
_ => unreachable!("outer match limits the keys"),
};
if let Some(sv) = slot.as_mut() {
sv.push_override(s.to_string(), source, origin);
} else {
let mut sv = SourcedValue::new(s.to_string(), source);
sv.origin = origin;
*slot = Some(sv);
}
ApplyOutcome::Applied
}
"flavor" => {
let Some(s) = value.as_str() else {
return ApplyOutcome::TypeMismatch { expected: "string" };
};
match MarkdownFlavor::from_str(s) {
Ok(flavor) => {
global.flavor.push_override(flavor, source, origin);
ApplyOutcome::Applied
}
Err(_) => ApplyOutcome::InvalidValue {
message: format!("unknown markdown flavor '{s}'"),
},
}
}
_ => ApplyOutcome::Unrecognized,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::registry::default_registry;
fn apply(key: &str, value: &toml::Value) -> (SourcedGlobalConfig, ApplyOutcome) {
let mut global = SourcedGlobalConfig::default();
let outcome = apply_global_key(
&mut global,
key,
value,
ConfigSource::ProjectConfig,
Some("test.toml"),
default_registry(),
);
(global, outcome)
}
fn effective(sourced: &SourcedGlobalConfig) -> GlobalConfig {
let sourced = crate::config::SourcedConfig {
global: sourced.clone(),
..Default::default()
};
let config: crate::config::Config = sourced.into_validated_unchecked().into();
config.global
}
#[test]
fn every_global_key_is_recognized() {
for key in GLOBAL_VALUE_KEYS {
let (_, outcome) = apply(key, &toml::Value::Datetime("1979-05-27".parse().unwrap()));
assert!(
!matches!(outcome, ApplyOutcome::Unrecognized),
"key '{key}' is listed but not dispatched"
);
}
let (_, outcome) = apply("not-a-key", &toml::Value::Boolean(true));
assert!(matches!(outcome, ApplyOutcome::Unrecognized));
}
#[test]
fn every_global_key_reads_back() {
let global = SourcedGlobalConfig::default();
let config = effective(&global);
for key in GLOBAL_VALUE_KEYS {
assert!(
read_global_key(&config, &global, key).is_some(),
"key '{key}' is listed but cannot be read back"
);
}
assert!(read_global_key(&config, &global, "not-a-key").is_none());
}
#[test]
fn a_set_value_reads_back_with_its_provenance() {
let (global, _) = apply("line-length", &toml::Value::Integer(120));
let Some(GlobalKeyValue::Set(value, source)) = read_global_key(&effective(&global), &global, "line-length")
else {
panic!("a set line-length must read back as Set");
};
assert_eq!(value, toml::Value::Integer(120));
assert_eq!(source, ConfigSource::ProjectConfig);
}
#[test]
fn an_unset_optional_key_reads_back_as_unset_not_missing() {
let global = SourcedGlobalConfig::default();
assert!(matches!(
read_global_key(&effective(&global), &global, "output-format"),
Some(GlobalKeyValue::Unset)
));
assert!(matches!(
read_global_key(&effective(&global), &global, "cache-dir"),
Some(GlobalKeyValue::Unset)
));
let (global, _) = apply("output-format", &toml::Value::String("json".to_string()));
assert!(matches!(
read_global_key(&effective(&global), &global, "output-format"),
Some(GlobalKeyValue::Set(toml::Value::String(_), _))
));
}
#[test]
fn a_rule_list_reads_back_as_the_run_will_use_it() {
let (global, _) = apply(
"disable",
&toml::Value::Array(vec![toml::Value::String("MD013".to_string())]),
);
let mut sourced = crate::config::SourcedConfig {
global,
..Default::default()
};
sourced.rules.entry("MD013".to_string()).or_default().values.insert(
"enabled".to_string(),
SourcedValue::new(toml::Value::Boolean(true), ConfigSource::ProjectConfig),
);
let config: crate::config::Config = sourced.clone().into_validated_unchecked().into();
let Some(GlobalKeyValue::Set(disabled, _)) = read_global_key(&config.global, &sourced.global, "disable") else {
panic!("disable must read back as Set");
};
assert_eq!(
disabled,
toml::Value::Array(vec![]),
"MD013 is enabled by its own section, so it is not in the effective disable list"
);
let (global, _) = apply(
"disable",
&toml::Value::Array(vec![toml::Value::String("MD013".to_string())]),
);
let sourced = crate::config::SourcedConfig {
global,
..Default::default()
};
let config: crate::config::Config = sourced.clone().into_validated_unchecked().into();
let Some(GlobalKeyValue::Set(disabled, _)) = read_global_key(&config.global, &sourced.global, "disable") else {
panic!("disable must read back as Set");
};
assert_eq!(
disabled,
toml::Value::Array(vec![toml::Value::String("MD013".to_string())])
);
}
#[test]
fn applies_values_with_origin() {
let (global, outcome) = apply("line-length", &toml::Value::Integer(120));
assert!(matches!(outcome, ApplyOutcome::Applied));
assert_eq!(global.line_length.value.get(), 120);
assert_eq!(global.line_length.origin.as_deref(), Some("test.toml"));
let (global, outcome) = apply(
"enable",
&toml::Value::Array(vec![toml::Value::String("ul-style".to_string())]),
);
assert!(matches!(outcome, ApplyOutcome::Applied));
assert_eq!(global.enable.value, vec!["MD004".to_string()], "aliases resolve");
}
#[test]
fn rejects_wrong_types_without_storing() {
let (global, outcome) = apply("line-length", &toml::Value::String("wide".to_string()));
assert!(matches!(outcome, ApplyOutcome::TypeMismatch { expected: "integer" }));
assert_eq!(global.line_length.source, ConfigSource::Default);
}
#[test]
fn negative_line_length_clamps_to_zero() {
let (global, outcome) = apply("line-length", &toml::Value::Integer(-5));
assert!(matches!(outcome, ApplyOutcome::Applied));
assert_eq!(global.line_length.value.get(), 0);
}
#[test]
fn unknown_flavor_is_invalid_not_stored() {
let (global, outcome) = apply("flavor", &toml::Value::String("nonexistent".to_string()));
assert!(matches!(outcome, ApplyOutcome::InvalidValue { .. }));
assert_eq!(global.flavor.source, ConfigSource::Default);
}
}