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