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
236/// Represents a config validation warning or error
237#[derive(Debug, Clone)]
238pub struct ConfigValidationWarning {
239    pub message: String,
240    pub rule: Option<String>,
241    pub key: Option<String>,
242}
243
244/// Configuration with provenance tracking for values.
245///
246/// The `State` type parameter encodes the validation state:
247/// - `ConfigLoaded`: Config has been loaded but not validated
248/// - `ConfigValidated`: Config has been validated and can be converted to `Config`
249///
250/// # Typestate Pattern
251///
252/// This uses the typestate pattern to ensure validation happens before conversion:
253///
254/// ```ignore
255/// let loaded: SourcedConfig<ConfigLoaded> = SourcedConfig::load_with_discovery(...)?;
256/// let validated: SourcedConfig<ConfigValidated> = loaded.validate(&registry)?;
257/// let config: Config = validated.into();  // Only works on ConfigValidated!
258/// ```
259///
260/// Attempting to convert a `ConfigLoaded` config directly to `Config` is a compile error.
261#[derive(Debug, Clone)]
262pub struct SourcedConfig<State = ConfigLoaded> {
263    pub global: SourcedGlobalConfig,
264    pub per_file_ignores: SourcedValue<BTreeMap<String, Vec<String>>>,
265    pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
266    pub code_block_tools: SourcedValue<crate::code_block_tools::CodeBlockToolsConfig>,
267    pub rules: BTreeMap<String, SourcedRuleConfig>,
268    /// Every config file that contributed, by resolved path, in load order.
269    ///
270    /// A file reached through `extends` appears here as the path its reference
271    /// expanded to, unlike every message about such a file (see `ConfigOrigin`).
272    /// This list is not a message: it exists to
273    /// answer which files took effect, which `rumdl config` is asked directly and
274    /// a language server answers for the editor that started it, and the resolved
275    /// path is the answer.
276    pub loaded_files: Vec<String>,
277    /// `(section, key, display_name)`, as on [`SourcedConfigFragment`].
278    pub unknown_keys: Vec<(String, String, Option<String>)>,
279    /// Project root directory (parent of config file), used for resolving relative paths
280    pub project_root: Option<std::path::PathBuf>,
281    /// Warnings produced while finding and reading config files: a `rumdl.toml`
282    /// shadowed by a sibling `.rumdl.toml`, or a setting a file could not
283    /// contribute (see [`SourcedConfigFragment::load_warnings`]). Shadowing is
284    /// reported by auto-discovery only, so an explicit `--config` path and
285    /// `--no-config`/`--isolated` see only the reading half.
286    pub discovery_warnings: Vec<String>,
287    /// Validation warnings (populated after validate() is called)
288    pub validation_warnings: Vec<ConfigValidationWarning>,
289    /// Phantom data for the state type parameter
290    pub(super) _state: PhantomData<State>,
291}
292
293impl Default for SourcedConfig<ConfigLoaded> {
294    fn default() -> Self {
295        Self {
296            global: SourcedGlobalConfig::default(),
297            per_file_ignores: SourcedValue::new(BTreeMap::new(), ConfigSource::Default),
298            per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
299            code_block_tools: SourcedValue::new(
300                crate::code_block_tools::CodeBlockToolsConfig::default(),
301                ConfigSource::Default,
302            ),
303            rules: BTreeMap::new(),
304            loaded_files: Vec::new(),
305            unknown_keys: Vec::new(),
306            project_root: None,
307            discovery_warnings: Vec::new(),
308            validation_warnings: Vec::new(),
309            _state: PhantomData,
310        }
311    }
312}