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 (lower values override higher values):
11/// - Default (0): Built-in defaults
12/// - UserConfig (1): User-level ~/.config/rumdl/rumdl.toml
13/// - PyprojectToml (2): Project-level pyproject.toml
14/// - ProjectConfig (3): Project-level .rumdl.toml (most specific)
15/// - Cli (4): Command-line flags (highest priority)
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ConfigSource {
18    /// Built-in default configuration
19    Default,
20    /// User-level configuration from ~/.config/rumdl/rumdl.toml
21    UserConfig,
22    /// Project-level configuration from pyproject.toml
23    PyprojectToml,
24    /// Project-level configuration from .rumdl.toml or rumdl.toml
25    ProjectConfig,
26    /// Command-line flags (highest precedence)
27    Cli,
28}
29
30#[derive(Debug, Clone)]
31pub struct ConfigOverride<T> {
32    pub value: T,
33    pub source: ConfigSource,
34    pub file: Option<String>,
35    pub line: Option<usize>,
36}
37
38#[derive(Debug, Clone)]
39pub struct SourcedValue<T> {
40    pub value: T,
41    pub source: ConfigSource,
42    pub overrides: Vec<ConfigOverride<T>>,
43}
44
45impl<T: Clone> SourcedValue<T> {
46    pub fn new(value: T, source: ConfigSource) -> Self {
47        Self {
48            value: value.clone(),
49            source,
50            overrides: vec![ConfigOverride {
51                value,
52                source,
53                file: None,
54                line: None,
55            }],
56        }
57    }
58
59    /// Merges a new override into this SourcedValue based on source precedence.
60    /// If the new source has higher or equal precedence, the value and source are updated,
61    /// and the new override is added to the history.
62    pub fn merge_override(
63        &mut self,
64        new_value: T,
65        new_source: ConfigSource,
66        new_file: Option<String>,
67        new_line: Option<usize>,
68    ) {
69        // Helper function to get precedence, defined locally or globally
70        fn source_precedence(src: ConfigSource) -> u8 {
71            match src {
72                ConfigSource::Default => 0,
73                ConfigSource::UserConfig => 1,
74                ConfigSource::PyprojectToml => 2,
75                ConfigSource::ProjectConfig => 3,
76                ConfigSource::Cli => 4,
77            }
78        }
79
80        if source_precedence(new_source) >= source_precedence(self.source) {
81            self.value = new_value.clone();
82            self.source = new_source;
83            self.overrides.push(ConfigOverride {
84                value: new_value,
85                source: new_source,
86                file: new_file,
87                line: new_line,
88            });
89        }
90    }
91
92    pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
93        // This is essentially merge_override without the precedence check
94        // We might consolidate these later, but keep separate for now during refactor
95        self.value = value.clone();
96        self.source = source;
97        self.overrides.push(ConfigOverride {
98            value,
99            source,
100            file,
101            line,
102        });
103    }
104}
105
106impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
107    /// Merges a new value using union semantics (for arrays like `disable`)
108    /// Values from both sources are combined, with deduplication
109    pub fn merge_union(
110        &mut self,
111        new_value: Vec<T>,
112        new_source: ConfigSource,
113        new_file: Option<String>,
114        new_line: Option<usize>,
115    ) {
116        fn source_precedence(src: ConfigSource) -> u8 {
117            match src {
118                ConfigSource::Default => 0,
119                ConfigSource::UserConfig => 1,
120                ConfigSource::PyprojectToml => 2,
121                ConfigSource::ProjectConfig => 3,
122                ConfigSource::Cli => 4,
123            }
124        }
125
126        if source_precedence(new_source) >= source_precedence(self.source) {
127            // Union: combine values from both sources with deduplication
128            let mut combined = self.value.clone();
129            for item in &new_value {
130                if !combined.contains(item) {
131                    combined.push(item.clone());
132                }
133            }
134
135            self.value = combined;
136            self.source = new_source;
137            self.overrides.push(ConfigOverride {
138                value: new_value,
139                source: new_source,
140                file: new_file,
141                line: new_line,
142            });
143        }
144    }
145}
146
147#[derive(Debug, Clone)]
148pub struct SourcedGlobalConfig {
149    pub enable: SourcedValue<Vec<String>>,
150    pub disable: SourcedValue<Vec<String>>,
151    pub exclude: SourcedValue<Vec<String>>,
152    pub include: SourcedValue<Vec<String>>,
153    pub respect_gitignore: SourcedValue<bool>,
154    pub line_length: SourcedValue<LineLength>,
155    pub output_format: Option<SourcedValue<String>>,
156    pub fixable: SourcedValue<Vec<String>>,
157    pub unfixable: SourcedValue<Vec<String>>,
158    pub flavor: SourcedValue<MarkdownFlavor>,
159    pub force_exclude: SourcedValue<bool>,
160    pub cache_dir: Option<SourcedValue<String>>,
161    pub cache: SourcedValue<bool>,
162    pub extend_enable: SourcedValue<Vec<String>>,
163    pub extend_disable: SourcedValue<Vec<String>>,
164}
165
166impl Default for SourcedGlobalConfig {
167    fn default() -> Self {
168        SourcedGlobalConfig {
169            enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
170            disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
171            exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
172            include: SourcedValue::new(Vec::new(), ConfigSource::Default),
173            respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
174            line_length: SourcedValue::new(LineLength::default(), ConfigSource::Default),
175            output_format: None,
176            fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
177            unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
178            flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
179            force_exclude: SourcedValue::new(false, ConfigSource::Default),
180            cache_dir: None,
181            cache: SourcedValue::new(true, ConfigSource::Default),
182            extend_enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
183            extend_disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
184        }
185    }
186}
187
188#[derive(Debug, Default, Clone)]
189pub struct SourcedRuleConfig {
190    pub severity: Option<SourcedValue<crate::rule::Severity>>,
191    pub values: BTreeMap<String, SourcedValue<toml::Value>>,
192}
193
194/// Represents configuration loaded from a single source file, with provenance.
195/// Used as an intermediate step before merging into the final SourcedConfig.
196#[derive(Debug, Clone)]
197pub struct SourcedConfigFragment {
198    /// Path to a base config file to inherit from (consumed during loading, not a config setting)
199    pub extends: Option<String>,
200    pub global: SourcedGlobalConfig,
201    pub per_file_ignores: SourcedValue<BTreeMap<String, Vec<String>>>,
202    pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
203    pub code_block_tools: SourcedValue<crate::code_block_tools::CodeBlockToolsConfig>,
204    pub rules: BTreeMap<String, SourcedRuleConfig>,
205    /// Maps canonical rule IDs to their preferred display names (used by import).
206    /// When importing from markdownlint configs, this preserves the user's original
207    /// naming preference (e.g., "line-length" instead of "MD013").
208    pub rule_display_names: HashMap<String, String>,
209    pub unknown_keys: Vec<(String, String, Option<String>)>, // (section, key, file_path)
210                                                             // Note: loaded_files is tracked globally in SourcedConfig.
211}
212
213impl Default for SourcedConfigFragment {
214    fn default() -> Self {
215        Self {
216            extends: None,
217            global: SourcedGlobalConfig::default(),
218            per_file_ignores: SourcedValue::new(BTreeMap::new(), ConfigSource::Default),
219            per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
220            code_block_tools: SourcedValue::new(
221                crate::code_block_tools::CodeBlockToolsConfig::default(),
222                ConfigSource::Default,
223            ),
224            rules: BTreeMap::new(),
225            rule_display_names: HashMap::new(),
226            unknown_keys: Vec::new(),
227        }
228    }
229}
230
231/// Represents a config validation warning or error
232#[derive(Debug, Clone)]
233pub struct ConfigValidationWarning {
234    pub message: String,
235    pub rule: Option<String>,
236    pub key: Option<String>,
237}
238
239/// Configuration with provenance tracking for values.
240///
241/// The `State` type parameter encodes the validation state:
242/// - `ConfigLoaded`: Config has been loaded but not validated
243/// - `ConfigValidated`: Config has been validated and can be converted to `Config`
244///
245/// # Typestate Pattern
246///
247/// This uses the typestate pattern to ensure validation happens before conversion:
248///
249/// ```ignore
250/// let loaded: SourcedConfig<ConfigLoaded> = SourcedConfig::load_with_discovery(...)?;
251/// let validated: SourcedConfig<ConfigValidated> = loaded.validate(&registry)?;
252/// let config: Config = validated.into();  // Only works on ConfigValidated!
253/// ```
254///
255/// Attempting to convert a `ConfigLoaded` config directly to `Config` is a compile error.
256#[derive(Debug, Clone)]
257pub struct SourcedConfig<State = ConfigLoaded> {
258    pub global: SourcedGlobalConfig,
259    pub per_file_ignores: SourcedValue<BTreeMap<String, Vec<String>>>,
260    pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
261    pub code_block_tools: SourcedValue<crate::code_block_tools::CodeBlockToolsConfig>,
262    pub rules: BTreeMap<String, SourcedRuleConfig>,
263    pub loaded_files: Vec<String>,
264    pub unknown_keys: Vec<(String, String, Option<String>)>, // (section, key, file_path)
265    /// Project root directory (parent of config file), used for resolving relative paths
266    pub project_root: Option<std::path::PathBuf>,
267    /// Validation warnings (populated after validate() is called)
268    pub validation_warnings: Vec<ConfigValidationWarning>,
269    /// Phantom data for the state type parameter
270    pub(super) _state: PhantomData<State>,
271}
272
273impl Default for SourcedConfig<ConfigLoaded> {
274    fn default() -> Self {
275        Self {
276            global: SourcedGlobalConfig::default(),
277            per_file_ignores: SourcedValue::new(BTreeMap::new(), ConfigSource::Default),
278            per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
279            code_block_tools: SourcedValue::new(
280                crate::code_block_tools::CodeBlockToolsConfig::default(),
281                ConfigSource::Default,
282            ),
283            rules: BTreeMap::new(),
284            loaded_files: Vec::new(),
285            unknown_keys: Vec::new(),
286            project_root: None,
287            validation_warnings: Vec::new(),
288            _state: PhantomData,
289        }
290    }
291}