Skip to main content

rumdl_lib/config/
types.rs

1use crate::discovery::PathAliases;
2use crate::types::LineLength;
3use globset::{GlobMatcher, GlobSet, GlobSetBuilder};
4use indexmap::IndexMap;
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, HashSet};
7use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, OnceLock};
11
12use super::flavor::{MarkdownFlavor, normalize_key};
13
14/// Represents a rule-specific configuration
15#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
16pub struct RuleConfig {
17    /// Severity override for this rule (Error, Warning, or Info)
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub severity: Option<crate::rule::Severity>,
20
21    /// Configuration values for the rule
22    #[serde(flatten)]
23    #[schemars(schema_with = "arbitrary_value_schema")]
24    pub values: BTreeMap<String, toml::Value>,
25}
26
27/// Generate a JSON schema for arbitrary configuration values
28fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
29    schemars::json_schema!({
30        "type": "object",
31        "additionalProperties": true
32    })
33}
34
35/// Represents the complete configuration loaded from rumdl.toml
36#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
37#[schemars(
38    description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
39)]
40pub struct Config {
41    /// Path to a base config file to inherit settings from.
42    /// Supports relative paths, absolute paths, `~/` for the home directory, and
43    /// `$VAR` / `${VAR}` environment-variable expansion (a literal `$` is written `$$`).
44    /// Example: `extends = "../base.rumdl.toml"` or `extends = "$GEM_PATH/base.rumdl.toml"`
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub extends: Option<String>,
47
48    /// Global configuration options
49    #[serde(default)]
50    pub global: GlobalConfig,
51
52    /// Resolved CLI2 file-selection options; not native rumdl configuration.
53    #[serde(skip)]
54    #[schemars(skip)]
55    pub cli2_ignores: Vec<super::Cli2Ignore>,
56
57    /// Per-file rule ignores: maps file patterns to lists of rules to ignore.
58    /// Patterns are relative to the project root; a leading `~/` expands to the
59    /// home directory and absolute paths are matched as written.
60    /// Example: { "README.md": ["MD033"], "docs/**/*.md": ["MD013"] }
61    #[serde(default, rename = "per-file-ignores")]
62    pub per_file_ignores: BTreeMap<String, Vec<String>>,
63
64    /// Per-file flavor overrides: maps file patterns to Markdown flavors.
65    /// Patterns are relative to the project root; a leading `~/` expands to the
66    /// home directory and absolute paths are matched as written.
67    /// Example: { "docs/**/*.md": MkDocs, "**/*.mdx": MDX }
68    /// Uses IndexMap to preserve config file order for "first match wins" semantics
69    #[serde(default, rename = "per-file-flavor")]
70    #[schemars(with = "BTreeMap<String, MarkdownFlavor>")]
71    pub per_file_flavor: IndexMap<String, MarkdownFlavor>,
72
73    /// Code block tools configuration for per-language linting and formatting
74    /// using external tools like ruff, prettier, shellcheck, etc.
75    #[serde(default, rename = "code-block-tools")]
76    pub code_block_tools: crate::code_block_tools::CodeBlockToolsConfig,
77
78    /// Rule-specific configurations (e.g., MD013, MD007, MD044)
79    /// Each rule section can contain options specific to that rule.
80    ///
81    /// Common examples:
82    /// - MD013: line_length, code_blocks, tables, headings
83    /// - MD007: indent
84    /// - MD003: style ("atx", "atx-closed", "setext")
85    /// - MD044: names (array of proper names to check)
86    ///
87    /// See <https://github.com/rvben/rumdl> for full rule documentation.
88    #[serde(flatten)]
89    pub rules: BTreeMap<String, RuleConfig>,
90
91    /// Rules holding at least one option value that came from a config file
92    /// whose contents may not be quoted back (an `extends` target, whose path
93    /// is arbitrary and whose text the extending project need not be able to
94    /// read). The values apply as written; only a message about one has to
95    /// leave it out.
96    ///
97    /// Granularity is the rule section, because that is what a rule's options
98    /// are deserialized as: an error names one value but not which key it came
99    /// from, so a section mixing an extended value with a directly named one
100    /// withholds both. Erring that way keeps a message from carrying text the
101    /// project never wrote.
102    ///
103    /// Provenance rather than configuration, so it stays out of the serialized
104    /// form and the JSON schema.
105    #[serde(skip)]
106    #[schemars(skip)]
107    pub withheld_rule_values: std::collections::BTreeSet<String>,
108
109    /// Project root directory, used for resolving relative paths in per-file-ignores
110    #[serde(skip)]
111    pub project_root: Option<std::path::PathBuf>,
112
113    #[serde(skip)]
114    #[schemars(skip)]
115    pub(super) per_file_ignores_cache: Arc<OnceLock<PerFileIgnoreCache>>,
116
117    #[serde(skip)]
118    #[schemars(skip)]
119    pub(super) per_file_flavor_cache: Arc<OnceLock<PerFileFlavorCache>>,
120
121    /// Lazily-computed canonical form of `project_root`.
122    ///
123    /// `normalize_match_path` needs the canonical project root to strip
124    /// prefixes from absolute file paths. Without this cache, every per-file
125    /// lookup would re-canonicalize the project root (one syscall per file).
126    ///
127    /// ## Invariants
128    ///
129    /// - **Single-shot**: computed once on first use of [`Config::canonical_project_root`].
130    /// - **Never invalidated**: callers must not mutate `project_root` after
131    ///   the first call. `Config` is treated as immutable post-construction
132    ///   (the same assumption as `per_file_ignores_cache` and `per_file_flavor_cache`).
133    /// - **Construction-time existence**: the cache stores `None` if
134    ///   `project_root` is unset, missing on disk, or otherwise can't be
135    ///   canonicalized. In practice `project_root` is set after walking up to
136    ///   `.git`, so the directory always exists at the time the cache is first
137    ///   read; if a caller sets `project_root` to a not-yet-existing path,
138    ///   the cache will permanently store `None`.
139    /// - **`Arc` wrapping**: `Config` derives `Clone`, and clones share the
140    ///   same `OnceLock` so a value computed by one clone is observable to all.
141    ///
142    /// `cwd` deliberately is NOT cached symmetrically: callers read it fresh
143    /// from `std::env::current_dir()` per call because tests (and embedding
144    /// hosts like LSP servers) may legitimately mutate the process cwd
145    /// between lookups.
146    #[serde(skip)]
147    #[schemars(skip)]
148    pub(super) canonical_project_root_cache: Arc<OnceLock<Option<PathBuf>>>,
149}
150
151impl PartialEq for Config {
152    fn eq(&self, other: &Self) -> bool {
153        self.global == other.global
154            && self.cli2_ignores == other.cli2_ignores
155            && self.per_file_ignores == other.per_file_ignores
156            && self.per_file_flavor == other.per_file_flavor
157            && self.code_block_tools == other.code_block_tools
158            && self.rules == other.rules
159            && self.project_root == other.project_root
160    }
161}
162
163#[derive(Debug)]
164pub(super) struct PerFileIgnoreCache {
165    globset: GlobSet,
166    rules: Vec<Vec<String>>,
167    /// Whether any pattern is absolute, i.e. whether matching has to consider
168    /// the file's absolute path as well as its project-relative form.
169    has_absolute: bool,
170    /// Spellings of the file that absolute patterns reach through a symlink.
171    aliases: PathAliases,
172}
173
174#[derive(Debug)]
175pub(super) struct PerFileFlavorCache {
176    matchers: Vec<(GlobMatcher, MarkdownFlavor)>,
177    /// See [`PerFileIgnoreCache::has_absolute`].
178    has_absolute: bool,
179    /// See [`PerFileIgnoreCache::aliases`].
180    aliases: PathAliases,
181}
182
183/// The file's absolute path, for matching against absolute patterns, resolved
184/// whether or not the file exists yet. `None` when the caller has no absolute
185/// pattern to match (the common case, which must not pay for the resolution).
186fn absolute_match_path(file_path: &Path, has_absolute: bool) -> Option<PathBuf> {
187    if !has_absolute {
188        return None;
189    }
190    Some(crate::discovery::resolve_for_matching(file_path))
191}
192
193/// The file's other spellings, for matching against absolute patterns that
194/// named a location through a symlink (`/var/folders/**` on macOS, whose files
195/// canonicalize to `/private/var/folders/…`). Empty for the configurations that
196/// have no such pattern, which is nearly all of them.
197fn alias_match_paths(aliases: &PathAliases, absolute: Option<&Path>) -> Vec<String> {
198    match absolute {
199        Some(absolute) if !aliases.is_empty() => aliases.spellings_of(absolute),
200        _ => Vec::new(),
201    }
202}
203
204impl Config {
205    /// Whether CLI2 excludes this file before linting or formatting.
206    pub fn is_cli2_ignored(&self, path: &Path) -> bool {
207        self.cli2_ignores.iter().any(|scope| scope.matches(path))
208    }
209
210    /// Check if the Markdown flavor is set to MkDocs
211    pub fn is_mkdocs_flavor(&self) -> bool {
212        self.global.flavor == MarkdownFlavor::MkDocs
213    }
214
215    // Future methods for when GFM and CommonMark are implemented:
216    // pub fn is_gfm_flavor(&self) -> bool
217    // pub fn is_commonmark_flavor(&self) -> bool
218
219    /// Get the configured Markdown flavor
220    pub fn markdown_flavor(&self) -> MarkdownFlavor {
221        self.global.flavor
222    }
223
224    /// Legacy method for backwards compatibility - redirects to is_mkdocs_flavor
225    pub fn is_mkdocs_project(&self) -> bool {
226        self.is_mkdocs_flavor()
227    }
228
229    /// Apply per-rule `enabled` config to the global enable/disable lists.
230    ///
231    /// For `[MD060] enabled = true`: adds the rule to `extend_enable` and
232    /// removes it from `disable` and `extend_disable`, ensuring the rule is active.
233    ///
234    /// For `[MD041] enabled = false`: adds the rule to `disable` and
235    /// removes it from `extend_enable`, ensuring the rule is inactive.
236    ///
237    /// Per-rule `enabled` takes precedence over global lists when there
238    /// is a conflict, since it represents a more specific intent.
239    pub fn apply_per_rule_enabled(&mut self) {
240        let mut to_enable: Vec<String> = Vec::new();
241        let mut to_disable: Vec<String> = Vec::new();
242
243        for (name, cfg) in &self.rules {
244            match cfg.values.get("enabled") {
245                Some(toml::Value::Boolean(true)) => {
246                    to_enable.push(name.clone());
247                }
248                Some(toml::Value::Boolean(false)) => {
249                    to_disable.push(name.clone());
250                }
251                _ => {}
252            }
253        }
254
255        for name in to_enable {
256            if !self.global.extend_enable.contains(&name) {
257                self.global.extend_enable.push(name.clone());
258            }
259            self.global.disable.retain(|n| n != &name);
260            self.global.extend_disable.retain(|n| n != &name);
261        }
262
263        for name in to_disable {
264            if !self.global.disable.contains(&name) {
265                self.global.disable.push(name.clone());
266            }
267            self.global.extend_enable.retain(|n| n != &name);
268        }
269    }
270
271    /// Get the severity override for a specific rule, if configured
272    pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
273        self.rules.get(rule_name).and_then(|r| r.severity)
274    }
275
276    /// Return the canonical form of `project_root`, computed once and cached.
277    ///
278    /// Returns `None` if `project_root` is unset, doesn't exist on disk, or
279    /// otherwise cannot be canonicalized. Subsequent calls reuse the cached
280    /// value, eliminating the per-file `canonicalize()` syscall that
281    /// `normalize_match_path` would otherwise perform.
282    pub(super) fn canonical_project_root(&self) -> Option<&Path> {
283        self.canonical_project_root_cache
284            .get_or_init(|| {
285                self.project_root
286                    .as_deref()
287                    .and_then(crate::discovery::canonicalize_for_matching)
288            })
289            .as_deref()
290    }
291
292    /// Get the set of rules that should be ignored for a specific file based on per-file-ignores configuration
293    /// Returns a HashSet of rule names (uppercase, e.g., "MD033") that match the given file path
294    pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
295        let mut ignored_rules = HashSet::new();
296
297        if self.per_file_ignores.is_empty() {
298            return ignored_rules;
299        }
300
301        let path_for_matching = normalize_match_path(file_path, self.canonical_project_root(), || {
302            std::env::current_dir().ok()
303        });
304
305        let cache = self
306            .per_file_ignores_cache
307            .get_or_init(|| PerFileIgnoreCache::new(&self.per_file_ignores));
308
309        // Match the file path against all patterns, by its project-relative
310        // form and - for absolute patterns, including expanded `~` ones - by
311        // its absolute path and by the spellings of that path a pattern named
312        // through a symlink.
313        let absolute = absolute_match_path(file_path, cache.has_absolute);
314        let aliases = alias_match_paths(&cache.aliases, absolute.as_deref());
315        let matches = cache
316            .globset
317            .matches(path_for_matching.as_ref())
318            .into_iter()
319            .chain(absolute.iter().flat_map(|abs| cache.globset.matches(abs)))
320            .chain(aliases.iter().flat_map(|alias| cache.globset.matches(alias)));
321
322        for match_idx in matches {
323            if let Some(rules) = cache.rules.get(match_idx) {
324                for rule in rules {
325                    // Normalize rule names to uppercase (MD033, md033 -> MD033)
326                    ignored_rules.insert(rule.clone());
327                }
328            }
329        }
330
331        ignored_rules
332    }
333
334    /// Get the MarkdownFlavor for a specific file based on per-file-flavor configuration.
335    /// Returns the first matching pattern's flavor, or falls back to global flavor,
336    /// or auto-detects from extension, or defaults to Standard.
337    pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
338        // If no per-file patterns, use fallback logic
339        if self.per_file_flavor.is_empty() {
340            return self.resolve_flavor_fallback(file_path);
341        }
342
343        let path_for_matching = normalize_match_path(file_path, self.canonical_project_root(), || {
344            std::env::current_dir().ok()
345        });
346
347        let cache = self
348            .per_file_flavor_cache
349            .get_or_init(|| PerFileFlavorCache::new(&self.per_file_flavor));
350
351        // Iterate in config order and return first match (IndexMap preserves order).
352        // Each pattern sees the file's project-relative form and - for absolute
353        // patterns, including expanded `~` ones - its absolute path and the
354        // spellings of that path a pattern named through a symlink.
355        let absolute = absolute_match_path(file_path, cache.has_absolute);
356        let aliases = alias_match_paths(&cache.aliases, absolute.as_deref());
357        for (matcher, flavor) in &cache.matchers {
358            if matcher.is_match(path_for_matching.as_ref())
359                || absolute.as_ref().is_some_and(|abs| matcher.is_match(abs))
360                || aliases.iter().any(|alias| matcher.is_match(alias))
361            {
362                return *flavor;
363            }
364        }
365
366        // No pattern matched, use fallback
367        self.resolve_flavor_fallback(file_path)
368    }
369
370    /// Fallback flavor resolution: global flavor → auto-detect → Standard
371    fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
372        // If global flavor is explicitly set to non-Standard, use it
373        if self.global.flavor != MarkdownFlavor::Standard {
374            return self.global.flavor;
375        }
376        // Auto-detect from extension
377        MarkdownFlavor::from_path(file_path)
378    }
379
380    /// Canonicalize every rule-name list inside this `Config`.
381    ///
382    /// This is the single enforcement point for the runtime invariant:
383    /// **after a `Config` is fully built, every rule-name list contains
384    /// canonical rule IDs (`"MD033"`) — never aliases (`"no-inline-html"`).**
385    ///
386    /// The invariant lets every consumer (`rules::filter_rules`, the LSP,
387    /// WASM, fix coordinator, per-file-ignore lookups) match against
388    /// `Rule::name()` with simple string equality. Mutation boundaries
389    /// (`From<SourcedConfig> for Config`, LSP `apply_lsp_settings_*`, WASM
390    /// `to_config_with_warnings`) call this before handing the `Config` to
391    /// the linting pipeline.
392    ///
393    /// Covers `global.{enable,disable,extend_enable,extend_disable,fixable,unfixable}`
394    /// and the values of `per_file_ignores`. Idempotent.
395    pub fn canonicalize_rule_lists(&mut self) {
396        use super::registry::canonicalize_rule_list_in_place;
397        self.global.canonicalize_rule_lists();
398        for rules in self.per_file_ignores.values_mut() {
399            canonicalize_rule_list_in_place(rules);
400        }
401    }
402
403    /// Merge inline configuration overrides into a copy of this config
404    ///
405    /// This enables automatic inline config support - the engine can merge
406    /// inline overrides and recreate rules without any per-rule changes.
407    ///
408    /// Returns a new Config with the inline overrides merged in.
409    /// If there are no inline overrides, returns a clone of self.
410    pub fn merge_with_inline_config(&self, inline_config: &crate::inline_config::InlineConfig) -> Self {
411        let overrides = inline_config.get_all_rule_configs();
412        if overrides.is_empty() {
413            return self.clone();
414        }
415
416        let mut merged = self.clone();
417
418        for (rule_name, json_override) in overrides {
419            // Get or create the rule config entry
420            let rule_config = merged.rules.entry(rule_name.clone()).or_default();
421
422            // Merge JSON values into the rule's config
423            if let Some(obj) = json_override.as_object() {
424                for (key, value) in obj {
425                    // Normalize key to kebab-case for consistency
426                    let normalized_key = key.replace('_', "-");
427
428                    // Convert JSON value to TOML value
429                    if let Some(toml_value) = json_to_toml(value) {
430                        rule_config.values.insert(normalized_key, toml_value);
431                    }
432                }
433            }
434        }
435
436        merged
437    }
438}
439
440/// Normalize a file path for matching against a glob pattern from configuration.
441///
442/// Glob patterns in `per-file-ignores` and `per-file-flavor` are written relative
443/// to the project root (e.g. `docs/**/*.md`), and a glob is anchored at the start
444/// of the string it matches, so an absolute path like `/home/user/proj/docs/x.md`
445/// will not match `docs/**/*.md`. This helper produces the form the glob expects:
446///
447/// 1. **Relative path** → return as-is.
448/// 2. **Absolute path under `project_root`** → return path relative to `project_root`.
449/// 3. **Absolute path under `cwd`** → return path relative to `cwd`. This is the
450///    safety net for invocations where `project_root` could not be discovered
451///    (no `.git` upward, LSP/CLI calls outside a project) but the file still
452///    lives somewhere under the working directory.
453/// 4. **Anywhere else** → return the raw path. A relative glob simply won't
454///    match it, which is the desired outcome for files outside any known root.
455///
456/// An absolute path need not exist: an editor buffer or a file about to be
457/// created is resolved through its deepest existing ancestor (see
458/// [`crate::discovery::resolve_for_matching`]), so it matches the patterns it
459/// will match once written.
460///
461/// `canonical_project_root` is expected to already be canonical (via
462/// `Config::canonical_project_root`). `cwd` yields the working directory, which
463/// is canonicalized here: it is read fresh on each call, and only once the
464/// project root has failed to claim the file, since reading it walks the path
465/// on macOS and a file under the project root never needs it.
466pub(super) fn normalize_match_path<'a>(
467    file_path: &'a Path,
468    canonical_project_root: Option<&Path>,
469    cwd: impl FnOnce() -> Option<PathBuf>,
470) -> std::borrow::Cow<'a, Path> {
471    use std::borrow::Cow;
472
473    if file_path.is_relative() {
474        return Cow::Borrowed(file_path);
475    }
476
477    let canonical_file = crate::discovery::resolve_for_matching(file_path);
478
479    if let Some(root) = canonical_project_root
480        && let Ok(rel) = canonical_file.strip_prefix(root)
481    {
482        return Cow::Owned(rel.to_path_buf());
483    }
484
485    let cwd = cwd();
486    if let Some(working_dir) = cwd.as_deref()
487        && let Some(canonical_cwd) = crate::discovery::canonicalize_for_matching(working_dir)
488        && let Ok(rel) = canonical_file.strip_prefix(&canonical_cwd)
489    {
490        return Cow::Owned(rel.to_path_buf());
491    }
492
493    // Surface the silent fallback once per process at warn level so users with
494    // per-file glob configs notice when their patterns can't match a file.
495    // Subsequent occurrences stay at debug to avoid log spam.
496    static SILENT_FALLBACK_WARNED: OnceLock<()> = OnceLock::new();
497    log::log!(
498        first_call_warn_else_debug(&SILENT_FALLBACK_WARNED),
499        "{}",
500        format_silent_fallback_message(file_path, canonical_project_root, cwd.as_deref()),
501    );
502    Cow::Borrowed(file_path)
503}
504
505/// Returns [`log::Level::Warn`] the first time it is called with a given
506/// `latch`, and [`log::Level::Debug`] on every subsequent call. The latch
507/// is consumed by the first caller via `OnceLock::set`; later callers
508/// observe the latch as already set and downgrade.
509///
510/// Used to flag a fallback condition once per process without flooding
511/// logs when the same condition recurs (e.g. once per linted file).
512pub(super) fn first_call_warn_else_debug(latch: &OnceLock<()>) -> log::Level {
513    if latch.set(()).is_ok() {
514        log::Level::Warn
515    } else {
516        log::Level::Debug
517    }
518}
519
520/// Format the diagnostic emitted when [`normalize_match_path`] cannot
521/// relativise `file_path` against either the project root or the current
522/// working directory. Extracted so the exact wording can be asserted in
523/// tests without capturing log output.
524pub(super) fn format_silent_fallback_message(
525    file_path: &Path,
526    canonical_project_root: Option<&Path>,
527    cwd: Option<&Path>,
528) -> String {
529    format!(
530        "Per-file glob patterns will not match {}: file is outside project_root ({}) and cwd ({})",
531        file_path.display(),
532        DisplayPathOrUnset(canonical_project_root),
533        DisplayPathOrUnset(cwd),
534    )
535}
536
537/// Display adapter for `Option<&Path>` that renders the path via
538/// [`Path::display`] when present, or the literal `<unset>` when absent.
539/// Angle brackets follow Rust's diagnostic convention (e.g. `<unknown>`)
540/// and avoid double-paren rendering when the surrounding format string
541/// already wraps the value in `(…)`.
542struct DisplayPathOrUnset<'a>(Option<&'a Path>);
543
544impl std::fmt::Display for DisplayPathOrUnset<'_> {
545    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546        match self.0 {
547            Some(path) => std::fmt::Display::fmt(&path.display(), f),
548            None => f.write_str("<unset>"),
549        }
550    }
551}
552
553/// Convert a serde_json::Value to a toml::Value
554pub(super) fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
555    match json {
556        serde_json::Value::Null => None,
557        serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
558        serde_json::Value::Number(n) => n
559            .as_i64()
560            .map(toml::Value::Integer)
561            .or_else(|| n.as_f64().map(toml::Value::Float)),
562        serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
563        serde_json::Value::Array(arr) => {
564            let toml_arr: Vec<toml::Value> = arr.iter().filter_map(json_to_toml).collect();
565            Some(toml::Value::Array(toml_arr))
566        }
567        serde_json::Value::Object(obj) => {
568            let mut table = toml::map::Map::new();
569            for (k, v) in obj {
570                if let Some(tv) = json_to_toml(v) {
571                    table.insert(k.clone(), tv);
572                }
573            }
574            Some(toml::Value::Table(table))
575        }
576    }
577}
578
579impl PerFileIgnoreCache {
580    fn new(per_file_ignores: &BTreeMap<String, Vec<String>>) -> Self {
581        let mut builder = GlobSetBuilder::new();
582        let mut rules = Vec::new();
583
584        let mut has_absolute = false;
585        for (pattern, rules_list) in per_file_ignores {
586            let pattern = crate::discovery::prepare_pattern(pattern);
587            has_absolute |= crate::discovery::has_absolute_spelling(&pattern);
588            if let Ok(glob) = crate::discovery::path_glob(&pattern, false) {
589                builder.add(glob);
590                // Canonicalize defensively: callers should have run
591                // Config::canonicalize_rule_lists already, but per-file-ignores
592                // has reached this cache directly from a few code paths
593                // historically, so we re-canonicalize here to keep the cache
594                // sound regardless of caller discipline.
595                rules.push(
596                    rules_list
597                        .iter()
598                        .map(|rule| super::registry::resolve_rule_name(rule))
599                        .collect(),
600                );
601            } else {
602                log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
603            }
604        }
605
606        let globset = builder.build().unwrap_or_else(|e| {
607            log::error!("Failed to build globset for per-file-ignores: {e}");
608            GlobSetBuilder::new().build().unwrap()
609        });
610
611        Self {
612            globset,
613            rules,
614            has_absolute,
615            aliases: PathAliases::new(per_file_ignores.keys().map(String::as_str)),
616        }
617    }
618}
619
620impl PerFileFlavorCache {
621    fn new(per_file_flavor: &IndexMap<String, MarkdownFlavor>) -> Self {
622        let mut matchers = Vec::new();
623
624        let mut has_absolute = false;
625        for (pattern, flavor) in per_file_flavor {
626            let pattern = crate::discovery::prepare_pattern(pattern);
627            has_absolute |= crate::discovery::has_absolute_spelling(&pattern);
628            if let Ok(glob) = crate::discovery::path_glob(&pattern, true) {
629                matchers.push((glob.compile_matcher(), *flavor));
630            } else {
631                log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
632            }
633        }
634
635        Self {
636            matchers,
637            has_absolute,
638            aliases: PathAliases::new(per_file_flavor.keys().map(String::as_str)),
639        }
640    }
641}
642
643/// Global configuration options
644#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
645#[serde(default, rename_all = "kebab-case")]
646pub struct GlobalConfig {
647    /// Enabled rules
648    #[serde(default)]
649    pub enable: Vec<String>,
650
651    /// Disabled rules
652    #[serde(default)]
653    pub disable: Vec<String>,
654
655    /// Files to exclude. Glob patterns, relative to the project root; a
656    /// leading `~/` expands to the home directory and absolute paths are
657    /// matched as written.
658    #[serde(default)]
659    pub exclude: Vec<String>,
660
661    /// Files to include. Glob patterns, relative to the project root; a
662    /// leading `~/` expands to the home directory and absolute paths are
663    /// matched as written.
664    #[serde(default)]
665    pub include: Vec<String>,
666
667    /// Respect .gitignore, .ignore and git exclude files when scanning
668    /// directories. .markdownlintignore applies regardless, and explicitly
669    /// named files bypass both.
670    #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
671    pub respect_gitignore: bool,
672
673    /// Global line length setting (used by MD013 and other rules if not overridden)
674    #[serde(default, alias = "line_length")]
675    pub line_length: LineLength,
676
677    /// Output format for linting results (e.g., "text", "json", "pylint", etc.)
678    #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
679    pub output_format: Option<String>,
680
681    /// Rules that are allowed to be fixed when --fix is used
682    /// If specified, only these rules will be fixed
683    #[serde(default)]
684    pub fixable: Vec<String>,
685
686    /// Rules that should never be fixed, even when --fix is used
687    /// Takes precedence over fixable
688    #[serde(default)]
689    pub unfixable: Vec<String>,
690
691    /// Markdown flavor/dialect to use (mkdocs, gfm, commonmark, etc.)
692    /// When set, adjusts parsing and validation rules for that specific Markdown variant
693    #[serde(default)]
694    pub flavor: MarkdownFlavor,
695
696    /// \[DEPRECATED\] Whether to enforce exclude patterns for explicitly passed paths.
697    /// This option is deprecated as of v0.0.156 and has no effect.
698    /// Exclude patterns are now always respected, even for explicitly provided files.
699    /// This prevents duplication between rumdl config and tool configs like pre-commit.
700    #[serde(default, alias = "force_exclude")]
701    #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
702    pub force_exclude: bool,
703
704    /// Directory to store cache files (default: .rumdl_cache).
705    /// A leading `~/` expands to the home directory; a relative path resolves
706    /// against the project root.
707    /// Can also be set via --cache-dir CLI flag or RUMDL_CACHE_DIR environment variable
708    #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
709    pub cache_dir: Option<String>,
710
711    /// Whether caching is enabled (default: true)
712    /// Can also be disabled via --no-cache CLI flag
713    #[serde(default = "default_true")]
714    pub cache: bool,
715
716    /// Additional rules to enable on top of the base set (additive)
717    #[serde(default, alias = "extend_enable")]
718    pub extend_enable: Vec<String>,
719
720    /// Additional rules to disable on top of the base set (additive)
721    #[serde(default, alias = "extend_disable")]
722    pub extend_disable: Vec<String>,
723
724    /// Whether to read settings from `.editorconfig` files (default: false).
725    /// When enabled, the `.editorconfig` properties that map onto rumdl
726    /// settings fill in anything no rumdl config sets, resolved per file so
727    /// section globs and nested `.editorconfig` files apply as written.
728    #[serde(default)]
729    pub editorconfig: bool,
730
731    /// Whether the enable list was explicitly set (even if empty).
732    /// Used to distinguish "no enable list configured" from "enable list is empty"
733    /// (e.g., markdownlint `default: false` with no rules enabled).
734    #[serde(skip)]
735    pub enable_is_explicit: bool,
736
737    /// How to name the file that supplied [`Self::include`] when a message about
738    /// those patterns may not quote them (an `extends` target, whose path is
739    /// arbitrary and whose text the extending project need not be able to read),
740    /// and `None` when it may. Provenance rather than configuration, so it stays
741    /// out of the serialized form and the JSON schema.
742    #[serde(skip)]
743    pub include_withheld: Option<String>,
744}
745
746fn default_respect_gitignore() -> bool {
747    true
748}
749
750fn default_true() -> bool {
751    true
752}
753
754// Add the Default impl
755impl Default for GlobalConfig {
756    #[allow(deprecated)]
757    fn default() -> Self {
758        Self {
759            enable: Vec::new(),
760            disable: Vec::new(),
761            exclude: Vec::new(),
762            include: Vec::new(),
763            respect_gitignore: true,
764            line_length: LineLength::default(),
765            output_format: None,
766            fixable: Vec::new(),
767            unfixable: Vec::new(),
768            flavor: MarkdownFlavor::default(),
769            force_exclude: false,
770            cache_dir: None,
771            cache: true,
772            extend_enable: Vec::new(),
773            extend_disable: Vec::new(),
774            editorconfig: false,
775            enable_is_explicit: false,
776            include_withheld: None,
777        }
778    }
779}
780
781impl GlobalConfig {
782    /// Canonicalize every rule-name list in this `GlobalConfig`.
783    ///
784    /// Rewrites `enable`, `disable`, `extend_enable`, `extend_disable`, `fixable`,
785    /// and `unfixable` so that all entries are canonical rule IDs (`"MD033"`)
786    /// rather than aliases (`"no-inline-html"`). Duplicates are removed,
787    /// preserving first-occurrence order; the special `"all"` keyword is
788    /// preserved.
789    ///
790    /// This must be called by every code path that mutates a runtime
791    /// `Config`'s rule lists from external input (markdownlint configs,
792    /// `.rumdl.toml`, LSP `initializationOptions`, WASM bindings, etc.) so
793    /// that downstream consumers (`rules::filter_rules`, the LSP, WASM) can
794    /// match against `Rule::name()` with simple string equality.
795    pub fn canonicalize_rule_lists(&mut self) {
796        use super::registry::canonicalize_rule_list_in_place;
797        canonicalize_rule_list_in_place(&mut self.enable);
798        canonicalize_rule_list_in_place(&mut self.disable);
799        canonicalize_rule_list_in_place(&mut self.extend_enable);
800        canonicalize_rule_list_in_place(&mut self.extend_disable);
801        canonicalize_rule_list_in_place(&mut self.fixable);
802        canonicalize_rule_list_in_place(&mut self.unfixable);
803    }
804}
805
806/// Names of rumdl-native config files, searched in precedence order when
807/// walking up a directory tree.
808///
809/// This is the single source of truth for config discovery. Both the CLI
810/// (`SourcedConfig::discover_config_upward`, `discover_config_for_dir`) and
811/// the LSP (`RumdlLanguageServer::resolve_config_for_file`) must use this
812/// list; any deviation causes silent config-not-found bugs where the CLI
813/// recognises a config but the LSP does not (or vice versa).
814///
815/// See `src/lsp/tests.rs::test_lsp_cli_resolver_parity_on_fixtures` for
816/// the side-by-side resolver parity test that pins this invariant across
817/// several directory layouts.
818pub const RUMDL_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
819
820/// Names of markdownlint config files, in precedence order within one directory.
821///
822/// `.markdownlint.*` files come first because markdownlint-cli2 lints with a
823/// directory's `.markdownlint.*` file in preference to the `config` of its
824/// `.markdownlint-cli2.*` file.
825pub const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
826    ".markdownlint.jsonc",
827    ".markdownlint.json",
828    ".markdownlint.yaml",
829    ".markdownlint.yml",
830    ".markdownlint-cli2.jsonc",
831    ".markdownlint-cli2.yaml",
832    ".markdownlint-cli2.yml",
833    "markdownlint.json",
834    "markdownlint.jsonc",
835    "markdownlint.yaml",
836    "markdownlint.yml",
837];
838
839/// Create a default configuration file at the specified path
840pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
841    create_preset_config("default", path)
842}
843
844/// Create a configuration file with a specific style preset
845pub fn create_preset_config(preset: &str, path: &str) -> Result<(), ConfigError> {
846    if Path::new(path).exists() {
847        return Err(ConfigError::FileExists { path: path.to_string() });
848    }
849
850    let config_content = match preset {
851        "default" => generate_default_preset(),
852        "google" => generate_google_preset(),
853        "relaxed" => generate_relaxed_preset(),
854        _ => {
855            return Err(ConfigError::UnknownPreset {
856                name: preset.to_string(),
857            });
858        }
859    };
860
861    match fs::write(path, config_content) {
862        Ok(_) => Ok(()),
863        Err(err) => Err(ConfigError::IoError {
864            source: err,
865            path: path.to_string(),
866        }),
867    }
868}
869
870/// Generate the default preset configuration content.
871/// Returns the same content as `create_default_config`.
872fn generate_default_preset() -> String {
873    r#"# rumdl configuration file
874
875# Inherit settings from another config file (relative to this file's directory)
876# extends = "../base.rumdl.toml"
877
878# Global configuration options
879[global]
880# List of rules to disable (uncomment and modify as needed)
881# disable = ["MD013", "MD033"]
882
883# List of rules to enable exclusively (replaces defaults; only these rules will run)
884# enable = ["MD001", "MD003", "MD004"]
885
886# Additional rules to enable on top of defaults (additive, does not replace)
887# Use this to activate opt-in rules like MD060, MD063, MD072, MD073, MD074
888# extend-enable = ["MD060", "MD063"]
889
890# Additional rules to disable on top of the disable list (additive)
891# extend-disable = ["MD041"]
892
893# List of file/directory patterns to include for linting (if provided, only these will be linted)
894# include = [
895#    "docs/*.md",
896#    "src/**/*.md",
897#    "README.md"
898# ]
899
900# List of file/directory patterns to exclude from linting
901exclude = [
902    # Common directories to exclude
903    ".git",
904    ".github",
905    "node_modules",
906    "vendor",
907    "dist",
908    "build",
909
910    # Specific files or patterns
911    "CHANGELOG.md",
912    "LICENSE.md",
913]
914
915# Respect .gitignore, .ignore and git exclude files when scanning directories
916# (default: true). .markdownlintignore applies regardless.
917respect-gitignore = true
918
919# Markdown flavor/dialect (uncomment to enable)
920# Options: standard (default), gfm, commonmark, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst, hugo, mdg, gh-aw
921# flavor = "mkdocs"
922
923# Rule-specific configurations (uncomment and modify as needed)
924
925# [MD003]
926# style = "atx"  # Heading style (atx, atx_closed, setext)
927
928# [MD004]
929# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
930
931# [MD007]
932# indent = 4  # Unordered list indentation
933
934# [MD013]
935# line-length = 100  # Line length
936# code-blocks = false  # Exclude code blocks from line length check
937# tables = false  # Exclude tables from line length check
938# headings = true  # Include headings in line length check
939
940# [MD044]
941# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
942# code-blocks = false  # Check code blocks for proper names (default: false, skips code blocks)
943"#
944    .to_string()
945}
946
947/// Generate Google developer documentation style preset.
948/// Based on <https://google.github.io/styleguide/docguide/style.html>
949fn generate_google_preset() -> String {
950    r#"# rumdl configuration - Google developer documentation style
951# Based on https://google.github.io/styleguide/docguide/style.html
952
953[global]
954exclude = [
955    ".git",
956    ".github",
957    "node_modules",
958    "vendor",
959    "dist",
960    "build",
961    "CHANGELOG.md",
962    "LICENSE.md",
963]
964respect-gitignore = true
965
966# ATX-style headings required
967[MD003]
968style = "atx"
969
970# Unordered list style: dash
971[MD004]
972style = "dash"
973
974# 4-space indent for nested lists
975[MD007]
976indent = 4
977
978# Strict mode: no trailing spaces allowed (Google uses backslash for line breaks)
979[MD009]
980strict = true
981
982# 80-character line length
983[MD013]
984line-length = 80
985code-blocks = false
986tables = false
987
988# No trailing punctuation in headings
989[MD026]
990punctuation = ".,;:!。,;:!"
991
992# Fenced code blocks only (no indented code blocks)
993[MD046]
994style = "fenced"
995
996# Emphasis with underscores
997[MD049]
998style = "underscore"
999
1000# Strong with asterisks
1001[MD050]
1002style = "asterisk"
1003"#
1004    .to_string()
1005}
1006
1007/// Generate relaxed preset for existing projects adopting rumdl incrementally.
1008/// Longer line lengths, fewer rules, lenient settings to minimize initial warnings.
1009fn generate_relaxed_preset() -> String {
1010    r#"# rumdl configuration - Relaxed preset
1011# Lenient settings for existing projects adopting rumdl incrementally.
1012# Minimizes initial warnings while still catching important issues.
1013
1014[global]
1015exclude = [
1016    ".git",
1017    ".github",
1018    "node_modules",
1019    "vendor",
1020    "dist",
1021    "build",
1022    "CHANGELOG.md",
1023    "LICENSE.md",
1024]
1025respect-gitignore = true
1026
1027# Disable rules that produce the most noise on existing projects
1028disable = [
1029    "MD013",  # Line length - most existing files exceed 80 chars
1030    "MD033",  # Inline HTML - commonly used in real-world markdown
1031    "MD041",  # First line heading - not all files need it
1032]
1033
1034# Consistent heading style (any style, just be consistent)
1035[MD003]
1036style = "consistent"
1037
1038# Consistent list style
1039[MD004]
1040style = "consistent"
1041
1042# Consistent emphasis style
1043[MD049]
1044style = "consistent"
1045
1046# Consistent strong style
1047[MD050]
1048style = "consistent"
1049"#
1050    .to_string()
1051}
1052
1053/// How a config file being loaded was reached, which decides how much of it may
1054/// appear in a message about it.
1055///
1056/// A `Direct` file is one the user named: discovered in their project, or given
1057/// on the command line. Naming its path and quoting the line a parse error
1058/// points at tells them nothing they could not already read.
1059///
1060/// An `Extends` file was reached by following an `extends` value, and two things
1061/// about it are not the user's to see. Its path is built by substituting
1062/// environment variables, so printing the path prints their values wherever the
1063/// error goes, which under CI is the build log. And `extends` names an arbitrary
1064/// path chosen by whichever config declared it, so the file need not be config
1065/// at all: a repository you merely cloned can point rumdl at a private key and
1066/// have the parse error quote a line of it. Such a file is named by the
1067/// `extends` value as written, and its text is never quoted back.
1068///
1069/// This governs errors and warnings, which any lint run can emit unasked and
1070/// which therefore reach a build log that had no reason to hold a variable's
1071/// value. Output that answers a question about the configuration itself still
1072/// shows resolved paths, because that is the answer: `rumdl config` reports which
1073/// files took effect, a language server reports the same thing to the editor that
1074/// started it, and debug logging is how an `extends` chain gets diagnosed at all.
1075/// The first two read [`crate::config::SourcedConfig::loaded_files`], which is a
1076/// record of what loaded rather than a message about a problem, and it holds
1077/// resolved paths deliberately.
1078#[derive(Debug, Clone, Copy)]
1079pub(crate) enum ConfigOrigin<'a> {
1080    Direct,
1081    Extends {
1082        /// The reference together with the config that declared it. This is how
1083        /// the file is named by a message reporting a problem with the file
1084        /// itself, where knowing which config reached for it is what makes the
1085        /// problem diagnosable.
1086        described_as: &'a str,
1087        /// The reference alone, for the places a message only needs to identify
1088        /// the file: listing an `extends` chain, or naming it as the config that
1089        /// declared a further `extends`. Repeating the full form at every link
1090        /// nests one description inside the next.
1091        short_name: &'a str,
1092    },
1093}
1094
1095/// Stands in for text that came out of a file reached through `extends`.
1096///
1097/// Public because the file walk raises one of these messages itself: an `include`
1098/// pattern is judged where it is used rather than where it is parsed.
1099pub const WITHHELD: &str = "<withheld>";
1100
1101impl ConfigOrigin<'_> {
1102    /// How to name the file this origin describes in a message about the file.
1103    pub(crate) fn display_name(&self, path: &str) -> String {
1104        match self {
1105            Self::Direct => crate::config::validation::to_relative_display_path(path),
1106            Self::Extends { described_as, .. } => (*described_as).to_string(),
1107        }
1108    }
1109
1110    /// How to name this file when a message is about something else and only has
1111    /// to say which file it means.
1112    pub(crate) fn short_name(&self, path: &str) -> String {
1113        match self {
1114            Self::Direct => crate::config::validation::to_relative_display_path(path),
1115            Self::Extends { short_name, .. } => (*short_name).to_string(),
1116        }
1117    }
1118
1119    /// Whether a message about this file may quote the file's own text.
1120    pub(crate) fn may_quote_contents(&self) -> bool {
1121        matches!(self, Self::Direct)
1122    }
1123
1124    /// A piece of text read from this file, as a message about the file may
1125    /// show it.
1126    ///
1127    /// A `Direct` file's text is shown as written. An `Extends` target's is
1128    /// replaced by [`WITHHELD`], because an unrecognized key or an invalid value
1129    /// is still text out of a file rumdl was merely pointed at. The message
1130    /// keeps saying what kind of problem it found and which file holds it, and
1131    /// leaves the rest to whoever can open that file.
1132    pub(crate) fn quote<'t>(&self, text: &'t str) -> &'t str {
1133        if self.may_quote_contents() { text } else { WITHHELD }
1134    }
1135}
1136
1137/// A config file as a message about it refers to it: the name to use, and
1138/// whether the file's own text may be shown alongside.
1139///
1140/// Pairs the display name, worked out once when a file is parsed, with the
1141/// [`ConfigOrigin`] that decided it. Every warning a parser emits names the file
1142/// and asks the same question about its contents, so both travel together rather
1143/// than as two parameters that could drift apart.
1144#[derive(Debug, Clone, Copy)]
1145pub(crate) struct ConfigRef<'a> {
1146    name: &'a str,
1147    origin: ConfigOrigin<'a>,
1148}
1149
1150impl<'a> ConfigRef<'a> {
1151    pub(crate) fn new(name: &'a str, origin: ConfigOrigin<'a>) -> Self {
1152        Self { name, origin }
1153    }
1154
1155    /// See [`ConfigOrigin::quote`].
1156    pub(crate) fn quote<'t>(&self, text: &'t str) -> &'t str {
1157        self.origin.quote(text)
1158    }
1159
1160    /// See [`ConfigOrigin::may_quote_contents`].
1161    pub(crate) fn may_quote_contents(&self) -> bool {
1162        self.origin.may_quote_contents()
1163    }
1164}
1165
1166/// Displays as the file's name, so a message can name the file by interpolating
1167/// the reference itself.
1168impl std::fmt::Display for ConfigRef<'_> {
1169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1170        f.write_str(self.name)
1171    }
1172}
1173
1174/// Errors that can occur when loading configuration
1175#[derive(Debug, thiserror::Error)]
1176pub enum ConfigError {
1177    /// Failed to read the configuration file
1178    #[error("Failed to read config file at {path}: {source}")]
1179    IoError { source: io::Error, path: String },
1180
1181    /// Failed to parse the configuration content (TOML or JSON)
1182    #[error("Failed to parse config: {0}")]
1183    ParseError(String),
1184
1185    /// Configuration file already exists
1186    #[error("Configuration file already exists at {path}")]
1187    FileExists { path: String },
1188
1189    /// Circular extends reference detected
1190    #[error("Circular extends reference: {path} already in chain {chain:?}")]
1191    CircularExtends { path: String, chain: Vec<String> },
1192
1193    /// Extends chain exceeds maximum depth
1194    #[error("Extends chain exceeds maximum depth of {max_depth} at {path}")]
1195    ExtendsDepthExceeded { path: String, max_depth: usize },
1196
1197    /// Extends target file not found
1198    #[error("extends target not found: {path} (referenced from {from})")]
1199    ExtendsNotFound { path: String, from: String },
1200
1201    /// An `extends` path referenced an environment variable that is not set
1202    #[error("extends path references undefined environment variable {var} (referenced from {from})")]
1203    ExtendsUndefinedVar {
1204        /// The variable as the message may name it: `$NAME`, or [`WITHHELD`] when
1205        /// the file that wrote it was itself reached through `extends` and its
1206        /// text may not be repeated.
1207        var: String,
1208        from: String,
1209    },
1210
1211    /// Unknown preset name
1212    #[error("Unknown preset: {name}. Valid presets: default, google, relaxed")]
1213    UnknownPreset { name: String },
1214}
1215
1216/// Why a config file found by discovery could not be loaded.
1217///
1218/// The two cases call for different handling. A discovered project file that
1219/// cannot be parsed is local to one project and a caller may reasonably skip it
1220/// and keep looking. A broken user config is machine-wide: every project that
1221/// merges onto it resolves to something other than what the user configured, so
1222/// it has to be surfaced instead of worked around.
1223#[derive(Debug, thiserror::Error)]
1224pub enum DiscoveredConfigError {
1225    /// The discovered file itself is unusable.
1226    #[error(transparent)]
1227    ProjectConfig(ConfigError),
1228
1229    /// The discovered file is fine, but the user config it merges on top of is
1230    /// unusable. Only a discovered markdownlint config has such a base.
1231    #[error(transparent)]
1232    UserConfig(ConfigError),
1233}
1234
1235impl From<DiscoveredConfigError> for ConfigError {
1236    fn from(error: DiscoveredConfigError) -> Self {
1237        match error {
1238            DiscoveredConfigError::ProjectConfig(error) | DiscoveredConfigError::UserConfig(error) => error,
1239        }
1240    }
1241}
1242
1243/// Get a rule-specific configuration value
1244/// Automatically tries both the original key and normalized variants (kebab-case ↔ snake_case)
1245/// for better markdownlint compatibility
1246pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
1247    let norm_rule_name = rule_name.to_ascii_uppercase(); // Use uppercase for lookup
1248
1249    let rule_config = config.rules.get(&norm_rule_name)?;
1250
1251    // Try multiple key variants to support both underscore and kebab-case formats
1252    let key_variants = [
1253        key.to_string(),       // Original key as provided
1254        normalize_key(key),    // Normalized key (lowercase, kebab-case)
1255        key.replace('-', "_"), // Convert kebab-case to snake_case
1256        key.replace('_', "-"), // Convert snake_case to kebab-case
1257    ];
1258
1259    // Try each variant until we find a match
1260    for variant in &key_variants {
1261        if let Some(value) = rule_config.values.get(variant)
1262            && let Ok(result) = T::deserialize(value.clone())
1263        {
1264            return Some(result);
1265        }
1266    }
1267
1268    None
1269}
1270
1271/// Generate preset configuration for pyproject.toml format.
1272/// Converts the .rumdl.toml preset to pyproject.toml section format.
1273pub fn generate_pyproject_preset_config(preset: &str) -> Result<String, ConfigError> {
1274    match preset {
1275        "default" => Ok(generate_pyproject_config()),
1276        other => {
1277            let rumdl_config = match other {
1278                "google" => generate_google_preset(),
1279                "relaxed" => generate_relaxed_preset(),
1280                _ => {
1281                    return Err(ConfigError::UnknownPreset {
1282                        name: other.to_string(),
1283                    });
1284                }
1285            };
1286            Ok(convert_rumdl_to_pyproject(&rumdl_config))
1287        }
1288    }
1289}
1290
1291/// Convert a .rumdl.toml config string to pyproject.toml format.
1292/// Rewrites `[global]` → `[tool.rumdl]` and `[MDXXX]` → `[tool.rumdl.MDXXX]`.
1293fn convert_rumdl_to_pyproject(rumdl_config: &str) -> String {
1294    let mut output = String::with_capacity(rumdl_config.len() + 128);
1295    for line in rumdl_config.lines() {
1296        let trimmed = line.trim();
1297        if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("# [") {
1298            let section = &trimmed[1..trimmed.len() - 1];
1299            if section == "global" {
1300                output.push_str("[tool.rumdl]");
1301            } else {
1302                output.push_str(&format!("[tool.rumdl.{section}]"));
1303            }
1304        } else {
1305            output.push_str(line);
1306        }
1307        output.push('\n');
1308    }
1309    output
1310}
1311
1312/// Generate default rumdl configuration for pyproject.toml
1313pub fn generate_pyproject_config() -> String {
1314    let config_content = r#"
1315[tool.rumdl]
1316# Global configuration options
1317line-length = 100
1318disable = []
1319# extend-enable = ["MD060"]  # Add opt-in rules (additive, keeps defaults)
1320# extend-disable = []  # Additional rules to disable (additive)
1321exclude = [
1322    # Common directories to exclude
1323    ".git",
1324    ".github",
1325    "node_modules",
1326    "vendor",
1327    "dist",
1328    "build",
1329]
1330respect-gitignore = true
1331
1332# Rule-specific configurations (uncomment and modify as needed)
1333
1334# [tool.rumdl.MD003]
1335# style = "atx"  # Heading style (atx, atx_closed, setext)
1336
1337# [tool.rumdl.MD004]
1338# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
1339
1340# [tool.rumdl.MD007]
1341# indent = 4  # Unordered list indentation
1342
1343# [tool.rumdl.MD013]
1344# line-length = 100  # Line length
1345# code-blocks = false  # Exclude code blocks from line length check
1346# tables = false  # Exclude tables from line length check
1347# headings = true  # Include headings in line length check
1348
1349# [tool.rumdl.MD044]
1350# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
1351# code-blocks = false  # Check code blocks for proper names (default: false, skips code blocks)
1352"#;
1353
1354    config_content.to_string()
1355}