Skip to main content

rumdl_lib/config/
types.rs

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