Skip to main content

rumdl_lib/config/
types.rs

1use crate::types::LineLength;
2use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6use std::collections::{HashMap, HashSet};
7use std::fs;
8use std::io;
9use std::path::Path;
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    /// Global configuration options
42    #[serde(default)]
43    pub global: GlobalConfig,
44
45    /// Per-file rule ignores: maps file patterns to lists of rules to ignore
46    /// Example: { "README.md": ["MD033"], "docs/**/*.md": ["MD013"] }
47    #[serde(default, rename = "per-file-ignores")]
48    pub per_file_ignores: HashMap<String, Vec<String>>,
49
50    /// Per-file flavor overrides: maps file patterns to Markdown flavors
51    /// Example: { "docs/**/*.md": MkDocs, "**/*.mdx": MDX }
52    /// Uses IndexMap to preserve config file order for "first match wins" semantics
53    #[serde(default, rename = "per-file-flavor")]
54    #[schemars(with = "HashMap<String, MarkdownFlavor>")]
55    pub per_file_flavor: IndexMap<String, MarkdownFlavor>,
56
57    /// Code block tools configuration for per-language linting and formatting
58    /// using external tools like ruff, prettier, shellcheck, etc.
59    #[serde(default, rename = "code-block-tools")]
60    pub code_block_tools: crate::code_block_tools::CodeBlockToolsConfig,
61
62    /// Rule-specific configurations (e.g., MD013, MD007, MD044)
63    /// Each rule section can contain options specific to that rule.
64    ///
65    /// Common examples:
66    /// - MD013: line_length, code_blocks, tables, headings
67    /// - MD007: indent
68    /// - MD003: style ("atx", "atx-closed", "setext")
69    /// - MD044: names (array of proper names to check)
70    ///
71    /// See <https://github.com/rvben/rumdl> for full rule documentation.
72    #[serde(flatten)]
73    pub rules: BTreeMap<String, RuleConfig>,
74
75    /// Project root directory, used for resolving relative paths in per-file-ignores
76    #[serde(skip)]
77    pub project_root: Option<std::path::PathBuf>,
78
79    #[serde(skip)]
80    #[schemars(skip)]
81    pub(super) per_file_ignores_cache: Arc<OnceLock<PerFileIgnoreCache>>,
82
83    #[serde(skip)]
84    #[schemars(skip)]
85    pub(super) per_file_flavor_cache: Arc<OnceLock<PerFileFlavorCache>>,
86}
87
88impl PartialEq for Config {
89    fn eq(&self, other: &Self) -> bool {
90        self.global == other.global
91            && self.per_file_ignores == other.per_file_ignores
92            && self.per_file_flavor == other.per_file_flavor
93            && self.code_block_tools == other.code_block_tools
94            && self.rules == other.rules
95            && self.project_root == other.project_root
96    }
97}
98
99#[derive(Debug)]
100pub(super) struct PerFileIgnoreCache {
101    globset: GlobSet,
102    rules: Vec<Vec<String>>,
103}
104
105#[derive(Debug)]
106pub(super) struct PerFileFlavorCache {
107    matchers: Vec<(GlobMatcher, MarkdownFlavor)>,
108}
109
110impl Config {
111    /// Check if the Markdown flavor is set to MkDocs
112    pub fn is_mkdocs_flavor(&self) -> bool {
113        self.global.flavor == MarkdownFlavor::MkDocs
114    }
115
116    // Future methods for when GFM and CommonMark are implemented:
117    // pub fn is_gfm_flavor(&self) -> bool
118    // pub fn is_commonmark_flavor(&self) -> bool
119
120    /// Get the configured Markdown flavor
121    pub fn markdown_flavor(&self) -> MarkdownFlavor {
122        self.global.flavor
123    }
124
125    /// Legacy method for backwards compatibility - redirects to is_mkdocs_flavor
126    pub fn is_mkdocs_project(&self) -> bool {
127        self.is_mkdocs_flavor()
128    }
129
130    /// For rules with `enabled = true` in their per-rule config, add them to
131    /// `extend_enable` so they pass through `filter_rules()`.
132    ///
133    /// This bridges the deprecated `[MD060] enabled = true` syntax to the
134    /// `extend-enable` mechanism. Only affects rules not already in `extend_enable`.
135    /// Promoting non-opt-in rules is harmless since `filter_rules()` only
136    /// consults `extend_enable` for opt-in rules.
137    pub fn promote_enabled_to_extend_enable(&mut self) {
138        let to_promote: Vec<String> = self
139            .rules
140            .iter()
141            .filter(|(name, cfg)| {
142                matches!(cfg.values.get("enabled"), Some(toml::Value::Boolean(true)))
143                    && !self.global.extend_enable.contains(name)
144            })
145            .map(|(name, _)| name.clone())
146            .collect();
147        self.global.extend_enable.extend(to_promote);
148    }
149
150    /// Get the severity override for a specific rule, if configured
151    pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
152        self.rules.get(rule_name).and_then(|r| r.severity)
153    }
154
155    /// Get the set of rules that should be ignored for a specific file based on per-file-ignores configuration
156    /// Returns a HashSet of rule names (uppercase, e.g., "MD033") that match the given file path
157    pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
158        let mut ignored_rules = HashSet::new();
159
160        if self.per_file_ignores.is_empty() {
161            return ignored_rules;
162        }
163
164        // Normalize the file path to be relative to project_root for pattern matching
165        // This ensures patterns like ".github/file.md" work with absolute paths
166        let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
167            if let Ok(canonical_path) = file_path.canonicalize() {
168                if let Ok(canonical_root) = root.canonicalize() {
169                    if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
170                        std::borrow::Cow::Owned(relative.to_path_buf())
171                    } else {
172                        std::borrow::Cow::Borrowed(file_path)
173                    }
174                } else {
175                    std::borrow::Cow::Borrowed(file_path)
176                }
177            } else {
178                std::borrow::Cow::Borrowed(file_path)
179            }
180        } else {
181            std::borrow::Cow::Borrowed(file_path)
182        };
183
184        let cache = self
185            .per_file_ignores_cache
186            .get_or_init(|| PerFileIgnoreCache::new(&self.per_file_ignores));
187
188        // Match the file path against all patterns
189        for match_idx in cache.globset.matches(path_for_matching.as_ref()) {
190            if let Some(rules) = cache.rules.get(match_idx) {
191                for rule in rules.iter() {
192                    // Normalize rule names to uppercase (MD033, md033 -> MD033)
193                    ignored_rules.insert(rule.clone());
194                }
195            }
196        }
197
198        ignored_rules
199    }
200
201    /// Get the MarkdownFlavor for a specific file based on per-file-flavor configuration.
202    /// Returns the first matching pattern's flavor, or falls back to global flavor,
203    /// or auto-detects from extension, or defaults to Standard.
204    pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
205        // If no per-file patterns, use fallback logic
206        if self.per_file_flavor.is_empty() {
207            return self.resolve_flavor_fallback(file_path);
208        }
209
210        // Normalize path for matching (same logic as get_ignored_rules_for_file)
211        let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
212            if let Ok(canonical_path) = file_path.canonicalize() {
213                if let Ok(canonical_root) = root.canonicalize() {
214                    if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
215                        std::borrow::Cow::Owned(relative.to_path_buf())
216                    } else {
217                        std::borrow::Cow::Borrowed(file_path)
218                    }
219                } else {
220                    std::borrow::Cow::Borrowed(file_path)
221                }
222            } else {
223                std::borrow::Cow::Borrowed(file_path)
224            }
225        } else {
226            std::borrow::Cow::Borrowed(file_path)
227        };
228
229        let cache = self
230            .per_file_flavor_cache
231            .get_or_init(|| PerFileFlavorCache::new(&self.per_file_flavor));
232
233        // Iterate in config order and return first match (IndexMap preserves order)
234        for (matcher, flavor) in &cache.matchers {
235            if matcher.is_match(path_for_matching.as_ref()) {
236                return *flavor;
237            }
238        }
239
240        // No pattern matched, use fallback
241        self.resolve_flavor_fallback(file_path)
242    }
243
244    /// Fallback flavor resolution: global flavor → auto-detect → Standard
245    fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
246        // If global flavor is explicitly set to non-Standard, use it
247        if self.global.flavor != MarkdownFlavor::Standard {
248            return self.global.flavor;
249        }
250        // Auto-detect from extension
251        MarkdownFlavor::from_path(file_path)
252    }
253
254    /// Merge inline configuration overrides into a copy of this config
255    ///
256    /// This enables automatic inline config support - the engine can merge
257    /// inline overrides and recreate rules without any per-rule changes.
258    ///
259    /// Returns a new Config with the inline overrides merged in.
260    /// If there are no inline overrides, returns a clone of self.
261    pub fn merge_with_inline_config(&self, inline_config: &crate::inline_config::InlineConfig) -> Self {
262        let overrides = inline_config.get_all_rule_configs();
263        if overrides.is_empty() {
264            return self.clone();
265        }
266
267        let mut merged = self.clone();
268
269        for (rule_name, json_override) in overrides {
270            // Get or create the rule config entry
271            let rule_config = merged.rules.entry(rule_name.clone()).or_default();
272
273            // Merge JSON values into the rule's config
274            if let Some(obj) = json_override.as_object() {
275                for (key, value) in obj {
276                    // Normalize key to kebab-case for consistency
277                    let normalized_key = key.replace('_', "-");
278
279                    // Convert JSON value to TOML value
280                    if let Some(toml_value) = json_to_toml(value) {
281                        rule_config.values.insert(normalized_key, toml_value);
282                    }
283                }
284            }
285        }
286
287        merged
288    }
289}
290
291/// Convert a serde_json::Value to a toml::Value
292pub(super) fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
293    match json {
294        serde_json::Value::Null => None,
295        serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
296        serde_json::Value::Number(n) => n
297            .as_i64()
298            .map(toml::Value::Integer)
299            .or_else(|| n.as_f64().map(toml::Value::Float)),
300        serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
301        serde_json::Value::Array(arr) => {
302            let toml_arr: Vec<toml::Value> = arr.iter().filter_map(json_to_toml).collect();
303            Some(toml::Value::Array(toml_arr))
304        }
305        serde_json::Value::Object(obj) => {
306            let mut table = toml::map::Map::new();
307            for (k, v) in obj {
308                if let Some(tv) = json_to_toml(v) {
309                    table.insert(k.clone(), tv);
310                }
311            }
312            Some(toml::Value::Table(table))
313        }
314    }
315}
316
317impl PerFileIgnoreCache {
318    fn new(per_file_ignores: &HashMap<String, Vec<String>>) -> Self {
319        let mut builder = GlobSetBuilder::new();
320        let mut rules = Vec::new();
321
322        for (pattern, rules_list) in per_file_ignores {
323            if let Ok(glob) = Glob::new(pattern) {
324                builder.add(glob);
325                rules.push(rules_list.iter().map(|rule| normalize_key(rule)).collect());
326            } else {
327                log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
328            }
329        }
330
331        let globset = builder.build().unwrap_or_else(|e| {
332            log::error!("Failed to build globset for per-file-ignores: {e}");
333            GlobSetBuilder::new().build().unwrap()
334        });
335
336        Self { globset, rules }
337    }
338}
339
340impl PerFileFlavorCache {
341    fn new(per_file_flavor: &IndexMap<String, MarkdownFlavor>) -> Self {
342        let mut matchers = Vec::new();
343
344        for (pattern, flavor) in per_file_flavor {
345            if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
346                matchers.push((glob.compile_matcher(), *flavor));
347            } else {
348                log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
349            }
350        }
351
352        Self { matchers }
353    }
354}
355
356/// Global configuration options
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
358#[serde(default, rename_all = "kebab-case")]
359pub struct GlobalConfig {
360    /// Enabled rules
361    #[serde(default)]
362    pub enable: Vec<String>,
363
364    /// Disabled rules
365    #[serde(default)]
366    pub disable: Vec<String>,
367
368    /// Files to exclude
369    #[serde(default)]
370    pub exclude: Vec<String>,
371
372    /// Files to include
373    #[serde(default)]
374    pub include: Vec<String>,
375
376    /// Respect .gitignore files when scanning directories
377    #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
378    pub respect_gitignore: bool,
379
380    /// Global line length setting (used by MD013 and other rules if not overridden)
381    #[serde(default, alias = "line_length")]
382    pub line_length: LineLength,
383
384    /// Output format for linting results (e.g., "text", "json", "pylint", etc.)
385    #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
386    pub output_format: Option<String>,
387
388    /// Rules that are allowed to be fixed when --fix is used
389    /// If specified, only these rules will be fixed
390    #[serde(default)]
391    pub fixable: Vec<String>,
392
393    /// Rules that should never be fixed, even when --fix is used
394    /// Takes precedence over fixable
395    #[serde(default)]
396    pub unfixable: Vec<String>,
397
398    /// Markdown flavor/dialect to use (mkdocs, gfm, commonmark, etc.)
399    /// When set, adjusts parsing and validation rules for that specific Markdown variant
400    #[serde(default)]
401    pub flavor: MarkdownFlavor,
402
403    /// \[DEPRECATED\] Whether to enforce exclude patterns for explicitly passed paths.
404    /// This option is deprecated as of v0.0.156 and has no effect.
405    /// Exclude patterns are now always respected, even for explicitly provided files.
406    /// This prevents duplication between rumdl config and tool configs like pre-commit.
407    #[serde(default, alias = "force_exclude")]
408    #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
409    pub force_exclude: bool,
410
411    /// Directory to store cache files (default: .rumdl_cache)
412    /// Can also be set via --cache-dir CLI flag or RUMDL_CACHE_DIR environment variable
413    #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
414    pub cache_dir: Option<String>,
415
416    /// Whether caching is enabled (default: true)
417    /// Can also be disabled via --no-cache CLI flag
418    #[serde(default = "default_true")]
419    pub cache: bool,
420
421    /// Additional rules to enable on top of the base set (additive)
422    #[serde(default, alias = "extend_enable")]
423    pub extend_enable: Vec<String>,
424
425    /// Additional rules to disable on top of the base set (additive)
426    #[serde(default, alias = "extend_disable")]
427    pub extend_disable: Vec<String>,
428
429    /// Whether the enable list was explicitly set (even if empty).
430    /// Used to distinguish "no enable list configured" from "enable list is empty"
431    /// (e.g., markdownlint `default: false` with no rules enabled).
432    #[serde(skip)]
433    pub enable_is_explicit: bool,
434}
435
436fn default_respect_gitignore() -> bool {
437    true
438}
439
440fn default_true() -> bool {
441    true
442}
443
444// Add the Default impl
445impl Default for GlobalConfig {
446    #[allow(deprecated)]
447    fn default() -> Self {
448        Self {
449            enable: Vec::new(),
450            disable: Vec::new(),
451            exclude: Vec::new(),
452            include: Vec::new(),
453            respect_gitignore: true,
454            line_length: LineLength::default(),
455            output_format: None,
456            fixable: Vec::new(),
457            unfixable: Vec::new(),
458            flavor: MarkdownFlavor::default(),
459            force_exclude: false,
460            cache_dir: None,
461            cache: true,
462            extend_enable: Vec::new(),
463            extend_disable: Vec::new(),
464            enable_is_explicit: false,
465        }
466    }
467}
468
469pub(crate) const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
470    ".markdownlint.json",
471    ".markdownlint.jsonc",
472    ".markdownlint.yaml",
473    ".markdownlint.yml",
474    "markdownlint.json",
475    "markdownlint.jsonc",
476    "markdownlint.yaml",
477    "markdownlint.yml",
478];
479
480/// Create a default configuration file at the specified path
481pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
482    create_preset_config("default", path)
483}
484
485/// Create a configuration file with a specific style preset
486pub fn create_preset_config(preset: &str, path: &str) -> Result<(), ConfigError> {
487    if Path::new(path).exists() {
488        return Err(ConfigError::FileExists { path: path.to_string() });
489    }
490
491    let config_content = match preset {
492        "default" => generate_default_preset(),
493        "google" => generate_google_preset(),
494        "relaxed" => generate_relaxed_preset(),
495        _ => {
496            return Err(ConfigError::UnknownPreset {
497                name: preset.to_string(),
498            });
499        }
500    };
501
502    match fs::write(path, config_content) {
503        Ok(_) => Ok(()),
504        Err(err) => Err(ConfigError::IoError {
505            source: err,
506            path: path.to_string(),
507        }),
508    }
509}
510
511/// Generate the default preset configuration content.
512/// Returns the same content as `create_default_config`.
513fn generate_default_preset() -> String {
514    r#"# rumdl configuration file
515
516# Inherit settings from another config file (relative to this file's directory)
517# extends = "../base.rumdl.toml"
518
519# Global configuration options
520[global]
521# List of rules to disable (uncomment and modify as needed)
522# disable = ["MD013", "MD033"]
523
524# List of rules to enable exclusively (replaces defaults; only these rules will run)
525# enable = ["MD001", "MD003", "MD004"]
526
527# Additional rules to enable on top of defaults (additive, does not replace)
528# Use this to activate opt-in rules like MD060, MD063, MD072, MD073, MD074
529# extend-enable = ["MD060", "MD063"]
530
531# Additional rules to disable on top of the disable list (additive)
532# extend-disable = ["MD041"]
533
534# List of file/directory patterns to include for linting (if provided, only these will be linted)
535# include = [
536#    "docs/*.md",
537#    "src/**/*.md",
538#    "README.md"
539# ]
540
541# List of file/directory patterns to exclude from linting
542exclude = [
543    # Common directories to exclude
544    ".git",
545    ".github",
546    "node_modules",
547    "vendor",
548    "dist",
549    "build",
550
551    # Specific files or patterns
552    "CHANGELOG.md",
553    "LICENSE.md",
554]
555
556# Respect .gitignore files when scanning directories (default: true)
557respect-gitignore = true
558
559# Markdown flavor/dialect (uncomment to enable)
560# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
561# flavor = "mkdocs"
562
563# Rule-specific configurations (uncomment and modify as needed)
564
565# [MD003]
566# style = "atx"  # Heading style (atx, atx_closed, setext)
567
568# [MD004]
569# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
570
571# [MD007]
572# indent = 4  # Unordered list indentation
573
574# [MD013]
575# line-length = 100  # Line length
576# code-blocks = false  # Exclude code blocks from line length check
577# tables = false  # Exclude tables from line length check
578# headings = true  # Include headings in line length check
579
580# [MD044]
581# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
582# code-blocks = false  # Check code blocks for proper names (default: false, skips code blocks)
583"#
584    .to_string()
585}
586
587/// Generate Google developer documentation style preset.
588/// Based on https://google.github.io/styleguide/docguide/style.html
589fn generate_google_preset() -> String {
590    r#"# rumdl configuration - Google developer documentation style
591# Based on https://google.github.io/styleguide/docguide/style.html
592
593[global]
594exclude = [
595    ".git",
596    ".github",
597    "node_modules",
598    "vendor",
599    "dist",
600    "build",
601    "CHANGELOG.md",
602    "LICENSE.md",
603]
604respect-gitignore = true
605
606# ATX-style headings required
607[MD003]
608style = "atx"
609
610# Unordered list style: dash
611[MD004]
612style = "dash"
613
614# 4-space indent for nested lists
615[MD007]
616indent = 4
617
618# Strict mode: no trailing spaces allowed (Google uses backslash for line breaks)
619[MD009]
620strict = true
621
622# 80-character line length
623[MD013]
624line-length = 80
625code-blocks = false
626tables = false
627
628# No trailing punctuation in headings
629[MD026]
630punctuation = ".,;:!。,;:!"
631
632# Fenced code blocks only (no indented code blocks)
633[MD046]
634style = "fenced"
635
636# Emphasis with underscores
637[MD049]
638style = "underscore"
639
640# Strong with asterisks
641[MD050]
642style = "asterisk"
643"#
644    .to_string()
645}
646
647/// Generate relaxed preset for existing projects adopting rumdl incrementally.
648/// Longer line lengths, fewer rules, lenient settings to minimize initial warnings.
649fn generate_relaxed_preset() -> String {
650    r#"# rumdl configuration - Relaxed preset
651# Lenient settings for existing projects adopting rumdl incrementally.
652# Minimizes initial warnings while still catching important issues.
653
654[global]
655exclude = [
656    ".git",
657    ".github",
658    "node_modules",
659    "vendor",
660    "dist",
661    "build",
662    "CHANGELOG.md",
663    "LICENSE.md",
664]
665respect-gitignore = true
666
667# Disable rules that produce the most noise on existing projects
668disable = [
669    "MD013",  # Line length - most existing files exceed 80 chars
670    "MD033",  # Inline HTML - commonly used in real-world markdown
671    "MD041",  # First line heading - not all files need it
672]
673
674# Consistent heading style (any style, just be consistent)
675[MD003]
676style = "consistent"
677
678# Consistent list style
679[MD004]
680style = "consistent"
681
682# Consistent emphasis style
683[MD049]
684style = "consistent"
685
686# Consistent strong style
687[MD050]
688style = "consistent"
689"#
690    .to_string()
691}
692
693/// Errors that can occur when loading configuration
694#[derive(Debug, thiserror::Error)]
695pub enum ConfigError {
696    /// Failed to read the configuration file
697    #[error("Failed to read config file at {path}: {source}")]
698    IoError { source: io::Error, path: String },
699
700    /// Failed to parse the configuration content (TOML or JSON)
701    #[error("Failed to parse config: {0}")]
702    ParseError(String),
703
704    /// Configuration file already exists
705    #[error("Configuration file already exists at {path}")]
706    FileExists { path: String },
707
708    /// Circular extends reference detected
709    #[error("Circular extends reference: {path} already in chain {chain:?}")]
710    CircularExtends { path: String, chain: Vec<String> },
711
712    /// Extends chain exceeds maximum depth
713    #[error("extends chain exceeds maximum depth of {max_depth} at {path}")]
714    ExtendsDepthExceeded { path: String, max_depth: usize },
715
716    /// Extends target file not found
717    #[error("extends target not found: {path} (referenced from {from})")]
718    ExtendsNotFound { path: String, from: String },
719
720    /// Unknown preset name
721    #[error("Unknown preset: {name}. Valid presets: default, google, relaxed")]
722    UnknownPreset { name: String },
723}
724
725/// Get a rule-specific configuration value
726/// Automatically tries both the original key and normalized variants (kebab-case ↔ snake_case)
727/// for better markdownlint compatibility
728pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
729    let norm_rule_name = rule_name.to_ascii_uppercase(); // Use uppercase for lookup
730
731    let rule_config = config.rules.get(&norm_rule_name)?;
732
733    // Try multiple key variants to support both underscore and kebab-case formats
734    let key_variants = [
735        key.to_string(),       // Original key as provided
736        normalize_key(key),    // Normalized key (lowercase, kebab-case)
737        key.replace('-', "_"), // Convert kebab-case to snake_case
738        key.replace('_', "-"), // Convert snake_case to kebab-case
739    ];
740
741    // Try each variant until we find a match
742    for variant in &key_variants {
743        if let Some(value) = rule_config.values.get(variant)
744            && let Ok(result) = T::deserialize(value.clone())
745        {
746            return Some(result);
747        }
748    }
749
750    None
751}
752
753/// Generate preset configuration for pyproject.toml format.
754/// Converts the .rumdl.toml preset to pyproject.toml section format.
755pub fn generate_pyproject_preset_config(preset: &str) -> Result<String, ConfigError> {
756    match preset {
757        "default" => Ok(generate_pyproject_config()),
758        other => {
759            let rumdl_config = match other {
760                "google" => generate_google_preset(),
761                "relaxed" => generate_relaxed_preset(),
762                _ => {
763                    return Err(ConfigError::UnknownPreset {
764                        name: other.to_string(),
765                    });
766                }
767            };
768            Ok(convert_rumdl_to_pyproject(&rumdl_config))
769        }
770    }
771}
772
773/// Convert a .rumdl.toml config string to pyproject.toml format.
774/// Rewrites `[global]` → `[tool.rumdl]` and `[MDXXX]` → `[tool.rumdl.MDXXX]`.
775fn convert_rumdl_to_pyproject(rumdl_config: &str) -> String {
776    let mut output = String::with_capacity(rumdl_config.len() + 128);
777    for line in rumdl_config.lines() {
778        let trimmed = line.trim();
779        if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("# [") {
780            let section = &trimmed[1..trimmed.len() - 1];
781            if section == "global" {
782                output.push_str("[tool.rumdl]");
783            } else {
784                output.push_str(&format!("[tool.rumdl.{section}]"));
785            }
786        } else {
787            output.push_str(line);
788        }
789        output.push('\n');
790    }
791    output
792}
793
794/// Generate default rumdl configuration for pyproject.toml
795pub fn generate_pyproject_config() -> String {
796    let config_content = r#"
797[tool.rumdl]
798# Global configuration options
799line-length = 100
800disable = []
801# extend-enable = ["MD060"]  # Add opt-in rules (additive, keeps defaults)
802# extend-disable = []  # Additional rules to disable (additive)
803exclude = [
804    # Common directories to exclude
805    ".git",
806    ".github",
807    "node_modules",
808    "vendor",
809    "dist",
810    "build",
811]
812respect-gitignore = true
813
814# Rule-specific configurations (uncomment and modify as needed)
815
816# [tool.rumdl.MD003]
817# style = "atx"  # Heading style (atx, atx_closed, setext)
818
819# [tool.rumdl.MD004]
820# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
821
822# [tool.rumdl.MD007]
823# indent = 4  # Unordered list indentation
824
825# [tool.rumdl.MD013]
826# line-length = 100  # Line length
827# code-blocks = false  # Exclude code blocks from line length check
828# tables = false  # Exclude tables from line length check
829# headings = true  # Include headings in line length check
830
831# [tool.rumdl.MD044]
832# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
833# code-blocks = false  # Check code blocks for proper names (default: false, skips code blocks)
834"#;
835
836    config_content.to_string()
837}