Skip to main content

rumdl_lib/config/
global_keys.rs

1//! The single dispatch table for global configuration keys.
2//!
3//! Three contexts accept global keys: `rumdl.toml`/`.rumdl.toml` (parsed
4//! with `toml_edit`), `pyproject.toml` (parsed with `toml`), and inline
5//! `--config key=value` overrides. They previously each carried their own
6//! per-key match with its own type checks, and drifted. The key list, the
7//! expected types, and the setters now live here once: adding a global key
8//! means adding it to [`GLOBAL_VALUE_KEYS`] and one arm in
9//! [`apply_global_key`]; every context picks it up.
10//!
11//! Callers keep their own key discovery (which table to scan, alias
12//! spellings) and their own diagnostics phrasing, driven by the returned
13//! [`ApplyOutcome`].
14
15use 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
23/// Global configuration keys that hold plain values (normalized kebab-case).
24pub 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
43/// Whether a (normalized) key names a global value setting.
44pub fn is_global_value_key(key: &str) -> bool {
45    GLOBAL_VALUE_KEYS.contains(&key)
46}
47
48/// What reading a global key found.
49#[derive(Debug)]
50pub enum GlobalKeyValue {
51    /// The key holds a value, shown with where it came from.
52    Set(toml::Value, ConfigSource),
53    /// The key is accepted but holds nothing and has no default to show. A caller
54    /// must report this as unset, never as an unknown key: the two are different
55    /// answers and collapsing them tells the user a real setting does not exist.
56    Unset,
57}
58
59/// Read one global key back out of the global config section.
60///
61/// The read half of [`apply_global_key`], kept beside it so the two cannot drift:
62/// every key in [`GLOBAL_VALUE_KEYS`] answers here. Returns `None` only for a key
63/// that is not a global setting at all.
64///
65/// The value comes from `effective` and the provenance from `sourced`, because the
66/// two answer different questions. `sourced` records what the config files said;
67/// `effective` is what the run actually uses, after
68/// [`Config::apply_per_rule_enabled`](crate::config::Config::apply_per_rule_enabled)
69/// has folded per-rule `enabled` into the rule lists and after those lists have been
70/// canonicalized. Reporting the sourced value would tell a user that MD013 is
71/// disabled while the very same config runs it.
72pub 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            // The field is deprecated and inert, but it is still a key a config may
104            // carry, so `config get` answers for it rather than calling it unknown.
105            #[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/// Result of applying a candidate global key.
123#[derive(Debug)]
124pub enum ApplyOutcome {
125    /// Key recognized and value stored.
126    Applied,
127    /// Key recognized but the value has the wrong TOML type; nothing stored.
128    TypeMismatch { expected: &'static str },
129    /// Key recognized, type correct, but the value is invalid (e.g. an
130    /// unknown flavor name); nothing stored.
131    InvalidValue { message: String },
132    /// Not a global value key.
133    Unrecognized,
134}
135
136/// Apply one global key to the global config section.
137///
138/// `norm_key` must already be normalized (see [`normalize_key`]); rule-list
139/// values resolve rule-name aliases through `registry`. `origin` is the
140/// config file supplying the value (`None` for CLI input) and feeds the
141/// provenance shown by `rumdl config`.
142pub 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            // Negative lengths are nonsense; clamp instead of wrapping.
208            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    /// The effective config a sourced one produces, built through the same conversion
268    /// the CLI uses rather than by mirroring fields here, so a field this test reads
269    /// cannot silently stop tracking the real one.
270    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        // The key list and the dispatch must stay in lockstep: every listed
282        // key must produce Applied or TypeMismatch, never Unrecognized.
283        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        // The read half must cover the same key list as the write half, or
297        // `rumdl config get global.<key>` calls a real setting unknown.
298        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        // `[global] disable = ["MD013"]` with `[MD013] enabled = true` runs MD013:
342        // per-rule `enabled` outranks the global list. Reporting the list as the config
343        // file wrote it would name a rule as disabled while the same config lints with
344        // it, so the read reports the list the run actually uses.
345        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        // Control: without the per-rule override the rule stays disabled and listed.
369        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}