1use std::str::FromStr;
16
17use super::flavor::{MarkdownFlavor, normalize_key};
18use super::registry::RuleRegistry;
19use super::source_tracking::{ConfigSource, SourcedGlobalConfig, SourcedValue};
20use super::types::GlobalConfig;
21use crate::types::LineLength;
22
23pub const GLOBAL_VALUE_KEYS: &[&str] = &[
25 "enable",
26 "disable",
27 "include",
28 "exclude",
29 "extend-enable",
30 "extend-disable",
31 "respect-gitignore",
32 "force-exclude",
33 "line-length",
34 "output-format",
35 "cache-dir",
36 "cache",
37 "fixable",
38 "unfixable",
39 "flavor",
40 "editorconfig",
41];
42
43pub fn is_global_value_key(key: &str) -> bool {
45 GLOBAL_VALUE_KEYS.contains(&key)
46}
47
48#[derive(Debug)]
50pub enum GlobalKeyValue {
51 Set(toml::Value, ConfigSource),
53 Unset,
57}
58
59pub fn read_global_key(
73 effective: &GlobalConfig,
74 sourced: &SourcedGlobalConfig,
75 norm_key: &str,
76) -> Option<GlobalKeyValue> {
77 let strings = |value: &[String], sv: &SourcedValue<Vec<String>>| {
78 GlobalKeyValue::Set(
79 toml::Value::Array(value.iter().map(|s| toml::Value::String(s.clone())).collect()),
80 sv.source,
81 )
82 };
83 let boolean = |value: bool, sv: &SourcedValue<bool>| GlobalKeyValue::Set(toml::Value::Boolean(value), sv.source);
84 let optional_string = |value: &Option<String>, slot: &Option<SourcedValue<String>>| match value {
85 Some(value) => GlobalKeyValue::Set(
86 toml::Value::String(value.clone()),
87 slot.as_ref().map_or(ConfigSource::Default, |sv| sv.source),
88 ),
89 None => GlobalKeyValue::Unset,
90 };
91
92 Some(match norm_key {
93 "enable" => strings(&effective.enable, &sourced.enable),
94 "disable" => strings(&effective.disable, &sourced.disable),
95 "include" => strings(&effective.include, &sourced.include),
96 "exclude" => strings(&effective.exclude, &sourced.exclude),
97 "extend-enable" => strings(&effective.extend_enable, &sourced.extend_enable),
98 "extend-disable" => strings(&effective.extend_disable, &sourced.extend_disable),
99 "fixable" => strings(&effective.fixable, &sourced.fixable),
100 "unfixable" => strings(&effective.unfixable, &sourced.unfixable),
101 "respect-gitignore" => boolean(effective.respect_gitignore, &sourced.respect_gitignore),
102 "force-exclude" => {
103 #[allow(deprecated)]
106 let value = effective.force_exclude;
107 boolean(value, &sourced.force_exclude)
108 }
109 "cache" => boolean(effective.cache, &sourced.cache),
110 "editorconfig" => boolean(effective.editorconfig, &sourced.editorconfig),
111 "line-length" => GlobalKeyValue::Set(
112 toml::Value::Integer(effective.line_length.get() as i64),
113 sourced.line_length.source,
114 ),
115 "output-format" => optional_string(&effective.output_format, &sourced.output_format),
116 "cache-dir" => optional_string(&effective.cache_dir, &sourced.cache_dir),
117 "flavor" => GlobalKeyValue::Set(toml::Value::String(effective.flavor.to_string()), sourced.flavor.source),
118 _ => return None,
119 })
120}
121
122#[derive(Debug)]
124pub enum ApplyOutcome {
125 Applied,
127 TypeMismatch { expected: &'static str },
129 InvalidValue { message: String },
132 Unrecognized,
134}
135
136pub fn apply_global_key(
143 global: &mut SourcedGlobalConfig,
144 norm_key: &str,
145 value: &toml::Value,
146 source: ConfigSource,
147 origin: Option<&str>,
148 registry: &RuleRegistry,
149) -> ApplyOutcome {
150 let origin = origin.map(std::string::ToString::to_string);
151
152 let resolve_rule_list = |arr: &[toml::Value]| -> Vec<String> {
153 arr.iter()
154 .filter_map(|v| v.as_str())
155 .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
156 .collect()
157 };
158 let to_strings =
159 |arr: &[toml::Value]| -> Vec<String> { arr.iter().filter_map(|v| v.as_str()).map(str::to_string).collect() };
160
161 match norm_key {
162 "enable" | "disable" | "extend-enable" | "extend-disable" | "fixable" | "unfixable" => {
163 let toml::Value::Array(arr) = value else {
164 return ApplyOutcome::TypeMismatch { expected: "array" };
165 };
166 let values = resolve_rule_list(arr);
167 match norm_key {
168 "enable" => global.enable.push_override(values, source, origin),
169 "disable" => global.disable.push_override(values, source, origin),
170 "extend-enable" => global.extend_enable.push_override(values, source, origin),
171 "extend-disable" => global.extend_disable.push_override(values, source, origin),
172 "fixable" => global.fixable.push_override(values, source, origin),
173 "unfixable" => global.unfixable.push_override(values, source, origin),
174 _ => unreachable!("outer match limits the keys"),
175 }
176 ApplyOutcome::Applied
177 }
178 "include" | "exclude" => {
179 let toml::Value::Array(arr) = value else {
180 return ApplyOutcome::TypeMismatch { expected: "array" };
181 };
182 let values = to_strings(arr);
183 match norm_key {
184 "include" => global.include.push_override(values, source, origin),
185 "exclude" => global.exclude.push_override(values, source, origin),
186 _ => unreachable!("outer match limits the keys"),
187 }
188 ApplyOutcome::Applied
189 }
190 "respect-gitignore" | "force-exclude" | "cache" | "editorconfig" => {
191 let Some(b) = value.as_bool() else {
192 return ApplyOutcome::TypeMismatch { expected: "boolean" };
193 };
194 match norm_key {
195 "respect-gitignore" => global.respect_gitignore.push_override(b, source, origin),
196 "force-exclude" => global.force_exclude.push_override(b, source, origin),
197 "cache" => global.cache.push_override(b, source, origin),
198 "editorconfig" => global.editorconfig.push_override(b, source, origin),
199 _ => unreachable!("outer match limits the keys"),
200 }
201 ApplyOutcome::Applied
202 }
203 "line-length" => {
204 let Some(n) = value.as_integer() else {
205 return ApplyOutcome::TypeMismatch { expected: "integer" };
206 };
207 global
209 .line_length
210 .push_override(LineLength::new(n.max(0) as usize), source, origin);
211 ApplyOutcome::Applied
212 }
213 "output-format" | "cache-dir" => {
214 let Some(s) = value.as_str() else {
215 return ApplyOutcome::TypeMismatch { expected: "string" };
216 };
217 let slot = match norm_key {
218 "output-format" => &mut global.output_format,
219 "cache-dir" => &mut global.cache_dir,
220 _ => unreachable!("outer match limits the keys"),
221 };
222 if let Some(sv) = slot.as_mut() {
223 sv.push_override(s.to_string(), source, origin);
224 } else {
225 let mut sv = SourcedValue::new(s.to_string(), source);
226 sv.origin = origin;
227 *slot = Some(sv);
228 }
229 ApplyOutcome::Applied
230 }
231 "flavor" => {
232 let Some(s) = value.as_str() else {
233 return ApplyOutcome::TypeMismatch { expected: "string" };
234 };
235 match MarkdownFlavor::from_str(s) {
236 Ok(flavor) => {
237 global.flavor.push_override(flavor, source, origin);
238 ApplyOutcome::Applied
239 }
240 Err(_) => ApplyOutcome::InvalidValue {
241 message: format!("unknown markdown flavor '{s}'"),
242 },
243 }
244 }
245 _ => ApplyOutcome::Unrecognized,
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::config::registry::default_registry;
253
254 fn apply(key: &str, value: &toml::Value) -> (SourcedGlobalConfig, ApplyOutcome) {
255 let mut global = SourcedGlobalConfig::default();
256 let outcome = apply_global_key(
257 &mut global,
258 key,
259 value,
260 ConfigSource::ProjectConfig,
261 Some("test.toml"),
262 default_registry(),
263 );
264 (global, outcome)
265 }
266
267 fn effective(sourced: &SourcedGlobalConfig) -> GlobalConfig {
271 let sourced = crate::config::SourcedConfig {
272 global: sourced.clone(),
273 ..Default::default()
274 };
275 let config: crate::config::Config = sourced.into_validated_unchecked().into();
276 config.global
277 }
278
279 #[test]
280 fn every_global_key_is_recognized() {
281 for key in GLOBAL_VALUE_KEYS {
284 let (_, outcome) = apply(key, &toml::Value::Datetime("1979-05-27".parse().unwrap()));
285 assert!(
286 !matches!(outcome, ApplyOutcome::Unrecognized),
287 "key '{key}' is listed but not dispatched"
288 );
289 }
290 let (_, outcome) = apply("not-a-key", &toml::Value::Boolean(true));
291 assert!(matches!(outcome, ApplyOutcome::Unrecognized));
292 }
293
294 #[test]
295 fn every_global_key_reads_back() {
296 let global = SourcedGlobalConfig::default();
299 let config = effective(&global);
300 for key in GLOBAL_VALUE_KEYS {
301 assert!(
302 read_global_key(&config, &global, key).is_some(),
303 "key '{key}' is listed but cannot be read back"
304 );
305 }
306 assert!(read_global_key(&config, &global, "not-a-key").is_none());
307 }
308
309 #[test]
310 fn a_set_value_reads_back_with_its_provenance() {
311 let (global, _) = apply("line-length", &toml::Value::Integer(120));
312 let Some(GlobalKeyValue::Set(value, source)) = read_global_key(&effective(&global), &global, "line-length")
313 else {
314 panic!("a set line-length must read back as Set");
315 };
316 assert_eq!(value, toml::Value::Integer(120));
317 assert_eq!(source, ConfigSource::ProjectConfig);
318 }
319
320 #[test]
321 fn an_unset_optional_key_reads_back_as_unset_not_missing() {
322 let global = SourcedGlobalConfig::default();
323 assert!(matches!(
324 read_global_key(&effective(&global), &global, "output-format"),
325 Some(GlobalKeyValue::Unset)
326 ));
327 assert!(matches!(
328 read_global_key(&effective(&global), &global, "cache-dir"),
329 Some(GlobalKeyValue::Unset)
330 ));
331
332 let (global, _) = apply("output-format", &toml::Value::String("json".to_string()));
333 assert!(matches!(
334 read_global_key(&effective(&global), &global, "output-format"),
335 Some(GlobalKeyValue::Set(toml::Value::String(_), _))
336 ));
337 }
338
339 #[test]
340 fn a_rule_list_reads_back_as_the_run_will_use_it() {
341 let (global, _) = apply(
346 "disable",
347 &toml::Value::Array(vec![toml::Value::String("MD013".to_string())]),
348 );
349 let mut sourced = crate::config::SourcedConfig {
350 global,
351 ..Default::default()
352 };
353 sourced.rules.entry("MD013".to_string()).or_default().values.insert(
354 "enabled".to_string(),
355 SourcedValue::new(toml::Value::Boolean(true), ConfigSource::ProjectConfig),
356 );
357 let config: crate::config::Config = sourced.clone().into_validated_unchecked().into();
358
359 let Some(GlobalKeyValue::Set(disabled, _)) = read_global_key(&config.global, &sourced.global, "disable") else {
360 panic!("disable must read back as Set");
361 };
362 assert_eq!(
363 disabled,
364 toml::Value::Array(vec![]),
365 "MD013 is enabled by its own section, so it is not in the effective disable list"
366 );
367
368 let (global, _) = apply(
370 "disable",
371 &toml::Value::Array(vec![toml::Value::String("MD013".to_string())]),
372 );
373 let sourced = crate::config::SourcedConfig {
374 global,
375 ..Default::default()
376 };
377 let config: crate::config::Config = sourced.clone().into_validated_unchecked().into();
378 let Some(GlobalKeyValue::Set(disabled, _)) = read_global_key(&config.global, &sourced.global, "disable") else {
379 panic!("disable must read back as Set");
380 };
381 assert_eq!(
382 disabled,
383 toml::Value::Array(vec![toml::Value::String("MD013".to_string())])
384 );
385 }
386
387 #[test]
388 fn applies_values_with_origin() {
389 let (global, outcome) = apply("line-length", &toml::Value::Integer(120));
390 assert!(matches!(outcome, ApplyOutcome::Applied));
391 assert_eq!(global.line_length.value.get(), 120);
392 assert_eq!(global.line_length.origin.as_deref(), Some("test.toml"));
393
394 let (global, outcome) = apply(
395 "enable",
396 &toml::Value::Array(vec![toml::Value::String("ul-style".to_string())]),
397 );
398 assert!(matches!(outcome, ApplyOutcome::Applied));
399 assert_eq!(global.enable.value, vec!["MD004".to_string()], "aliases resolve");
400 }
401
402 #[test]
403 fn rejects_wrong_types_without_storing() {
404 let (global, outcome) = apply("line-length", &toml::Value::String("wide".to_string()));
405 assert!(matches!(outcome, ApplyOutcome::TypeMismatch { expected: "integer" }));
406 assert_eq!(global.line_length.source, ConfigSource::Default);
407 }
408
409 #[test]
410 fn negative_line_length_clamps_to_zero() {
411 let (global, outcome) = apply("line-length", &toml::Value::Integer(-5));
412 assert!(matches!(outcome, ApplyOutcome::Applied));
413 assert_eq!(global.line_length.value.get(), 0);
414 }
415
416 #[test]
417 fn unknown_flavor_is_invalid_not_stored() {
418 let (global, outcome) = apply("flavor", &toml::Value::String("nonexistent".to_string()));
419 assert!(matches!(outcome, ApplyOutcome::InvalidValue { .. }));
420 assert_eq!(global.flavor.source, ConfigSource::Default);
421 }
422}