Skip to main content

rumdl_lib/config/
source_tracking.rs

1use crate::types::LineLength;
2use indexmap::IndexMap;
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::marker::PhantomData;
5
6use super::flavor::{ConfigLoaded, MarkdownFlavor};
7
8/// Configuration source with clear precedence hierarchy.
9///
10/// Precedence order (higher values override lower values):
11/// - Default (0): Built-in defaults
12/// - EditorConfig (1): A `.editorconfig` file, when `editorconfig = true`
13/// - UserConfig (2): User-level ~/.config/rumdl/rumdl.toml
14/// - PyprojectToml (3): Project-level pyproject.toml
15/// - ProjectConfig (4): Project-level .rumdl.toml (most specific)
16/// - Cli (5): Command-line flags (highest priority)
17///
18/// `.editorconfig` sits directly above the built-in defaults: it fills in
19/// settings no rumdl config mentions, and anything written in a rumdl config
20/// wins over it.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ConfigSource {
23    /// Built-in default configuration
24    Default,
25    /// A `.editorconfig` file applying to the linted file
26    EditorConfig,
27    /// User-level configuration from ~/.config/rumdl/rumdl.toml
28    UserConfig,
29    /// Project-level configuration from pyproject.toml
30    PyprojectToml,
31    /// Project-level configuration from .rumdl.toml or rumdl.toml
32    ProjectConfig,
33    /// Command-line flags (highest precedence)
34    Cli,
35}
36
37fn source_precedence(src: ConfigSource) -> u8 {
38    match src {
39        ConfigSource::Default => 0,
40        ConfigSource::EditorConfig => 1,
41        ConfigSource::UserConfig => 2,
42        ConfigSource::PyprojectToml => 3,
43        ConfigSource::ProjectConfig => 4,
44        ConfigSource::Cli => 5,
45    }
46}
47
48/// A config value with its provenance: which kind of source set it, and for
49/// file-based sources, which file. The origin makes `rumdl config` output
50/// file-precise, which matters for `extends` chains where the source kind
51/// alone cannot distinguish the base config from the extending one.
52#[derive(Debug, Clone)]
53pub struct SourcedValue<T> {
54    pub value: T,
55    pub source: ConfigSource,
56    /// Path of the config file that supplied the winning value. `None` for
57    /// defaults and CLI flags.
58    pub origin: Option<String>,
59}
60
61impl<T: Clone> SourcedValue<T> {
62    pub fn new(value: T, source: ConfigSource) -> Self {
63        Self {
64            value,
65            source,
66            origin: None,
67        }
68    }
69
70    /// Merges a new value into this SourcedValue based on source precedence.
71    /// If the new source has higher or equal precedence, the value, source,
72    /// and origin are replaced.
73    ///
74    /// Returns whether the incoming value won, which is what a caller tracking
75    /// something about the value alongside it needs in order to follow it.
76    pub fn merge_override(&mut self, new_value: T, new_source: ConfigSource, new_origin: Option<String>) -> bool {
77        if source_precedence(new_source) >= source_precedence(self.source) {
78            self.value = new_value;
79            self.source = new_source;
80            self.origin = new_origin;
81            true
82        } else {
83            false
84        }
85    }
86
87    /// Merge another SourcedValue with replace semantics. See
88    /// [`Self::merge_override`] for the return value.
89    pub fn merge_from(&mut self, other: SourcedValue<T>) -> bool {
90        self.merge_override(other.value, other.source, other.origin)
91    }
92
93    /// Sets the value unconditionally (no precedence check). Used while
94    /// parsing a single file, where later keys legitimately replace earlier
95    /// ones regardless of source.
96    pub fn push_override(&mut self, value: T, source: ConfigSource, origin: Option<String>) {
97        self.value = value;
98        self.source = source;
99        self.origin = origin;
100    }
101}
102
103impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
104    /// Merges a new value using union semantics (for arrays like `extend-disable`):
105    /// values from both sources are combined, with deduplication. The origin
106    /// reflects the most recent contributor.
107    pub fn merge_union(&mut self, new_value: Vec<T>, new_source: ConfigSource, new_origin: Option<String>) {
108        if source_precedence(new_source) >= source_precedence(self.source) {
109            for item in new_value {
110                if !self.value.contains(&item) {
111                    self.value.push(item);
112                }
113            }
114            self.source = new_source;
115            self.origin = new_origin;
116        }
117    }
118
119    /// Merge another SourcedValue with union semantics.
120    pub fn merge_union_from(&mut self, other: SourcedValue<Vec<T>>) {
121        self.merge_union(other.value, other.source, other.origin);
122    }
123}
124
125#[derive(Debug, Clone)]
126pub struct SourcedGlobalConfig {
127    pub enable: SourcedValue<Vec<String>>,
128    pub disable: SourcedValue<Vec<String>>,
129    pub exclude: SourcedValue<Vec<String>>,
130    pub include: SourcedValue<Vec<String>>,
131    /// How to name the file that supplied [`Self::include`] when its contents may
132    /// not be quoted back (an `extends` target), and `None` when they may. The
133    /// patterns apply as written; only the walk's message about one it cannot use
134    /// has to leave it out. Already display-ready, as
135    /// [`SourcedConfigFragment::unknown_keys`] describes.
136    pub include_withheld: Option<String>,
137    pub respect_gitignore: SourcedValue<bool>,
138    pub line_length: SourcedValue<LineLength>,
139    pub output_format: Option<SourcedValue<String>>,
140    pub fixable: SourcedValue<Vec<String>>,
141    pub unfixable: SourcedValue<Vec<String>>,
142    pub flavor: SourcedValue<MarkdownFlavor>,
143    pub force_exclude: SourcedValue<bool>,
144    pub cache_dir: Option<SourcedValue<String>>,
145    pub cache: SourcedValue<bool>,
146    pub extend_enable: SourcedValue<Vec<String>>,
147    pub extend_disable: SourcedValue<Vec<String>>,
148    pub editorconfig: SourcedValue<bool>,
149}
150
151impl Default for SourcedGlobalConfig {
152    fn default() -> Self {
153        SourcedGlobalConfig {
154            enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
155            disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
156            exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
157            include: SourcedValue::new(Vec::new(), ConfigSource::Default),
158            include_withheld: None,
159            respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
160            line_length: SourcedValue::new(LineLength::default(), ConfigSource::Default),
161            output_format: None,
162            fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
163            unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
164            flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
165            force_exclude: SourcedValue::new(false, ConfigSource::Default),
166            cache_dir: None,
167            cache: SourcedValue::new(true, ConfigSource::Default),
168            extend_enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
169            extend_disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
170            editorconfig: SourcedValue::new(false, ConfigSource::Default),
171        }
172    }
173}
174
175#[derive(Debug, Default, Clone)]
176pub struct SourcedRuleConfig {
177    pub severity: Option<SourcedValue<crate::rule::Severity>>,
178    pub values: BTreeMap<String, SourcedValue<toml::Value>>,
179    /// Keys in [`Self::values`] whose winning value came from a config file
180    /// whose contents may not be quoted back (an `extends` target). The value
181    /// applies as written; only a message about it has to leave it out.
182    ///
183    /// Tracked per key because a config that names the value itself takes the
184    /// key over, and its own value is quotable again.
185    pub withheld_keys: BTreeSet<String>,
186}
187
188/// Represents configuration loaded from a single source file, with provenance.
189/// Used as an intermediate step before merging into the final SourcedConfig.
190#[derive(Debug, Clone)]
191pub struct SourcedConfigFragment {
192    /// Path to a base config file to inherit from (consumed during loading, not a config setting)
193    pub extends: Option<String>,
194    pub global: SourcedGlobalConfig,
195    pub per_file_ignores: SourcedValue<BTreeMap<String, Vec<String>>>,
196    pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
197    pub code_block_tools: SourcedValue<crate::code_block_tools::CodeBlockToolsConfig>,
198    pub rules: BTreeMap<String, SourcedRuleConfig>,
199    /// Maps canonical rule IDs to their preferred display names (used by import).
200    /// When importing from markdownlint configs, this preserves the user's original
201    /// naming preference (e.g., "line-length" instead of "MD013").
202    pub rule_display_names: HashMap<String, String>,
203    /// `(section, key, display_name)`. The third element names the file for a
204    /// warning message and is already display-ready: relative to the working
205    /// directory for a config the user named, and for one reached through
206    /// `extends` the reference as written rather than the path it resolved to.
207    /// `None` for keys that came from the command line and belong to no file.
208    pub unknown_keys: Vec<(String, String, Option<String>)>,
209    /// Problems found while parsing this file that the code acting on the value
210    /// can no longer report itself, because the value was withheld before it got
211    /// there. Merged into [`SourcedConfig::discovery_warnings`], which is the
212    /// channel the withheld consumer's own message went to.
213    pub load_warnings: Vec<String>,
214    // Note: loaded_files is tracked globally in SourcedConfig.
215}
216
217impl Default for SourcedConfigFragment {
218    fn default() -> Self {
219        Self {
220            extends: None,
221            global: SourcedGlobalConfig::default(),
222            per_file_ignores: SourcedValue::new(BTreeMap::new(), ConfigSource::Default),
223            per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
224            code_block_tools: SourcedValue::new(
225                crate::code_block_tools::CodeBlockToolsConfig::default(),
226                ConfigSource::Default,
227            ),
228            rules: BTreeMap::new(),
229            rule_display_names: HashMap::new(),
230            unknown_keys: Vec::new(),
231            load_warnings: Vec::new(),
232        }
233    }
234}
235
236impl SourcedGlobalConfig {
237    /// Whether every global setting is still at its default.
238    ///
239    /// Written as an exhaustive destructuring so that adding a field to the
240    /// struct fails to compile here until it is accounted for: a field this
241    /// missed would make a config carrying only that setting look empty.
242    pub fn is_empty(&self) -> bool {
243        let Self {
244            enable,
245            disable,
246            exclude,
247            include,
248            include_withheld: _,
249            respect_gitignore,
250            line_length,
251            output_format,
252            fixable,
253            unfixable,
254            flavor,
255            force_exclude,
256            cache_dir,
257            cache,
258            extend_enable,
259            extend_disable,
260            editorconfig,
261        } = self;
262        // `include_withheld` describes `include` rather than being a setting of
263        // its own, so it is covered by the `include` check.
264        output_format.is_none()
265            && cache_dir.is_none()
266            && [
267                enable.source,
268                disable.source,
269                exclude.source,
270                include.source,
271                fixable.source,
272                unfixable.source,
273                extend_enable.source,
274                extend_disable.source,
275            ]
276            .iter()
277            .all(|source| *source == ConfigSource::Default)
278            && respect_gitignore.source == ConfigSource::Default
279            && line_length.source == ConfigSource::Default
280            && flavor.source == ConfigSource::Default
281            && force_exclude.source == ConfigSource::Default
282            && cache.source == ConfigSource::Default
283            && editorconfig.source == ConfigSource::Default
284    }
285}
286
287impl SourcedConfigFragment {
288    /// Whether this fragment says nothing at all, so the file it came from need
289    /// not be loaded or reported.
290    ///
291    /// Written as an exhaustive destructuring for the reason
292    /// [`SourcedGlobalConfig::is_empty`] gives: a configuration section that
293    /// this check forgets is one a config file can consist entirely of and be
294    /// discarded, warnings included.
295    pub fn is_empty(&self) -> bool {
296        let Self {
297            extends,
298            global,
299            per_file_ignores,
300            per_file_flavor,
301            code_block_tools,
302            rules,
303            rule_display_names,
304            unknown_keys,
305            load_warnings,
306        } = self;
307        extends.is_none()
308            && global.is_empty()
309            && per_file_ignores.source == ConfigSource::Default
310            && per_file_flavor.source == ConfigSource::Default
311            && code_block_tools.source == ConfigSource::Default
312            && rules.is_empty()
313            && rule_display_names.is_empty()
314            && unknown_keys.is_empty()
315            && load_warnings.is_empty()
316    }
317}
318
319/// Represents a config validation warning or error
320#[derive(Debug, Clone)]
321pub struct ConfigValidationWarning {
322    pub message: String,
323    pub rule: Option<String>,
324    pub key: Option<String>,
325}
326
327/// Configuration with provenance tracking for values.
328///
329/// The `State` type parameter encodes the validation state:
330/// - `ConfigLoaded`: Config has been loaded but not validated
331/// - `ConfigValidated`: Config has been validated and can be converted to `Config`
332///
333/// # Typestate Pattern
334///
335/// This uses the typestate pattern to ensure validation happens before conversion:
336///
337/// ```ignore
338/// let loaded: SourcedConfig<ConfigLoaded> = SourcedConfig::load_with_discovery(...)?;
339/// let validated: SourcedConfig<ConfigValidated> = loaded.validate(&registry)?;
340/// let config: Config = validated.into();  // Only works on ConfigValidated!
341/// ```
342///
343/// Attempting to convert a `ConfigLoaded` config directly to `Config` is a compile error.
344#[derive(Debug, Clone)]
345pub struct SourcedConfig<State = ConfigLoaded> {
346    pub global: SourcedGlobalConfig,
347    pub per_file_ignores: SourcedValue<BTreeMap<String, Vec<String>>>,
348    pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
349    pub code_block_tools: SourcedValue<crate::code_block_tools::CodeBlockToolsConfig>,
350    pub rules: BTreeMap<String, SourcedRuleConfig>,
351    /// Every config file that contributed, by resolved path, in load order.
352    ///
353    /// A file reached through `extends` appears here as the path its reference
354    /// expanded to, unlike every message about such a file (see `ConfigOrigin`).
355    /// This list is not a message: it exists to
356    /// answer which files took effect, which `rumdl config` is asked directly and
357    /// a language server answers for the editor that started it, and the resolved
358    /// path is the answer.
359    pub loaded_files: Vec<String>,
360    /// `(section, key, display_name)`, as on [`SourcedConfigFragment`].
361    pub unknown_keys: Vec<(String, String, Option<String>)>,
362    /// Project root directory (parent of config file), used for resolving relative paths
363    pub project_root: Option<std::path::PathBuf>,
364    /// Warnings about where the configuration came from and what of it could
365    /// not be used: a `rumdl.toml` shadowed by a sibling `.rumdl.toml`, a
366    /// setting a file could not contribute (see
367    /// [`SourcedConfigFragment::load_warnings`]), or a value an inline
368    /// `--config` override could not apply. Shadowing is reported by
369    /// auto-discovery only, so an explicit `--config` path and
370    /// `--no-config`/`--isolated` see only the other kinds.
371    pub discovery_warnings: Vec<String>,
372    /// Validation warnings (populated after validate() is called)
373    pub validation_warnings: Vec<ConfigValidationWarning>,
374    /// Phantom data for the state type parameter
375    pub(super) _state: PhantomData<State>,
376}
377
378impl Default for SourcedConfig<ConfigLoaded> {
379    fn default() -> Self {
380        Self {
381            global: SourcedGlobalConfig::default(),
382            per_file_ignores: SourcedValue::new(BTreeMap::new(), ConfigSource::Default),
383            per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
384            code_block_tools: SourcedValue::new(
385                crate::code_block_tools::CodeBlockToolsConfig::default(),
386                ConfigSource::Default,
387            ),
388            rules: BTreeMap::new(),
389            loaded_files: Vec::new(),
390            unknown_keys: Vec::new(),
391            project_root: None,
392            discovery_warnings: Vec::new(),
393            validation_warnings: Vec::new(),
394            _state: PhantomData,
395        }
396    }
397}