Skip to main content

rumdl_lib/config/
source_tracking.rs

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