Skip to main content

rumdl_lib/config/
source_tracking.rs

1use crate::types::LineLength;
2use indexmap::IndexMap;
3use std::collections::{BTreeMap, 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    pub fn merge_override(&mut self, new_value: T, new_source: ConfigSource, new_origin: Option<String>) {
74        if source_precedence(new_source) >= source_precedence(self.source) {
75            self.value = new_value;
76            self.source = new_source;
77            self.origin = new_origin;
78        }
79    }
80
81    /// Merge another SourcedValue with replace semantics.
82    pub fn merge_from(&mut self, other: SourcedValue<T>) {
83        self.merge_override(other.value, other.source, other.origin);
84    }
85
86    /// Sets the value unconditionally (no precedence check). Used while
87    /// parsing a single file, where later keys legitimately replace earlier
88    /// ones regardless of source.
89    pub fn push_override(&mut self, value: T, source: ConfigSource, origin: Option<String>) {
90        self.value = value;
91        self.source = source;
92        self.origin = origin;
93    }
94}
95
96impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
97    /// Merges a new value using union semantics (for arrays like `extend-disable`):
98    /// values from both sources are combined, with deduplication. The origin
99    /// reflects the most recent contributor.
100    pub fn merge_union(&mut self, new_value: Vec<T>, new_source: ConfigSource, new_origin: Option<String>) {
101        if source_precedence(new_source) >= source_precedence(self.source) {
102            for item in new_value {
103                if !self.value.contains(&item) {
104                    self.value.push(item);
105                }
106            }
107            self.source = new_source;
108            self.origin = new_origin;
109        }
110    }
111
112    /// Merge another SourcedValue with union semantics.
113    pub fn merge_union_from(&mut self, other: SourcedValue<Vec<T>>) {
114        self.merge_union(other.value, other.source, other.origin);
115    }
116}
117
118#[derive(Debug, Clone)]
119pub struct SourcedGlobalConfig {
120    pub enable: SourcedValue<Vec<String>>,
121    pub disable: SourcedValue<Vec<String>>,
122    pub exclude: SourcedValue<Vec<String>>,
123    pub include: SourcedValue<Vec<String>>,
124    pub respect_gitignore: SourcedValue<bool>,
125    pub line_length: SourcedValue<LineLength>,
126    pub output_format: Option<SourcedValue<String>>,
127    pub fixable: SourcedValue<Vec<String>>,
128    pub unfixable: SourcedValue<Vec<String>>,
129    pub flavor: SourcedValue<MarkdownFlavor>,
130    pub force_exclude: SourcedValue<bool>,
131    pub cache_dir: Option<SourcedValue<String>>,
132    pub cache: SourcedValue<bool>,
133    pub extend_enable: SourcedValue<Vec<String>>,
134    pub extend_disable: SourcedValue<Vec<String>>,
135    pub editorconfig: SourcedValue<bool>,
136}
137
138impl Default for SourcedGlobalConfig {
139    fn default() -> Self {
140        SourcedGlobalConfig {
141            enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
142            disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
143            exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
144            include: SourcedValue::new(Vec::new(), ConfigSource::Default),
145            respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
146            line_length: SourcedValue::new(LineLength::default(), ConfigSource::Default),
147            output_format: None,
148            fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
149            unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
150            flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
151            force_exclude: SourcedValue::new(false, ConfigSource::Default),
152            cache_dir: None,
153            cache: SourcedValue::new(true, ConfigSource::Default),
154            extend_enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
155            extend_disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
156            editorconfig: SourcedValue::new(false, ConfigSource::Default),
157        }
158    }
159}
160
161#[derive(Debug, Default, Clone)]
162pub struct SourcedRuleConfig {
163    pub severity: Option<SourcedValue<crate::rule::Severity>>,
164    pub values: BTreeMap<String, SourcedValue<toml::Value>>,
165}
166
167/// Represents configuration loaded from a single source file, with provenance.
168/// Used as an intermediate step before merging into the final SourcedConfig.
169#[derive(Debug, Clone)]
170pub struct SourcedConfigFragment {
171    /// Path to a base config file to inherit from (consumed during loading, not a config setting)
172    pub extends: Option<String>,
173    pub global: SourcedGlobalConfig,
174    pub per_file_ignores: SourcedValue<BTreeMap<String, Vec<String>>>,
175    pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
176    pub code_block_tools: SourcedValue<crate::code_block_tools::CodeBlockToolsConfig>,
177    pub rules: BTreeMap<String, SourcedRuleConfig>,
178    /// Maps canonical rule IDs to their preferred display names (used by import).
179    /// When importing from markdownlint configs, this preserves the user's original
180    /// naming preference (e.g., "line-length" instead of "MD013").
181    pub rule_display_names: HashMap<String, String>,
182    pub unknown_keys: Vec<(String, String, Option<String>)>, // (section, key, file_path)
183                                                             // Note: loaded_files is tracked globally in SourcedConfig.
184}
185
186impl Default for SourcedConfigFragment {
187    fn default() -> Self {
188        Self {
189            extends: None,
190            global: SourcedGlobalConfig::default(),
191            per_file_ignores: SourcedValue::new(BTreeMap::new(), ConfigSource::Default),
192            per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
193            code_block_tools: SourcedValue::new(
194                crate::code_block_tools::CodeBlockToolsConfig::default(),
195                ConfigSource::Default,
196            ),
197            rules: BTreeMap::new(),
198            rule_display_names: HashMap::new(),
199            unknown_keys: Vec::new(),
200        }
201    }
202}
203
204/// Represents a config validation warning or error
205#[derive(Debug, Clone)]
206pub struct ConfigValidationWarning {
207    pub message: String,
208    pub rule: Option<String>,
209    pub key: Option<String>,
210}
211
212/// Configuration with provenance tracking for values.
213///
214/// The `State` type parameter encodes the validation state:
215/// - `ConfigLoaded`: Config has been loaded but not validated
216/// - `ConfigValidated`: Config has been validated and can be converted to `Config`
217///
218/// # Typestate Pattern
219///
220/// This uses the typestate pattern to ensure validation happens before conversion:
221///
222/// ```ignore
223/// let loaded: SourcedConfig<ConfigLoaded> = SourcedConfig::load_with_discovery(...)?;
224/// let validated: SourcedConfig<ConfigValidated> = loaded.validate(&registry)?;
225/// let config: Config = validated.into();  // Only works on ConfigValidated!
226/// ```
227///
228/// Attempting to convert a `ConfigLoaded` config directly to `Config` is a compile error.
229#[derive(Debug, Clone)]
230pub struct SourcedConfig<State = ConfigLoaded> {
231    pub global: SourcedGlobalConfig,
232    pub per_file_ignores: SourcedValue<BTreeMap<String, Vec<String>>>,
233    pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
234    pub code_block_tools: SourcedValue<crate::code_block_tools::CodeBlockToolsConfig>,
235    pub rules: BTreeMap<String, SourcedRuleConfig>,
236    pub loaded_files: Vec<String>,
237    pub unknown_keys: Vec<(String, String, Option<String>)>, // (section, key, file_path)
238    /// Project root directory (parent of config file), used for resolving relative paths
239    pub project_root: Option<std::path::PathBuf>,
240    /// Warnings produced during config discovery (e.g. a `rumdl.toml` shadowed by a
241    /// sibling `.rumdl.toml`). Populated by auto-discovery only; empty for explicit
242    /// `--config` paths and `--no-config`/`--isolated`.
243    pub discovery_warnings: Vec<String>,
244    /// Validation warnings (populated after validate() is called)
245    pub validation_warnings: Vec<ConfigValidationWarning>,
246    /// Phantom data for the state type parameter
247    pub(super) _state: PhantomData<State>,
248}
249
250impl Default for SourcedConfig<ConfigLoaded> {
251    fn default() -> Self {
252        Self {
253            global: SourcedGlobalConfig::default(),
254            per_file_ignores: SourcedValue::new(BTreeMap::new(), ConfigSource::Default),
255            per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
256            code_block_tools: SourcedValue::new(
257                crate::code_block_tools::CodeBlockToolsConfig::default(),
258                ConfigSource::Default,
259            ),
260            rules: BTreeMap::new(),
261            loaded_files: Vec::new(),
262            unknown_keys: Vec::new(),
263            project_root: None,
264            discovery_warnings: Vec::new(),
265            validation_warnings: Vec::new(),
266            _state: PhantomData,
267        }
268    }
269}