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