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