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(®istry)?;
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 produced while finding and reading config files: a `rumdl.toml`
365 /// shadowed by a sibling `.rumdl.toml`, or a setting a file could not
366 /// contribute (see [`SourcedConfigFragment::load_warnings`]). Shadowing is
367 /// reported by auto-discovery only, so an explicit `--config` path and
368 /// `--no-config`/`--isolated` see only the reading half.
369 pub discovery_warnings: Vec<String>,
370 /// Validation warnings (populated after validate() is called)
371 pub validation_warnings: Vec<ConfigValidationWarning>,
372 /// Phantom data for the state type parameter
373 pub(super) _state: PhantomData<State>,
374}
375
376impl Default for SourcedConfig<ConfigLoaded> {
377 fn default() -> Self {
378 Self {
379 global: SourcedGlobalConfig::default(),
380 per_file_ignores: SourcedValue::new(BTreeMap::new(), ConfigSource::Default),
381 per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
382 code_block_tools: SourcedValue::new(
383 crate::code_block_tools::CodeBlockToolsConfig::default(),
384 ConfigSource::Default,
385 ),
386 rules: BTreeMap::new(),
387 loaded_files: Vec::new(),
388 unknown_keys: Vec::new(),
389 project_root: None,
390 discovery_warnings: Vec::new(),
391 validation_warnings: Vec::new(),
392 _state: PhantomData,
393 }
394 }
395}