rumdl_lib/
config.rs

1//!
2//! This module defines configuration structures, loading logic, and provenance tracking for rumdl.
3//! Supports TOML, pyproject.toml, and markdownlint config formats, and provides merging and override logic.
4
5use crate::rule::Rule;
6use crate::rules;
7use crate::types::LineLength;
8use log;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::collections::{HashMap, HashSet};
12use std::fmt;
13use std::fs;
14use std::io;
15use std::marker::PhantomData;
16use std::path::Path;
17use std::str::FromStr;
18use toml_edit::DocumentMut;
19
20// ============================================================================
21// Typestate markers for configuration pipeline
22// ============================================================================
23
24/// Marker type for configuration that has been loaded but not yet validated.
25/// This is the initial state after `load_with_discovery()`.
26#[derive(Debug, Clone, Copy, Default)]
27pub struct ConfigLoaded;
28
29/// Marker type for configuration that has been validated.
30/// Only validated configs can be converted to `Config`.
31#[derive(Debug, Clone, Copy, Default)]
32pub struct ConfigValidated;
33
34/// Markdown flavor/dialect enumeration
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema)]
36#[serde(rename_all = "lowercase")]
37pub enum MarkdownFlavor {
38    /// Standard Markdown without flavor-specific adjustments
39    #[serde(rename = "standard", alias = "none", alias = "")]
40    #[default]
41    Standard,
42    /// MkDocs flavor with auto-reference support
43    #[serde(rename = "mkdocs")]
44    MkDocs,
45    /// MDX flavor with JSX and ESM support (.mdx files)
46    #[serde(rename = "mdx")]
47    MDX,
48    /// Quarto/RMarkdown flavor for scientific publishing (.qmd, .Rmd files)
49    #[serde(rename = "quarto")]
50    Quarto,
51    // Future flavors can be added here when they have actual implementation differences
52    // Planned: GFM (GitHub Flavored Markdown) - for GitHub-specific features like tables, strikethrough
53    // Planned: CommonMark - for strict CommonMark compliance
54}
55
56impl fmt::Display for MarkdownFlavor {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        match self {
59            MarkdownFlavor::Standard => write!(f, "standard"),
60            MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
61            MarkdownFlavor::MDX => write!(f, "mdx"),
62            MarkdownFlavor::Quarto => write!(f, "quarto"),
63        }
64    }
65}
66
67impl FromStr for MarkdownFlavor {
68    type Err = String;
69
70    fn from_str(s: &str) -> Result<Self, Self::Err> {
71        match s.to_lowercase().as_str() {
72            "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
73            "mkdocs" => Ok(MarkdownFlavor::MkDocs),
74            "mdx" => Ok(MarkdownFlavor::MDX),
75            "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
76            // GFM and CommonMark are aliases for Standard since the base parser
77            // (pulldown-cmark) already supports GFM extensions (tables, task lists,
78            // strikethrough, autolinks, etc.) which are a superset of CommonMark
79            "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
80            _ => Err(format!("Unknown markdown flavor: {s}")),
81        }
82    }
83}
84
85impl MarkdownFlavor {
86    /// Detect flavor from file extension
87    pub fn from_extension(ext: &str) -> Self {
88        match ext.to_lowercase().as_str() {
89            "mdx" => Self::MDX,
90            "qmd" => Self::Quarto,
91            "rmd" => Self::Quarto,
92            _ => Self::Standard,
93        }
94    }
95
96    /// Detect flavor from file path
97    pub fn from_path(path: &std::path::Path) -> Self {
98        path.extension()
99            .and_then(|e| e.to_str())
100            .map(Self::from_extension)
101            .unwrap_or(Self::Standard)
102    }
103
104    /// Check if this flavor supports ESM imports/exports (MDX-specific)
105    pub fn supports_esm_blocks(self) -> bool {
106        matches!(self, Self::MDX)
107    }
108
109    /// Check if this flavor supports JSX components (MDX-specific)
110    pub fn supports_jsx(self) -> bool {
111        matches!(self, Self::MDX)
112    }
113
114    /// Check if this flavor supports auto-references (MkDocs-specific)
115    pub fn supports_auto_references(self) -> bool {
116        matches!(self, Self::MkDocs)
117    }
118
119    /// Get a human-readable name for this flavor
120    pub fn name(self) -> &'static str {
121        match self {
122            Self::Standard => "Standard",
123            Self::MkDocs => "MkDocs",
124            Self::MDX => "MDX",
125            Self::Quarto => "Quarto",
126        }
127    }
128}
129
130/// Normalizes configuration keys (rule names, option names) to lowercase kebab-case.
131pub fn normalize_key(key: &str) -> String {
132    // If the key looks like a rule name (e.g., MD013), uppercase it
133    if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
134        key.to_ascii_uppercase()
135    } else {
136        key.replace('_', "-").to_ascii_lowercase()
137    }
138}
139
140/// Represents a rule-specific configuration
141#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
142pub struct RuleConfig {
143    /// Severity override for this rule (Error, Warning, or Info)
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub severity: Option<crate::rule::Severity>,
146
147    /// Configuration values for the rule
148    #[serde(flatten)]
149    #[schemars(schema_with = "arbitrary_value_schema")]
150    pub values: BTreeMap<String, toml::Value>,
151}
152
153/// Generate a JSON schema for arbitrary configuration values
154fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
155    schemars::json_schema!({
156        "type": "object",
157        "additionalProperties": true
158    })
159}
160
161/// Represents the complete configuration loaded from rumdl.toml
162#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
163#[schemars(
164    description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
165)]
166pub struct Config {
167    /// Global configuration options
168    #[serde(default)]
169    pub global: GlobalConfig,
170
171    /// Per-file rule ignores: maps file patterns to lists of rules to ignore
172    /// Example: { "README.md": ["MD033"], "docs/**/*.md": ["MD013"] }
173    #[serde(default, rename = "per-file-ignores")]
174    pub per_file_ignores: HashMap<String, Vec<String>>,
175
176    /// Rule-specific configurations (e.g., MD013, MD007, MD044)
177    /// Each rule section can contain options specific to that rule.
178    ///
179    /// Common examples:
180    /// - MD013: line_length, code_blocks, tables, headings
181    /// - MD007: indent
182    /// - MD003: style ("atx", "atx_closed", "setext")
183    /// - MD044: names (array of proper names to check)
184    ///
185    /// See https://github.com/rvben/rumdl for full rule documentation.
186    #[serde(flatten)]
187    pub rules: BTreeMap<String, RuleConfig>,
188
189    /// Project root directory, used for resolving relative paths in per-file-ignores
190    #[serde(skip)]
191    pub project_root: Option<std::path::PathBuf>,
192}
193
194impl Config {
195    /// Check if the Markdown flavor is set to MkDocs
196    pub fn is_mkdocs_flavor(&self) -> bool {
197        self.global.flavor == MarkdownFlavor::MkDocs
198    }
199
200    // Future methods for when GFM and CommonMark are implemented:
201    // pub fn is_gfm_flavor(&self) -> bool
202    // pub fn is_commonmark_flavor(&self) -> bool
203
204    /// Get the configured Markdown flavor
205    pub fn markdown_flavor(&self) -> MarkdownFlavor {
206        self.global.flavor
207    }
208
209    /// Legacy method for backwards compatibility - redirects to is_mkdocs_flavor
210    pub fn is_mkdocs_project(&self) -> bool {
211        self.is_mkdocs_flavor()
212    }
213
214    /// Get the severity override for a specific rule, if configured
215    pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
216        self.rules.get(rule_name).and_then(|r| r.severity)
217    }
218
219    /// Get the set of rules that should be ignored for a specific file based on per-file-ignores configuration
220    /// Returns a HashSet of rule names (uppercase, e.g., "MD033") that match the given file path
221    pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
222        use globset::{Glob, GlobSetBuilder};
223
224        let mut ignored_rules = HashSet::new();
225
226        if self.per_file_ignores.is_empty() {
227            return ignored_rules;
228        }
229
230        // Normalize the file path to be relative to project_root for pattern matching
231        // This ensures patterns like ".github/file.md" work with absolute paths
232        let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
233            if let Ok(canonical_path) = file_path.canonicalize() {
234                if let Ok(canonical_root) = root.canonicalize() {
235                    if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
236                        std::borrow::Cow::Owned(relative.to_path_buf())
237                    } else {
238                        std::borrow::Cow::Borrowed(file_path)
239                    }
240                } else {
241                    std::borrow::Cow::Borrowed(file_path)
242                }
243            } else {
244                std::borrow::Cow::Borrowed(file_path)
245            }
246        } else {
247            std::borrow::Cow::Borrowed(file_path)
248        };
249
250        // Build a globset for efficient matching
251        let mut builder = GlobSetBuilder::new();
252        let mut pattern_to_rules: Vec<(usize, &Vec<String>)> = Vec::new();
253
254        for (idx, (pattern, rules)) in self.per_file_ignores.iter().enumerate() {
255            if let Ok(glob) = Glob::new(pattern) {
256                builder.add(glob);
257                pattern_to_rules.push((idx, rules));
258            } else {
259                log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
260            }
261        }
262
263        let globset = match builder.build() {
264            Ok(gs) => gs,
265            Err(e) => {
266                log::error!("Failed to build globset for per-file-ignores: {e}");
267                return ignored_rules;
268            }
269        };
270
271        // Match the file path against all patterns
272        for match_idx in globset.matches(path_for_matching.as_ref()) {
273            if let Some((_, rules)) = pattern_to_rules.get(match_idx) {
274                for rule in rules.iter() {
275                    // Normalize rule names to uppercase (MD033, md033 -> MD033)
276                    ignored_rules.insert(normalize_key(rule));
277                }
278            }
279        }
280
281        ignored_rules
282    }
283}
284
285/// Global configuration options
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
287#[serde(default, rename_all = "kebab-case")]
288pub struct GlobalConfig {
289    /// Enabled rules
290    #[serde(default)]
291    pub enable: Vec<String>,
292
293    /// Disabled rules
294    #[serde(default)]
295    pub disable: Vec<String>,
296
297    /// Files to exclude
298    #[serde(default)]
299    pub exclude: Vec<String>,
300
301    /// Files to include
302    #[serde(default)]
303    pub include: Vec<String>,
304
305    /// Respect .gitignore files when scanning directories
306    #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
307    pub respect_gitignore: bool,
308
309    /// Global line length setting (used by MD013 and other rules if not overridden)
310    #[serde(default, alias = "line_length")]
311    pub line_length: LineLength,
312
313    /// Output format for linting results (e.g., "text", "json", "pylint", etc.)
314    #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
315    pub output_format: Option<String>,
316
317    /// Rules that are allowed to be fixed when --fix is used
318    /// If specified, only these rules will be fixed
319    #[serde(default)]
320    pub fixable: Vec<String>,
321
322    /// Rules that should never be fixed, even when --fix is used
323    /// Takes precedence over fixable
324    #[serde(default)]
325    pub unfixable: Vec<String>,
326
327    /// Markdown flavor/dialect to use (mkdocs, gfm, commonmark, etc.)
328    /// When set, adjusts parsing and validation rules for that specific Markdown variant
329    #[serde(default)]
330    pub flavor: MarkdownFlavor,
331
332    /// [DEPRECATED] Whether to enforce exclude patterns for explicitly passed paths.
333    /// This option is deprecated as of v0.0.156 and has no effect.
334    /// Exclude patterns are now always respected, even for explicitly provided files.
335    /// This prevents duplication between rumdl config and tool configs like pre-commit.
336    #[serde(default, alias = "force_exclude")]
337    #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
338    pub force_exclude: bool,
339
340    /// Directory to store cache files (default: .rumdl_cache)
341    /// Can also be set via --cache-dir CLI flag or RUMDL_CACHE_DIR environment variable
342    #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
343    pub cache_dir: Option<String>,
344
345    /// Whether caching is enabled (default: true)
346    /// Can also be disabled via --no-cache CLI flag
347    #[serde(default = "default_true")]
348    pub cache: bool,
349}
350
351fn default_respect_gitignore() -> bool {
352    true
353}
354
355fn default_true() -> bool {
356    true
357}
358
359// Add the Default impl
360impl Default for GlobalConfig {
361    #[allow(deprecated)]
362    fn default() -> Self {
363        Self {
364            enable: Vec::new(),
365            disable: Vec::new(),
366            exclude: Vec::new(),
367            include: Vec::new(),
368            respect_gitignore: true,
369            line_length: LineLength::default(),
370            output_format: None,
371            fixable: Vec::new(),
372            unfixable: Vec::new(),
373            flavor: MarkdownFlavor::default(),
374            force_exclude: false,
375            cache_dir: None,
376            cache: true,
377        }
378    }
379}
380
381const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
382    ".markdownlint.json",
383    ".markdownlint.jsonc",
384    ".markdownlint.yaml",
385    ".markdownlint.yml",
386    "markdownlint.json",
387    "markdownlint.jsonc",
388    "markdownlint.yaml",
389    "markdownlint.yml",
390];
391
392/// Create a default configuration file at the specified path
393pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
394    // Check if file already exists
395    if Path::new(path).exists() {
396        return Err(ConfigError::FileExists { path: path.to_string() });
397    }
398
399    // Default configuration content
400    let default_config = r#"# rumdl configuration file
401
402# Global configuration options
403[global]
404# List of rules to disable (uncomment and modify as needed)
405# disable = ["MD013", "MD033"]
406
407# List of rules to enable exclusively (if provided, only these rules will run)
408# enable = ["MD001", "MD003", "MD004"]
409
410# List of file/directory patterns to include for linting (if provided, only these will be linted)
411# include = [
412#    "docs/*.md",
413#    "src/**/*.md",
414#    "README.md"
415# ]
416
417# List of file/directory patterns to exclude from linting
418exclude = [
419    # Common directories to exclude
420    ".git",
421    ".github",
422    "node_modules",
423    "vendor",
424    "dist",
425    "build",
426
427    # Specific files or patterns
428    "CHANGELOG.md",
429    "LICENSE.md",
430]
431
432# Respect .gitignore files when scanning directories (default: true)
433respect-gitignore = true
434
435# Markdown flavor/dialect (uncomment to enable)
436# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
437# flavor = "mkdocs"
438
439# Rule-specific configurations (uncomment and modify as needed)
440
441# [MD003]
442# style = "atx"  # Heading style (atx, atx_closed, setext)
443
444# [MD004]
445# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
446
447# [MD007]
448# indent = 4  # Unordered list indentation
449
450# [MD013]
451# line-length = 100  # Line length
452# code-blocks = false  # Exclude code blocks from line length check
453# tables = false  # Exclude tables from line length check
454# headings = true  # Include headings in line length check
455
456# [MD044]
457# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
458# code-blocks = false  # Check code blocks for proper names (default: false, skips code blocks)
459"#;
460
461    // Write the default configuration to the file
462    match fs::write(path, default_config) {
463        Ok(_) => Ok(()),
464        Err(err) => Err(ConfigError::IoError {
465            source: err,
466            path: path.to_string(),
467        }),
468    }
469}
470
471/// Errors that can occur when loading configuration
472#[derive(Debug, thiserror::Error)]
473pub enum ConfigError {
474    /// Failed to read the configuration file
475    #[error("Failed to read config file at {path}: {source}")]
476    IoError { source: io::Error, path: String },
477
478    /// Failed to parse the configuration content (TOML or JSON)
479    #[error("Failed to parse config: {0}")]
480    ParseError(String),
481
482    /// Configuration file already exists
483    #[error("Configuration file already exists at {path}")]
484    FileExists { path: String },
485}
486
487/// Get a rule-specific configuration value
488/// Automatically tries both the original key and normalized variants (kebab-case ↔ snake_case)
489/// for better markdownlint compatibility
490pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
491    let norm_rule_name = rule_name.to_ascii_uppercase(); // Use uppercase for lookup
492
493    let rule_config = config.rules.get(&norm_rule_name)?;
494
495    // Try multiple key variants to support both underscore and kebab-case formats
496    let key_variants = [
497        key.to_string(),       // Original key as provided
498        normalize_key(key),    // Normalized key (lowercase, kebab-case)
499        key.replace('-', "_"), // Convert kebab-case to snake_case
500        key.replace('_', "-"), // Convert snake_case to kebab-case
501    ];
502
503    // Try each variant until we find a match
504    for variant in &key_variants {
505        if let Some(value) = rule_config.values.get(variant)
506            && let Ok(result) = T::deserialize(value.clone())
507        {
508            return Some(result);
509        }
510    }
511
512    None
513}
514
515/// Generate default rumdl configuration for pyproject.toml
516pub fn generate_pyproject_config() -> String {
517    let config_content = r#"
518[tool.rumdl]
519# Global configuration options
520line-length = 100
521disable = []
522exclude = [
523    # Common directories to exclude
524    ".git",
525    ".github",
526    "node_modules",
527    "vendor",
528    "dist",
529    "build",
530]
531respect-gitignore = true
532
533# Rule-specific configurations (uncomment and modify as needed)
534
535# [tool.rumdl.MD003]
536# style = "atx"  # Heading style (atx, atx_closed, setext)
537
538# [tool.rumdl.MD004]
539# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
540
541# [tool.rumdl.MD007]
542# indent = 4  # Unordered list indentation
543
544# [tool.rumdl.MD013]
545# line-length = 100  # Line length
546# code-blocks = false  # Exclude code blocks from line length check
547# tables = false  # Exclude tables from line length check
548# headings = true  # Include headings in line length check
549
550# [tool.rumdl.MD044]
551# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
552# code-blocks = false  # Check code blocks for proper names (default: false, skips code blocks)
553"#;
554
555    config_content.to_string()
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use std::fs;
562    use tempfile::tempdir;
563
564    #[test]
565    fn test_flavor_loading() {
566        let temp_dir = tempdir().unwrap();
567        let config_path = temp_dir.path().join(".rumdl.toml");
568        let config_content = r#"
569[global]
570flavor = "mkdocs"
571disable = ["MD001"]
572"#;
573        fs::write(&config_path, config_content).unwrap();
574
575        // Load the config
576        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
577        let config: Config = sourced.into_validated_unchecked().into();
578
579        // Check that flavor was loaded
580        assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
581        assert!(config.is_mkdocs_flavor());
582        assert!(config.is_mkdocs_project()); // Test backwards compatibility
583        assert_eq!(config.global.disable, vec!["MD001".to_string()]);
584    }
585
586    #[test]
587    fn test_pyproject_toml_root_level_config() {
588        let temp_dir = tempdir().unwrap();
589        let config_path = temp_dir.path().join("pyproject.toml");
590
591        // Create a test pyproject.toml with root-level configuration
592        let content = r#"
593[tool.rumdl]
594line-length = 120
595disable = ["MD033"]
596enable = ["MD001", "MD004"]
597include = ["docs/*.md"]
598exclude = ["node_modules"]
599respect-gitignore = true
600        "#;
601
602        fs::write(&config_path, content).unwrap();
603
604        // Load the config with skip_auto_discovery to avoid environment config files
605        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
606        let config: Config = sourced.into_validated_unchecked().into(); // Convert to plain config for assertions
607
608        // Check global settings
609        assert_eq!(config.global.disable, vec!["MD033".to_string()]);
610        assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
611        // Should now contain only the configured pattern since auto-discovery is disabled
612        assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
613        assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
614        assert!(config.global.respect_gitignore);
615
616        // Check line-length was correctly added to MD013
617        let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
618        assert_eq!(line_length, Some(120));
619    }
620
621    #[test]
622    fn test_pyproject_toml_snake_case_and_kebab_case() {
623        let temp_dir = tempdir().unwrap();
624        let config_path = temp_dir.path().join("pyproject.toml");
625
626        // Test with both kebab-case and snake_case variants
627        let content = r#"
628[tool.rumdl]
629line-length = 150
630respect_gitignore = true
631        "#;
632
633        fs::write(&config_path, content).unwrap();
634
635        // Load the config with skip_auto_discovery to avoid environment config files
636        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
637        let config: Config = sourced.into_validated_unchecked().into(); // Convert to plain config for assertions
638
639        // Check settings were correctly loaded
640        assert!(config.global.respect_gitignore);
641        let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
642        assert_eq!(line_length, Some(150));
643    }
644
645    #[test]
646    fn test_md013_key_normalization_in_rumdl_toml() {
647        let temp_dir = tempdir().unwrap();
648        let config_path = temp_dir.path().join(".rumdl.toml");
649        let config_content = r#"
650[MD013]
651line_length = 111
652line-length = 222
653"#;
654        fs::write(&config_path, config_content).unwrap();
655        // Load the config with skip_auto_discovery to avoid environment config files
656        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
657        let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
658        // Now we should only get the explicitly configured key
659        let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
660        assert_eq!(keys, vec!["line-length"]);
661        let val = &rule_cfg.values["line-length"].value;
662        assert_eq!(val.as_integer(), Some(222));
663        // get_rule_config_value should retrieve the value for both snake_case and kebab-case
664        let config: Config = sourced.clone().into_validated_unchecked().into();
665        let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
666        let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
667        assert_eq!(v1, Some(222));
668        assert_eq!(v2, Some(222));
669    }
670
671    #[test]
672    fn test_md013_section_case_insensitivity() {
673        let temp_dir = tempdir().unwrap();
674        let config_path = temp_dir.path().join(".rumdl.toml");
675        let config_content = r#"
676[md013]
677line-length = 101
678
679[Md013]
680line-length = 102
681
682[MD013]
683line-length = 103
684"#;
685        fs::write(&config_path, config_content).unwrap();
686        // Load the config with skip_auto_discovery to avoid environment config files
687        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
688        let config: Config = sourced.clone().into_validated_unchecked().into();
689        // Only the last section should win, and be present
690        let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
691        let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
692        assert_eq!(keys, vec!["line-length"]);
693        let val = &rule_cfg.values["line-length"].value;
694        assert_eq!(val.as_integer(), Some(103));
695        let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
696        assert_eq!(v, Some(103));
697    }
698
699    #[test]
700    fn test_md013_key_snake_and_kebab_case() {
701        let temp_dir = tempdir().unwrap();
702        let config_path = temp_dir.path().join(".rumdl.toml");
703        let config_content = r#"
704[MD013]
705line_length = 201
706line-length = 202
707"#;
708        fs::write(&config_path, config_content).unwrap();
709        // Load the config with skip_auto_discovery to avoid environment config files
710        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
711        let config: Config = sourced.clone().into_validated_unchecked().into();
712        let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
713        let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
714        assert_eq!(keys, vec!["line-length"]);
715        let val = &rule_cfg.values["line-length"].value;
716        assert_eq!(val.as_integer(), Some(202));
717        let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
718        let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
719        assert_eq!(v1, Some(202));
720        assert_eq!(v2, Some(202));
721    }
722
723    #[test]
724    fn test_unknown_rule_section_is_ignored() {
725        let temp_dir = tempdir().unwrap();
726        let config_path = temp_dir.path().join(".rumdl.toml");
727        let config_content = r#"
728[MD999]
729foo = 1
730bar = 2
731[MD013]
732line-length = 303
733"#;
734        fs::write(&config_path, config_content).unwrap();
735        // Load the config with skip_auto_discovery to avoid environment config files
736        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
737        let config: Config = sourced.clone().into_validated_unchecked().into();
738        // MD999 should not be present
739        assert!(!sourced.rules.contains_key("MD999"));
740        // MD013 should be present and correct
741        let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
742        assert_eq!(v, Some(303));
743    }
744
745    #[test]
746    fn test_invalid_toml_syntax() {
747        let temp_dir = tempdir().unwrap();
748        let config_path = temp_dir.path().join(".rumdl.toml");
749
750        // Invalid TOML with unclosed string
751        let config_content = r#"
752[MD013]
753line-length = "unclosed string
754"#;
755        fs::write(&config_path, config_content).unwrap();
756
757        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
758        assert!(result.is_err());
759        match result.unwrap_err() {
760            ConfigError::ParseError(msg) => {
761                // The actual error message from toml parser might vary
762                assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
763            }
764            _ => panic!("Expected ParseError"),
765        }
766    }
767
768    #[test]
769    fn test_wrong_type_for_config_value() {
770        let temp_dir = tempdir().unwrap();
771        let config_path = temp_dir.path().join(".rumdl.toml");
772
773        // line-length should be a number, not a string
774        let config_content = r#"
775[MD013]
776line-length = "not a number"
777"#;
778        fs::write(&config_path, config_content).unwrap();
779
780        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
781        let config: Config = sourced.into_validated_unchecked().into();
782
783        // The value should be loaded as a string, not converted
784        let rule_config = config.rules.get("MD013").unwrap();
785        let value = rule_config.values.get("line-length").unwrap();
786        assert!(matches!(value, toml::Value::String(_)));
787    }
788
789    #[test]
790    fn test_empty_config_file() {
791        let temp_dir = tempdir().unwrap();
792        let config_path = temp_dir.path().join(".rumdl.toml");
793
794        // Empty file
795        fs::write(&config_path, "").unwrap();
796
797        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
798        let config: Config = sourced.into_validated_unchecked().into();
799
800        // Should have default values
801        assert_eq!(config.global.line_length.get(), 80);
802        assert!(config.global.respect_gitignore);
803        assert!(config.rules.is_empty());
804    }
805
806    #[test]
807    fn test_malformed_pyproject_toml() {
808        let temp_dir = tempdir().unwrap();
809        let config_path = temp_dir.path().join("pyproject.toml");
810
811        // Missing closing bracket
812        let content = r#"
813[tool.rumdl
814line-length = 120
815"#;
816        fs::write(&config_path, content).unwrap();
817
818        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
819        assert!(result.is_err());
820    }
821
822    #[test]
823    fn test_conflicting_config_values() {
824        let temp_dir = tempdir().unwrap();
825        let config_path = temp_dir.path().join(".rumdl.toml");
826
827        // Both enable and disable the same rule - these need to be in a global section
828        let config_content = r#"
829[global]
830enable = ["MD013"]
831disable = ["MD013"]
832"#;
833        fs::write(&config_path, config_content).unwrap();
834
835        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
836        let config: Config = sourced.into_validated_unchecked().into();
837
838        // Conflict resolution: enable wins over disable
839        assert!(config.global.enable.contains(&"MD013".to_string()));
840        assert!(!config.global.disable.contains(&"MD013".to_string()));
841    }
842
843    #[test]
844    fn test_invalid_rule_names() {
845        let temp_dir = tempdir().unwrap();
846        let config_path = temp_dir.path().join(".rumdl.toml");
847
848        let config_content = r#"
849[global]
850enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
851disable = ["MD-001", "MD_002"]
852"#;
853        fs::write(&config_path, config_content).unwrap();
854
855        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
856        let config: Config = sourced.into_validated_unchecked().into();
857
858        // All values should be preserved as-is
859        assert_eq!(config.global.enable.len(), 4);
860        assert_eq!(config.global.disable.len(), 2);
861    }
862
863    #[test]
864    fn test_deeply_nested_config() {
865        let temp_dir = tempdir().unwrap();
866        let config_path = temp_dir.path().join(".rumdl.toml");
867
868        // This should be ignored as we don't support nested tables within rule configs
869        let config_content = r#"
870[MD013]
871line-length = 100
872[MD013.nested]
873value = 42
874"#;
875        fs::write(&config_path, config_content).unwrap();
876
877        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
878        let config: Config = sourced.into_validated_unchecked().into();
879
880        let rule_config = config.rules.get("MD013").unwrap();
881        assert_eq!(
882            rule_config.values.get("line-length").unwrap(),
883            &toml::Value::Integer(100)
884        );
885        // Nested table should not be present
886        assert!(!rule_config.values.contains_key("nested"));
887    }
888
889    #[test]
890    fn test_unicode_in_config() {
891        let temp_dir = tempdir().unwrap();
892        let config_path = temp_dir.path().join(".rumdl.toml");
893
894        let config_content = r#"
895[global]
896include = ["文档/*.md", "ドキュメント/*.md"]
897exclude = ["测试/*", "🚀/*"]
898
899[MD013]
900line-length = 80
901message = "行太长了 🚨"
902"#;
903        fs::write(&config_path, config_content).unwrap();
904
905        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
906        let config: Config = sourced.into_validated_unchecked().into();
907
908        assert_eq!(config.global.include.len(), 2);
909        assert_eq!(config.global.exclude.len(), 2);
910        assert!(config.global.include[0].contains("文档"));
911        assert!(config.global.exclude[1].contains("🚀"));
912
913        let rule_config = config.rules.get("MD013").unwrap();
914        let message = rule_config.values.get("message").unwrap();
915        if let toml::Value::String(s) = message {
916            assert!(s.contains("行太长了"));
917            assert!(s.contains("🚨"));
918        }
919    }
920
921    #[test]
922    fn test_extremely_long_values() {
923        let temp_dir = tempdir().unwrap();
924        let config_path = temp_dir.path().join(".rumdl.toml");
925
926        let long_string = "a".repeat(10000);
927        let config_content = format!(
928            r#"
929[global]
930exclude = ["{long_string}"]
931
932[MD013]
933line-length = 999999999
934"#
935        );
936
937        fs::write(&config_path, config_content).unwrap();
938
939        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
940        let config: Config = sourced.into_validated_unchecked().into();
941
942        assert_eq!(config.global.exclude[0].len(), 10000);
943        let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
944        assert_eq!(line_length, Some(999999999));
945    }
946
947    #[test]
948    fn test_config_with_comments() {
949        let temp_dir = tempdir().unwrap();
950        let config_path = temp_dir.path().join(".rumdl.toml");
951
952        let config_content = r#"
953[global]
954# This is a comment
955enable = ["MD001"] # Enable MD001
956# disable = ["MD002"] # This is commented out
957
958[MD013] # Line length rule
959line-length = 100 # Set to 100 characters
960# ignored = true # This setting is commented out
961"#;
962        fs::write(&config_path, config_content).unwrap();
963
964        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
965        let config: Config = sourced.into_validated_unchecked().into();
966
967        assert_eq!(config.global.enable, vec!["MD001"]);
968        assert!(config.global.disable.is_empty()); // Commented out
969
970        let rule_config = config.rules.get("MD013").unwrap();
971        assert_eq!(rule_config.values.len(), 1); // Only line-length
972        assert!(!rule_config.values.contains_key("ignored"));
973    }
974
975    #[test]
976    fn test_arrays_in_rule_config() {
977        let temp_dir = tempdir().unwrap();
978        let config_path = temp_dir.path().join(".rumdl.toml");
979
980        let config_content = r#"
981[MD003]
982levels = [1, 2, 3]
983tags = ["important", "critical"]
984mixed = [1, "two", true]
985"#;
986        fs::write(&config_path, config_content).unwrap();
987
988        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
989        let config: Config = sourced.into_validated_unchecked().into();
990
991        // Arrays should now be properly parsed
992        let rule_config = config.rules.get("MD003").expect("MD003 config should exist");
993
994        // Check that arrays are present and correctly parsed
995        assert!(rule_config.values.contains_key("levels"));
996        assert!(rule_config.values.contains_key("tags"));
997        assert!(rule_config.values.contains_key("mixed"));
998
999        // Verify array contents
1000        if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
1001            assert_eq!(levels.len(), 3);
1002            assert_eq!(levels[0], toml::Value::Integer(1));
1003            assert_eq!(levels[1], toml::Value::Integer(2));
1004            assert_eq!(levels[2], toml::Value::Integer(3));
1005        } else {
1006            panic!("levels should be an array");
1007        }
1008
1009        if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
1010            assert_eq!(tags.len(), 2);
1011            assert_eq!(tags[0], toml::Value::String("important".to_string()));
1012            assert_eq!(tags[1], toml::Value::String("critical".to_string()));
1013        } else {
1014            panic!("tags should be an array");
1015        }
1016
1017        if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
1018            assert_eq!(mixed.len(), 3);
1019            assert_eq!(mixed[0], toml::Value::Integer(1));
1020            assert_eq!(mixed[1], toml::Value::String("two".to_string()));
1021            assert_eq!(mixed[2], toml::Value::Boolean(true));
1022        } else {
1023            panic!("mixed should be an array");
1024        }
1025    }
1026
1027    #[test]
1028    fn test_normalize_key_edge_cases() {
1029        // Rule names
1030        assert_eq!(normalize_key("MD001"), "MD001");
1031        assert_eq!(normalize_key("md001"), "MD001");
1032        assert_eq!(normalize_key("Md001"), "MD001");
1033        assert_eq!(normalize_key("mD001"), "MD001");
1034
1035        // Non-rule names
1036        assert_eq!(normalize_key("line_length"), "line-length");
1037        assert_eq!(normalize_key("line-length"), "line-length");
1038        assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
1039        assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
1040
1041        // Edge cases
1042        assert_eq!(normalize_key("MD"), "md"); // Too short to be a rule
1043        assert_eq!(normalize_key("MD00"), "md00"); // Too short
1044        assert_eq!(normalize_key("MD0001"), "md0001"); // Too long
1045        assert_eq!(normalize_key("MDabc"), "mdabc"); // Non-digit
1046        assert_eq!(normalize_key("MD00a"), "md00a"); // Partial digit
1047        assert_eq!(normalize_key(""), "");
1048        assert_eq!(normalize_key("_"), "-");
1049        assert_eq!(normalize_key("___"), "---");
1050    }
1051
1052    #[test]
1053    fn test_missing_config_file() {
1054        let temp_dir = tempdir().unwrap();
1055        let config_path = temp_dir.path().join("nonexistent.toml");
1056
1057        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1058        assert!(result.is_err());
1059        match result.unwrap_err() {
1060            ConfigError::IoError { .. } => {}
1061            _ => panic!("Expected IoError for missing file"),
1062        }
1063    }
1064
1065    #[test]
1066    #[cfg(unix)]
1067    fn test_permission_denied_config() {
1068        use std::os::unix::fs::PermissionsExt;
1069
1070        let temp_dir = tempdir().unwrap();
1071        let config_path = temp_dir.path().join(".rumdl.toml");
1072
1073        fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
1074
1075        // Remove read permissions
1076        let mut perms = fs::metadata(&config_path).unwrap().permissions();
1077        perms.set_mode(0o000);
1078        fs::set_permissions(&config_path, perms).unwrap();
1079
1080        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1081
1082        // Restore permissions for cleanup
1083        let mut perms = fs::metadata(&config_path).unwrap().permissions();
1084        perms.set_mode(0o644);
1085        fs::set_permissions(&config_path, perms).unwrap();
1086
1087        assert!(result.is_err());
1088        match result.unwrap_err() {
1089            ConfigError::IoError { .. } => {}
1090            _ => panic!("Expected IoError for permission denied"),
1091        }
1092    }
1093
1094    #[test]
1095    fn test_circular_reference_detection() {
1096        // This test is more conceptual since TOML doesn't support circular references
1097        // But we test that deeply nested structures don't cause stack overflow
1098        let temp_dir = tempdir().unwrap();
1099        let config_path = temp_dir.path().join(".rumdl.toml");
1100
1101        let mut config_content = String::from("[MD001]\n");
1102        for i in 0..100 {
1103            config_content.push_str(&format!("key{i} = {i}\n"));
1104        }
1105
1106        fs::write(&config_path, config_content).unwrap();
1107
1108        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1109        let config: Config = sourced.into_validated_unchecked().into();
1110
1111        let rule_config = config.rules.get("MD001").unwrap();
1112        assert_eq!(rule_config.values.len(), 100);
1113    }
1114
1115    #[test]
1116    fn test_special_toml_values() {
1117        let temp_dir = tempdir().unwrap();
1118        let config_path = temp_dir.path().join(".rumdl.toml");
1119
1120        let config_content = r#"
1121[MD001]
1122infinity = inf
1123neg_infinity = -inf
1124not_a_number = nan
1125datetime = 1979-05-27T07:32:00Z
1126local_date = 1979-05-27
1127local_time = 07:32:00
1128"#;
1129        fs::write(&config_path, config_content).unwrap();
1130
1131        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1132        let config: Config = sourced.into_validated_unchecked().into();
1133
1134        // Some values might not be parsed due to parser limitations
1135        if let Some(rule_config) = config.rules.get("MD001") {
1136            // Check special float values if present
1137            if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
1138                assert!(f.is_infinite() && f.is_sign_positive());
1139            }
1140            if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
1141                assert!(f.is_infinite() && f.is_sign_negative());
1142            }
1143            if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
1144                assert!(f.is_nan());
1145            }
1146
1147            // Check datetime values if present
1148            if let Some(val) = rule_config.values.get("datetime") {
1149                assert!(matches!(val, toml::Value::Datetime(_)));
1150            }
1151            // Note: local_date and local_time might not be parsed by the current implementation
1152        }
1153    }
1154
1155    #[test]
1156    fn test_default_config_passes_validation() {
1157        use crate::rules;
1158
1159        let temp_dir = tempdir().unwrap();
1160        let config_path = temp_dir.path().join(".rumdl.toml");
1161        let config_path_str = config_path.to_str().unwrap();
1162
1163        // Create the default config using the same function that `rumdl init` uses
1164        create_default_config(config_path_str).unwrap();
1165
1166        // Load it back as a SourcedConfig
1167        let sourced =
1168            SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
1169
1170        // Create the rule registry
1171        let all_rules = rules::all_rules(&Config::default());
1172        let registry = RuleRegistry::from_rules(&all_rules);
1173
1174        // Validate the config
1175        let warnings = validate_config_sourced(&sourced, &registry);
1176
1177        // The default config should have no warnings
1178        if !warnings.is_empty() {
1179            for warning in &warnings {
1180                eprintln!("Config validation warning: {}", warning.message);
1181                if let Some(rule) = &warning.rule {
1182                    eprintln!("  Rule: {rule}");
1183                }
1184                if let Some(key) = &warning.key {
1185                    eprintln!("  Key: {key}");
1186                }
1187            }
1188        }
1189        assert!(
1190            warnings.is_empty(),
1191            "Default config from rumdl init should pass validation without warnings"
1192        );
1193    }
1194
1195    #[test]
1196    fn test_per_file_ignores_config_parsing() {
1197        let temp_dir = tempdir().unwrap();
1198        let config_path = temp_dir.path().join(".rumdl.toml");
1199        let config_content = r#"
1200[per-file-ignores]
1201"README.md" = ["MD033"]
1202"docs/**/*.md" = ["MD013", "MD033"]
1203"test/*.md" = ["MD041"]
1204"#;
1205        fs::write(&config_path, config_content).unwrap();
1206
1207        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1208        let config: Config = sourced.into_validated_unchecked().into();
1209
1210        // Verify per-file-ignores was loaded
1211        assert_eq!(config.per_file_ignores.len(), 3);
1212        assert_eq!(
1213            config.per_file_ignores.get("README.md"),
1214            Some(&vec!["MD033".to_string()])
1215        );
1216        assert_eq!(
1217            config.per_file_ignores.get("docs/**/*.md"),
1218            Some(&vec!["MD013".to_string(), "MD033".to_string()])
1219        );
1220        assert_eq!(
1221            config.per_file_ignores.get("test/*.md"),
1222            Some(&vec!["MD041".to_string()])
1223        );
1224    }
1225
1226    #[test]
1227    fn test_per_file_ignores_glob_matching() {
1228        use std::path::PathBuf;
1229
1230        let temp_dir = tempdir().unwrap();
1231        let config_path = temp_dir.path().join(".rumdl.toml");
1232        let config_content = r#"
1233[per-file-ignores]
1234"README.md" = ["MD033"]
1235"docs/**/*.md" = ["MD013"]
1236"**/test_*.md" = ["MD041"]
1237"#;
1238        fs::write(&config_path, config_content).unwrap();
1239
1240        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1241        let config: Config = sourced.into_validated_unchecked().into();
1242
1243        // Test exact match
1244        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1245        assert!(ignored.contains("MD033"));
1246        assert_eq!(ignored.len(), 1);
1247
1248        // Test glob pattern matching
1249        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1250        assert!(ignored.contains("MD013"));
1251        assert_eq!(ignored.len(), 1);
1252
1253        // Test recursive glob pattern
1254        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("tests/fixtures/test_example.md"));
1255        assert!(ignored.contains("MD041"));
1256        assert_eq!(ignored.len(), 1);
1257
1258        // Test non-matching path
1259        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("other/file.md"));
1260        assert!(ignored.is_empty());
1261    }
1262
1263    #[test]
1264    fn test_per_file_ignores_pyproject_toml() {
1265        let temp_dir = tempdir().unwrap();
1266        let config_path = temp_dir.path().join("pyproject.toml");
1267        let config_content = r#"
1268[tool.rumdl]
1269[tool.rumdl.per-file-ignores]
1270"README.md" = ["MD033", "MD013"]
1271"generated/*.md" = ["MD041"]
1272"#;
1273        fs::write(&config_path, config_content).unwrap();
1274
1275        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1276        let config: Config = sourced.into_validated_unchecked().into();
1277
1278        // Verify per-file-ignores was loaded from pyproject.toml
1279        assert_eq!(config.per_file_ignores.len(), 2);
1280        assert_eq!(
1281            config.per_file_ignores.get("README.md"),
1282            Some(&vec!["MD033".to_string(), "MD013".to_string()])
1283        );
1284        assert_eq!(
1285            config.per_file_ignores.get("generated/*.md"),
1286            Some(&vec!["MD041".to_string()])
1287        );
1288    }
1289
1290    #[test]
1291    fn test_per_file_ignores_multiple_patterns_match() {
1292        use std::path::PathBuf;
1293
1294        let temp_dir = tempdir().unwrap();
1295        let config_path = temp_dir.path().join(".rumdl.toml");
1296        let config_content = r#"
1297[per-file-ignores]
1298"docs/**/*.md" = ["MD013"]
1299"**/api/*.md" = ["MD033"]
1300"docs/api/overview.md" = ["MD041"]
1301"#;
1302        fs::write(&config_path, config_content).unwrap();
1303
1304        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1305        let config: Config = sourced.into_validated_unchecked().into();
1306
1307        // File matches multiple patterns - should get union of all rules
1308        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1309        assert_eq!(ignored.len(), 3);
1310        assert!(ignored.contains("MD013"));
1311        assert!(ignored.contains("MD033"));
1312        assert!(ignored.contains("MD041"));
1313    }
1314
1315    #[test]
1316    fn test_per_file_ignores_rule_name_normalization() {
1317        use std::path::PathBuf;
1318
1319        let temp_dir = tempdir().unwrap();
1320        let config_path = temp_dir.path().join(".rumdl.toml");
1321        let config_content = r#"
1322[per-file-ignores]
1323"README.md" = ["md033", "MD013", "Md041"]
1324"#;
1325        fs::write(&config_path, config_content).unwrap();
1326
1327        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1328        let config: Config = sourced.into_validated_unchecked().into();
1329
1330        // All rule names should be normalized to uppercase
1331        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1332        assert_eq!(ignored.len(), 3);
1333        assert!(ignored.contains("MD033"));
1334        assert!(ignored.contains("MD013"));
1335        assert!(ignored.contains("MD041"));
1336    }
1337
1338    #[test]
1339    fn test_per_file_ignores_invalid_glob_pattern() {
1340        use std::path::PathBuf;
1341
1342        let temp_dir = tempdir().unwrap();
1343        let config_path = temp_dir.path().join(".rumdl.toml");
1344        let config_content = r#"
1345[per-file-ignores]
1346"[invalid" = ["MD033"]
1347"valid/*.md" = ["MD013"]
1348"#;
1349        fs::write(&config_path, config_content).unwrap();
1350
1351        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1352        let config: Config = sourced.into_validated_unchecked().into();
1353
1354        // Invalid pattern should be skipped, valid pattern should work
1355        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("valid/test.md"));
1356        assert!(ignored.contains("MD013"));
1357
1358        // Invalid pattern should not cause issues
1359        let ignored2 = config.get_ignored_rules_for_file(&PathBuf::from("[invalid"));
1360        assert!(ignored2.is_empty());
1361    }
1362
1363    #[test]
1364    fn test_per_file_ignores_empty_section() {
1365        use std::path::PathBuf;
1366
1367        let temp_dir = tempdir().unwrap();
1368        let config_path = temp_dir.path().join(".rumdl.toml");
1369        let config_content = r#"
1370[global]
1371disable = ["MD001"]
1372
1373[per-file-ignores]
1374"#;
1375        fs::write(&config_path, config_content).unwrap();
1376
1377        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1378        let config: Config = sourced.into_validated_unchecked().into();
1379
1380        // Empty per-file-ignores should work fine
1381        assert_eq!(config.per_file_ignores.len(), 0);
1382        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1383        assert!(ignored.is_empty());
1384    }
1385
1386    #[test]
1387    fn test_per_file_ignores_with_underscores_in_pyproject() {
1388        let temp_dir = tempdir().unwrap();
1389        let config_path = temp_dir.path().join("pyproject.toml");
1390        let config_content = r#"
1391[tool.rumdl]
1392[tool.rumdl.per_file_ignores]
1393"README.md" = ["MD033"]
1394"#;
1395        fs::write(&config_path, config_content).unwrap();
1396
1397        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1398        let config: Config = sourced.into_validated_unchecked().into();
1399
1400        // Should support both per-file-ignores and per_file_ignores
1401        assert_eq!(config.per_file_ignores.len(), 1);
1402        assert_eq!(
1403            config.per_file_ignores.get("README.md"),
1404            Some(&vec!["MD033".to_string()])
1405        );
1406    }
1407
1408    #[test]
1409    fn test_per_file_ignores_absolute_path_matching() {
1410        // Regression test for issue #208: per-file-ignores should work with absolute paths
1411        // This is critical for GitHub Actions which uses absolute paths like $GITHUB_WORKSPACE
1412        use std::path::PathBuf;
1413
1414        let temp_dir = tempdir().unwrap();
1415        let config_path = temp_dir.path().join(".rumdl.toml");
1416
1417        // Create a subdirectory and file to match against
1418        let github_dir = temp_dir.path().join(".github");
1419        fs::create_dir_all(&github_dir).unwrap();
1420        let test_file = github_dir.join("pull_request_template.md");
1421        fs::write(&test_file, "Test content").unwrap();
1422
1423        let config_content = r#"
1424[per-file-ignores]
1425".github/pull_request_template.md" = ["MD041"]
1426"docs/**/*.md" = ["MD013"]
1427"#;
1428        fs::write(&config_path, config_content).unwrap();
1429
1430        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1431        let config: Config = sourced.into_validated_unchecked().into();
1432
1433        // Test with absolute path (like GitHub Actions would use)
1434        let absolute_path = test_file.canonicalize().unwrap();
1435        let ignored = config.get_ignored_rules_for_file(&absolute_path);
1436        assert!(
1437            ignored.contains("MD041"),
1438            "Should match absolute path {absolute_path:?} against relative pattern"
1439        );
1440        assert_eq!(ignored.len(), 1);
1441
1442        // Also verify relative path still works
1443        let relative_path = PathBuf::from(".github/pull_request_template.md");
1444        let ignored = config.get_ignored_rules_for_file(&relative_path);
1445        assert!(ignored.contains("MD041"), "Should match relative path");
1446    }
1447
1448    #[test]
1449    fn test_generate_json_schema() {
1450        use schemars::schema_for;
1451        use std::env;
1452
1453        let schema = schema_for!(Config);
1454        let schema_json = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema");
1455
1456        // Write schema to file if RUMDL_UPDATE_SCHEMA env var is set
1457        if env::var("RUMDL_UPDATE_SCHEMA").is_ok() {
1458            let schema_path = env::current_dir().unwrap().join("rumdl.schema.json");
1459            fs::write(&schema_path, &schema_json).expect("Failed to write schema file");
1460            println!("Schema written to: {}", schema_path.display());
1461        }
1462
1463        // Basic validation that schema was generated
1464        assert!(schema_json.contains("\"title\": \"Config\""));
1465        assert!(schema_json.contains("\"global\""));
1466        assert!(schema_json.contains("\"per-file-ignores\""));
1467    }
1468
1469    #[test]
1470    fn test_project_config_is_standalone() {
1471        // Ruff model: Project config is standalone, user config is NOT merged
1472        // This ensures reproducibility across machines and CI/local consistency
1473        let temp_dir = tempdir().unwrap();
1474
1475        // Create a fake user config directory
1476        // Note: user_configuration_path_impl adds /rumdl to the config dir
1477        let user_config_dir = temp_dir.path().join("user_config");
1478        let rumdl_config_dir = user_config_dir.join("rumdl");
1479        fs::create_dir_all(&rumdl_config_dir).unwrap();
1480        let user_config_path = rumdl_config_dir.join("rumdl.toml");
1481
1482        // User config disables MD013 and MD041
1483        let user_config_content = r#"
1484[global]
1485disable = ["MD013", "MD041"]
1486line-length = 100
1487"#;
1488        fs::write(&user_config_path, user_config_content).unwrap();
1489
1490        // Create a project config that enables MD001
1491        let project_config_path = temp_dir.path().join("project").join("pyproject.toml");
1492        fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1493        let project_config_content = r#"
1494[tool.rumdl]
1495enable = ["MD001"]
1496"#;
1497        fs::write(&project_config_path, project_config_content).unwrap();
1498
1499        // Load config with explicit project path, passing user_config_dir
1500        let sourced = SourcedConfig::load_with_discovery_impl(
1501            Some(project_config_path.to_str().unwrap()),
1502            None,
1503            false,
1504            Some(&user_config_dir),
1505        )
1506        .unwrap();
1507
1508        let config: Config = sourced.into_validated_unchecked().into();
1509
1510        // User config settings should NOT be present (Ruff model: project is standalone)
1511        assert!(
1512            !config.global.disable.contains(&"MD013".to_string()),
1513            "User config should NOT be merged with project config"
1514        );
1515        assert!(
1516            !config.global.disable.contains(&"MD041".to_string()),
1517            "User config should NOT be merged with project config"
1518        );
1519
1520        // Project config settings should be applied
1521        assert!(
1522            config.global.enable.contains(&"MD001".to_string()),
1523            "Project config enabled rules should be applied"
1524        );
1525    }
1526
1527    #[test]
1528    fn test_user_config_as_fallback_when_no_project_config() {
1529        // Ruff model: User config is used as fallback when no project config exists
1530        use std::env;
1531
1532        let temp_dir = tempdir().unwrap();
1533        let original_dir = env::current_dir().unwrap();
1534
1535        // Create a fake user config directory
1536        let user_config_dir = temp_dir.path().join("user_config");
1537        let rumdl_config_dir = user_config_dir.join("rumdl");
1538        fs::create_dir_all(&rumdl_config_dir).unwrap();
1539        let user_config_path = rumdl_config_dir.join("rumdl.toml");
1540
1541        // User config with specific settings
1542        let user_config_content = r#"
1543[global]
1544disable = ["MD013", "MD041"]
1545line-length = 88
1546"#;
1547        fs::write(&user_config_path, user_config_content).unwrap();
1548
1549        // Create a project directory WITHOUT any config
1550        let project_dir = temp_dir.path().join("project_no_config");
1551        fs::create_dir_all(&project_dir).unwrap();
1552
1553        // Change to project directory
1554        env::set_current_dir(&project_dir).unwrap();
1555
1556        // Load config - should use user config as fallback
1557        let sourced = SourcedConfig::load_with_discovery_impl(None, None, false, Some(&user_config_dir)).unwrap();
1558
1559        let config: Config = sourced.into_validated_unchecked().into();
1560
1561        // User config should be loaded as fallback
1562        assert!(
1563            config.global.disable.contains(&"MD013".to_string()),
1564            "User config should be loaded as fallback when no project config"
1565        );
1566        assert!(
1567            config.global.disable.contains(&"MD041".to_string()),
1568            "User config should be loaded as fallback when no project config"
1569        );
1570        assert_eq!(
1571            config.global.line_length.get(),
1572            88,
1573            "User config line-length should be loaded as fallback"
1574        );
1575
1576        env::set_current_dir(original_dir).unwrap();
1577    }
1578
1579    #[test]
1580    fn test_typestate_validate_method() {
1581        use tempfile::tempdir;
1582
1583        let temp_dir = tempdir().expect("Failed to create temporary directory");
1584        let config_path = temp_dir.path().join("test.toml");
1585
1586        // Create config with an unknown rule option to trigger a validation warning
1587        let config_content = r#"
1588[global]
1589enable = ["MD001"]
1590
1591[MD013]
1592line_length = 80
1593unknown_option = true
1594"#;
1595        std::fs::write(&config_path, config_content).expect("Failed to write config");
1596
1597        // Load config - this returns SourcedConfig<ConfigLoaded>
1598        let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
1599            .expect("Should load config");
1600
1601        // Create a rule registry for validation
1602        let default_config = Config::default();
1603        let all_rules = crate::rules::all_rules(&default_config);
1604        let registry = RuleRegistry::from_rules(&all_rules);
1605
1606        // Validate - this transitions to SourcedConfig<ConfigValidated>
1607        let validated = loaded.validate(&registry).expect("Should validate config");
1608
1609        // Check that validation warnings were captured for the unknown option
1610        // Note: The validation checks rule options against the rule's schema
1611        let has_unknown_option_warning = validated
1612            .validation_warnings
1613            .iter()
1614            .any(|w| w.message.contains("unknown_option") || w.message.contains("Unknown option"));
1615
1616        // Print warnings for debugging if assertion fails
1617        if !has_unknown_option_warning {
1618            for w in &validated.validation_warnings {
1619                eprintln!("Warning: {}", w.message);
1620            }
1621        }
1622        assert!(
1623            has_unknown_option_warning,
1624            "Should have warning for unknown option. Got {} warnings: {:?}",
1625            validated.validation_warnings.len(),
1626            validated
1627                .validation_warnings
1628                .iter()
1629                .map(|w| &w.message)
1630                .collect::<Vec<_>>()
1631        );
1632
1633        // Now we can convert to Config (this would be a compile error with ConfigLoaded)
1634        let config: Config = validated.into();
1635
1636        // Verify the config values are correct
1637        assert!(config.global.enable.contains(&"MD001".to_string()));
1638    }
1639
1640    #[test]
1641    fn test_typestate_validate_into_convenience_method() {
1642        use tempfile::tempdir;
1643
1644        let temp_dir = tempdir().expect("Failed to create temporary directory");
1645        let config_path = temp_dir.path().join("test.toml");
1646
1647        let config_content = r#"
1648[global]
1649enable = ["MD022"]
1650
1651[MD022]
1652lines_above = 2
1653"#;
1654        std::fs::write(&config_path, config_content).expect("Failed to write config");
1655
1656        let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
1657            .expect("Should load config");
1658
1659        let default_config = Config::default();
1660        let all_rules = crate::rules::all_rules(&default_config);
1661        let registry = RuleRegistry::from_rules(&all_rules);
1662
1663        // Use the convenience method that validates and converts in one step
1664        let (config, warnings) = loaded.validate_into(&registry).expect("Should validate and convert");
1665
1666        // Should have no warnings for valid config
1667        assert!(warnings.is_empty(), "Should have no warnings for valid config");
1668
1669        // Config should be usable
1670        assert!(config.global.enable.contains(&"MD022".to_string()));
1671    }
1672
1673    #[test]
1674    fn test_resolve_rule_name_canonical() {
1675        // Canonical IDs should resolve to themselves
1676        assert_eq!(resolve_rule_name("MD001"), "MD001");
1677        assert_eq!(resolve_rule_name("MD013"), "MD013");
1678        assert_eq!(resolve_rule_name("MD069"), "MD069");
1679    }
1680
1681    #[test]
1682    fn test_resolve_rule_name_aliases() {
1683        // Aliases should resolve to canonical IDs
1684        assert_eq!(resolve_rule_name("heading-increment"), "MD001");
1685        assert_eq!(resolve_rule_name("line-length"), "MD013");
1686        assert_eq!(resolve_rule_name("no-bare-urls"), "MD034");
1687        assert_eq!(resolve_rule_name("ul-style"), "MD004");
1688    }
1689
1690    #[test]
1691    fn test_resolve_rule_name_case_insensitive() {
1692        // Case should not matter
1693        assert_eq!(resolve_rule_name("HEADING-INCREMENT"), "MD001");
1694        assert_eq!(resolve_rule_name("Heading-Increment"), "MD001");
1695        assert_eq!(resolve_rule_name("md001"), "MD001");
1696        assert_eq!(resolve_rule_name("MD001"), "MD001");
1697    }
1698
1699    #[test]
1700    fn test_resolve_rule_name_underscore_to_hyphen() {
1701        // Underscores should be converted to hyphens
1702        assert_eq!(resolve_rule_name("heading_increment"), "MD001");
1703        assert_eq!(resolve_rule_name("line_length"), "MD013");
1704        assert_eq!(resolve_rule_name("no_bare_urls"), "MD034");
1705    }
1706
1707    #[test]
1708    fn test_resolve_rule_name_unknown() {
1709        // Unknown names should fall back to normalization
1710        assert_eq!(resolve_rule_name("custom-rule"), "custom-rule");
1711        assert_eq!(resolve_rule_name("CUSTOM_RULE"), "custom-rule");
1712        assert_eq!(resolve_rule_name("md999"), "MD999"); // Looks like an MD rule
1713    }
1714
1715    #[test]
1716    fn test_resolve_rule_names_basic() {
1717        let result = resolve_rule_names("MD001,line-length,heading-increment");
1718        assert!(result.contains("MD001"));
1719        assert!(result.contains("MD013")); // line-length
1720        // Note: heading-increment also resolves to MD001, so set should contain MD001 and MD013
1721        assert_eq!(result.len(), 2);
1722    }
1723
1724    #[test]
1725    fn test_resolve_rule_names_with_whitespace() {
1726        let result = resolve_rule_names("  MD001 , line-length , MD034  ");
1727        assert!(result.contains("MD001"));
1728        assert!(result.contains("MD013"));
1729        assert!(result.contains("MD034"));
1730        assert_eq!(result.len(), 3);
1731    }
1732
1733    #[test]
1734    fn test_resolve_rule_names_empty_entries() {
1735        let result = resolve_rule_names("MD001,,MD013,");
1736        assert!(result.contains("MD001"));
1737        assert!(result.contains("MD013"));
1738        assert_eq!(result.len(), 2);
1739    }
1740
1741    #[test]
1742    fn test_resolve_rule_names_empty_string() {
1743        let result = resolve_rule_names("");
1744        assert!(result.is_empty());
1745    }
1746
1747    #[test]
1748    fn test_resolve_rule_names_mixed() {
1749        // Mix of canonical IDs, aliases, and unknown
1750        let result = resolve_rule_names("MD001,line-length,custom-rule");
1751        assert!(result.contains("MD001"));
1752        assert!(result.contains("MD013"));
1753        assert!(result.contains("custom-rule"));
1754        assert_eq!(result.len(), 3);
1755    }
1756
1757    // =========================================================================
1758    // Unit tests for is_valid_rule_name() and validate_cli_rule_names()
1759    // =========================================================================
1760
1761    #[test]
1762    fn test_is_valid_rule_name_canonical() {
1763        // Valid canonical rule IDs
1764        assert!(is_valid_rule_name("MD001"));
1765        assert!(is_valid_rule_name("MD013"));
1766        assert!(is_valid_rule_name("MD041"));
1767        assert!(is_valid_rule_name("MD069"));
1768
1769        // Case insensitive
1770        assert!(is_valid_rule_name("md001"));
1771        assert!(is_valid_rule_name("Md001"));
1772        assert!(is_valid_rule_name("mD001"));
1773    }
1774
1775    #[test]
1776    fn test_is_valid_rule_name_aliases() {
1777        // Valid aliases
1778        assert!(is_valid_rule_name("line-length"));
1779        assert!(is_valid_rule_name("heading-increment"));
1780        assert!(is_valid_rule_name("no-bare-urls"));
1781        assert!(is_valid_rule_name("ul-style"));
1782
1783        // Case insensitive
1784        assert!(is_valid_rule_name("LINE-LENGTH"));
1785        assert!(is_valid_rule_name("Line-Length"));
1786
1787        // Underscore variant
1788        assert!(is_valid_rule_name("line_length"));
1789        assert!(is_valid_rule_name("ul_style"));
1790    }
1791
1792    #[test]
1793    fn test_is_valid_rule_name_special_all() {
1794        assert!(is_valid_rule_name("all"));
1795        assert!(is_valid_rule_name("ALL"));
1796        assert!(is_valid_rule_name("All"));
1797        assert!(is_valid_rule_name("aLl"));
1798    }
1799
1800    #[test]
1801    fn test_is_valid_rule_name_invalid() {
1802        // Non-existent rules
1803        assert!(!is_valid_rule_name("MD000"));
1804        assert!(!is_valid_rule_name("MD002")); // gap in numbering
1805        assert!(!is_valid_rule_name("MD006")); // gap in numbering
1806        assert!(!is_valid_rule_name("MD999"));
1807        assert!(!is_valid_rule_name("MD100"));
1808
1809        // Invalid formats
1810        assert!(!is_valid_rule_name(""));
1811        assert!(!is_valid_rule_name("INVALID"));
1812        assert!(!is_valid_rule_name("not-a-rule"));
1813        assert!(!is_valid_rule_name("random-text"));
1814        assert!(!is_valid_rule_name("abc"));
1815
1816        // Edge cases
1817        assert!(!is_valid_rule_name("MD"));
1818        assert!(!is_valid_rule_name("MD1"));
1819        assert!(!is_valid_rule_name("MD12"));
1820    }
1821
1822    #[test]
1823    fn test_validate_cli_rule_names_valid() {
1824        // All valid - should return no warnings
1825        let warnings = validate_cli_rule_names(
1826            Some("MD001,MD013"),
1827            Some("line-length"),
1828            Some("heading-increment"),
1829            Some("all"),
1830        );
1831        assert!(warnings.is_empty(), "Expected no warnings for valid rules");
1832    }
1833
1834    #[test]
1835    fn test_validate_cli_rule_names_invalid() {
1836        // Invalid rule in --enable
1837        let warnings = validate_cli_rule_names(Some("abc"), None, None, None);
1838        assert_eq!(warnings.len(), 1);
1839        assert!(warnings[0].message.contains("Unknown rule in --enable: abc"));
1840
1841        // Invalid rule in --disable
1842        let warnings = validate_cli_rule_names(None, Some("xyz"), None, None);
1843        assert_eq!(warnings.len(), 1);
1844        assert!(warnings[0].message.contains("Unknown rule in --disable: xyz"));
1845
1846        // Invalid rule in --extend-enable
1847        let warnings = validate_cli_rule_names(None, None, Some("nonexistent"), None);
1848        assert_eq!(warnings.len(), 1);
1849        assert!(
1850            warnings[0]
1851                .message
1852                .contains("Unknown rule in --extend-enable: nonexistent")
1853        );
1854
1855        // Invalid rule in --extend-disable
1856        let warnings = validate_cli_rule_names(None, None, None, Some("fake-rule"));
1857        assert_eq!(warnings.len(), 1);
1858        assert!(
1859            warnings[0]
1860                .message
1861                .contains("Unknown rule in --extend-disable: fake-rule")
1862        );
1863    }
1864
1865    #[test]
1866    fn test_validate_cli_rule_names_mixed() {
1867        // Mix of valid and invalid
1868        let warnings = validate_cli_rule_names(Some("MD001,abc,MD003"), None, None, None);
1869        assert_eq!(warnings.len(), 1);
1870        assert!(warnings[0].message.contains("abc"));
1871    }
1872
1873    #[test]
1874    fn test_validate_cli_rule_names_suggestions() {
1875        // Typo should suggest correction
1876        let warnings = validate_cli_rule_names(Some("line-lenght"), None, None, None);
1877        assert_eq!(warnings.len(), 1);
1878        assert!(warnings[0].message.contains("did you mean"));
1879        assert!(warnings[0].message.contains("line-length"));
1880    }
1881
1882    #[test]
1883    fn test_validate_cli_rule_names_none() {
1884        // All None - should return no warnings
1885        let warnings = validate_cli_rule_names(None, None, None, None);
1886        assert!(warnings.is_empty());
1887    }
1888
1889    #[test]
1890    fn test_validate_cli_rule_names_empty_string() {
1891        // Empty strings should produce no warnings
1892        let warnings = validate_cli_rule_names(Some(""), Some(""), Some(""), Some(""));
1893        assert!(warnings.is_empty());
1894    }
1895
1896    #[test]
1897    fn test_validate_cli_rule_names_whitespace() {
1898        // Whitespace handling
1899        let warnings = validate_cli_rule_names(Some("  MD001  ,  MD013  "), None, None, None);
1900        assert!(warnings.is_empty(), "Whitespace should be trimmed");
1901    }
1902
1903    #[test]
1904    fn test_all_implemented_rules_have_aliases() {
1905        // This test ensures we don't forget to add aliases when adding new rules.
1906        // If this test fails, add the missing rule to RULE_ALIAS_MAP in config.rs
1907        // with both the canonical entry (e.g., "MD071" => "MD071") and an alias
1908        // (e.g., "BLANK-LINE-AFTER-FRONTMATTER" => "MD071").
1909
1910        // Get all implemented rules from the rules module
1911        let config = crate::config::Config::default();
1912        let all_rules = crate::rules::all_rules(&config);
1913
1914        let mut missing_rules = Vec::new();
1915        for rule in &all_rules {
1916            let rule_name = rule.name();
1917            // Check if the canonical entry exists in RULE_ALIAS_MAP
1918            if resolve_rule_name_alias(rule_name).is_none() {
1919                missing_rules.push(rule_name.to_string());
1920            }
1921        }
1922
1923        assert!(
1924            missing_rules.is_empty(),
1925            "The following rules are missing from RULE_ALIAS_MAP: {:?}\n\
1926             Add entries like:\n\
1927             - Canonical: \"{}\" => \"{}\"\n\
1928             - Alias: \"RULE-NAME-HERE\" => \"{}\"",
1929            missing_rules,
1930            missing_rules.first().unwrap_or(&"MDxxx".to_string()),
1931            missing_rules.first().unwrap_or(&"MDxxx".to_string()),
1932            missing_rules.first().unwrap_or(&"MDxxx".to_string()),
1933        );
1934    }
1935}
1936
1937/// Configuration source with clear precedence hierarchy.
1938///
1939/// Precedence order (lower values override higher values):
1940/// - Default (0): Built-in defaults
1941/// - UserConfig (1): User-level ~/.config/rumdl/rumdl.toml
1942/// - PyprojectToml (2): Project-level pyproject.toml
1943/// - ProjectConfig (3): Project-level .rumdl.toml (most specific)
1944/// - Cli (4): Command-line flags (highest priority)
1945#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1946pub enum ConfigSource {
1947    /// Built-in default configuration
1948    Default,
1949    /// User-level configuration from ~/.config/rumdl/rumdl.toml
1950    UserConfig,
1951    /// Project-level configuration from pyproject.toml
1952    PyprojectToml,
1953    /// Project-level configuration from .rumdl.toml or rumdl.toml
1954    ProjectConfig,
1955    /// Command-line flags (highest precedence)
1956    Cli,
1957}
1958
1959#[derive(Debug, Clone)]
1960pub struct ConfigOverride<T> {
1961    pub value: T,
1962    pub source: ConfigSource,
1963    pub file: Option<String>,
1964    pub line: Option<usize>,
1965}
1966
1967#[derive(Debug, Clone)]
1968pub struct SourcedValue<T> {
1969    pub value: T,
1970    pub source: ConfigSource,
1971    pub overrides: Vec<ConfigOverride<T>>,
1972}
1973
1974impl<T: Clone> SourcedValue<T> {
1975    pub fn new(value: T, source: ConfigSource) -> Self {
1976        Self {
1977            value: value.clone(),
1978            source,
1979            overrides: vec![ConfigOverride {
1980                value,
1981                source,
1982                file: None,
1983                line: None,
1984            }],
1985        }
1986    }
1987
1988    /// Merges a new override into this SourcedValue based on source precedence.
1989    /// If the new source has higher or equal precedence, the value and source are updated,
1990    /// and the new override is added to the history.
1991    pub fn merge_override(
1992        &mut self,
1993        new_value: T,
1994        new_source: ConfigSource,
1995        new_file: Option<String>,
1996        new_line: Option<usize>,
1997    ) {
1998        // Helper function to get precedence, defined locally or globally
1999        fn source_precedence(src: ConfigSource) -> u8 {
2000            match src {
2001                ConfigSource::Default => 0,
2002                ConfigSource::UserConfig => 1,
2003                ConfigSource::PyprojectToml => 2,
2004                ConfigSource::ProjectConfig => 3,
2005                ConfigSource::Cli => 4,
2006            }
2007        }
2008
2009        if source_precedence(new_source) >= source_precedence(self.source) {
2010            self.value = new_value.clone();
2011            self.source = new_source;
2012            self.overrides.push(ConfigOverride {
2013                value: new_value,
2014                source: new_source,
2015                file: new_file,
2016                line: new_line,
2017            });
2018        }
2019    }
2020
2021    pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
2022        // This is essentially merge_override without the precedence check
2023        // We might consolidate these later, but keep separate for now during refactor
2024        self.value = value.clone();
2025        self.source = source;
2026        self.overrides.push(ConfigOverride {
2027            value,
2028            source,
2029            file,
2030            line,
2031        });
2032    }
2033}
2034
2035impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
2036    /// Merges a new value using union semantics (for arrays like `disable`)
2037    /// Values from both sources are combined, with deduplication
2038    pub fn merge_union(
2039        &mut self,
2040        new_value: Vec<T>,
2041        new_source: ConfigSource,
2042        new_file: Option<String>,
2043        new_line: Option<usize>,
2044    ) {
2045        fn source_precedence(src: ConfigSource) -> u8 {
2046            match src {
2047                ConfigSource::Default => 0,
2048                ConfigSource::UserConfig => 1,
2049                ConfigSource::PyprojectToml => 2,
2050                ConfigSource::ProjectConfig => 3,
2051                ConfigSource::Cli => 4,
2052            }
2053        }
2054
2055        if source_precedence(new_source) >= source_precedence(self.source) {
2056            // Union: combine values from both sources with deduplication
2057            let mut combined = self.value.clone();
2058            for item in new_value.iter() {
2059                if !combined.contains(item) {
2060                    combined.push(item.clone());
2061                }
2062            }
2063
2064            self.value = combined;
2065            self.source = new_source;
2066            self.overrides.push(ConfigOverride {
2067                value: new_value,
2068                source: new_source,
2069                file: new_file,
2070                line: new_line,
2071            });
2072        }
2073    }
2074}
2075
2076#[derive(Debug, Clone)]
2077pub struct SourcedGlobalConfig {
2078    pub enable: SourcedValue<Vec<String>>,
2079    pub disable: SourcedValue<Vec<String>>,
2080    pub exclude: SourcedValue<Vec<String>>,
2081    pub include: SourcedValue<Vec<String>>,
2082    pub respect_gitignore: SourcedValue<bool>,
2083    pub line_length: SourcedValue<LineLength>,
2084    pub output_format: Option<SourcedValue<String>>,
2085    pub fixable: SourcedValue<Vec<String>>,
2086    pub unfixable: SourcedValue<Vec<String>>,
2087    pub flavor: SourcedValue<MarkdownFlavor>,
2088    pub force_exclude: SourcedValue<bool>,
2089    pub cache_dir: Option<SourcedValue<String>>,
2090    pub cache: SourcedValue<bool>,
2091}
2092
2093impl Default for SourcedGlobalConfig {
2094    fn default() -> Self {
2095        SourcedGlobalConfig {
2096            enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2097            disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2098            exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
2099            include: SourcedValue::new(Vec::new(), ConfigSource::Default),
2100            respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
2101            line_length: SourcedValue::new(LineLength::default(), ConfigSource::Default),
2102            output_format: None,
2103            fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2104            unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2105            flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
2106            force_exclude: SourcedValue::new(false, ConfigSource::Default),
2107            cache_dir: None,
2108            cache: SourcedValue::new(true, ConfigSource::Default),
2109        }
2110    }
2111}
2112
2113#[derive(Debug, Default, Clone)]
2114pub struct SourcedRuleConfig {
2115    pub severity: Option<SourcedValue<crate::rule::Severity>>,
2116    pub values: BTreeMap<String, SourcedValue<toml::Value>>,
2117}
2118
2119/// Represents configuration loaded from a single source file, with provenance.
2120/// Used as an intermediate step before merging into the final SourcedConfig.
2121#[derive(Debug, Clone)]
2122pub struct SourcedConfigFragment {
2123    pub global: SourcedGlobalConfig,
2124    pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
2125    pub rules: BTreeMap<String, SourcedRuleConfig>,
2126    pub unknown_keys: Vec<(String, String, Option<String>)>, // (section, key, file_path)
2127                                                             // Note: loaded_files is tracked globally in SourcedConfig.
2128}
2129
2130impl Default for SourcedConfigFragment {
2131    fn default() -> Self {
2132        Self {
2133            global: SourcedGlobalConfig::default(),
2134            per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
2135            rules: BTreeMap::new(),
2136            unknown_keys: Vec::new(),
2137        }
2138    }
2139}
2140
2141/// Configuration with provenance tracking for values.
2142///
2143/// The `State` type parameter encodes the validation state:
2144/// - `ConfigLoaded`: Config has been loaded but not validated
2145/// - `ConfigValidated`: Config has been validated and can be converted to `Config`
2146///
2147/// # Typestate Pattern
2148///
2149/// This uses the typestate pattern to ensure validation happens before conversion:
2150///
2151/// ```ignore
2152/// let loaded: SourcedConfig<ConfigLoaded> = SourcedConfig::load_with_discovery(...)?;
2153/// let validated: SourcedConfig<ConfigValidated> = loaded.validate(&registry)?;
2154/// let config: Config = validated.into();  // Only works on ConfigValidated!
2155/// ```
2156///
2157/// Attempting to convert a `ConfigLoaded` config directly to `Config` is a compile error.
2158#[derive(Debug, Clone)]
2159pub struct SourcedConfig<State = ConfigLoaded> {
2160    pub global: SourcedGlobalConfig,
2161    pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
2162    pub rules: BTreeMap<String, SourcedRuleConfig>,
2163    pub loaded_files: Vec<String>,
2164    pub unknown_keys: Vec<(String, String, Option<String>)>, // (section, key, file_path)
2165    /// Project root directory (parent of config file), used for resolving relative paths
2166    pub project_root: Option<std::path::PathBuf>,
2167    /// Validation warnings (populated after validate() is called)
2168    pub validation_warnings: Vec<ConfigValidationWarning>,
2169    /// Phantom data for the state type parameter
2170    _state: PhantomData<State>,
2171}
2172
2173impl Default for SourcedConfig<ConfigLoaded> {
2174    fn default() -> Self {
2175        Self {
2176            global: SourcedGlobalConfig::default(),
2177            per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
2178            rules: BTreeMap::new(),
2179            loaded_files: Vec::new(),
2180            unknown_keys: Vec::new(),
2181            project_root: None,
2182            validation_warnings: Vec::new(),
2183            _state: PhantomData,
2184        }
2185    }
2186}
2187
2188impl SourcedConfig<ConfigLoaded> {
2189    /// Merges another SourcedConfigFragment into this SourcedConfig.
2190    /// Uses source precedence to determine which values take effect.
2191    fn merge(&mut self, fragment: SourcedConfigFragment) {
2192        // Merge global config
2193        // Enable uses replace semantics (project can enforce rules)
2194        self.global.enable.merge_override(
2195            fragment.global.enable.value,
2196            fragment.global.enable.source,
2197            fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
2198            fragment.global.enable.overrides.first().and_then(|o| o.line),
2199        );
2200
2201        // Disable uses union semantics (user can add to project disables)
2202        self.global.disable.merge_union(
2203            fragment.global.disable.value,
2204            fragment.global.disable.source,
2205            fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
2206            fragment.global.disable.overrides.first().and_then(|o| o.line),
2207        );
2208
2209        // Conflict resolution: Enable overrides disable
2210        // Remove any rules from disable that appear in enable
2211        self.global
2212            .disable
2213            .value
2214            .retain(|rule| !self.global.enable.value.contains(rule));
2215        self.global.include.merge_override(
2216            fragment.global.include.value,
2217            fragment.global.include.source,
2218            fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
2219            fragment.global.include.overrides.first().and_then(|o| o.line),
2220        );
2221        self.global.exclude.merge_override(
2222            fragment.global.exclude.value,
2223            fragment.global.exclude.source,
2224            fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
2225            fragment.global.exclude.overrides.first().and_then(|o| o.line),
2226        );
2227        self.global.respect_gitignore.merge_override(
2228            fragment.global.respect_gitignore.value,
2229            fragment.global.respect_gitignore.source,
2230            fragment
2231                .global
2232                .respect_gitignore
2233                .overrides
2234                .first()
2235                .and_then(|o| o.file.clone()),
2236            fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
2237        );
2238        self.global.line_length.merge_override(
2239            fragment.global.line_length.value,
2240            fragment.global.line_length.source,
2241            fragment
2242                .global
2243                .line_length
2244                .overrides
2245                .first()
2246                .and_then(|o| o.file.clone()),
2247            fragment.global.line_length.overrides.first().and_then(|o| o.line),
2248        );
2249        self.global.fixable.merge_override(
2250            fragment.global.fixable.value,
2251            fragment.global.fixable.source,
2252            fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
2253            fragment.global.fixable.overrides.first().and_then(|o| o.line),
2254        );
2255        self.global.unfixable.merge_override(
2256            fragment.global.unfixable.value,
2257            fragment.global.unfixable.source,
2258            fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
2259            fragment.global.unfixable.overrides.first().and_then(|o| o.line),
2260        );
2261
2262        // Merge flavor
2263        self.global.flavor.merge_override(
2264            fragment.global.flavor.value,
2265            fragment.global.flavor.source,
2266            fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
2267            fragment.global.flavor.overrides.first().and_then(|o| o.line),
2268        );
2269
2270        // Merge force_exclude
2271        self.global.force_exclude.merge_override(
2272            fragment.global.force_exclude.value,
2273            fragment.global.force_exclude.source,
2274            fragment
2275                .global
2276                .force_exclude
2277                .overrides
2278                .first()
2279                .and_then(|o| o.file.clone()),
2280            fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
2281        );
2282
2283        // Merge output_format if present
2284        if let Some(output_format_fragment) = fragment.global.output_format {
2285            if let Some(ref mut output_format) = self.global.output_format {
2286                output_format.merge_override(
2287                    output_format_fragment.value,
2288                    output_format_fragment.source,
2289                    output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
2290                    output_format_fragment.overrides.first().and_then(|o| o.line),
2291                );
2292            } else {
2293                self.global.output_format = Some(output_format_fragment);
2294            }
2295        }
2296
2297        // Merge cache_dir if present
2298        if let Some(cache_dir_fragment) = fragment.global.cache_dir {
2299            if let Some(ref mut cache_dir) = self.global.cache_dir {
2300                cache_dir.merge_override(
2301                    cache_dir_fragment.value,
2302                    cache_dir_fragment.source,
2303                    cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
2304                    cache_dir_fragment.overrides.first().and_then(|o| o.line),
2305                );
2306            } else {
2307                self.global.cache_dir = Some(cache_dir_fragment);
2308            }
2309        }
2310
2311        // Merge cache if not default (only override when explicitly set)
2312        if fragment.global.cache.source != ConfigSource::Default {
2313            self.global.cache.merge_override(
2314                fragment.global.cache.value,
2315                fragment.global.cache.source,
2316                fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
2317                fragment.global.cache.overrides.first().and_then(|o| o.line),
2318            );
2319        }
2320
2321        // Merge per_file_ignores
2322        self.per_file_ignores.merge_override(
2323            fragment.per_file_ignores.value,
2324            fragment.per_file_ignores.source,
2325            fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
2326            fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
2327        );
2328
2329        // Merge rule configs
2330        for (rule_name, rule_fragment) in fragment.rules {
2331            let norm_rule_name = rule_name.to_ascii_uppercase(); // Normalize to uppercase for case-insensitivity
2332            let rule_entry = self.rules.entry(norm_rule_name).or_default();
2333
2334            // Merge severity if present in fragment
2335            if let Some(severity_fragment) = rule_fragment.severity {
2336                if let Some(ref mut existing_severity) = rule_entry.severity {
2337                    existing_severity.merge_override(
2338                        severity_fragment.value,
2339                        severity_fragment.source,
2340                        severity_fragment.overrides.first().and_then(|o| o.file.clone()),
2341                        severity_fragment.overrides.first().and_then(|o| o.line),
2342                    );
2343                } else {
2344                    rule_entry.severity = Some(severity_fragment);
2345                }
2346            }
2347
2348            // Merge values
2349            for (key, sourced_value_fragment) in rule_fragment.values {
2350                let sv_entry = rule_entry
2351                    .values
2352                    .entry(key.clone())
2353                    .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
2354                let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
2355                let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
2356                sv_entry.merge_override(
2357                    sourced_value_fragment.value,  // Use the value from the fragment
2358                    sourced_value_fragment.source, // Use the source from the fragment
2359                    file_from_fragment,            // Pass the file path from the fragment override
2360                    line_from_fragment,            // Pass the line number from the fragment override
2361                );
2362            }
2363        }
2364
2365        // Merge unknown_keys from fragment
2366        for (section, key, file_path) in fragment.unknown_keys {
2367            // Deduplicate: only add if not already present
2368            if !self.unknown_keys.iter().any(|(s, k, _)| s == &section && k == &key) {
2369                self.unknown_keys.push((section, key, file_path));
2370            }
2371        }
2372    }
2373
2374    /// Load and merge configurations from files and CLI overrides.
2375    pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
2376        Self::load_with_discovery(config_path, cli_overrides, false)
2377    }
2378
2379    /// Finds project root by walking up from start_dir looking for .git directory.
2380    /// Falls back to start_dir if no .git found.
2381    fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
2382        // Convert relative paths to absolute to ensure correct traversal
2383        let mut current = if start_dir.is_relative() {
2384            std::env::current_dir()
2385                .map(|cwd| cwd.join(start_dir))
2386                .unwrap_or_else(|_| start_dir.to_path_buf())
2387        } else {
2388            start_dir.to_path_buf()
2389        };
2390        const MAX_DEPTH: usize = 100;
2391
2392        for _ in 0..MAX_DEPTH {
2393            if current.join(".git").exists() {
2394                log::debug!("[rumdl-config] Found .git at: {}", current.display());
2395                return current;
2396            }
2397
2398            match current.parent() {
2399                Some(parent) => current = parent.to_path_buf(),
2400                None => break,
2401            }
2402        }
2403
2404        // No .git found, use start_dir as project root
2405        log::debug!(
2406            "[rumdl-config] No .git found, using config location as project root: {}",
2407            start_dir.display()
2408        );
2409        start_dir.to_path_buf()
2410    }
2411
2412    /// Discover configuration file by traversing up the directory tree.
2413    /// Returns the first configuration file found.
2414    /// Discovers config file and returns both the config path and project root.
2415    /// Returns: (config_file_path, project_root_path)
2416    /// Project root is the directory containing .git, or config parent as fallback.
2417    fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
2418        use std::env;
2419
2420        const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
2421        const MAX_DEPTH: usize = 100; // Prevent infinite traversal
2422
2423        let start_dir = match env::current_dir() {
2424            Ok(dir) => dir,
2425            Err(e) => {
2426                log::debug!("[rumdl-config] Failed to get current directory: {e}");
2427                return None;
2428            }
2429        };
2430
2431        let mut current_dir = start_dir.clone();
2432        let mut depth = 0;
2433        let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2434
2435        loop {
2436            if depth >= MAX_DEPTH {
2437                log::debug!("[rumdl-config] Maximum traversal depth reached");
2438                break;
2439            }
2440
2441            log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
2442
2443            // Check for config files in order of precedence (only if not already found)
2444            if found_config.is_none() {
2445                for config_name in CONFIG_FILES {
2446                    let config_path = current_dir.join(config_name);
2447
2448                    if config_path.exists() {
2449                        // For pyproject.toml, verify it contains [tool.rumdl] section
2450                        if *config_name == "pyproject.toml" {
2451                            if let Ok(content) = std::fs::read_to_string(&config_path) {
2452                                if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
2453                                    log::debug!("[rumdl-config] Found config file: {}", config_path.display());
2454                                    // Store config, but continue looking for .git
2455                                    found_config = Some((config_path.clone(), current_dir.clone()));
2456                                    break;
2457                                }
2458                                log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
2459                                continue;
2460                            }
2461                        } else {
2462                            log::debug!("[rumdl-config] Found config file: {}", config_path.display());
2463                            // Store config, but continue looking for .git
2464                            found_config = Some((config_path.clone(), current_dir.clone()));
2465                            break;
2466                        }
2467                    }
2468                }
2469            }
2470
2471            // Check for .git directory (stop boundary)
2472            if current_dir.join(".git").exists() {
2473                log::debug!("[rumdl-config] Stopping at .git directory");
2474                break;
2475            }
2476
2477            // Move to parent directory
2478            match current_dir.parent() {
2479                Some(parent) => {
2480                    current_dir = parent.to_owned();
2481                    depth += 1;
2482                }
2483                None => {
2484                    log::debug!("[rumdl-config] Reached filesystem root");
2485                    break;
2486                }
2487            }
2488        }
2489
2490        // If config found, determine project root by walking up from config location
2491        if let Some((config_path, config_dir)) = found_config {
2492            let project_root = Self::find_project_root_from(&config_dir);
2493            return Some((config_path, project_root));
2494        }
2495
2496        None
2497    }
2498
2499    /// Discover markdownlint configuration file by traversing up the directory tree.
2500    /// Similar to discover_config_upward but for .markdownlint.yaml/json files.
2501    /// Returns the path to the config file if found.
2502    fn discover_markdownlint_config_upward() -> Option<std::path::PathBuf> {
2503        use std::env;
2504
2505        const MAX_DEPTH: usize = 100;
2506
2507        let start_dir = match env::current_dir() {
2508            Ok(dir) => dir,
2509            Err(e) => {
2510                log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
2511                return None;
2512            }
2513        };
2514
2515        let mut current_dir = start_dir.clone();
2516        let mut depth = 0;
2517
2518        loop {
2519            if depth >= MAX_DEPTH {
2520                log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
2521                break;
2522            }
2523
2524            log::debug!(
2525                "[rumdl-config] Searching for markdownlint config in: {}",
2526                current_dir.display()
2527            );
2528
2529            // Check for markdownlint config files in order of precedence
2530            for config_name in MARKDOWNLINT_CONFIG_FILES {
2531                let config_path = current_dir.join(config_name);
2532                if config_path.exists() {
2533                    log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
2534                    return Some(config_path);
2535                }
2536            }
2537
2538            // Check for .git directory (stop boundary)
2539            if current_dir.join(".git").exists() {
2540                log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
2541                break;
2542            }
2543
2544            // Move to parent directory
2545            match current_dir.parent() {
2546                Some(parent) => {
2547                    current_dir = parent.to_owned();
2548                    depth += 1;
2549                }
2550                None => {
2551                    log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
2552                    break;
2553                }
2554            }
2555        }
2556
2557        None
2558    }
2559
2560    /// Internal implementation that accepts config directory for testing
2561    fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
2562        let config_dir = config_dir.join("rumdl");
2563
2564        // Check for config files in precedence order (same as project discovery)
2565        const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
2566
2567        log::debug!(
2568            "[rumdl-config] Checking for user configuration in: {}",
2569            config_dir.display()
2570        );
2571
2572        for filename in USER_CONFIG_FILES {
2573            let config_path = config_dir.join(filename);
2574
2575            if config_path.exists() {
2576                // For pyproject.toml, verify it contains [tool.rumdl] section
2577                if *filename == "pyproject.toml" {
2578                    if let Ok(content) = std::fs::read_to_string(&config_path) {
2579                        if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
2580                            log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
2581                            return Some(config_path);
2582                        }
2583                        log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
2584                        continue;
2585                    }
2586                } else {
2587                    log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
2588                    return Some(config_path);
2589                }
2590            }
2591        }
2592
2593        log::debug!(
2594            "[rumdl-config] No user configuration found in: {}",
2595            config_dir.display()
2596        );
2597        None
2598    }
2599
2600    /// Discover user-level configuration file from platform-specific config directory.
2601    /// Returns the first configuration file found in the user config directory.
2602    #[cfg(feature = "native")]
2603    fn user_configuration_path() -> Option<std::path::PathBuf> {
2604        use etcetera::{BaseStrategy, choose_base_strategy};
2605
2606        match choose_base_strategy() {
2607            Ok(strategy) => {
2608                let config_dir = strategy.config_dir();
2609                Self::user_configuration_path_impl(&config_dir)
2610            }
2611            Err(e) => {
2612                log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
2613                None
2614            }
2615        }
2616    }
2617
2618    /// Stub for WASM builds - user config not supported
2619    #[cfg(not(feature = "native"))]
2620    fn user_configuration_path() -> Option<std::path::PathBuf> {
2621        None
2622    }
2623
2624    /// Load an explicit config file (standalone, no user config merging)
2625    fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
2626        let path_obj = Path::new(path);
2627        let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
2628        let path_str = path.to_string();
2629
2630        log::debug!("[rumdl-config] Loading explicit config file: {filename}");
2631
2632        // Find project root by walking up from config location looking for .git
2633        if let Some(config_parent) = path_obj.parent() {
2634            let project_root = Self::find_project_root_from(config_parent);
2635            log::debug!(
2636                "[rumdl-config] Project root (from explicit config): {}",
2637                project_root.display()
2638            );
2639            sourced_config.project_root = Some(project_root);
2640        }
2641
2642        // Known markdownlint config files
2643        const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
2644
2645        if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
2646            let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2647                source: e,
2648                path: path_str.clone(),
2649            })?;
2650            if filename == "pyproject.toml" {
2651                if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2652                    sourced_config.merge(fragment);
2653                    sourced_config.loaded_files.push(path_str);
2654                }
2655            } else {
2656                let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2657                sourced_config.merge(fragment);
2658                sourced_config.loaded_files.push(path_str);
2659            }
2660        } else if MARKDOWNLINT_FILENAMES.contains(&filename)
2661            || path_str.ends_with(".json")
2662            || path_str.ends_with(".jsonc")
2663            || path_str.ends_with(".yaml")
2664            || path_str.ends_with(".yml")
2665        {
2666            // Parse as markdownlint config (JSON/YAML)
2667            let fragment = load_from_markdownlint(&path_str)?;
2668            sourced_config.merge(fragment);
2669            sourced_config.loaded_files.push(path_str);
2670        } else {
2671            // Try TOML only
2672            let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2673                source: e,
2674                path: path_str.clone(),
2675            })?;
2676            let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2677            sourced_config.merge(fragment);
2678            sourced_config.loaded_files.push(path_str);
2679        }
2680
2681        Ok(())
2682    }
2683
2684    /// Load user config as fallback when no project config exists
2685    fn load_user_config_as_fallback(
2686        sourced_config: &mut Self,
2687        user_config_dir: Option<&Path>,
2688    ) -> Result<(), ConfigError> {
2689        let user_config_path = if let Some(dir) = user_config_dir {
2690            Self::user_configuration_path_impl(dir)
2691        } else {
2692            Self::user_configuration_path()
2693        };
2694
2695        if let Some(user_config_path) = user_config_path {
2696            let path_str = user_config_path.display().to_string();
2697            let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
2698
2699            log::debug!("[rumdl-config] Loading user config as fallback: {path_str}");
2700
2701            if filename == "pyproject.toml" {
2702                let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2703                    source: e,
2704                    path: path_str.clone(),
2705                })?;
2706                if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2707                    sourced_config.merge(fragment);
2708                    sourced_config.loaded_files.push(path_str);
2709                }
2710            } else {
2711                let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2712                    source: e,
2713                    path: path_str.clone(),
2714                })?;
2715                let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
2716                sourced_config.merge(fragment);
2717                sourced_config.loaded_files.push(path_str);
2718            }
2719        } else {
2720            log::debug!("[rumdl-config] No user configuration file found");
2721        }
2722
2723        Ok(())
2724    }
2725
2726    /// Internal implementation that accepts user config directory for testing
2727    #[doc(hidden)]
2728    pub fn load_with_discovery_impl(
2729        config_path: Option<&str>,
2730        cli_overrides: Option<&SourcedGlobalConfig>,
2731        skip_auto_discovery: bool,
2732        user_config_dir: Option<&Path>,
2733    ) -> Result<Self, ConfigError> {
2734        use std::env;
2735        log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
2736
2737        let mut sourced_config = SourcedConfig::default();
2738
2739        // Ruff model: Project config is standalone, user config is fallback only
2740        //
2741        // Priority order:
2742        // 1. If explicit config path provided → use ONLY that (standalone)
2743        // 2. Else if project config discovered → use ONLY that (standalone)
2744        // 3. Else if user config exists → use it as fallback
2745        // 4. CLI overrides always apply last
2746        //
2747        // This ensures project configs are reproducible across machines and
2748        // CI/local runs behave identically.
2749
2750        // Explicit config path always takes precedence
2751        if let Some(path) = config_path {
2752            // Explicit config path provided - use ONLY this config (standalone)
2753            log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
2754            Self::load_explicit_config(&mut sourced_config, path)?;
2755        } else if skip_auto_discovery {
2756            log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
2757            // No config loading, just apply CLI overrides at the end
2758        } else {
2759            // No explicit path - try auto-discovery
2760            log::debug!("[rumdl-config] No explicit config_path, searching default locations");
2761
2762            // Try to discover project config first
2763            if let Some((config_file, project_root)) = Self::discover_config_upward() {
2764                // Project config found - use ONLY this (standalone, no user config)
2765                let path_str = config_file.display().to_string();
2766                let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
2767
2768                log::debug!("[rumdl-config] Found project config: {path_str}");
2769                log::debug!("[rumdl-config] Project root: {}", project_root.display());
2770
2771                sourced_config.project_root = Some(project_root);
2772
2773                if filename == "pyproject.toml" {
2774                    let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2775                        source: e,
2776                        path: path_str.clone(),
2777                    })?;
2778                    if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2779                        sourced_config.merge(fragment);
2780                        sourced_config.loaded_files.push(path_str);
2781                    }
2782                } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
2783                    let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2784                        source: e,
2785                        path: path_str.clone(),
2786                    })?;
2787                    let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2788                    sourced_config.merge(fragment);
2789                    sourced_config.loaded_files.push(path_str);
2790                }
2791            } else {
2792                // No rumdl project config - try markdownlint config
2793                log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
2794
2795                if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward() {
2796                    let path_str = markdownlint_path.display().to_string();
2797                    log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
2798                    match load_from_markdownlint(&path_str) {
2799                        Ok(fragment) => {
2800                            sourced_config.merge(fragment);
2801                            sourced_config.loaded_files.push(path_str);
2802                        }
2803                        Err(_e) => {
2804                            log::debug!("[rumdl-config] Failed to load markdownlint config, trying user config");
2805                            Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
2806                        }
2807                    }
2808                } else {
2809                    // No project config at all - use user config as fallback
2810                    log::debug!("[rumdl-config] No project config found, using user config as fallback");
2811                    Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
2812                }
2813            }
2814        }
2815
2816        // Apply CLI overrides (highest precedence)
2817        if let Some(cli) = cli_overrides {
2818            sourced_config
2819                .global
2820                .enable
2821                .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
2822            sourced_config
2823                .global
2824                .disable
2825                .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
2826            sourced_config
2827                .global
2828                .exclude
2829                .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
2830            sourced_config
2831                .global
2832                .include
2833                .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
2834            sourced_config.global.respect_gitignore.merge_override(
2835                cli.respect_gitignore.value,
2836                ConfigSource::Cli,
2837                None,
2838                None,
2839            );
2840            sourced_config
2841                .global
2842                .fixable
2843                .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
2844            sourced_config
2845                .global
2846                .unfixable
2847                .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
2848            // No rule-specific CLI overrides implemented yet
2849        }
2850
2851        // Unknown keys are now collected during parsing and validated via validate_config_sourced()
2852
2853        Ok(sourced_config)
2854    }
2855
2856    /// Load and merge configurations from files and CLI overrides.
2857    /// If skip_auto_discovery is true, only explicit config paths are loaded.
2858    pub fn load_with_discovery(
2859        config_path: Option<&str>,
2860        cli_overrides: Option<&SourcedGlobalConfig>,
2861        skip_auto_discovery: bool,
2862    ) -> Result<Self, ConfigError> {
2863        Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
2864    }
2865
2866    /// Validate the configuration against a rule registry.
2867    ///
2868    /// This method transitions the config from `ConfigLoaded` to `ConfigValidated` state,
2869    /// enabling conversion to `Config`. Validation warnings are stored in the config
2870    /// and can be displayed to the user.
2871    ///
2872    /// # Example
2873    ///
2874    /// ```ignore
2875    /// let loaded = SourcedConfig::load_with_discovery(path, None, false)?;
2876    /// let validated = loaded.validate(&registry)?;
2877    /// let config: Config = validated.into();
2878    /// ```
2879    pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
2880        let warnings = validate_config_sourced_internal(&self, registry);
2881
2882        Ok(SourcedConfig {
2883            global: self.global,
2884            per_file_ignores: self.per_file_ignores,
2885            rules: self.rules,
2886            loaded_files: self.loaded_files,
2887            unknown_keys: self.unknown_keys,
2888            project_root: self.project_root,
2889            validation_warnings: warnings,
2890            _state: PhantomData,
2891        })
2892    }
2893
2894    /// Validate and convert to Config in one step (convenience method).
2895    ///
2896    /// This combines `validate()` and `into()` for callers who want the
2897    /// validation warnings separately.
2898    pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
2899        let validated = self.validate(registry)?;
2900        let warnings = validated.validation_warnings.clone();
2901        Ok((validated.into(), warnings))
2902    }
2903
2904    /// Skip validation and convert directly to ConfigValidated state.
2905    ///
2906    /// # Safety
2907    ///
2908    /// This method bypasses validation. Use only when:
2909    /// - You've already validated via `validate_config_sourced()`
2910    /// - You're in test code that doesn't need validation
2911    /// - You're migrating legacy code and will add proper validation later
2912    ///
2913    /// Prefer `validate()` for new code.
2914    pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
2915        SourcedConfig {
2916            global: self.global,
2917            per_file_ignores: self.per_file_ignores,
2918            rules: self.rules,
2919            loaded_files: self.loaded_files,
2920            unknown_keys: self.unknown_keys,
2921            project_root: self.project_root,
2922            validation_warnings: Vec::new(),
2923            _state: PhantomData,
2924        }
2925    }
2926}
2927
2928/// Convert a validated configuration to the final Config type.
2929///
2930/// This implementation only exists for `SourcedConfig<ConfigValidated>`,
2931/// ensuring that validation must occur before conversion.
2932impl From<SourcedConfig<ConfigValidated>> for Config {
2933    fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
2934        let mut rules = BTreeMap::new();
2935        for (rule_name, sourced_rule_cfg) in sourced.rules {
2936            // Normalize rule name to uppercase for case-insensitive lookup
2937            let normalized_rule_name = rule_name.to_ascii_uppercase();
2938            let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
2939            let mut values = BTreeMap::new();
2940            for (key, sourced_val) in sourced_rule_cfg.values {
2941                values.insert(key, sourced_val.value);
2942            }
2943            rules.insert(normalized_rule_name, RuleConfig { severity, values });
2944        }
2945        #[allow(deprecated)]
2946        let global = GlobalConfig {
2947            enable: sourced.global.enable.value,
2948            disable: sourced.global.disable.value,
2949            exclude: sourced.global.exclude.value,
2950            include: sourced.global.include.value,
2951            respect_gitignore: sourced.global.respect_gitignore.value,
2952            line_length: sourced.global.line_length.value,
2953            output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
2954            fixable: sourced.global.fixable.value,
2955            unfixable: sourced.global.unfixable.value,
2956            flavor: sourced.global.flavor.value,
2957            force_exclude: sourced.global.force_exclude.value,
2958            cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
2959            cache: sourced.global.cache.value,
2960        };
2961        Config {
2962            global,
2963            per_file_ignores: sourced.per_file_ignores.value,
2964            rules,
2965            project_root: sourced.project_root,
2966        }
2967    }
2968}
2969
2970/// Registry of all known rules and their config schemas
2971pub struct RuleRegistry {
2972    /// Map of rule name (e.g. "MD013") to set of valid config keys and their TOML value types
2973    pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
2974    /// Map of rule name to config key aliases
2975    pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
2976}
2977
2978impl RuleRegistry {
2979    /// Build a registry from a list of rules
2980    pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
2981        let mut rule_schemas = std::collections::BTreeMap::new();
2982        let mut rule_aliases = std::collections::BTreeMap::new();
2983
2984        for rule in rules {
2985            let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
2986                let norm_name = normalize_key(&name); // Normalize the name from default_config_section
2987                rule_schemas.insert(norm_name.clone(), table);
2988                norm_name
2989            } else {
2990                let norm_name = normalize_key(rule.name()); // Normalize the name from rule.name()
2991                rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
2992                norm_name
2993            };
2994
2995            // Store aliases if the rule provides them
2996            if let Some(aliases) = rule.config_aliases() {
2997                rule_aliases.insert(norm_name, aliases);
2998            }
2999        }
3000
3001        RuleRegistry {
3002            rule_schemas,
3003            rule_aliases,
3004        }
3005    }
3006
3007    /// Get all known rule names
3008    pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
3009        self.rule_schemas.keys().cloned().collect()
3010    }
3011
3012    /// Get the valid configuration keys for a rule, including both original and normalized variants
3013    pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
3014        self.rule_schemas.get(rule).map(|schema| {
3015            let mut all_keys = std::collections::BTreeSet::new();
3016
3017            // Always allow 'severity' for any rule
3018            all_keys.insert("severity".to_string());
3019
3020            // Add original keys from schema
3021            for key in schema.keys() {
3022                all_keys.insert(key.clone());
3023            }
3024
3025            // Add normalized variants for markdownlint compatibility
3026            for key in schema.keys() {
3027                // Add kebab-case variant
3028                all_keys.insert(key.replace('_', "-"));
3029                // Add snake_case variant
3030                all_keys.insert(key.replace('-', "_"));
3031                // Add normalized variant
3032                all_keys.insert(normalize_key(key));
3033            }
3034
3035            // Add any aliases defined by the rule
3036            if let Some(aliases) = self.rule_aliases.get(rule) {
3037                for alias_key in aliases.keys() {
3038                    all_keys.insert(alias_key.clone());
3039                    // Also add normalized variants of the alias
3040                    all_keys.insert(alias_key.replace('_', "-"));
3041                    all_keys.insert(alias_key.replace('-', "_"));
3042                    all_keys.insert(normalize_key(alias_key));
3043                }
3044            }
3045
3046            all_keys
3047        })
3048    }
3049
3050    /// Get the expected value type for a rule's configuration key, trying variants
3051    pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
3052        if let Some(schema) = self.rule_schemas.get(rule) {
3053            // Check if this key is an alias
3054            if let Some(aliases) = self.rule_aliases.get(rule)
3055                && let Some(canonical_key) = aliases.get(key)
3056            {
3057                // Use the canonical key for schema lookup
3058                if let Some(value) = schema.get(canonical_key) {
3059                    return Some(value);
3060                }
3061            }
3062
3063            // Try the original key
3064            if let Some(value) = schema.get(key) {
3065                return Some(value);
3066            }
3067
3068            // Try key variants
3069            let key_variants = [
3070                key.replace('-', "_"), // Convert kebab-case to snake_case
3071                key.replace('_', "-"), // Convert snake_case to kebab-case
3072                normalize_key(key),    // Normalized key (lowercase, kebab-case)
3073            ];
3074
3075            for variant in &key_variants {
3076                if let Some(value) = schema.get(variant) {
3077                    return Some(value);
3078                }
3079            }
3080        }
3081        None
3082    }
3083
3084    /// Resolve any rule name (canonical or alias) to its canonical form
3085    /// Returns None if the rule name is not recognized
3086    ///
3087    /// Resolution order:
3088    /// 1. Direct canonical name match
3089    /// 2. Static aliases (built-in markdownlint aliases)
3090    pub fn resolve_rule_name(&self, name: &str) -> Option<String> {
3091        // Try normalized canonical name first
3092        let normalized = normalize_key(name);
3093        if self.rule_schemas.contains_key(&normalized) {
3094            return Some(normalized);
3095        }
3096
3097        // Try static alias resolution (O(1) perfect hash lookup)
3098        resolve_rule_name_alias(name).map(|s| s.to_string())
3099    }
3100}
3101
3102/// Compile-time perfect hash map for O(1) rule alias lookups
3103/// Uses phf for zero-cost abstraction - compiles to direct jumps
3104pub static RULE_ALIAS_MAP: phf::Map<&'static str, &'static str> = phf::phf_map! {
3105    // Canonical names (identity mapping for consistency)
3106    "MD001" => "MD001",
3107    "MD003" => "MD003",
3108    "MD004" => "MD004",
3109    "MD005" => "MD005",
3110    "MD007" => "MD007",
3111    "MD009" => "MD009",
3112    "MD010" => "MD010",
3113    "MD011" => "MD011",
3114    "MD012" => "MD012",
3115    "MD013" => "MD013",
3116    "MD014" => "MD014",
3117    "MD018" => "MD018",
3118    "MD019" => "MD019",
3119    "MD020" => "MD020",
3120    "MD021" => "MD021",
3121    "MD022" => "MD022",
3122    "MD023" => "MD023",
3123    "MD024" => "MD024",
3124    "MD025" => "MD025",
3125    "MD026" => "MD026",
3126    "MD027" => "MD027",
3127    "MD028" => "MD028",
3128    "MD029" => "MD029",
3129    "MD030" => "MD030",
3130    "MD031" => "MD031",
3131    "MD032" => "MD032",
3132    "MD033" => "MD033",
3133    "MD034" => "MD034",
3134    "MD035" => "MD035",
3135    "MD036" => "MD036",
3136    "MD037" => "MD037",
3137    "MD038" => "MD038",
3138    "MD039" => "MD039",
3139    "MD040" => "MD040",
3140    "MD041" => "MD041",
3141    "MD042" => "MD042",
3142    "MD043" => "MD043",
3143    "MD044" => "MD044",
3144    "MD045" => "MD045",
3145    "MD046" => "MD046",
3146    "MD047" => "MD047",
3147    "MD048" => "MD048",
3148    "MD049" => "MD049",
3149    "MD050" => "MD050",
3150    "MD051" => "MD051",
3151    "MD052" => "MD052",
3152    "MD053" => "MD053",
3153    "MD054" => "MD054",
3154    "MD055" => "MD055",
3155    "MD056" => "MD056",
3156    "MD057" => "MD057",
3157    "MD058" => "MD058",
3158    "MD059" => "MD059",
3159    "MD060" => "MD060",
3160    "MD061" => "MD061",
3161    "MD062" => "MD062",
3162    "MD063" => "MD063",
3163    "MD064" => "MD064",
3164    "MD065" => "MD065",
3165    "MD066" => "MD066",
3166    "MD067" => "MD067",
3167    "MD068" => "MD068",
3168    "MD069" => "MD069",
3169    "MD070" => "MD070",
3170    "MD071" => "MD071",
3171    "MD072" => "MD072",
3172
3173    // Aliases (hyphen format)
3174    "HEADING-INCREMENT" => "MD001",
3175    "HEADING-STYLE" => "MD003",
3176    "UL-STYLE" => "MD004",
3177    "LIST-INDENT" => "MD005",
3178    "UL-INDENT" => "MD007",
3179    "NO-TRAILING-SPACES" => "MD009",
3180    "NO-HARD-TABS" => "MD010",
3181    "NO-REVERSED-LINKS" => "MD011",
3182    "NO-MULTIPLE-BLANKS" => "MD012",
3183    "LINE-LENGTH" => "MD013",
3184    "COMMANDS-SHOW-OUTPUT" => "MD014",
3185    "NO-MISSING-SPACE-ATX" => "MD018",
3186    "NO-MULTIPLE-SPACE-ATX" => "MD019",
3187    "NO-MISSING-SPACE-CLOSED-ATX" => "MD020",
3188    "NO-MULTIPLE-SPACE-CLOSED-ATX" => "MD021",
3189    "BLANKS-AROUND-HEADINGS" => "MD022",
3190    "HEADING-START-LEFT" => "MD023",
3191    "NO-DUPLICATE-HEADING" => "MD024",
3192    "SINGLE-TITLE" => "MD025",
3193    "SINGLE-H1" => "MD025",
3194    "NO-TRAILING-PUNCTUATION" => "MD026",
3195    "NO-MULTIPLE-SPACE-BLOCKQUOTE" => "MD027",
3196    "NO-BLANKS-BLOCKQUOTE" => "MD028",
3197    "OL-PREFIX" => "MD029",
3198    "LIST-MARKER-SPACE" => "MD030",
3199    "BLANKS-AROUND-FENCES" => "MD031",
3200    "BLANKS-AROUND-LISTS" => "MD032",
3201    "NO-INLINE-HTML" => "MD033",
3202    "NO-BARE-URLS" => "MD034",
3203    "HR-STYLE" => "MD035",
3204    "NO-EMPHASIS-AS-HEADING" => "MD036",
3205    "NO-SPACE-IN-EMPHASIS" => "MD037",
3206    "NO-SPACE-IN-CODE" => "MD038",
3207    "NO-SPACE-IN-LINKS" => "MD039",
3208    "FENCED-CODE-LANGUAGE" => "MD040",
3209    "FIRST-LINE-HEADING" => "MD041",
3210    "FIRST-LINE-H1" => "MD041",
3211    "NO-EMPTY-LINKS" => "MD042",
3212    "REQUIRED-HEADINGS" => "MD043",
3213    "PROPER-NAMES" => "MD044",
3214    "NO-ALT-TEXT" => "MD045",
3215    "CODE-BLOCK-STYLE" => "MD046",
3216    "SINGLE-TRAILING-NEWLINE" => "MD047",
3217    "CODE-FENCE-STYLE" => "MD048",
3218    "EMPHASIS-STYLE" => "MD049",
3219    "STRONG-STYLE" => "MD050",
3220    "LINK-FRAGMENTS" => "MD051",
3221    "REFERENCE-LINKS-IMAGES" => "MD052",
3222    "LINK-IMAGE-REFERENCE-DEFINITIONS" => "MD053",
3223    "LINK-IMAGE-STYLE" => "MD054",
3224    "TABLE-PIPE-STYLE" => "MD055",
3225    "TABLE-COLUMN-COUNT" => "MD056",
3226    "EXISTING-RELATIVE-LINKS" => "MD057",
3227    "BLANKS-AROUND-TABLES" => "MD058",
3228    "TABLE-CELL-ALIGNMENT" => "MD059",
3229    "TABLE-FORMAT" => "MD060",
3230    "FORBIDDEN-TERMS" => "MD061",
3231    "LINK-DESTINATION-WHITESPACE" => "MD062",
3232    "HEADING-CAPITALIZATION" => "MD063",
3233    "NO-MULTIPLE-CONSECUTIVE-SPACES" => "MD064",
3234    "BLANKS-AROUND-HORIZONTAL-RULES" => "MD065",
3235    "FOOTNOTE-VALIDATION" => "MD066",
3236    "FOOTNOTE-DEFINITION-ORDER" => "MD067",
3237    "EMPTY-FOOTNOTE-DEFINITION" => "MD068",
3238    "NO-DUPLICATE-LIST-MARKERS" => "MD069",
3239    "NESTED-CODE-FENCE" => "MD070",
3240    "BLANK-LINE-AFTER-FRONTMATTER" => "MD071",
3241    "FRONTMATTER-KEY-SORT" => "MD072",
3242};
3243
3244/// Resolve a rule name alias to its canonical form with O(1) perfect hash lookup
3245/// Converts rule aliases (like "ul-style", "line-length") to canonical IDs (like "MD004", "MD013")
3246/// Returns None if the rule name is not recognized
3247pub fn resolve_rule_name_alias(key: &str) -> Option<&'static str> {
3248    // Normalize: uppercase and replace underscores with hyphens
3249    let normalized_key = key.to_ascii_uppercase().replace('_', "-");
3250
3251    // O(1) perfect hash lookup
3252    RULE_ALIAS_MAP.get(normalized_key.as_str()).copied()
3253}
3254
3255/// Resolves a rule name to its canonical ID, supporting both rule IDs and aliases.
3256/// Returns the canonical ID (e.g., "MD001") for any valid input:
3257/// - "MD001" → "MD001" (canonical)
3258/// - "heading-increment" → "MD001" (alias)
3259/// - "HEADING_INCREMENT" → "MD001" (case-insensitive, underscore variant)
3260///
3261/// For unknown names, falls back to normalization (uppercase for MDxxx pattern, otherwise kebab-case).
3262pub fn resolve_rule_name(name: &str) -> String {
3263    resolve_rule_name_alias(name)
3264        .map(|s| s.to_string())
3265        .unwrap_or_else(|| normalize_key(name))
3266}
3267
3268/// Resolves a comma-separated list of rule names to canonical IDs.
3269/// Handles CLI input like "MD001,line-length,heading-increment".
3270/// Empty entries and whitespace are filtered out.
3271pub fn resolve_rule_names(input: &str) -> std::collections::HashSet<String> {
3272    input
3273        .split(',')
3274        .map(|s| s.trim())
3275        .filter(|s| !s.is_empty())
3276        .map(resolve_rule_name)
3277        .collect()
3278}
3279
3280/// Validates rule names from CLI flags against the known rule set.
3281/// Returns warnings for unknown rules with "did you mean" suggestions.
3282///
3283/// This provides consistent validation between config files and CLI flags.
3284/// Unknown rules are warned about but don't cause failures.
3285pub fn validate_cli_rule_names(
3286    enable: Option<&str>,
3287    disable: Option<&str>,
3288    extend_enable: Option<&str>,
3289    extend_disable: Option<&str>,
3290) -> Vec<ConfigValidationWarning> {
3291    let mut warnings = Vec::new();
3292    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
3293
3294    let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
3295        for name in input.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
3296            // Check for special "all" value (case-insensitive)
3297            if name.eq_ignore_ascii_case("all") {
3298                continue;
3299            }
3300            if resolve_rule_name_alias(name).is_none() {
3301                let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
3302                    let formatted = if suggestion.starts_with("MD") {
3303                        suggestion
3304                    } else {
3305                        suggestion.to_lowercase()
3306                    };
3307                    format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
3308                } else {
3309                    format!("Unknown rule in {flag_name}: {name}")
3310                };
3311                warnings.push(ConfigValidationWarning {
3312                    message,
3313                    rule: Some(name.to_string()),
3314                    key: None,
3315                });
3316            }
3317        }
3318    };
3319
3320    if let Some(e) = enable {
3321        validate_list(e, "--enable", &mut warnings);
3322    }
3323    if let Some(d) = disable {
3324        validate_list(d, "--disable", &mut warnings);
3325    }
3326    if let Some(ee) = extend_enable {
3327        validate_list(ee, "--extend-enable", &mut warnings);
3328    }
3329    if let Some(ed) = extend_disable {
3330        validate_list(ed, "--extend-disable", &mut warnings);
3331    }
3332
3333    warnings
3334}
3335
3336/// Checks if a rule name (or alias) is valid.
3337/// Returns true if the name resolves to a known rule.
3338/// Handles the special "all" value and all aliases.
3339pub fn is_valid_rule_name(name: &str) -> bool {
3340    // Check for special "all" value (case-insensitive)
3341    if name.eq_ignore_ascii_case("all") {
3342        return true;
3343    }
3344    resolve_rule_name_alias(name).is_some()
3345}
3346
3347/// Represents a config validation warning or error
3348#[derive(Debug, Clone)]
3349pub struct ConfigValidationWarning {
3350    pub message: String,
3351    pub rule: Option<String>,
3352    pub key: Option<String>,
3353}
3354
3355/// Internal validation function that works with any SourcedConfig state.
3356/// This is used by both the public `validate_config_sourced` and the typestate `validate()` method.
3357fn validate_config_sourced_internal<S>(
3358    sourced: &SourcedConfig<S>,
3359    registry: &RuleRegistry,
3360) -> Vec<ConfigValidationWarning> {
3361    let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
3362
3363    // Validate enable/disable arrays in [global] section
3364    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
3365
3366    for rule_name in &sourced.global.enable.value {
3367        if !is_valid_rule_name(rule_name) {
3368            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
3369                let formatted = if suggestion.starts_with("MD") {
3370                    suggestion
3371                } else {
3372                    suggestion.to_lowercase()
3373                };
3374                format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
3375            } else {
3376                format!("Unknown rule in global.enable: {rule_name}")
3377            };
3378            warnings.push(ConfigValidationWarning {
3379                message,
3380                rule: Some(rule_name.clone()),
3381                key: None,
3382            });
3383        }
3384    }
3385
3386    for rule_name in &sourced.global.disable.value {
3387        if !is_valid_rule_name(rule_name) {
3388            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
3389                let formatted = if suggestion.starts_with("MD") {
3390                    suggestion
3391                } else {
3392                    suggestion.to_lowercase()
3393                };
3394                format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
3395            } else {
3396                format!("Unknown rule in global.disable: {rule_name}")
3397            };
3398            warnings.push(ConfigValidationWarning {
3399                message,
3400                rule: Some(rule_name.clone()),
3401                key: None,
3402            });
3403        }
3404    }
3405
3406    warnings
3407}
3408
3409/// Core validation implementation that doesn't depend on SourcedConfig type parameter.
3410fn validate_config_sourced_impl(
3411    rules: &BTreeMap<String, SourcedRuleConfig>,
3412    unknown_keys: &[(String, String, Option<String>)],
3413    registry: &RuleRegistry,
3414) -> Vec<ConfigValidationWarning> {
3415    let mut warnings = Vec::new();
3416    let known_rules = registry.rule_names();
3417    // 1. Unknown rules
3418    for rule in rules.keys() {
3419        if !known_rules.contains(rule) {
3420            // Include both canonical names AND aliases for fuzzy matching
3421            let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
3422            let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
3423                // Convert alias suggestions to lowercase for better UX (MD001 stays uppercase, ul-style becomes lowercase)
3424                let formatted_suggestion = if suggestion.starts_with("MD") {
3425                    suggestion
3426                } else {
3427                    suggestion.to_lowercase()
3428                };
3429                format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
3430            } else {
3431                format!("Unknown rule in config: {rule}")
3432            };
3433            warnings.push(ConfigValidationWarning {
3434                message,
3435                rule: Some(rule.clone()),
3436                key: None,
3437            });
3438        }
3439    }
3440    // 2. Unknown options and type mismatches
3441    for (rule, rule_cfg) in rules {
3442        if let Some(valid_keys) = registry.config_keys_for(rule) {
3443            for key in rule_cfg.values.keys() {
3444                if !valid_keys.contains(key) {
3445                    let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
3446                    let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
3447                        format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
3448                    } else {
3449                        format!("Unknown option for rule {rule}: {key}")
3450                    };
3451                    warnings.push(ConfigValidationWarning {
3452                        message,
3453                        rule: Some(rule.clone()),
3454                        key: Some(key.clone()),
3455                    });
3456                } else {
3457                    // Type check: compare type of value to type of default
3458                    if let Some(expected) = registry.expected_value_for(rule, key) {
3459                        let actual = &rule_cfg.values[key].value;
3460                        if !toml_value_type_matches(expected, actual) {
3461                            warnings.push(ConfigValidationWarning {
3462                                message: format!(
3463                                    "Type mismatch for {}.{}: expected {}, got {}",
3464                                    rule,
3465                                    key,
3466                                    toml_type_name(expected),
3467                                    toml_type_name(actual)
3468                                ),
3469                                rule: Some(rule.clone()),
3470                                key: Some(key.clone()),
3471                            });
3472                        }
3473                    }
3474                }
3475            }
3476        }
3477    }
3478    // 3. Unknown global options (from unknown_keys)
3479    let known_global_keys = vec![
3480        "enable".to_string(),
3481        "disable".to_string(),
3482        "include".to_string(),
3483        "exclude".to_string(),
3484        "respect-gitignore".to_string(),
3485        "line-length".to_string(),
3486        "fixable".to_string(),
3487        "unfixable".to_string(),
3488        "flavor".to_string(),
3489        "force-exclude".to_string(),
3490        "output-format".to_string(),
3491        "cache-dir".to_string(),
3492        "cache".to_string(),
3493    ];
3494
3495    for (section, key, file_path) in unknown_keys {
3496        if section.contains("[global]") || section.contains("[tool.rumdl]") {
3497            let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
3498                if let Some(path) = file_path {
3499                    format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
3500                } else {
3501                    format!("Unknown global option: {key} (did you mean: {suggestion}?)")
3502                }
3503            } else if let Some(path) = file_path {
3504                format!("Unknown global option in {path}: {key}")
3505            } else {
3506                format!("Unknown global option: {key}")
3507            };
3508            warnings.push(ConfigValidationWarning {
3509                message,
3510                rule: None,
3511                key: Some(key.clone()),
3512            });
3513        } else if !key.is_empty() {
3514            // This is an unknown rule section (key is empty means it's a section header)
3515            continue;
3516        } else {
3517            // Unknown rule section - suggest similar rule names
3518            let rule_name = section.trim_matches(|c| c == '[' || c == ']');
3519            let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
3520            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
3521                // Convert alias suggestions to lowercase for better UX (MD001 stays uppercase, ul-style becomes lowercase)
3522                let formatted_suggestion = if suggestion.starts_with("MD") {
3523                    suggestion
3524                } else {
3525                    suggestion.to_lowercase()
3526                };
3527                if let Some(path) = file_path {
3528                    format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
3529                } else {
3530                    format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
3531                }
3532            } else if let Some(path) = file_path {
3533                format!("Unknown rule in {path}: {rule_name}")
3534            } else {
3535                format!("Unknown rule in config: {rule_name}")
3536            };
3537            warnings.push(ConfigValidationWarning {
3538                message,
3539                rule: None,
3540                key: None,
3541            });
3542        }
3543    }
3544    warnings
3545}
3546
3547/// Validate a loaded config against the rule registry, using SourcedConfig for unknown key tracking.
3548///
3549/// This is the legacy API that works with `SourcedConfig<ConfigLoaded>`.
3550/// For new code, prefer using `sourced.validate(&registry)` which returns a
3551/// `SourcedConfig<ConfigValidated>` that can be converted to `Config`.
3552pub fn validate_config_sourced(
3553    sourced: &SourcedConfig<ConfigLoaded>,
3554    registry: &RuleRegistry,
3555) -> Vec<ConfigValidationWarning> {
3556    validate_config_sourced_internal(sourced, registry)
3557}
3558
3559/// Validate a config that has already been validated (no-op, returns stored warnings).
3560///
3561/// This exists for API consistency - validated configs already have their warnings stored.
3562pub fn validate_config_sourced_validated(
3563    sourced: &SourcedConfig<ConfigValidated>,
3564    _registry: &RuleRegistry,
3565) -> Vec<ConfigValidationWarning> {
3566    sourced.validation_warnings.clone()
3567}
3568
3569fn toml_type_name(val: &toml::Value) -> &'static str {
3570    match val {
3571        toml::Value::String(_) => "string",
3572        toml::Value::Integer(_) => "integer",
3573        toml::Value::Float(_) => "float",
3574        toml::Value::Boolean(_) => "boolean",
3575        toml::Value::Array(_) => "array",
3576        toml::Value::Table(_) => "table",
3577        toml::Value::Datetime(_) => "datetime",
3578    }
3579}
3580
3581/// Calculate Levenshtein distance between two strings (simple implementation)
3582fn levenshtein_distance(s1: &str, s2: &str) -> usize {
3583    let len1 = s1.len();
3584    let len2 = s2.len();
3585
3586    if len1 == 0 {
3587        return len2;
3588    }
3589    if len2 == 0 {
3590        return len1;
3591    }
3592
3593    let s1_chars: Vec<char> = s1.chars().collect();
3594    let s2_chars: Vec<char> = s2.chars().collect();
3595
3596    let mut prev_row: Vec<usize> = (0..=len2).collect();
3597    let mut curr_row = vec![0; len2 + 1];
3598
3599    for i in 1..=len1 {
3600        curr_row[0] = i;
3601        for j in 1..=len2 {
3602            let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
3603            curr_row[j] = (prev_row[j] + 1)          // deletion
3604                .min(curr_row[j - 1] + 1)            // insertion
3605                .min(prev_row[j - 1] + cost); // substitution
3606        }
3607        std::mem::swap(&mut prev_row, &mut curr_row);
3608    }
3609
3610    prev_row[len2]
3611}
3612
3613/// Suggest a similar key from a list of valid keys using fuzzy matching
3614pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
3615    let unknown_lower = unknown.to_lowercase();
3616    let max_distance = 2.max(unknown.len() / 3); // Allow up to 2 edits or 30% of string length
3617
3618    let mut best_match: Option<(String, usize)> = None;
3619
3620    for valid in valid_keys {
3621        let valid_lower = valid.to_lowercase();
3622        let distance = levenshtein_distance(&unknown_lower, &valid_lower);
3623
3624        if distance <= max_distance {
3625            if let Some((_, best_dist)) = &best_match {
3626                if distance < *best_dist {
3627                    best_match = Some((valid.clone(), distance));
3628                }
3629            } else {
3630                best_match = Some((valid.clone(), distance));
3631            }
3632        }
3633    }
3634
3635    best_match.map(|(key, _)| key)
3636}
3637
3638fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
3639    use toml::Value::*;
3640    match (expected, actual) {
3641        (String(_), String(_)) => true,
3642        (Integer(_), Integer(_)) => true,
3643        (Float(_), Float(_)) => true,
3644        (Boolean(_), Boolean(_)) => true,
3645        (Array(_), Array(_)) => true,
3646        (Table(_), Table(_)) => true,
3647        (Datetime(_), Datetime(_)) => true,
3648        // Allow integer for float
3649        (Float(_), Integer(_)) => true,
3650        _ => false,
3651    }
3652}
3653
3654/// Parses pyproject.toml content and extracts the [tool.rumdl] section if present.
3655fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
3656    let doc: toml::Value =
3657        toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
3658    let mut fragment = SourcedConfigFragment::default();
3659    let source = ConfigSource::PyprojectToml;
3660    let file = Some(path.to_string());
3661
3662    // Create rule registry for alias resolution
3663    let all_rules = rules::all_rules(&Config::default());
3664    let registry = RuleRegistry::from_rules(&all_rules);
3665
3666    // 1. Handle [tool.rumdl] and [tool.rumdl.global] sections
3667    if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
3668        && let Some(rumdl_table) = rumdl_config.as_table()
3669    {
3670        // Helper function to extract global config from a table
3671        let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
3672            // Extract global options from the given table
3673            if let Some(enable) = table.get("enable")
3674                && let Ok(values) = Vec::<String>::deserialize(enable.clone())
3675            {
3676                // Resolve rule name aliases (e.g., "ul-style" -> "MD004")
3677                let normalized_values = values
3678                    .into_iter()
3679                    .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
3680                    .collect();
3681                fragment
3682                    .global
3683                    .enable
3684                    .push_override(normalized_values, source, file.clone(), None);
3685            }
3686
3687            if let Some(disable) = table.get("disable")
3688                && let Ok(values) = Vec::<String>::deserialize(disable.clone())
3689            {
3690                // Resolve rule name aliases
3691                let normalized_values: Vec<String> = values
3692                    .into_iter()
3693                    .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
3694                    .collect();
3695                fragment
3696                    .global
3697                    .disable
3698                    .push_override(normalized_values, source, file.clone(), None);
3699            }
3700
3701            if let Some(include) = table.get("include")
3702                && let Ok(values) = Vec::<String>::deserialize(include.clone())
3703            {
3704                fragment
3705                    .global
3706                    .include
3707                    .push_override(values, source, file.clone(), None);
3708            }
3709
3710            if let Some(exclude) = table.get("exclude")
3711                && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
3712            {
3713                fragment
3714                    .global
3715                    .exclude
3716                    .push_override(values, source, file.clone(), None);
3717            }
3718
3719            if let Some(respect_gitignore) = table
3720                .get("respect-gitignore")
3721                .or_else(|| table.get("respect_gitignore"))
3722                && let Ok(value) = bool::deserialize(respect_gitignore.clone())
3723            {
3724                fragment
3725                    .global
3726                    .respect_gitignore
3727                    .push_override(value, source, file.clone(), None);
3728            }
3729
3730            if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
3731                && let Ok(value) = bool::deserialize(force_exclude.clone())
3732            {
3733                fragment
3734                    .global
3735                    .force_exclude
3736                    .push_override(value, source, file.clone(), None);
3737            }
3738
3739            if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
3740                && let Ok(value) = String::deserialize(output_format.clone())
3741            {
3742                if fragment.global.output_format.is_none() {
3743                    fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
3744                } else {
3745                    fragment
3746                        .global
3747                        .output_format
3748                        .as_mut()
3749                        .unwrap()
3750                        .push_override(value, source, file.clone(), None);
3751                }
3752            }
3753
3754            if let Some(fixable) = table.get("fixable")
3755                && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
3756            {
3757                let normalized_values = values
3758                    .into_iter()
3759                    .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
3760                    .collect();
3761                fragment
3762                    .global
3763                    .fixable
3764                    .push_override(normalized_values, source, file.clone(), None);
3765            }
3766
3767            if let Some(unfixable) = table.get("unfixable")
3768                && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
3769            {
3770                let normalized_values = values
3771                    .into_iter()
3772                    .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
3773                    .collect();
3774                fragment
3775                    .global
3776                    .unfixable
3777                    .push_override(normalized_values, source, file.clone(), None);
3778            }
3779
3780            if let Some(flavor) = table.get("flavor")
3781                && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
3782            {
3783                fragment.global.flavor.push_override(value, source, file.clone(), None);
3784            }
3785
3786            // Handle line-length special case - this should set the global line_length
3787            if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
3788                && let Ok(value) = u64::deserialize(line_length.clone())
3789            {
3790                fragment
3791                    .global
3792                    .line_length
3793                    .push_override(LineLength::new(value as usize), source, file.clone(), None);
3794
3795                // Also add to MD013 rule config for backward compatibility
3796                let norm_md013_key = normalize_key("MD013");
3797                let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
3798                let norm_line_length_key = normalize_key("line-length");
3799                let sv = rule_entry
3800                    .values
3801                    .entry(norm_line_length_key)
3802                    .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
3803                sv.push_override(line_length.clone(), source, file.clone(), None);
3804            }
3805
3806            if let Some(cache_dir) = table.get("cache-dir").or_else(|| table.get("cache_dir"))
3807                && let Ok(value) = String::deserialize(cache_dir.clone())
3808            {
3809                if fragment.global.cache_dir.is_none() {
3810                    fragment.global.cache_dir = Some(SourcedValue::new(value.clone(), source));
3811                } else {
3812                    fragment
3813                        .global
3814                        .cache_dir
3815                        .as_mut()
3816                        .unwrap()
3817                        .push_override(value, source, file.clone(), None);
3818                }
3819            }
3820
3821            if let Some(cache) = table.get("cache")
3822                && let Ok(value) = bool::deserialize(cache.clone())
3823            {
3824                fragment.global.cache.push_override(value, source, file.clone(), None);
3825            }
3826        };
3827
3828        // First, check for [tool.rumdl.global] section
3829        if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
3830            extract_global_config(&mut fragment, global_table);
3831        }
3832
3833        // Also extract global options from [tool.rumdl] directly (for flat structure)
3834        extract_global_config(&mut fragment, rumdl_table);
3835
3836        // --- Extract per-file-ignores configurations ---
3837        // Check both hyphenated and underscored versions for compatibility
3838        let per_file_ignores_key = rumdl_table
3839            .get("per-file-ignores")
3840            .or_else(|| rumdl_table.get("per_file_ignores"));
3841
3842        if let Some(per_file_ignores_value) = per_file_ignores_key
3843            && let Some(per_file_table) = per_file_ignores_value.as_table()
3844        {
3845            let mut per_file_map = HashMap::new();
3846            for (pattern, rules_value) in per_file_table {
3847                if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
3848                    let normalized_rules = rules
3849                        .into_iter()
3850                        .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
3851                        .collect();
3852                    per_file_map.insert(pattern.clone(), normalized_rules);
3853                } else {
3854                    log::warn!(
3855                        "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {rules_value:?}"
3856                    );
3857                }
3858            }
3859            fragment
3860                .per_file_ignores
3861                .push_override(per_file_map, source, file.clone(), None);
3862        }
3863
3864        // --- Extract rule-specific configurations ---
3865        for (key, value) in rumdl_table {
3866            let norm_rule_key = normalize_key(key);
3867
3868            // Skip keys already handled as global or special cases
3869            // Note: Only skip these if they're NOT tables (rule sections are tables)
3870            let is_global_key = [
3871                "enable",
3872                "disable",
3873                "include",
3874                "exclude",
3875                "respect_gitignore",
3876                "respect-gitignore",
3877                "force_exclude",
3878                "force-exclude",
3879                "output_format",
3880                "output-format",
3881                "fixable",
3882                "unfixable",
3883                "per-file-ignores",
3884                "per_file_ignores",
3885                "global",
3886                "flavor",
3887                "cache_dir",
3888                "cache-dir",
3889                "cache",
3890            ]
3891            .contains(&norm_rule_key.as_str());
3892
3893            // Special handling for line-length: could be global config OR rule section
3894            let is_line_length_global =
3895                (norm_rule_key == "line-length" || norm_rule_key == "line_length") && !value.is_table();
3896
3897            if is_global_key || is_line_length_global {
3898                continue;
3899            }
3900
3901            // Try to resolve as a rule name (handles both canonical names and aliases)
3902            if let Some(resolved_rule_name) = registry.resolve_rule_name(key)
3903                && value.is_table()
3904                && let Some(rule_config_table) = value.as_table()
3905            {
3906                let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
3907                for (rk, rv) in rule_config_table {
3908                    let norm_rk = normalize_key(rk);
3909
3910                    // Special handling for severity - store in rule_entry.severity
3911                    if norm_rk == "severity" {
3912                        if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
3913                            if rule_entry.severity.is_none() {
3914                                rule_entry.severity = Some(SourcedValue::new(severity, source));
3915                            } else {
3916                                rule_entry.severity.as_mut().unwrap().push_override(
3917                                    severity,
3918                                    source,
3919                                    file.clone(),
3920                                    None,
3921                                );
3922                            }
3923                        }
3924                        continue; // Skip regular value processing for severity
3925                    }
3926
3927                    let toml_val = rv.clone();
3928
3929                    let sv = rule_entry
3930                        .values
3931                        .entry(norm_rk.clone())
3932                        .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
3933                    sv.push_override(toml_val, source, file.clone(), None);
3934                }
3935            } else if registry.resolve_rule_name(key).is_none() {
3936                // Key is not a global/special key and not a recognized rule name
3937                // Track unknown keys under [tool.rumdl] for validation
3938                fragment
3939                    .unknown_keys
3940                    .push(("[tool.rumdl]".to_string(), key.to_string(), Some(path.to_string())));
3941            }
3942        }
3943    }
3944
3945    // 2. Handle [tool.rumdl.MDxxx] sections as rule-specific config (nested under [tool])
3946    if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
3947        for (key, value) in tool_table.iter() {
3948            if let Some(rule_name) = key.strip_prefix("rumdl.") {
3949                // Try to resolve as a rule name (handles both canonical names and aliases)
3950                if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
3951                    if let Some(rule_table) = value.as_table() {
3952                        let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
3953                        for (rk, rv) in rule_table {
3954                            let norm_rk = normalize_key(rk);
3955
3956                            // Special handling for severity - store in rule_entry.severity
3957                            if norm_rk == "severity" {
3958                                if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
3959                                    if rule_entry.severity.is_none() {
3960                                        rule_entry.severity = Some(SourcedValue::new(severity, source));
3961                                    } else {
3962                                        rule_entry.severity.as_mut().unwrap().push_override(
3963                                            severity,
3964                                            source,
3965                                            file.clone(),
3966                                            None,
3967                                        );
3968                                    }
3969                                }
3970                                continue; // Skip regular value processing for severity
3971                            }
3972
3973                            let toml_val = rv.clone();
3974                            let sv = rule_entry
3975                                .values
3976                                .entry(norm_rk.clone())
3977                                .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
3978                            sv.push_override(toml_val, source, file.clone(), None);
3979                        }
3980                    }
3981                } else if rule_name.to_ascii_uppercase().starts_with("MD")
3982                    || rule_name.chars().any(|c| c.is_alphabetic())
3983                {
3984                    // Track unknown rule sections like [tool.rumdl.MD999] or [tool.rumdl.unknown-rule]
3985                    fragment.unknown_keys.push((
3986                        format!("[tool.rumdl.{rule_name}]"),
3987                        String::new(),
3988                        Some(path.to_string()),
3989                    ));
3990                }
3991            }
3992        }
3993    }
3994
3995    // 3. Handle [tool.rumdl.MDxxx] sections as top-level keys (e.g., [tool.rumdl.MD007] or [tool.rumdl.line-length])
3996    if let Some(doc_table) = doc.as_table() {
3997        for (key, value) in doc_table.iter() {
3998            if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
3999                // Try to resolve as a rule name (handles both canonical names and aliases)
4000                if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
4001                    if let Some(rule_table) = value.as_table() {
4002                        let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4003                        for (rk, rv) in rule_table {
4004                            let norm_rk = normalize_key(rk);
4005
4006                            // Special handling for severity - store in rule_entry.severity
4007                            if norm_rk == "severity" {
4008                                if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4009                                    if rule_entry.severity.is_none() {
4010                                        rule_entry.severity = Some(SourcedValue::new(severity, source));
4011                                    } else {
4012                                        rule_entry.severity.as_mut().unwrap().push_override(
4013                                            severity,
4014                                            source,
4015                                            file.clone(),
4016                                            None,
4017                                        );
4018                                    }
4019                                }
4020                                continue; // Skip regular value processing for severity
4021                            }
4022
4023                            let toml_val = rv.clone();
4024                            let sv = rule_entry
4025                                .values
4026                                .entry(norm_rk.clone())
4027                                .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
4028                            sv.push_override(toml_val, source, file.clone(), None);
4029                        }
4030                    }
4031                } else if rule_name.to_ascii_uppercase().starts_with("MD")
4032                    || rule_name.chars().any(|c| c.is_alphabetic())
4033                {
4034                    // Track unknown rule sections like [tool.rumdl.MD999] or [tool.rumdl.unknown-rule]
4035                    fragment.unknown_keys.push((
4036                        format!("[tool.rumdl.{rule_name}]"),
4037                        String::new(),
4038                        Some(path.to_string()),
4039                    ));
4040                }
4041            }
4042        }
4043    }
4044
4045    // Only return Some(fragment) if any config was found
4046    let has_any = !fragment.global.enable.value.is_empty()
4047        || !fragment.global.disable.value.is_empty()
4048        || !fragment.global.include.value.is_empty()
4049        || !fragment.global.exclude.value.is_empty()
4050        || !fragment.global.fixable.value.is_empty()
4051        || !fragment.global.unfixable.value.is_empty()
4052        || fragment.global.output_format.is_some()
4053        || fragment.global.cache_dir.is_some()
4054        || !fragment.global.cache.value
4055        || !fragment.per_file_ignores.value.is_empty()
4056        || !fragment.rules.is_empty();
4057    if has_any { Ok(Some(fragment)) } else { Ok(None) }
4058}
4059
4060/// Parses rumdl.toml / .rumdl.toml content.
4061fn parse_rumdl_toml(content: &str, path: &str, source: ConfigSource) -> Result<SourcedConfigFragment, ConfigError> {
4062    let doc = content
4063        .parse::<DocumentMut>()
4064        .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
4065    let mut fragment = SourcedConfigFragment::default();
4066    // source parameter provided by caller
4067    let file = Some(path.to_string());
4068
4069    // Define known rules before the loop
4070    let all_rules = rules::all_rules(&Config::default());
4071    let registry = RuleRegistry::from_rules(&all_rules);
4072
4073    // Handle [global] section
4074    if let Some(global_item) = doc.get("global")
4075        && let Some(global_table) = global_item.as_table()
4076    {
4077        for (key, value_item) in global_table.iter() {
4078            let norm_key = normalize_key(key);
4079            match norm_key.as_str() {
4080                "enable" | "disable" | "include" | "exclude" => {
4081                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4082                        // Corrected: Iterate directly over the Formatted<Array>
4083                        let values: Vec<String> = formatted_array
4084                                .iter()
4085                                .filter_map(|item| item.as_str()) // Extract strings
4086                                .map(|s| s.to_string())
4087                                .collect();
4088
4089                        // Resolve rule name aliases for enable/disable (e.g., "ul-style" -> "MD004")
4090                        let final_values = if norm_key == "enable" || norm_key == "disable" {
4091                            values
4092                                .into_iter()
4093                                .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4094                                .collect()
4095                        } else {
4096                            values
4097                        };
4098
4099                        match norm_key.as_str() {
4100                            "enable" => fragment
4101                                .global
4102                                .enable
4103                                .push_override(final_values, source, file.clone(), None),
4104                            "disable" => {
4105                                fragment
4106                                    .global
4107                                    .disable
4108                                    .push_override(final_values, source, file.clone(), None)
4109                            }
4110                            "include" => {
4111                                fragment
4112                                    .global
4113                                    .include
4114                                    .push_override(final_values, source, file.clone(), None)
4115                            }
4116                            "exclude" => {
4117                                fragment
4118                                    .global
4119                                    .exclude
4120                                    .push_override(final_values, source, file.clone(), None)
4121                            }
4122                            _ => unreachable!("Outer match guarantees only enable/disable/include/exclude"),
4123                        }
4124                    } else {
4125                        log::warn!(
4126                            "[WARN] Expected array for global key '{}' in {}, found {}",
4127                            key,
4128                            path,
4129                            value_item.type_name()
4130                        );
4131                    }
4132                }
4133                "respect_gitignore" | "respect-gitignore" => {
4134                    // Handle both cases
4135                    if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
4136                        let val = *formatted_bool.value();
4137                        fragment
4138                            .global
4139                            .respect_gitignore
4140                            .push_override(val, source, file.clone(), None);
4141                    } else {
4142                        log::warn!(
4143                            "[WARN] Expected boolean for global key '{}' in {}, found {}",
4144                            key,
4145                            path,
4146                            value_item.type_name()
4147                        );
4148                    }
4149                }
4150                "force_exclude" | "force-exclude" => {
4151                    // Handle both cases
4152                    if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
4153                        let val = *formatted_bool.value();
4154                        fragment
4155                            .global
4156                            .force_exclude
4157                            .push_override(val, source, file.clone(), None);
4158                    } else {
4159                        log::warn!(
4160                            "[WARN] Expected boolean for global key '{}' in {}, found {}",
4161                            key,
4162                            path,
4163                            value_item.type_name()
4164                        );
4165                    }
4166                }
4167                "line_length" | "line-length" => {
4168                    // Handle both cases
4169                    if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
4170                        let val = LineLength::new(*formatted_int.value() as usize);
4171                        fragment
4172                            .global
4173                            .line_length
4174                            .push_override(val, source, file.clone(), None);
4175                    } else {
4176                        log::warn!(
4177                            "[WARN] Expected integer for global key '{}' in {}, found {}",
4178                            key,
4179                            path,
4180                            value_item.type_name()
4181                        );
4182                    }
4183                }
4184                "output_format" | "output-format" => {
4185                    // Handle both cases
4186                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4187                        let val = formatted_string.value().clone();
4188                        if fragment.global.output_format.is_none() {
4189                            fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
4190                        } else {
4191                            fragment.global.output_format.as_mut().unwrap().push_override(
4192                                val,
4193                                source,
4194                                file.clone(),
4195                                None,
4196                            );
4197                        }
4198                    } else {
4199                        log::warn!(
4200                            "[WARN] Expected string for global key '{}' in {}, found {}",
4201                            key,
4202                            path,
4203                            value_item.type_name()
4204                        );
4205                    }
4206                }
4207                "cache_dir" | "cache-dir" => {
4208                    // Handle both cases
4209                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4210                        let val = formatted_string.value().clone();
4211                        if fragment.global.cache_dir.is_none() {
4212                            fragment.global.cache_dir = Some(SourcedValue::new(val.clone(), source));
4213                        } else {
4214                            fragment
4215                                .global
4216                                .cache_dir
4217                                .as_mut()
4218                                .unwrap()
4219                                .push_override(val, source, file.clone(), None);
4220                        }
4221                    } else {
4222                        log::warn!(
4223                            "[WARN] Expected string for global key '{}' in {}, found {}",
4224                            key,
4225                            path,
4226                            value_item.type_name()
4227                        );
4228                    }
4229                }
4230                "cache" => {
4231                    if let Some(toml_edit::Value::Boolean(b)) = value_item.as_value() {
4232                        let val = *b.value();
4233                        fragment.global.cache.push_override(val, source, file.clone(), None);
4234                    } else {
4235                        log::warn!(
4236                            "[WARN] Expected boolean for global key '{}' in {}, found {}",
4237                            key,
4238                            path,
4239                            value_item.type_name()
4240                        );
4241                    }
4242                }
4243                "fixable" => {
4244                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4245                        let values: Vec<String> = formatted_array
4246                            .iter()
4247                            .filter_map(|item| item.as_str())
4248                            .map(normalize_key)
4249                            .collect();
4250                        fragment
4251                            .global
4252                            .fixable
4253                            .push_override(values, source, file.clone(), None);
4254                    } else {
4255                        log::warn!(
4256                            "[WARN] Expected array for global key '{}' in {}, found {}",
4257                            key,
4258                            path,
4259                            value_item.type_name()
4260                        );
4261                    }
4262                }
4263                "unfixable" => {
4264                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4265                        let values: Vec<String> = formatted_array
4266                            .iter()
4267                            .filter_map(|item| item.as_str())
4268                            .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
4269                            .collect();
4270                        fragment
4271                            .global
4272                            .unfixable
4273                            .push_override(values, source, file.clone(), None);
4274                    } else {
4275                        log::warn!(
4276                            "[WARN] Expected array for global key '{}' in {}, found {}",
4277                            key,
4278                            path,
4279                            value_item.type_name()
4280                        );
4281                    }
4282                }
4283                "flavor" => {
4284                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4285                        let val = formatted_string.value();
4286                        if let Ok(flavor) = MarkdownFlavor::from_str(val) {
4287                            fragment.global.flavor.push_override(flavor, source, file.clone(), None);
4288                        } else {
4289                            log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
4290                        }
4291                    } else {
4292                        log::warn!(
4293                            "[WARN] Expected string for global key '{}' in {}, found {}",
4294                            key,
4295                            path,
4296                            value_item.type_name()
4297                        );
4298                    }
4299                }
4300                _ => {
4301                    // Track unknown global keys for validation
4302                    fragment
4303                        .unknown_keys
4304                        .push(("[global]".to_string(), key.to_string(), Some(path.to_string())));
4305                    log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
4306                }
4307            }
4308        }
4309    }
4310
4311    // Handle [per-file-ignores] section
4312    if let Some(per_file_item) = doc.get("per-file-ignores")
4313        && let Some(per_file_table) = per_file_item.as_table()
4314    {
4315        let mut per_file_map = HashMap::new();
4316        for (pattern, value_item) in per_file_table.iter() {
4317            if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4318                let rules: Vec<String> = formatted_array
4319                    .iter()
4320                    .filter_map(|item| item.as_str())
4321                    .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
4322                    .collect();
4323                per_file_map.insert(pattern.to_string(), rules);
4324            } else {
4325                let type_name = value_item.type_name();
4326                log::warn!(
4327                    "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {type_name}"
4328                );
4329            }
4330        }
4331        fragment
4332            .per_file_ignores
4333            .push_override(per_file_map, source, file.clone(), None);
4334    }
4335
4336    // Rule-specific: all other top-level tables
4337    for (key, item) in doc.iter() {
4338        // Skip known special sections
4339        if key == "global" || key == "per-file-ignores" {
4340            continue;
4341        }
4342
4343        // Resolve rule name (handles both canonical names like "MD004" and aliases like "ul-style")
4344        let norm_rule_name = if let Some(resolved) = registry.resolve_rule_name(key) {
4345            resolved
4346        } else {
4347            // Unknown rule - always track it for validation and suggestions
4348            fragment
4349                .unknown_keys
4350                .push((format!("[{key}]"), String::new(), Some(path.to_string())));
4351            continue;
4352        };
4353
4354        if let Some(tbl) = item.as_table() {
4355            let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
4356            for (rk, rv_item) in tbl.iter() {
4357                let norm_rk = normalize_key(rk);
4358
4359                // Special handling for severity - store in rule_entry.severity
4360                if norm_rk == "severity" {
4361                    if let Some(toml_edit::Value::String(formatted_string)) = rv_item.as_value() {
4362                        let severity_str = formatted_string.value();
4363                        match crate::rule::Severity::deserialize(toml::Value::String(severity_str.to_string())) {
4364                            Ok(severity) => {
4365                                if rule_entry.severity.is_none() {
4366                                    rule_entry.severity = Some(SourcedValue::new(severity, source));
4367                                } else {
4368                                    rule_entry.severity.as_mut().unwrap().push_override(
4369                                        severity,
4370                                        source,
4371                                        file.clone(),
4372                                        None,
4373                                    );
4374                                }
4375                            }
4376                            Err(_) => {
4377                                log::warn!(
4378                                    "[WARN] Invalid severity '{severity_str}' for rule {norm_rule_name} in {path}. Valid values: error, warning"
4379                                );
4380                            }
4381                        }
4382                    }
4383                    continue; // Skip regular value processing for severity
4384                }
4385
4386                let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
4387                    Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
4388                    Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
4389                    Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
4390                    Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
4391                    Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
4392                    Some(toml_edit::Value::Array(formatted_array)) => {
4393                        // Convert toml_edit Array to toml::Value::Array
4394                        let mut values = Vec::new();
4395                        for item in formatted_array.iter() {
4396                            match item {
4397                                toml_edit::Value::String(formatted) => {
4398                                    values.push(toml::Value::String(formatted.value().clone()))
4399                                }
4400                                toml_edit::Value::Integer(formatted) => {
4401                                    values.push(toml::Value::Integer(*formatted.value()))
4402                                }
4403                                toml_edit::Value::Float(formatted) => {
4404                                    values.push(toml::Value::Float(*formatted.value()))
4405                                }
4406                                toml_edit::Value::Boolean(formatted) => {
4407                                    values.push(toml::Value::Boolean(*formatted.value()))
4408                                }
4409                                toml_edit::Value::Datetime(formatted) => {
4410                                    values.push(toml::Value::Datetime(*formatted.value()))
4411                                }
4412                                _ => {
4413                                    log::warn!(
4414                                        "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
4415                                    );
4416                                }
4417                            }
4418                        }
4419                        Some(toml::Value::Array(values))
4420                    }
4421                    Some(toml_edit::Value::InlineTable(_)) => {
4422                        log::warn!(
4423                            "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
4424                        );
4425                        None
4426                    }
4427                    None => {
4428                        log::warn!(
4429                            "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
4430                        );
4431                        None
4432                    }
4433                };
4434                if let Some(toml_val) = maybe_toml_val {
4435                    let sv = rule_entry
4436                        .values
4437                        .entry(norm_rk.clone())
4438                        .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
4439                    sv.push_override(toml_val, source, file.clone(), None);
4440                }
4441            }
4442        } else if item.is_value() {
4443            log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
4444        }
4445    }
4446
4447    Ok(fragment)
4448}
4449
4450/// Loads and converts a markdownlint config file (.json or .yaml) into a SourcedConfigFragment.
4451fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
4452    // Use the unified loader from markdownlint_config.rs
4453    let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
4454        .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
4455    Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
4456}
4457
4458#[cfg(test)]
4459#[path = "config_intelligent_merge_tests.rs"]
4460mod config_intelligent_merge_tests;