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 log;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::collections::{BTreeSet, HashMap, HashSet};
11use std::fmt;
12use std::fs;
13use std::io;
14use std::path::Path;
15use std::str::FromStr;
16use toml_edit::DocumentMut;
17
18/// Markdown flavor/dialect enumeration
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema)]
20#[serde(rename_all = "lowercase")]
21pub enum MarkdownFlavor {
22    /// Standard Markdown without flavor-specific adjustments
23    #[serde(rename = "standard", alias = "none", alias = "")]
24    #[default]
25    Standard,
26    /// MkDocs flavor with auto-reference support
27    #[serde(rename = "mkdocs")]
28    MkDocs,
29    /// MDX flavor with JSX and ESM support (.mdx files)
30    #[serde(rename = "mdx")]
31    MDX,
32    /// Quarto/RMarkdown flavor for scientific publishing (.qmd, .Rmd files)
33    #[serde(rename = "quarto")]
34    Quarto,
35    // Future flavors can be added here when they have actual implementation differences
36    // Planned: GFM (GitHub Flavored Markdown) - for GitHub-specific features like tables, strikethrough
37    // Planned: CommonMark - for strict CommonMark compliance
38}
39
40impl fmt::Display for MarkdownFlavor {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            MarkdownFlavor::Standard => write!(f, "standard"),
44            MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
45            MarkdownFlavor::MDX => write!(f, "mdx"),
46            MarkdownFlavor::Quarto => write!(f, "quarto"),
47        }
48    }
49}
50
51impl FromStr for MarkdownFlavor {
52    type Err = String;
53
54    fn from_str(s: &str) -> Result<Self, Self::Err> {
55        match s.to_lowercase().as_str() {
56            "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
57            "mkdocs" => Ok(MarkdownFlavor::MkDocs),
58            "mdx" => Ok(MarkdownFlavor::MDX),
59            "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
60            // Accept but warn about unimplemented flavors
61            "gfm" | "github" => {
62                eprintln!("Warning: GFM flavor not yet implemented, using standard");
63                Ok(MarkdownFlavor::Standard)
64            }
65            "commonmark" => {
66                eprintln!("Warning: CommonMark flavor not yet implemented, using standard");
67                Ok(MarkdownFlavor::Standard)
68            }
69            _ => Err(format!("Unknown markdown flavor: {s}")),
70        }
71    }
72}
73
74impl MarkdownFlavor {
75    /// Detect flavor from file extension
76    pub fn from_extension(ext: &str) -> Self {
77        match ext.to_lowercase().as_str() {
78            "mdx" => Self::MDX,
79            "qmd" => Self::Quarto,
80            "rmd" => Self::Quarto,
81            _ => Self::Standard,
82        }
83    }
84
85    /// Detect flavor from file path
86    pub fn from_path(path: &std::path::Path) -> Self {
87        path.extension()
88            .and_then(|e| e.to_str())
89            .map(Self::from_extension)
90            .unwrap_or(Self::Standard)
91    }
92
93    /// Check if this flavor supports ESM imports/exports (MDX-specific)
94    pub fn supports_esm_blocks(self) -> bool {
95        matches!(self, Self::MDX)
96    }
97
98    /// Check if this flavor supports JSX components (MDX-specific)
99    pub fn supports_jsx(self) -> bool {
100        matches!(self, Self::MDX)
101    }
102
103    /// Check if this flavor supports auto-references (MkDocs-specific)
104    pub fn supports_auto_references(self) -> bool {
105        matches!(self, Self::MkDocs)
106    }
107
108    /// Get a human-readable name for this flavor
109    pub fn name(self) -> &'static str {
110        match self {
111            Self::Standard => "Standard",
112            Self::MkDocs => "MkDocs",
113            Self::MDX => "MDX",
114            Self::Quarto => "Quarto",
115        }
116    }
117}
118
119/// Normalizes configuration keys (rule names, option names) to lowercase kebab-case.
120pub fn normalize_key(key: &str) -> String {
121    // If the key looks like a rule name (e.g., MD013), uppercase it
122    if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
123        key.to_ascii_uppercase()
124    } else {
125        key.replace('_', "-").to_ascii_lowercase()
126    }
127}
128
129/// Represents a rule-specific configuration
130#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
131pub struct RuleConfig {
132    /// Configuration values for the rule
133    #[serde(flatten)]
134    #[schemars(schema_with = "arbitrary_value_schema")]
135    pub values: BTreeMap<String, toml::Value>,
136}
137
138/// Generate a JSON schema for arbitrary configuration values
139fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
140    schemars::json_schema!({
141        "type": "object",
142        "additionalProperties": true
143    })
144}
145
146/// Represents the complete configuration loaded from rumdl.toml
147#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
148#[schemars(
149    description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
150)]
151pub struct Config {
152    /// Global configuration options
153    #[serde(default)]
154    pub global: GlobalConfig,
155
156    /// Per-file rule ignores: maps file patterns to lists of rules to ignore
157    /// Example: { "README.md": ["MD033"], "docs/**/*.md": ["MD013"] }
158    #[serde(default, rename = "per-file-ignores")]
159    pub per_file_ignores: HashMap<String, Vec<String>>,
160
161    /// Rule-specific configurations (e.g., MD013, MD007, MD044)
162    /// Each rule section can contain options specific to that rule.
163    ///
164    /// Common examples:
165    /// - MD013: line_length, code_blocks, tables, headings
166    /// - MD007: indent
167    /// - MD003: style ("atx", "atx_closed", "setext")
168    /// - MD044: names (array of proper names to check)
169    ///
170    /// See https://github.com/rvben/rumdl for full rule documentation.
171    #[serde(flatten)]
172    pub rules: BTreeMap<String, RuleConfig>,
173}
174
175impl Config {
176    /// Check if the Markdown flavor is set to MkDocs
177    pub fn is_mkdocs_flavor(&self) -> bool {
178        self.global.flavor == MarkdownFlavor::MkDocs
179    }
180
181    // Future methods for when GFM and CommonMark are implemented:
182    // pub fn is_gfm_flavor(&self) -> bool
183    // pub fn is_commonmark_flavor(&self) -> bool
184
185    /// Get the configured Markdown flavor
186    pub fn markdown_flavor(&self) -> MarkdownFlavor {
187        self.global.flavor
188    }
189
190    /// Legacy method for backwards compatibility - redirects to is_mkdocs_flavor
191    pub fn is_mkdocs_project(&self) -> bool {
192        self.is_mkdocs_flavor()
193    }
194
195    /// Get the set of rules that should be ignored for a specific file based on per-file-ignores configuration
196    /// Returns a HashSet of rule names (uppercase, e.g., "MD033") that match the given file path
197    pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
198        use globset::{Glob, GlobSetBuilder};
199
200        let mut ignored_rules = HashSet::new();
201
202        if self.per_file_ignores.is_empty() {
203            return ignored_rules;
204        }
205
206        // Build a globset for efficient matching
207        let mut builder = GlobSetBuilder::new();
208        let mut pattern_to_rules: Vec<(usize, &Vec<String>)> = Vec::new();
209
210        for (idx, (pattern, rules)) in self.per_file_ignores.iter().enumerate() {
211            if let Ok(glob) = Glob::new(pattern) {
212                builder.add(glob);
213                pattern_to_rules.push((idx, rules));
214            } else {
215                log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
216            }
217        }
218
219        let globset = match builder.build() {
220            Ok(gs) => gs,
221            Err(e) => {
222                log::error!("Failed to build globset for per-file-ignores: {e}");
223                return ignored_rules;
224            }
225        };
226
227        // Match the file path against all patterns
228        for match_idx in globset.matches(file_path) {
229            if let Some((_, rules)) = pattern_to_rules.get(match_idx) {
230                for rule in rules.iter() {
231                    // Normalize rule names to uppercase (MD033, md033 -> MD033)
232                    ignored_rules.insert(normalize_key(rule));
233                }
234            }
235        }
236
237        ignored_rules
238    }
239}
240
241/// Global configuration options
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
243#[serde(default, rename_all = "kebab-case")]
244pub struct GlobalConfig {
245    /// Enabled rules
246    #[serde(default)]
247    pub enable: Vec<String>,
248
249    /// Disabled rules
250    #[serde(default)]
251    pub disable: Vec<String>,
252
253    /// Files to exclude
254    #[serde(default)]
255    pub exclude: Vec<String>,
256
257    /// Files to include
258    #[serde(default)]
259    pub include: Vec<String>,
260
261    /// Respect .gitignore files when scanning directories
262    #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
263    pub respect_gitignore: bool,
264
265    /// Global line length setting (used by MD013 and other rules if not overridden)
266    #[serde(default = "default_line_length", alias = "line_length")]
267    pub line_length: u64,
268
269    /// Output format for linting results (e.g., "text", "json", "pylint", etc.)
270    #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
271    pub output_format: Option<String>,
272
273    /// Rules that are allowed to be fixed when --fix is used
274    /// If specified, only these rules will be fixed
275    #[serde(default)]
276    pub fixable: Vec<String>,
277
278    /// Rules that should never be fixed, even when --fix is used
279    /// Takes precedence over fixable
280    #[serde(default)]
281    pub unfixable: Vec<String>,
282
283    /// Markdown flavor/dialect to use (mkdocs, gfm, commonmark, etc.)
284    /// When set, adjusts parsing and validation rules for that specific Markdown variant
285    #[serde(default)]
286    pub flavor: MarkdownFlavor,
287
288    /// [DEPRECATED] Whether to enforce exclude patterns for explicitly passed paths.
289    /// This option is deprecated as of v0.0.156 and has no effect.
290    /// Exclude patterns are now always respected, even for explicitly provided files.
291    /// This prevents duplication between rumdl config and tool configs like pre-commit.
292    #[serde(default, alias = "force_exclude")]
293    #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
294    pub force_exclude: bool,
295
296    /// Directory to store cache files (default: .rumdl_cache)
297    /// Can also be set via --cache-dir CLI flag or RUMDL_CACHE_DIR environment variable
298    #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
299    pub cache_dir: Option<String>,
300}
301
302fn default_respect_gitignore() -> bool {
303    true
304}
305
306fn default_line_length() -> u64 {
307    80
308}
309
310// Add the Default impl
311impl Default for GlobalConfig {
312    #[allow(deprecated)]
313    fn default() -> Self {
314        Self {
315            enable: Vec::new(),
316            disable: Vec::new(),
317            exclude: Vec::new(),
318            include: Vec::new(),
319            respect_gitignore: true,
320            line_length: 80,
321            output_format: None,
322            fixable: Vec::new(),
323            unfixable: Vec::new(),
324            flavor: MarkdownFlavor::default(),
325            force_exclude: false,
326            cache_dir: None,
327        }
328    }
329}
330
331const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
332    ".markdownlint.json",
333    ".markdownlint.jsonc",
334    ".markdownlint.yaml",
335    ".markdownlint.yml",
336    "markdownlint.json",
337    "markdownlint.jsonc",
338    "markdownlint.yaml",
339    "markdownlint.yml",
340];
341
342/// Create a default configuration file at the specified path
343pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
344    // Check if file already exists
345    if Path::new(path).exists() {
346        return Err(ConfigError::FileExists { path: path.to_string() });
347    }
348
349    // Default configuration content
350    let default_config = r#"# rumdl configuration file
351
352# Global configuration options
353[global]
354# List of rules to disable (uncomment and modify as needed)
355# disable = ["MD013", "MD033"]
356
357# List of rules to enable exclusively (if provided, only these rules will run)
358# enable = ["MD001", "MD003", "MD004"]
359
360# List of file/directory patterns to include for linting (if provided, only these will be linted)
361# include = [
362#    "docs/*.md",
363#    "src/**/*.md",
364#    "README.md"
365# ]
366
367# List of file/directory patterns to exclude from linting
368exclude = [
369    # Common directories to exclude
370    ".git",
371    ".github",
372    "node_modules",
373    "vendor",
374    "dist",
375    "build",
376
377    # Specific files or patterns
378    "CHANGELOG.md",
379    "LICENSE.md",
380]
381
382# Respect .gitignore files when scanning directories (default: true)
383respect-gitignore = true
384
385# Markdown flavor/dialect (uncomment to enable)
386# Options: mkdocs, gfm, commonmark
387# flavor = "mkdocs"
388
389# Rule-specific configurations (uncomment and modify as needed)
390
391# [MD003]
392# style = "atx"  # Heading style (atx, atx_closed, setext)
393
394# [MD004]
395# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
396
397# [MD007]
398# indent = 4  # Unordered list indentation
399
400# [MD013]
401# line-length = 100  # Line length
402# code-blocks = false  # Exclude code blocks from line length check
403# tables = false  # Exclude tables from line length check
404# headings = true  # Include headings in line length check
405
406# [MD044]
407# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
408# code-blocks = false  # Check code blocks for proper names (default: false, skips code blocks)
409"#;
410
411    // Write the default configuration to the file
412    match fs::write(path, default_config) {
413        Ok(_) => Ok(()),
414        Err(err) => Err(ConfigError::IoError {
415            source: err,
416            path: path.to_string(),
417        }),
418    }
419}
420
421/// Errors that can occur when loading configuration
422#[derive(Debug, thiserror::Error)]
423pub enum ConfigError {
424    /// Failed to read the configuration file
425    #[error("Failed to read config file at {path}: {source}")]
426    IoError { source: io::Error, path: String },
427
428    /// Failed to parse the configuration content (TOML or JSON)
429    #[error("Failed to parse config: {0}")]
430    ParseError(String),
431
432    /// Configuration file already exists
433    #[error("Configuration file already exists at {path}")]
434    FileExists { path: String },
435}
436
437/// Get a rule-specific configuration value
438/// Automatically tries both the original key and normalized variants (kebab-case ↔ snake_case)
439/// for better markdownlint compatibility
440pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
441    let norm_rule_name = rule_name.to_ascii_uppercase(); // Use uppercase for lookup
442
443    let rule_config = config.rules.get(&norm_rule_name)?;
444
445    // Try multiple key variants to support both underscore and kebab-case formats
446    let key_variants = [
447        key.to_string(),       // Original key as provided
448        normalize_key(key),    // Normalized key (lowercase, kebab-case)
449        key.replace('-', "_"), // Convert kebab-case to snake_case
450        key.replace('_', "-"), // Convert snake_case to kebab-case
451    ];
452
453    // Try each variant until we find a match
454    for variant in &key_variants {
455        if let Some(value) = rule_config.values.get(variant)
456            && let Ok(result) = T::deserialize(value.clone())
457        {
458            return Some(result);
459        }
460    }
461
462    None
463}
464
465/// Generate default rumdl configuration for pyproject.toml
466pub fn generate_pyproject_config() -> String {
467    let config_content = r#"
468[tool.rumdl]
469# Global configuration options
470line-length = 100
471disable = []
472exclude = [
473    # Common directories to exclude
474    ".git",
475    ".github",
476    "node_modules",
477    "vendor",
478    "dist",
479    "build",
480]
481respect-gitignore = true
482
483# Rule-specific configurations (uncomment and modify as needed)
484
485# [tool.rumdl.MD003]
486# style = "atx"  # Heading style (atx, atx_closed, setext)
487
488# [tool.rumdl.MD004]
489# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
490
491# [tool.rumdl.MD007]
492# indent = 4  # Unordered list indentation
493
494# [tool.rumdl.MD013]
495# line-length = 100  # Line length
496# code-blocks = false  # Exclude code blocks from line length check
497# tables = false  # Exclude tables from line length check
498# headings = true  # Include headings in line length check
499
500# [tool.rumdl.MD044]
501# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
502# code-blocks = false  # Check code blocks for proper names (default: false, skips code blocks)
503"#;
504
505    config_content.to_string()
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use std::fs;
512    use tempfile::tempdir;
513
514    #[test]
515    fn test_flavor_loading() {
516        let temp_dir = tempdir().unwrap();
517        let config_path = temp_dir.path().join(".rumdl.toml");
518        let config_content = r#"
519[global]
520flavor = "mkdocs"
521disable = ["MD001"]
522"#;
523        fs::write(&config_path, config_content).unwrap();
524
525        // Load the config
526        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
527        let config: Config = sourced.into();
528
529        // Check that flavor was loaded
530        assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
531        assert!(config.is_mkdocs_flavor());
532        assert!(config.is_mkdocs_project()); // Test backwards compatibility
533        assert_eq!(config.global.disable, vec!["MD001".to_string()]);
534    }
535
536    #[test]
537    fn test_pyproject_toml_root_level_config() {
538        let temp_dir = tempdir().unwrap();
539        let config_path = temp_dir.path().join("pyproject.toml");
540
541        // Create a test pyproject.toml with root-level configuration
542        let content = r#"
543[tool.rumdl]
544line-length = 120
545disable = ["MD033"]
546enable = ["MD001", "MD004"]
547include = ["docs/*.md"]
548exclude = ["node_modules"]
549respect-gitignore = true
550        "#;
551
552        fs::write(&config_path, content).unwrap();
553
554        // Load the config with skip_auto_discovery to avoid environment config files
555        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
556        let config: Config = sourced.into(); // Convert to plain config for assertions
557
558        // Check global settings
559        assert_eq!(config.global.disable, vec!["MD033".to_string()]);
560        assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
561        // Should now contain only the configured pattern since auto-discovery is disabled
562        assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
563        assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
564        assert!(config.global.respect_gitignore);
565
566        // Check line-length was correctly added to MD013
567        let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
568        assert_eq!(line_length, Some(120));
569    }
570
571    #[test]
572    fn test_pyproject_toml_snake_case_and_kebab_case() {
573        let temp_dir = tempdir().unwrap();
574        let config_path = temp_dir.path().join("pyproject.toml");
575
576        // Test with both kebab-case and snake_case variants
577        let content = r#"
578[tool.rumdl]
579line-length = 150
580respect_gitignore = true
581        "#;
582
583        fs::write(&config_path, content).unwrap();
584
585        // Load the config with skip_auto_discovery to avoid environment config files
586        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
587        let config: Config = sourced.into(); // Convert to plain config for assertions
588
589        // Check settings were correctly loaded
590        assert!(config.global.respect_gitignore);
591        let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
592        assert_eq!(line_length, Some(150));
593    }
594
595    #[test]
596    fn test_md013_key_normalization_in_rumdl_toml() {
597        let temp_dir = tempdir().unwrap();
598        let config_path = temp_dir.path().join(".rumdl.toml");
599        let config_content = r#"
600[MD013]
601line_length = 111
602line-length = 222
603"#;
604        fs::write(&config_path, config_content).unwrap();
605        // Load the config with skip_auto_discovery to avoid environment config files
606        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
607        let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
608        // Now we should only get the explicitly configured key
609        let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
610        assert_eq!(keys, vec!["line-length"]);
611        let val = &rule_cfg.values["line-length"].value;
612        assert_eq!(val.as_integer(), Some(222));
613        // get_rule_config_value should retrieve the value for both snake_case and kebab-case
614        let config: Config = sourced.clone().into();
615        let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
616        let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
617        assert_eq!(v1, Some(222));
618        assert_eq!(v2, Some(222));
619    }
620
621    #[test]
622    fn test_md013_section_case_insensitivity() {
623        let temp_dir = tempdir().unwrap();
624        let config_path = temp_dir.path().join(".rumdl.toml");
625        let config_content = r#"
626[md013]
627line-length = 101
628
629[Md013]
630line-length = 102
631
632[MD013]
633line-length = 103
634"#;
635        fs::write(&config_path, config_content).unwrap();
636        // Load the config with skip_auto_discovery to avoid environment config files
637        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
638        let config: Config = sourced.clone().into();
639        // Only the last section should win, and be present
640        let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
641        let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
642        assert_eq!(keys, vec!["line-length"]);
643        let val = &rule_cfg.values["line-length"].value;
644        assert_eq!(val.as_integer(), Some(103));
645        let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
646        assert_eq!(v, Some(103));
647    }
648
649    #[test]
650    fn test_md013_key_snake_and_kebab_case() {
651        let temp_dir = tempdir().unwrap();
652        let config_path = temp_dir.path().join(".rumdl.toml");
653        let config_content = r#"
654[MD013]
655line_length = 201
656line-length = 202
657"#;
658        fs::write(&config_path, config_content).unwrap();
659        // Load the config with skip_auto_discovery to avoid environment config files
660        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
661        let config: Config = sourced.clone().into();
662        let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
663        let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
664        assert_eq!(keys, vec!["line-length"]);
665        let val = &rule_cfg.values["line-length"].value;
666        assert_eq!(val.as_integer(), Some(202));
667        let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
668        let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
669        assert_eq!(v1, Some(202));
670        assert_eq!(v2, Some(202));
671    }
672
673    #[test]
674    fn test_unknown_rule_section_is_ignored() {
675        let temp_dir = tempdir().unwrap();
676        let config_path = temp_dir.path().join(".rumdl.toml");
677        let config_content = r#"
678[MD999]
679foo = 1
680bar = 2
681[MD013]
682line-length = 303
683"#;
684        fs::write(&config_path, config_content).unwrap();
685        // Load the config with skip_auto_discovery to avoid environment config files
686        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
687        let config: Config = sourced.clone().into();
688        // MD999 should not be present
689        assert!(!sourced.rules.contains_key("MD999"));
690        // MD013 should be present and correct
691        let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
692        assert_eq!(v, Some(303));
693    }
694
695    #[test]
696    fn test_invalid_toml_syntax() {
697        let temp_dir = tempdir().unwrap();
698        let config_path = temp_dir.path().join(".rumdl.toml");
699
700        // Invalid TOML with unclosed string
701        let config_content = r#"
702[MD013]
703line-length = "unclosed string
704"#;
705        fs::write(&config_path, config_content).unwrap();
706
707        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
708        assert!(result.is_err());
709        match result.unwrap_err() {
710            ConfigError::ParseError(msg) => {
711                // The actual error message from toml parser might vary
712                assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
713            }
714            _ => panic!("Expected ParseError"),
715        }
716    }
717
718    #[test]
719    fn test_wrong_type_for_config_value() {
720        let temp_dir = tempdir().unwrap();
721        let config_path = temp_dir.path().join(".rumdl.toml");
722
723        // line-length should be a number, not a string
724        let config_content = r#"
725[MD013]
726line-length = "not a number"
727"#;
728        fs::write(&config_path, config_content).unwrap();
729
730        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
731        let config: Config = sourced.into();
732
733        // The value should be loaded as a string, not converted
734        let rule_config = config.rules.get("MD013").unwrap();
735        let value = rule_config.values.get("line-length").unwrap();
736        assert!(matches!(value, toml::Value::String(_)));
737    }
738
739    #[test]
740    fn test_empty_config_file() {
741        let temp_dir = tempdir().unwrap();
742        let config_path = temp_dir.path().join(".rumdl.toml");
743
744        // Empty file
745        fs::write(&config_path, "").unwrap();
746
747        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
748        let config: Config = sourced.into();
749
750        // Should have default values
751        assert_eq!(config.global.line_length, 80);
752        assert!(config.global.respect_gitignore);
753        assert!(config.rules.is_empty());
754    }
755
756    #[test]
757    fn test_malformed_pyproject_toml() {
758        let temp_dir = tempdir().unwrap();
759        let config_path = temp_dir.path().join("pyproject.toml");
760
761        // Missing closing bracket
762        let content = r#"
763[tool.rumdl
764line-length = 120
765"#;
766        fs::write(&config_path, content).unwrap();
767
768        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
769        assert!(result.is_err());
770    }
771
772    #[test]
773    fn test_conflicting_config_values() {
774        let temp_dir = tempdir().unwrap();
775        let config_path = temp_dir.path().join(".rumdl.toml");
776
777        // Both enable and disable the same rule - these need to be in a global section
778        let config_content = r#"
779[global]
780enable = ["MD013"]
781disable = ["MD013"]
782"#;
783        fs::write(&config_path, config_content).unwrap();
784
785        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
786        let config: Config = sourced.into();
787
788        // Conflict resolution: enable wins over disable
789        assert!(config.global.enable.contains(&"MD013".to_string()));
790        assert!(!config.global.disable.contains(&"MD013".to_string()));
791    }
792
793    #[test]
794    fn test_invalid_rule_names() {
795        let temp_dir = tempdir().unwrap();
796        let config_path = temp_dir.path().join(".rumdl.toml");
797
798        let config_content = r#"
799[global]
800enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
801disable = ["MD-001", "MD_002"]
802"#;
803        fs::write(&config_path, config_content).unwrap();
804
805        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
806        let config: Config = sourced.into();
807
808        // All values should be preserved as-is
809        assert_eq!(config.global.enable.len(), 4);
810        assert_eq!(config.global.disable.len(), 2);
811    }
812
813    #[test]
814    fn test_deeply_nested_config() {
815        let temp_dir = tempdir().unwrap();
816        let config_path = temp_dir.path().join(".rumdl.toml");
817
818        // This should be ignored as we don't support nested tables within rule configs
819        let config_content = r#"
820[MD013]
821line-length = 100
822[MD013.nested]
823value = 42
824"#;
825        fs::write(&config_path, config_content).unwrap();
826
827        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
828        let config: Config = sourced.into();
829
830        let rule_config = config.rules.get("MD013").unwrap();
831        assert_eq!(
832            rule_config.values.get("line-length").unwrap(),
833            &toml::Value::Integer(100)
834        );
835        // Nested table should not be present
836        assert!(!rule_config.values.contains_key("nested"));
837    }
838
839    #[test]
840    fn test_unicode_in_config() {
841        let temp_dir = tempdir().unwrap();
842        let config_path = temp_dir.path().join(".rumdl.toml");
843
844        let config_content = r#"
845[global]
846include = ["文档/*.md", "ドキュメント/*.md"]
847exclude = ["测试/*", "🚀/*"]
848
849[MD013]
850line-length = 80
851message = "行太长了 🚨"
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();
857
858        assert_eq!(config.global.include.len(), 2);
859        assert_eq!(config.global.exclude.len(), 2);
860        assert!(config.global.include[0].contains("文档"));
861        assert!(config.global.exclude[1].contains("🚀"));
862
863        let rule_config = config.rules.get("MD013").unwrap();
864        let message = rule_config.values.get("message").unwrap();
865        if let toml::Value::String(s) = message {
866            assert!(s.contains("行太长了"));
867            assert!(s.contains("🚨"));
868        }
869    }
870
871    #[test]
872    fn test_extremely_long_values() {
873        let temp_dir = tempdir().unwrap();
874        let config_path = temp_dir.path().join(".rumdl.toml");
875
876        let long_string = "a".repeat(10000);
877        let config_content = format!(
878            r#"
879[global]
880exclude = ["{long_string}"]
881
882[MD013]
883line-length = 999999999
884"#
885        );
886
887        fs::write(&config_path, config_content).unwrap();
888
889        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
890        let config: Config = sourced.into();
891
892        assert_eq!(config.global.exclude[0].len(), 10000);
893        let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
894        assert_eq!(line_length, Some(999999999));
895    }
896
897    #[test]
898    fn test_config_with_comments() {
899        let temp_dir = tempdir().unwrap();
900        let config_path = temp_dir.path().join(".rumdl.toml");
901
902        let config_content = r#"
903[global]
904# This is a comment
905enable = ["MD001"] # Enable MD001
906# disable = ["MD002"] # This is commented out
907
908[MD013] # Line length rule
909line-length = 100 # Set to 100 characters
910# ignored = true # This setting is commented out
911"#;
912        fs::write(&config_path, config_content).unwrap();
913
914        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
915        let config: Config = sourced.into();
916
917        assert_eq!(config.global.enable, vec!["MD001"]);
918        assert!(config.global.disable.is_empty()); // Commented out
919
920        let rule_config = config.rules.get("MD013").unwrap();
921        assert_eq!(rule_config.values.len(), 1); // Only line-length
922        assert!(!rule_config.values.contains_key("ignored"));
923    }
924
925    #[test]
926    fn test_arrays_in_rule_config() {
927        let temp_dir = tempdir().unwrap();
928        let config_path = temp_dir.path().join(".rumdl.toml");
929
930        let config_content = r#"
931[MD003]
932levels = [1, 2, 3]
933tags = ["important", "critical"]
934mixed = [1, "two", true]
935"#;
936        fs::write(&config_path, config_content).unwrap();
937
938        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
939        let config: Config = sourced.into();
940
941        // Arrays should now be properly parsed
942        let rule_config = config.rules.get("MD003").expect("MD003 config should exist");
943
944        // Check that arrays are present and correctly parsed
945        assert!(rule_config.values.contains_key("levels"));
946        assert!(rule_config.values.contains_key("tags"));
947        assert!(rule_config.values.contains_key("mixed"));
948
949        // Verify array contents
950        if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
951            assert_eq!(levels.len(), 3);
952            assert_eq!(levels[0], toml::Value::Integer(1));
953            assert_eq!(levels[1], toml::Value::Integer(2));
954            assert_eq!(levels[2], toml::Value::Integer(3));
955        } else {
956            panic!("levels should be an array");
957        }
958
959        if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
960            assert_eq!(tags.len(), 2);
961            assert_eq!(tags[0], toml::Value::String("important".to_string()));
962            assert_eq!(tags[1], toml::Value::String("critical".to_string()));
963        } else {
964            panic!("tags should be an array");
965        }
966
967        if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
968            assert_eq!(mixed.len(), 3);
969            assert_eq!(mixed[0], toml::Value::Integer(1));
970            assert_eq!(mixed[1], toml::Value::String("two".to_string()));
971            assert_eq!(mixed[2], toml::Value::Boolean(true));
972        } else {
973            panic!("mixed should be an array");
974        }
975    }
976
977    #[test]
978    fn test_normalize_key_edge_cases() {
979        // Rule names
980        assert_eq!(normalize_key("MD001"), "MD001");
981        assert_eq!(normalize_key("md001"), "MD001");
982        assert_eq!(normalize_key("Md001"), "MD001");
983        assert_eq!(normalize_key("mD001"), "MD001");
984
985        // Non-rule names
986        assert_eq!(normalize_key("line_length"), "line-length");
987        assert_eq!(normalize_key("line-length"), "line-length");
988        assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
989        assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
990
991        // Edge cases
992        assert_eq!(normalize_key("MD"), "md"); // Too short to be a rule
993        assert_eq!(normalize_key("MD00"), "md00"); // Too short
994        assert_eq!(normalize_key("MD0001"), "md0001"); // Too long
995        assert_eq!(normalize_key("MDabc"), "mdabc"); // Non-digit
996        assert_eq!(normalize_key("MD00a"), "md00a"); // Partial digit
997        assert_eq!(normalize_key(""), "");
998        assert_eq!(normalize_key("_"), "-");
999        assert_eq!(normalize_key("___"), "---");
1000    }
1001
1002    #[test]
1003    fn test_missing_config_file() {
1004        let temp_dir = tempdir().unwrap();
1005        let config_path = temp_dir.path().join("nonexistent.toml");
1006
1007        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1008        assert!(result.is_err());
1009        match result.unwrap_err() {
1010            ConfigError::IoError { .. } => {}
1011            _ => panic!("Expected IoError for missing file"),
1012        }
1013    }
1014
1015    #[test]
1016    #[cfg(unix)]
1017    fn test_permission_denied_config() {
1018        use std::os::unix::fs::PermissionsExt;
1019
1020        let temp_dir = tempdir().unwrap();
1021        let config_path = temp_dir.path().join(".rumdl.toml");
1022
1023        fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
1024
1025        // Remove read permissions
1026        let mut perms = fs::metadata(&config_path).unwrap().permissions();
1027        perms.set_mode(0o000);
1028        fs::set_permissions(&config_path, perms).unwrap();
1029
1030        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1031
1032        // Restore permissions for cleanup
1033        let mut perms = fs::metadata(&config_path).unwrap().permissions();
1034        perms.set_mode(0o644);
1035        fs::set_permissions(&config_path, perms).unwrap();
1036
1037        assert!(result.is_err());
1038        match result.unwrap_err() {
1039            ConfigError::IoError { .. } => {}
1040            _ => panic!("Expected IoError for permission denied"),
1041        }
1042    }
1043
1044    #[test]
1045    fn test_circular_reference_detection() {
1046        // This test is more conceptual since TOML doesn't support circular references
1047        // But we test that deeply nested structures don't cause stack overflow
1048        let temp_dir = tempdir().unwrap();
1049        let config_path = temp_dir.path().join(".rumdl.toml");
1050
1051        let mut config_content = String::from("[MD001]\n");
1052        for i in 0..100 {
1053            config_content.push_str(&format!("key{i} = {i}\n"));
1054        }
1055
1056        fs::write(&config_path, config_content).unwrap();
1057
1058        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1059        let config: Config = sourced.into();
1060
1061        let rule_config = config.rules.get("MD001").unwrap();
1062        assert_eq!(rule_config.values.len(), 100);
1063    }
1064
1065    #[test]
1066    fn test_special_toml_values() {
1067        let temp_dir = tempdir().unwrap();
1068        let config_path = temp_dir.path().join(".rumdl.toml");
1069
1070        let config_content = r#"
1071[MD001]
1072infinity = inf
1073neg_infinity = -inf
1074not_a_number = nan
1075datetime = 1979-05-27T07:32:00Z
1076local_date = 1979-05-27
1077local_time = 07:32:00
1078"#;
1079        fs::write(&config_path, config_content).unwrap();
1080
1081        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1082        let config: Config = sourced.into();
1083
1084        // Some values might not be parsed due to parser limitations
1085        if let Some(rule_config) = config.rules.get("MD001") {
1086            // Check special float values if present
1087            if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
1088                assert!(f.is_infinite() && f.is_sign_positive());
1089            }
1090            if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
1091                assert!(f.is_infinite() && f.is_sign_negative());
1092            }
1093            if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
1094                assert!(f.is_nan());
1095            }
1096
1097            // Check datetime values if present
1098            if let Some(val) = rule_config.values.get("datetime") {
1099                assert!(matches!(val, toml::Value::Datetime(_)));
1100            }
1101            // Note: local_date and local_time might not be parsed by the current implementation
1102        }
1103    }
1104
1105    #[test]
1106    fn test_default_config_passes_validation() {
1107        use crate::rules;
1108
1109        let temp_dir = tempdir().unwrap();
1110        let config_path = temp_dir.path().join(".rumdl.toml");
1111        let config_path_str = config_path.to_str().unwrap();
1112
1113        // Create the default config using the same function that `rumdl init` uses
1114        create_default_config(config_path_str).unwrap();
1115
1116        // Load it back as a SourcedConfig
1117        let sourced =
1118            SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
1119
1120        // Create the rule registry
1121        let all_rules = rules::all_rules(&Config::default());
1122        let registry = RuleRegistry::from_rules(&all_rules);
1123
1124        // Validate the config
1125        let warnings = validate_config_sourced(&sourced, &registry);
1126
1127        // The default config should have no warnings
1128        if !warnings.is_empty() {
1129            for warning in &warnings {
1130                eprintln!("Config validation warning: {}", warning.message);
1131                if let Some(rule) = &warning.rule {
1132                    eprintln!("  Rule: {rule}");
1133                }
1134                if let Some(key) = &warning.key {
1135                    eprintln!("  Key: {key}");
1136                }
1137            }
1138        }
1139        assert!(
1140            warnings.is_empty(),
1141            "Default config from rumdl init should pass validation without warnings"
1142        );
1143    }
1144
1145    #[test]
1146    fn test_per_file_ignores_config_parsing() {
1147        let temp_dir = tempdir().unwrap();
1148        let config_path = temp_dir.path().join(".rumdl.toml");
1149        let config_content = r#"
1150[per-file-ignores]
1151"README.md" = ["MD033"]
1152"docs/**/*.md" = ["MD013", "MD033"]
1153"test/*.md" = ["MD041"]
1154"#;
1155        fs::write(&config_path, config_content).unwrap();
1156
1157        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1158        let config: Config = sourced.into();
1159
1160        // Verify per-file-ignores was loaded
1161        assert_eq!(config.per_file_ignores.len(), 3);
1162        assert_eq!(
1163            config.per_file_ignores.get("README.md"),
1164            Some(&vec!["MD033".to_string()])
1165        );
1166        assert_eq!(
1167            config.per_file_ignores.get("docs/**/*.md"),
1168            Some(&vec!["MD013".to_string(), "MD033".to_string()])
1169        );
1170        assert_eq!(
1171            config.per_file_ignores.get("test/*.md"),
1172            Some(&vec!["MD041".to_string()])
1173        );
1174    }
1175
1176    #[test]
1177    fn test_per_file_ignores_glob_matching() {
1178        use std::path::PathBuf;
1179
1180        let temp_dir = tempdir().unwrap();
1181        let config_path = temp_dir.path().join(".rumdl.toml");
1182        let config_content = r#"
1183[per-file-ignores]
1184"README.md" = ["MD033"]
1185"docs/**/*.md" = ["MD013"]
1186"**/test_*.md" = ["MD041"]
1187"#;
1188        fs::write(&config_path, config_content).unwrap();
1189
1190        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1191        let config: Config = sourced.into();
1192
1193        // Test exact match
1194        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1195        assert!(ignored.contains("MD033"));
1196        assert_eq!(ignored.len(), 1);
1197
1198        // Test glob pattern matching
1199        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1200        assert!(ignored.contains("MD013"));
1201        assert_eq!(ignored.len(), 1);
1202
1203        // Test recursive glob pattern
1204        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("tests/fixtures/test_example.md"));
1205        assert!(ignored.contains("MD041"));
1206        assert_eq!(ignored.len(), 1);
1207
1208        // Test non-matching path
1209        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("other/file.md"));
1210        assert!(ignored.is_empty());
1211    }
1212
1213    #[test]
1214    fn test_per_file_ignores_pyproject_toml() {
1215        let temp_dir = tempdir().unwrap();
1216        let config_path = temp_dir.path().join("pyproject.toml");
1217        let config_content = r#"
1218[tool.rumdl]
1219[tool.rumdl.per-file-ignores]
1220"README.md" = ["MD033", "MD013"]
1221"generated/*.md" = ["MD041"]
1222"#;
1223        fs::write(&config_path, config_content).unwrap();
1224
1225        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1226        let config: Config = sourced.into();
1227
1228        // Verify per-file-ignores was loaded from pyproject.toml
1229        assert_eq!(config.per_file_ignores.len(), 2);
1230        assert_eq!(
1231            config.per_file_ignores.get("README.md"),
1232            Some(&vec!["MD033".to_string(), "MD013".to_string()])
1233        );
1234        assert_eq!(
1235            config.per_file_ignores.get("generated/*.md"),
1236            Some(&vec!["MD041".to_string()])
1237        );
1238    }
1239
1240    #[test]
1241    fn test_per_file_ignores_multiple_patterns_match() {
1242        use std::path::PathBuf;
1243
1244        let temp_dir = tempdir().unwrap();
1245        let config_path = temp_dir.path().join(".rumdl.toml");
1246        let config_content = r#"
1247[per-file-ignores]
1248"docs/**/*.md" = ["MD013"]
1249"**/api/*.md" = ["MD033"]
1250"docs/api/overview.md" = ["MD041"]
1251"#;
1252        fs::write(&config_path, config_content).unwrap();
1253
1254        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1255        let config: Config = sourced.into();
1256
1257        // File matches multiple patterns - should get union of all rules
1258        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1259        assert_eq!(ignored.len(), 3);
1260        assert!(ignored.contains("MD013"));
1261        assert!(ignored.contains("MD033"));
1262        assert!(ignored.contains("MD041"));
1263    }
1264
1265    #[test]
1266    fn test_per_file_ignores_rule_name_normalization() {
1267        use std::path::PathBuf;
1268
1269        let temp_dir = tempdir().unwrap();
1270        let config_path = temp_dir.path().join(".rumdl.toml");
1271        let config_content = r#"
1272[per-file-ignores]
1273"README.md" = ["md033", "MD013", "Md041"]
1274"#;
1275        fs::write(&config_path, config_content).unwrap();
1276
1277        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1278        let config: Config = sourced.into();
1279
1280        // All rule names should be normalized to uppercase
1281        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1282        assert_eq!(ignored.len(), 3);
1283        assert!(ignored.contains("MD033"));
1284        assert!(ignored.contains("MD013"));
1285        assert!(ignored.contains("MD041"));
1286    }
1287
1288    #[test]
1289    fn test_per_file_ignores_invalid_glob_pattern() {
1290        use std::path::PathBuf;
1291
1292        let temp_dir = tempdir().unwrap();
1293        let config_path = temp_dir.path().join(".rumdl.toml");
1294        let config_content = r#"
1295[per-file-ignores]
1296"[invalid" = ["MD033"]
1297"valid/*.md" = ["MD013"]
1298"#;
1299        fs::write(&config_path, config_content).unwrap();
1300
1301        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1302        let config: Config = sourced.into();
1303
1304        // Invalid pattern should be skipped, valid pattern should work
1305        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("valid/test.md"));
1306        assert!(ignored.contains("MD013"));
1307
1308        // Invalid pattern should not cause issues
1309        let ignored2 = config.get_ignored_rules_for_file(&PathBuf::from("[invalid"));
1310        assert!(ignored2.is_empty());
1311    }
1312
1313    #[test]
1314    fn test_per_file_ignores_empty_section() {
1315        use std::path::PathBuf;
1316
1317        let temp_dir = tempdir().unwrap();
1318        let config_path = temp_dir.path().join(".rumdl.toml");
1319        let config_content = r#"
1320[global]
1321disable = ["MD001"]
1322
1323[per-file-ignores]
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();
1329
1330        // Empty per-file-ignores should work fine
1331        assert_eq!(config.per_file_ignores.len(), 0);
1332        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1333        assert!(ignored.is_empty());
1334    }
1335
1336    #[test]
1337    fn test_per_file_ignores_with_underscores_in_pyproject() {
1338        let temp_dir = tempdir().unwrap();
1339        let config_path = temp_dir.path().join("pyproject.toml");
1340        let config_content = r#"
1341[tool.rumdl]
1342[tool.rumdl.per_file_ignores]
1343"README.md" = ["MD033"]
1344"#;
1345        fs::write(&config_path, config_content).unwrap();
1346
1347        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1348        let config: Config = sourced.into();
1349
1350        // Should support both per-file-ignores and per_file_ignores
1351        assert_eq!(config.per_file_ignores.len(), 1);
1352        assert_eq!(
1353            config.per_file_ignores.get("README.md"),
1354            Some(&vec!["MD033".to_string()])
1355        );
1356    }
1357
1358    #[test]
1359    fn test_generate_json_schema() {
1360        use schemars::schema_for;
1361        use std::env;
1362
1363        let schema = schema_for!(Config);
1364        let schema_json = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema");
1365
1366        // Write schema to file if RUMDL_UPDATE_SCHEMA env var is set
1367        if env::var("RUMDL_UPDATE_SCHEMA").is_ok() {
1368            let schema_path = env::current_dir().unwrap().join("rumdl.schema.json");
1369            fs::write(&schema_path, &schema_json).expect("Failed to write schema file");
1370            println!("Schema written to: {}", schema_path.display());
1371        }
1372
1373        // Basic validation that schema was generated
1374        assert!(schema_json.contains("\"title\": \"Config\""));
1375        assert!(schema_json.contains("\"global\""));
1376        assert!(schema_json.contains("\"per-file-ignores\""));
1377    }
1378
1379    #[test]
1380    fn test_user_config_loaded_with_explicit_project_config() {
1381        // Regression test for issue #131: User config should always be loaded as base layer,
1382        // even when an explicit project config path is provided
1383        let temp_dir = tempdir().unwrap();
1384
1385        // Create a fake user config directory
1386        // Note: user_configuration_path_impl adds /rumdl to the config dir
1387        let user_config_dir = temp_dir.path().join("user_config");
1388        let rumdl_config_dir = user_config_dir.join("rumdl");
1389        fs::create_dir_all(&rumdl_config_dir).unwrap();
1390        let user_config_path = rumdl_config_dir.join("rumdl.toml");
1391
1392        // User config disables MD013 and MD041
1393        let user_config_content = r#"
1394[global]
1395disable = ["MD013", "MD041"]
1396line-length = 100
1397"#;
1398        fs::write(&user_config_path, user_config_content).unwrap();
1399
1400        // Create a project config that enables MD001
1401        let project_config_path = temp_dir.path().join("project").join("pyproject.toml");
1402        fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1403        let project_config_content = r#"
1404[tool.rumdl]
1405enable = ["MD001"]
1406"#;
1407        fs::write(&project_config_path, project_config_content).unwrap();
1408
1409        // Load config with explicit project path, passing user_config_dir
1410        let sourced = SourcedConfig::load_with_discovery_impl(
1411            Some(project_config_path.to_str().unwrap()),
1412            None,
1413            false,
1414            Some(&user_config_dir),
1415        )
1416        .unwrap();
1417
1418        let config: Config = sourced.into();
1419
1420        // User config settings should be preserved
1421        assert!(
1422            config.global.disable.contains(&"MD013".to_string()),
1423            "User config disabled rules should be preserved"
1424        );
1425        assert!(
1426            config.global.disable.contains(&"MD041".to_string()),
1427            "User config disabled rules should be preserved"
1428        );
1429
1430        // Project config settings should also be applied (merged on top)
1431        assert!(
1432            config.global.enable.contains(&"MD001".to_string()),
1433            "Project config enabled rules should be applied"
1434        );
1435    }
1436}
1437
1438/// Configuration source with clear precedence hierarchy.
1439///
1440/// Precedence order (lower values override higher values):
1441/// - Default (0): Built-in defaults
1442/// - UserConfig (1): User-level ~/.config/rumdl/rumdl.toml
1443/// - PyprojectToml (2): Project-level pyproject.toml
1444/// - ProjectConfig (3): Project-level .rumdl.toml (most specific)
1445/// - Cli (4): Command-line flags (highest priority)
1446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1447pub enum ConfigSource {
1448    /// Built-in default configuration
1449    Default,
1450    /// User-level configuration from ~/.config/rumdl/rumdl.toml
1451    UserConfig,
1452    /// Project-level configuration from pyproject.toml
1453    PyprojectToml,
1454    /// Project-level configuration from .rumdl.toml or rumdl.toml
1455    ProjectConfig,
1456    /// Command-line flags (highest precedence)
1457    Cli,
1458}
1459
1460#[derive(Debug, Clone)]
1461pub struct ConfigOverride<T> {
1462    pub value: T,
1463    pub source: ConfigSource,
1464    pub file: Option<String>,
1465    pub line: Option<usize>,
1466}
1467
1468#[derive(Debug, Clone)]
1469pub struct SourcedValue<T> {
1470    pub value: T,
1471    pub source: ConfigSource,
1472    pub overrides: Vec<ConfigOverride<T>>,
1473}
1474
1475impl<T: Clone> SourcedValue<T> {
1476    pub fn new(value: T, source: ConfigSource) -> Self {
1477        Self {
1478            value: value.clone(),
1479            source,
1480            overrides: vec![ConfigOverride {
1481                value,
1482                source,
1483                file: None,
1484                line: None,
1485            }],
1486        }
1487    }
1488
1489    /// Merges a new override into this SourcedValue based on source precedence.
1490    /// If the new source has higher or equal precedence, the value and source are updated,
1491    /// and the new override is added to the history.
1492    pub fn merge_override(
1493        &mut self,
1494        new_value: T,
1495        new_source: ConfigSource,
1496        new_file: Option<String>,
1497        new_line: Option<usize>,
1498    ) {
1499        // Helper function to get precedence, defined locally or globally
1500        fn source_precedence(src: ConfigSource) -> u8 {
1501            match src {
1502                ConfigSource::Default => 0,
1503                ConfigSource::UserConfig => 1,
1504                ConfigSource::PyprojectToml => 2,
1505                ConfigSource::ProjectConfig => 3,
1506                ConfigSource::Cli => 4,
1507            }
1508        }
1509
1510        if source_precedence(new_source) >= source_precedence(self.source) {
1511            self.value = new_value.clone();
1512            self.source = new_source;
1513            self.overrides.push(ConfigOverride {
1514                value: new_value,
1515                source: new_source,
1516                file: new_file,
1517                line: new_line,
1518            });
1519        }
1520    }
1521
1522    pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
1523        // This is essentially merge_override without the precedence check
1524        // We might consolidate these later, but keep separate for now during refactor
1525        self.value = value.clone();
1526        self.source = source;
1527        self.overrides.push(ConfigOverride {
1528            value,
1529            source,
1530            file,
1531            line,
1532        });
1533    }
1534}
1535
1536impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
1537    /// Merges a new value using union semantics (for arrays like `disable`)
1538    /// Values from both sources are combined, with deduplication
1539    pub fn merge_union(
1540        &mut self,
1541        new_value: Vec<T>,
1542        new_source: ConfigSource,
1543        new_file: Option<String>,
1544        new_line: Option<usize>,
1545    ) {
1546        fn source_precedence(src: ConfigSource) -> u8 {
1547            match src {
1548                ConfigSource::Default => 0,
1549                ConfigSource::UserConfig => 1,
1550                ConfigSource::PyprojectToml => 2,
1551                ConfigSource::ProjectConfig => 3,
1552                ConfigSource::Cli => 4,
1553            }
1554        }
1555
1556        if source_precedence(new_source) >= source_precedence(self.source) {
1557            // Union: combine values from both sources with deduplication
1558            let mut combined = self.value.clone();
1559            for item in new_value.iter() {
1560                if !combined.contains(item) {
1561                    combined.push(item.clone());
1562                }
1563            }
1564
1565            self.value = combined;
1566            self.source = new_source;
1567            self.overrides.push(ConfigOverride {
1568                value: new_value,
1569                source: new_source,
1570                file: new_file,
1571                line: new_line,
1572            });
1573        }
1574    }
1575}
1576
1577#[derive(Debug, Clone)]
1578pub struct SourcedGlobalConfig {
1579    pub enable: SourcedValue<Vec<String>>,
1580    pub disable: SourcedValue<Vec<String>>,
1581    pub exclude: SourcedValue<Vec<String>>,
1582    pub include: SourcedValue<Vec<String>>,
1583    pub respect_gitignore: SourcedValue<bool>,
1584    pub line_length: SourcedValue<u64>,
1585    pub output_format: Option<SourcedValue<String>>,
1586    pub fixable: SourcedValue<Vec<String>>,
1587    pub unfixable: SourcedValue<Vec<String>>,
1588    pub flavor: SourcedValue<MarkdownFlavor>,
1589    pub force_exclude: SourcedValue<bool>,
1590    pub cache_dir: Option<SourcedValue<String>>,
1591}
1592
1593impl Default for SourcedGlobalConfig {
1594    fn default() -> Self {
1595        SourcedGlobalConfig {
1596            enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1597            disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1598            exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
1599            include: SourcedValue::new(Vec::new(), ConfigSource::Default),
1600            respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
1601            line_length: SourcedValue::new(80, ConfigSource::Default),
1602            output_format: None,
1603            fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1604            unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1605            flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
1606            force_exclude: SourcedValue::new(false, ConfigSource::Default),
1607            cache_dir: None,
1608        }
1609    }
1610}
1611
1612#[derive(Debug, Default, Clone)]
1613pub struct SourcedRuleConfig {
1614    pub values: BTreeMap<String, SourcedValue<toml::Value>>,
1615}
1616
1617/// Represents configuration loaded from a single source file, with provenance.
1618/// Used as an intermediate step before merging into the final SourcedConfig.
1619#[derive(Debug, Clone)]
1620pub struct SourcedConfigFragment {
1621    pub global: SourcedGlobalConfig,
1622    pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
1623    pub rules: BTreeMap<String, SourcedRuleConfig>,
1624    pub unknown_keys: Vec<(String, String, Option<String>)>, // (section, key, file_path)
1625                                                             // Note: loaded_files is tracked globally in SourcedConfig.
1626}
1627
1628impl Default for SourcedConfigFragment {
1629    fn default() -> Self {
1630        Self {
1631            global: SourcedGlobalConfig::default(),
1632            per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
1633            rules: BTreeMap::new(),
1634            unknown_keys: Vec::new(),
1635        }
1636    }
1637}
1638
1639#[derive(Debug, Clone)]
1640pub struct SourcedConfig {
1641    pub global: SourcedGlobalConfig,
1642    pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
1643    pub rules: BTreeMap<String, SourcedRuleConfig>,
1644    pub loaded_files: Vec<String>,
1645    pub unknown_keys: Vec<(String, String, Option<String>)>, // (section, key, file_path)
1646    /// Project root directory (parent of config file), used for resolving relative paths
1647    pub project_root: Option<std::path::PathBuf>,
1648}
1649
1650impl Default for SourcedConfig {
1651    fn default() -> Self {
1652        Self {
1653            global: SourcedGlobalConfig::default(),
1654            per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
1655            rules: BTreeMap::new(),
1656            loaded_files: Vec::new(),
1657            unknown_keys: Vec::new(),
1658            project_root: None,
1659        }
1660    }
1661}
1662
1663impl SourcedConfig {
1664    /// Merges another SourcedConfigFragment into this SourcedConfig.
1665    /// Uses source precedence to determine which values take effect.
1666    fn merge(&mut self, fragment: SourcedConfigFragment) {
1667        // Merge global config
1668        // Enable uses replace semantics (project can enforce rules)
1669        self.global.enable.merge_override(
1670            fragment.global.enable.value,
1671            fragment.global.enable.source,
1672            fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
1673            fragment.global.enable.overrides.first().and_then(|o| o.line),
1674        );
1675
1676        // Disable uses union semantics (user can add to project disables)
1677        self.global.disable.merge_union(
1678            fragment.global.disable.value,
1679            fragment.global.disable.source,
1680            fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
1681            fragment.global.disable.overrides.first().and_then(|o| o.line),
1682        );
1683
1684        // Conflict resolution: Enable overrides disable
1685        // Remove any rules from disable that appear in enable
1686        self.global
1687            .disable
1688            .value
1689            .retain(|rule| !self.global.enable.value.contains(rule));
1690        self.global.include.merge_override(
1691            fragment.global.include.value,
1692            fragment.global.include.source,
1693            fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
1694            fragment.global.include.overrides.first().and_then(|o| o.line),
1695        );
1696        self.global.exclude.merge_override(
1697            fragment.global.exclude.value,
1698            fragment.global.exclude.source,
1699            fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
1700            fragment.global.exclude.overrides.first().and_then(|o| o.line),
1701        );
1702        self.global.respect_gitignore.merge_override(
1703            fragment.global.respect_gitignore.value,
1704            fragment.global.respect_gitignore.source,
1705            fragment
1706                .global
1707                .respect_gitignore
1708                .overrides
1709                .first()
1710                .and_then(|o| o.file.clone()),
1711            fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
1712        );
1713        self.global.line_length.merge_override(
1714            fragment.global.line_length.value,
1715            fragment.global.line_length.source,
1716            fragment
1717                .global
1718                .line_length
1719                .overrides
1720                .first()
1721                .and_then(|o| o.file.clone()),
1722            fragment.global.line_length.overrides.first().and_then(|o| o.line),
1723        );
1724        self.global.fixable.merge_override(
1725            fragment.global.fixable.value,
1726            fragment.global.fixable.source,
1727            fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
1728            fragment.global.fixable.overrides.first().and_then(|o| o.line),
1729        );
1730        self.global.unfixable.merge_override(
1731            fragment.global.unfixable.value,
1732            fragment.global.unfixable.source,
1733            fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
1734            fragment.global.unfixable.overrides.first().and_then(|o| o.line),
1735        );
1736
1737        // Merge flavor
1738        self.global.flavor.merge_override(
1739            fragment.global.flavor.value,
1740            fragment.global.flavor.source,
1741            fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
1742            fragment.global.flavor.overrides.first().and_then(|o| o.line),
1743        );
1744
1745        // Merge force_exclude
1746        self.global.force_exclude.merge_override(
1747            fragment.global.force_exclude.value,
1748            fragment.global.force_exclude.source,
1749            fragment
1750                .global
1751                .force_exclude
1752                .overrides
1753                .first()
1754                .and_then(|o| o.file.clone()),
1755            fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
1756        );
1757
1758        // Merge output_format if present
1759        if let Some(output_format_fragment) = fragment.global.output_format {
1760            if let Some(ref mut output_format) = self.global.output_format {
1761                output_format.merge_override(
1762                    output_format_fragment.value,
1763                    output_format_fragment.source,
1764                    output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
1765                    output_format_fragment.overrides.first().and_then(|o| o.line),
1766                );
1767            } else {
1768                self.global.output_format = Some(output_format_fragment);
1769            }
1770        }
1771
1772        // Merge cache_dir if present
1773        if let Some(cache_dir_fragment) = fragment.global.cache_dir {
1774            if let Some(ref mut cache_dir) = self.global.cache_dir {
1775                cache_dir.merge_override(
1776                    cache_dir_fragment.value,
1777                    cache_dir_fragment.source,
1778                    cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
1779                    cache_dir_fragment.overrides.first().and_then(|o| o.line),
1780                );
1781            } else {
1782                self.global.cache_dir = Some(cache_dir_fragment);
1783            }
1784        }
1785
1786        // Merge per_file_ignores
1787        self.per_file_ignores.merge_override(
1788            fragment.per_file_ignores.value,
1789            fragment.per_file_ignores.source,
1790            fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
1791            fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
1792        );
1793
1794        // Merge rule configs
1795        for (rule_name, rule_fragment) in fragment.rules {
1796            let norm_rule_name = rule_name.to_ascii_uppercase(); // Normalize to uppercase for case-insensitivity
1797            let rule_entry = self.rules.entry(norm_rule_name).or_default();
1798            for (key, sourced_value_fragment) in rule_fragment.values {
1799                let sv_entry = rule_entry
1800                    .values
1801                    .entry(key.clone())
1802                    .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
1803                let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
1804                let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
1805                sv_entry.merge_override(
1806                    sourced_value_fragment.value,  // Use the value from the fragment
1807                    sourced_value_fragment.source, // Use the source from the fragment
1808                    file_from_fragment,            // Pass the file path from the fragment override
1809                    line_from_fragment,            // Pass the line number from the fragment override
1810                );
1811            }
1812        }
1813
1814        // Merge unknown_keys from fragment
1815        for (section, key, file_path) in fragment.unknown_keys {
1816            // Deduplicate: only add if not already present
1817            if !self.unknown_keys.iter().any(|(s, k, _)| s == &section && k == &key) {
1818                self.unknown_keys.push((section, key, file_path));
1819            }
1820        }
1821    }
1822
1823    /// Load and merge configurations from files and CLI overrides.
1824    pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
1825        Self::load_with_discovery(config_path, cli_overrides, false)
1826    }
1827
1828    /// Finds project root by walking up from start_dir looking for .git directory.
1829    /// Falls back to start_dir if no .git found.
1830    fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
1831        let mut current = start_dir.to_path_buf();
1832        const MAX_DEPTH: usize = 100;
1833
1834        for _ in 0..MAX_DEPTH {
1835            if current.join(".git").exists() {
1836                log::debug!("[rumdl-config] Found .git at: {}", current.display());
1837                return current;
1838            }
1839
1840            match current.parent() {
1841                Some(parent) => current = parent.to_path_buf(),
1842                None => break,
1843            }
1844        }
1845
1846        // No .git found, use start_dir as project root
1847        log::debug!(
1848            "[rumdl-config] No .git found, using config location as project root: {}",
1849            start_dir.display()
1850        );
1851        start_dir.to_path_buf()
1852    }
1853
1854    /// Discover configuration file by traversing up the directory tree.
1855    /// Returns the first configuration file found.
1856    /// Discovers config file and returns both the config path and project root.
1857    /// Returns: (config_file_path, project_root_path)
1858    /// Project root is the directory containing .git, or config parent as fallback.
1859    fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
1860        use std::env;
1861
1862        const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
1863        const MAX_DEPTH: usize = 100; // Prevent infinite traversal
1864
1865        let start_dir = match env::current_dir() {
1866            Ok(dir) => dir,
1867            Err(e) => {
1868                log::debug!("[rumdl-config] Failed to get current directory: {e}");
1869                return None;
1870            }
1871        };
1872
1873        let mut current_dir = start_dir.clone();
1874        let mut depth = 0;
1875        let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
1876
1877        loop {
1878            if depth >= MAX_DEPTH {
1879                log::debug!("[rumdl-config] Maximum traversal depth reached");
1880                break;
1881            }
1882
1883            log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
1884
1885            // Check for config files in order of precedence (only if not already found)
1886            if found_config.is_none() {
1887                for config_name in CONFIG_FILES {
1888                    let config_path = current_dir.join(config_name);
1889
1890                    if config_path.exists() {
1891                        // For pyproject.toml, verify it contains [tool.rumdl] section
1892                        if *config_name == "pyproject.toml" {
1893                            if let Ok(content) = std::fs::read_to_string(&config_path) {
1894                                if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1895                                    log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1896                                    // Store config, but continue looking for .git
1897                                    found_config = Some((config_path.clone(), current_dir.clone()));
1898                                    break;
1899                                }
1900                                log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
1901                                continue;
1902                            }
1903                        } else {
1904                            log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1905                            // Store config, but continue looking for .git
1906                            found_config = Some((config_path.clone(), current_dir.clone()));
1907                            break;
1908                        }
1909                    }
1910                }
1911            }
1912
1913            // Check for .git directory (stop boundary)
1914            if current_dir.join(".git").exists() {
1915                log::debug!("[rumdl-config] Stopping at .git directory");
1916                break;
1917            }
1918
1919            // Move to parent directory
1920            match current_dir.parent() {
1921                Some(parent) => {
1922                    current_dir = parent.to_owned();
1923                    depth += 1;
1924                }
1925                None => {
1926                    log::debug!("[rumdl-config] Reached filesystem root");
1927                    break;
1928                }
1929            }
1930        }
1931
1932        // If config found, determine project root by walking up from config location
1933        if let Some((config_path, config_dir)) = found_config {
1934            let project_root = Self::find_project_root_from(&config_dir);
1935            return Some((config_path, project_root));
1936        }
1937
1938        None
1939    }
1940
1941    /// Internal implementation that accepts config directory for testing
1942    fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
1943        let config_dir = config_dir.join("rumdl");
1944
1945        // Check for config files in precedence order (same as project discovery)
1946        const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
1947
1948        log::debug!(
1949            "[rumdl-config] Checking for user configuration in: {}",
1950            config_dir.display()
1951        );
1952
1953        for filename in USER_CONFIG_FILES {
1954            let config_path = config_dir.join(filename);
1955
1956            if config_path.exists() {
1957                // For pyproject.toml, verify it contains [tool.rumdl] section
1958                if *filename == "pyproject.toml" {
1959                    if let Ok(content) = std::fs::read_to_string(&config_path) {
1960                        if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1961                            log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
1962                            return Some(config_path);
1963                        }
1964                        log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
1965                        continue;
1966                    }
1967                } else {
1968                    log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
1969                    return Some(config_path);
1970                }
1971            }
1972        }
1973
1974        log::debug!(
1975            "[rumdl-config] No user configuration found in: {}",
1976            config_dir.display()
1977        );
1978        None
1979    }
1980
1981    /// Discover user-level configuration file from platform-specific config directory.
1982    /// Returns the first configuration file found in the user config directory.
1983    #[cfg(feature = "native")]
1984    fn user_configuration_path() -> Option<std::path::PathBuf> {
1985        use etcetera::{BaseStrategy, choose_base_strategy};
1986
1987        match choose_base_strategy() {
1988            Ok(strategy) => {
1989                let config_dir = strategy.config_dir();
1990                Self::user_configuration_path_impl(&config_dir)
1991            }
1992            Err(e) => {
1993                log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
1994                None
1995            }
1996        }
1997    }
1998
1999    /// Stub for WASM builds - user config not supported
2000    #[cfg(not(feature = "native"))]
2001    fn user_configuration_path() -> Option<std::path::PathBuf> {
2002        None
2003    }
2004
2005    /// Internal implementation that accepts user config directory for testing
2006    #[doc(hidden)]
2007    pub fn load_with_discovery_impl(
2008        config_path: Option<&str>,
2009        cli_overrides: Option<&SourcedGlobalConfig>,
2010        skip_auto_discovery: bool,
2011        user_config_dir: Option<&Path>,
2012    ) -> Result<Self, ConfigError> {
2013        use std::env;
2014        log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
2015        if config_path.is_none() {
2016            if skip_auto_discovery {
2017                log::debug!("[rumdl-config] Skipping auto-discovery due to --no-config flag");
2018            } else {
2019                log::debug!("[rumdl-config] No explicit config_path provided, will search default locations");
2020            }
2021        } else {
2022            log::debug!("[rumdl-config] Explicit config_path provided: {config_path:?}");
2023        }
2024        let mut sourced_config = SourcedConfig::default();
2025
2026        // 1. Always load user configuration first (unless auto-discovery is disabled)
2027        // User config serves as the base layer that project configs build upon
2028        if !skip_auto_discovery {
2029            let user_config_path = if let Some(dir) = user_config_dir {
2030                Self::user_configuration_path_impl(dir)
2031            } else {
2032                Self::user_configuration_path()
2033            };
2034
2035            if let Some(user_config_path) = user_config_path {
2036                let path_str = user_config_path.display().to_string();
2037                let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
2038
2039                log::debug!("[rumdl-config] Loading user configuration file: {path_str}");
2040
2041                if filename == "pyproject.toml" {
2042                    let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2043                        source: e,
2044                        path: path_str.clone(),
2045                    })?;
2046                    if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2047                        sourced_config.merge(fragment);
2048                        sourced_config.loaded_files.push(path_str);
2049                    }
2050                } else {
2051                    let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2052                        source: e,
2053                        path: path_str.clone(),
2054                    })?;
2055                    let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
2056                    sourced_config.merge(fragment);
2057                    sourced_config.loaded_files.push(path_str);
2058                }
2059            } else {
2060                log::debug!("[rumdl-config] No user configuration file found");
2061            }
2062        }
2063
2064        // 2. Load explicit config path if provided (overrides user config)
2065        if let Some(path) = config_path {
2066            let path_obj = Path::new(path);
2067            let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
2068            log::debug!("[rumdl-config] Trying to load config file: {filename}");
2069            let path_str = path.to_string();
2070
2071            // Find project root by walking up from config location looking for .git
2072            if let Some(config_parent) = path_obj.parent() {
2073                let project_root = Self::find_project_root_from(config_parent);
2074                log::debug!(
2075                    "[rumdl-config] Project root (from explicit config): {}",
2076                    project_root.display()
2077                );
2078                sourced_config.project_root = Some(project_root);
2079            }
2080
2081            // Known markdownlint config files
2082            const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
2083
2084            if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
2085                let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2086                    source: e,
2087                    path: path_str.clone(),
2088                })?;
2089                if filename == "pyproject.toml" {
2090                    if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2091                        sourced_config.merge(fragment);
2092                        sourced_config.loaded_files.push(path_str.clone());
2093                    }
2094                } else {
2095                    let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2096                    sourced_config.merge(fragment);
2097                    sourced_config.loaded_files.push(path_str.clone());
2098                }
2099            } else if MARKDOWNLINT_FILENAMES.contains(&filename)
2100                || path_str.ends_with(".json")
2101                || path_str.ends_with(".jsonc")
2102                || path_str.ends_with(".yaml")
2103                || path_str.ends_with(".yml")
2104            {
2105                // Parse as markdownlint config (JSON/YAML)
2106                let fragment = load_from_markdownlint(&path_str)?;
2107                sourced_config.merge(fragment);
2108                sourced_config.loaded_files.push(path_str.clone());
2109                // markdownlint is fallback only
2110            } else {
2111                // Try TOML only
2112                let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2113                    source: e,
2114                    path: path_str.clone(),
2115                })?;
2116                let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2117                sourced_config.merge(fragment);
2118                sourced_config.loaded_files.push(path_str.clone());
2119            }
2120        }
2121
2122        // 3. Perform auto-discovery for project config if not skipped AND no explicit config path
2123        if !skip_auto_discovery && config_path.is_none() {
2124            // Look for project configuration files (override user config)
2125            if let Some((config_file, project_root)) = Self::discover_config_upward() {
2126                let path_str = config_file.display().to_string();
2127                let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
2128
2129                log::debug!("[rumdl-config] Loading discovered config file: {path_str}");
2130                log::debug!("[rumdl-config] Project root: {}", project_root.display());
2131
2132                // Store project root for cache directory resolution
2133                sourced_config.project_root = Some(project_root);
2134
2135                if filename == "pyproject.toml" {
2136                    let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2137                        source: e,
2138                        path: path_str.clone(),
2139                    })?;
2140                    if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2141                        sourced_config.merge(fragment);
2142                        sourced_config.loaded_files.push(path_str);
2143                    }
2144                } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
2145                    let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2146                        source: e,
2147                        path: path_str.clone(),
2148                    })?;
2149                    let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2150                    sourced_config.merge(fragment);
2151                    sourced_config.loaded_files.push(path_str);
2152                }
2153            } else {
2154                log::debug!("[rumdl-config] No configuration file found via upward traversal");
2155
2156                // If no project config found, fallback to markdownlint config in current directory
2157                let mut found_markdownlint = false;
2158                for filename in MARKDOWNLINT_CONFIG_FILES {
2159                    if std::path::Path::new(filename).exists() {
2160                        match load_from_markdownlint(filename) {
2161                            Ok(fragment) => {
2162                                sourced_config.merge(fragment);
2163                                sourced_config.loaded_files.push(filename.to_string());
2164                                found_markdownlint = true;
2165                                break; // Load only the first one found
2166                            }
2167                            Err(_e) => {
2168                                // Log error but continue (it's just a fallback)
2169                            }
2170                        }
2171                    }
2172                }
2173
2174                if !found_markdownlint {
2175                    log::debug!("[rumdl-config] No markdownlint configuration file found");
2176                }
2177            }
2178        }
2179
2180        // 4. Apply CLI overrides (highest precedence)
2181        if let Some(cli) = cli_overrides {
2182            sourced_config
2183                .global
2184                .enable
2185                .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
2186            sourced_config
2187                .global
2188                .disable
2189                .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
2190            sourced_config
2191                .global
2192                .exclude
2193                .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
2194            sourced_config
2195                .global
2196                .include
2197                .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
2198            sourced_config.global.respect_gitignore.merge_override(
2199                cli.respect_gitignore.value,
2200                ConfigSource::Cli,
2201                None,
2202                None,
2203            );
2204            sourced_config
2205                .global
2206                .fixable
2207                .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
2208            sourced_config
2209                .global
2210                .unfixable
2211                .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
2212            // No rule-specific CLI overrides implemented yet
2213        }
2214
2215        // Unknown keys are now collected during parsing and validated via validate_config_sourced()
2216
2217        Ok(sourced_config)
2218    }
2219
2220    /// Load and merge configurations from files and CLI overrides.
2221    /// If skip_auto_discovery is true, only explicit config paths are loaded.
2222    pub fn load_with_discovery(
2223        config_path: Option<&str>,
2224        cli_overrides: Option<&SourcedGlobalConfig>,
2225        skip_auto_discovery: bool,
2226    ) -> Result<Self, ConfigError> {
2227        Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
2228    }
2229}
2230
2231impl From<SourcedConfig> for Config {
2232    fn from(sourced: SourcedConfig) -> Self {
2233        let mut rules = BTreeMap::new();
2234        for (rule_name, sourced_rule_cfg) in sourced.rules {
2235            // Normalize rule name to uppercase for case-insensitive lookup
2236            let normalized_rule_name = rule_name.to_ascii_uppercase();
2237            let mut values = BTreeMap::new();
2238            for (key, sourced_val) in sourced_rule_cfg.values {
2239                values.insert(key, sourced_val.value);
2240            }
2241            rules.insert(normalized_rule_name, RuleConfig { values });
2242        }
2243        #[allow(deprecated)]
2244        let global = GlobalConfig {
2245            enable: sourced.global.enable.value,
2246            disable: sourced.global.disable.value,
2247            exclude: sourced.global.exclude.value,
2248            include: sourced.global.include.value,
2249            respect_gitignore: sourced.global.respect_gitignore.value,
2250            line_length: sourced.global.line_length.value,
2251            output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
2252            fixable: sourced.global.fixable.value,
2253            unfixable: sourced.global.unfixable.value,
2254            flavor: sourced.global.flavor.value,
2255            force_exclude: sourced.global.force_exclude.value,
2256            cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
2257        };
2258        Config {
2259            global,
2260            per_file_ignores: sourced.per_file_ignores.value,
2261            rules,
2262        }
2263    }
2264}
2265
2266/// Registry of all known rules and their config schemas
2267pub struct RuleRegistry {
2268    /// Map of rule name (e.g. "MD013") to set of valid config keys and their TOML value types
2269    pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
2270    /// Map of rule name to config key aliases
2271    pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
2272}
2273
2274impl RuleRegistry {
2275    /// Build a registry from a list of rules
2276    pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
2277        let mut rule_schemas = std::collections::BTreeMap::new();
2278        let mut rule_aliases = std::collections::BTreeMap::new();
2279
2280        for rule in rules {
2281            let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
2282                let norm_name = normalize_key(&name); // Normalize the name from default_config_section
2283                rule_schemas.insert(norm_name.clone(), table);
2284                norm_name
2285            } else {
2286                let norm_name = normalize_key(rule.name()); // Normalize the name from rule.name()
2287                rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
2288                norm_name
2289            };
2290
2291            // Store aliases if the rule provides them
2292            if let Some(aliases) = rule.config_aliases() {
2293                rule_aliases.insert(norm_name, aliases);
2294            }
2295        }
2296
2297        RuleRegistry {
2298            rule_schemas,
2299            rule_aliases,
2300        }
2301    }
2302
2303    /// Get all known rule names
2304    pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
2305        self.rule_schemas.keys().cloned().collect()
2306    }
2307
2308    /// Get the valid configuration keys for a rule, including both original and normalized variants
2309    pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
2310        self.rule_schemas.get(rule).map(|schema| {
2311            let mut all_keys = std::collections::BTreeSet::new();
2312
2313            // Add original keys from schema
2314            for key in schema.keys() {
2315                all_keys.insert(key.clone());
2316            }
2317
2318            // Add normalized variants for markdownlint compatibility
2319            for key in schema.keys() {
2320                // Add kebab-case variant
2321                all_keys.insert(key.replace('_', "-"));
2322                // Add snake_case variant
2323                all_keys.insert(key.replace('-', "_"));
2324                // Add normalized variant
2325                all_keys.insert(normalize_key(key));
2326            }
2327
2328            // Add any aliases defined by the rule
2329            if let Some(aliases) = self.rule_aliases.get(rule) {
2330                for alias_key in aliases.keys() {
2331                    all_keys.insert(alias_key.clone());
2332                    // Also add normalized variants of the alias
2333                    all_keys.insert(alias_key.replace('_', "-"));
2334                    all_keys.insert(alias_key.replace('-', "_"));
2335                    all_keys.insert(normalize_key(alias_key));
2336                }
2337            }
2338
2339            all_keys
2340        })
2341    }
2342
2343    /// Get the expected value type for a rule's configuration key, trying variants
2344    pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
2345        if let Some(schema) = self.rule_schemas.get(rule) {
2346            // Check if this key is an alias
2347            if let Some(aliases) = self.rule_aliases.get(rule)
2348                && let Some(canonical_key) = aliases.get(key)
2349            {
2350                // Use the canonical key for schema lookup
2351                if let Some(value) = schema.get(canonical_key) {
2352                    return Some(value);
2353                }
2354            }
2355
2356            // Try the original key
2357            if let Some(value) = schema.get(key) {
2358                return Some(value);
2359            }
2360
2361            // Try key variants
2362            let key_variants = [
2363                key.replace('-', "_"), // Convert kebab-case to snake_case
2364                key.replace('_', "-"), // Convert snake_case to kebab-case
2365                normalize_key(key),    // Normalized key (lowercase, kebab-case)
2366            ];
2367
2368            for variant in &key_variants {
2369                if let Some(value) = schema.get(variant) {
2370                    return Some(value);
2371                }
2372            }
2373        }
2374        None
2375    }
2376}
2377
2378/// Represents a config validation warning or error
2379#[derive(Debug, Clone)]
2380pub struct ConfigValidationWarning {
2381    pub message: String,
2382    pub rule: Option<String>,
2383    pub key: Option<String>,
2384}
2385
2386/// Validate a loaded config against the rule registry, using SourcedConfig for unknown key tracking
2387pub fn validate_config_sourced(sourced: &SourcedConfig, registry: &RuleRegistry) -> Vec<ConfigValidationWarning> {
2388    let mut warnings = Vec::new();
2389    let known_rules = registry.rule_names();
2390    // 1. Unknown rules
2391    for rule in sourced.rules.keys() {
2392        if !known_rules.contains(rule) {
2393            warnings.push(ConfigValidationWarning {
2394                message: format!("Unknown rule in config: {rule}"),
2395                rule: Some(rule.clone()),
2396                key: None,
2397            });
2398        }
2399    }
2400    // 2. Unknown options and type mismatches
2401    for (rule, rule_cfg) in &sourced.rules {
2402        if let Some(valid_keys) = registry.config_keys_for(rule) {
2403            for key in rule_cfg.values.keys() {
2404                if !valid_keys.contains(key) {
2405                    let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
2406                    let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
2407                        format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
2408                    } else {
2409                        format!("Unknown option for rule {rule}: {key}")
2410                    };
2411                    warnings.push(ConfigValidationWarning {
2412                        message,
2413                        rule: Some(rule.clone()),
2414                        key: Some(key.clone()),
2415                    });
2416                } else {
2417                    // Type check: compare type of value to type of default
2418                    if let Some(expected) = registry.expected_value_for(rule, key) {
2419                        let actual = &rule_cfg.values[key].value;
2420                        if !toml_value_type_matches(expected, actual) {
2421                            warnings.push(ConfigValidationWarning {
2422                                message: format!(
2423                                    "Type mismatch for {}.{}: expected {}, got {}",
2424                                    rule,
2425                                    key,
2426                                    toml_type_name(expected),
2427                                    toml_type_name(actual)
2428                                ),
2429                                rule: Some(rule.clone()),
2430                                key: Some(key.clone()),
2431                            });
2432                        }
2433                    }
2434                }
2435            }
2436        }
2437    }
2438    // 3. Unknown global options (from unknown_keys)
2439    let known_global_keys = vec![
2440        "enable".to_string(),
2441        "disable".to_string(),
2442        "include".to_string(),
2443        "exclude".to_string(),
2444        "respect-gitignore".to_string(),
2445        "line-length".to_string(),
2446        "fixable".to_string(),
2447        "unfixable".to_string(),
2448        "flavor".to_string(),
2449        "force-exclude".to_string(),
2450        "output-format".to_string(),
2451        "cache-dir".to_string(),
2452    ];
2453
2454    for (section, key, file_path) in &sourced.unknown_keys {
2455        if section.contains("[global]") || section.contains("[tool.rumdl]") {
2456            let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
2457                if let Some(path) = file_path {
2458                    format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
2459                } else {
2460                    format!("Unknown global option: {key} (did you mean: {suggestion}?)")
2461                }
2462            } else if let Some(path) = file_path {
2463                format!("Unknown global option in {path}: {key}")
2464            } else {
2465                format!("Unknown global option: {key}")
2466            };
2467            warnings.push(ConfigValidationWarning {
2468                message,
2469                rule: None,
2470                key: Some(key.clone()),
2471            });
2472        } else if !key.is_empty() {
2473            // This is an unknown rule section (key is empty means it's a section header)
2474            // No suggestions for rule names - just warn
2475            continue;
2476        } else {
2477            // Unknown rule section
2478            let message = if let Some(path) = file_path {
2479                format!(
2480                    "Unknown rule in {path}: {}",
2481                    section.trim_matches(|c| c == '[' || c == ']')
2482                )
2483            } else {
2484                format!(
2485                    "Unknown rule in config: {}",
2486                    section.trim_matches(|c| c == '[' || c == ']')
2487                )
2488            };
2489            warnings.push(ConfigValidationWarning {
2490                message,
2491                rule: None,
2492                key: None,
2493            });
2494        }
2495    }
2496    warnings
2497}
2498
2499fn toml_type_name(val: &toml::Value) -> &'static str {
2500    match val {
2501        toml::Value::String(_) => "string",
2502        toml::Value::Integer(_) => "integer",
2503        toml::Value::Float(_) => "float",
2504        toml::Value::Boolean(_) => "boolean",
2505        toml::Value::Array(_) => "array",
2506        toml::Value::Table(_) => "table",
2507        toml::Value::Datetime(_) => "datetime",
2508    }
2509}
2510
2511/// Calculate Levenshtein distance between two strings (simple implementation)
2512fn levenshtein_distance(s1: &str, s2: &str) -> usize {
2513    let len1 = s1.len();
2514    let len2 = s2.len();
2515
2516    if len1 == 0 {
2517        return len2;
2518    }
2519    if len2 == 0 {
2520        return len1;
2521    }
2522
2523    let s1_chars: Vec<char> = s1.chars().collect();
2524    let s2_chars: Vec<char> = s2.chars().collect();
2525
2526    let mut prev_row: Vec<usize> = (0..=len2).collect();
2527    let mut curr_row = vec![0; len2 + 1];
2528
2529    for i in 1..=len1 {
2530        curr_row[0] = i;
2531        for j in 1..=len2 {
2532            let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
2533            curr_row[j] = (prev_row[j] + 1)          // deletion
2534                .min(curr_row[j - 1] + 1)            // insertion
2535                .min(prev_row[j - 1] + cost); // substitution
2536        }
2537        std::mem::swap(&mut prev_row, &mut curr_row);
2538    }
2539
2540    prev_row[len2]
2541}
2542
2543/// Suggest a similar key from a list of valid keys using fuzzy matching
2544fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
2545    let unknown_lower = unknown.to_lowercase();
2546    let max_distance = 2.max(unknown.len() / 3); // Allow up to 2 edits or 30% of string length
2547
2548    let mut best_match: Option<(String, usize)> = None;
2549
2550    for valid in valid_keys {
2551        let valid_lower = valid.to_lowercase();
2552        let distance = levenshtein_distance(&unknown_lower, &valid_lower);
2553
2554        if distance <= max_distance {
2555            if let Some((_, best_dist)) = &best_match {
2556                if distance < *best_dist {
2557                    best_match = Some((valid.clone(), distance));
2558                }
2559            } else {
2560                best_match = Some((valid.clone(), distance));
2561            }
2562        }
2563    }
2564
2565    best_match.map(|(key, _)| key)
2566}
2567
2568fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
2569    use toml::Value::*;
2570    match (expected, actual) {
2571        (String(_), String(_)) => true,
2572        (Integer(_), Integer(_)) => true,
2573        (Float(_), Float(_)) => true,
2574        (Boolean(_), Boolean(_)) => true,
2575        (Array(_), Array(_)) => true,
2576        (Table(_), Table(_)) => true,
2577        (Datetime(_), Datetime(_)) => true,
2578        // Allow integer for float
2579        (Float(_), Integer(_)) => true,
2580        _ => false,
2581    }
2582}
2583
2584/// Parses pyproject.toml content and extracts the [tool.rumdl] section if present.
2585fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
2586    let doc: toml::Value =
2587        toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2588    let mut fragment = SourcedConfigFragment::default();
2589    let source = ConfigSource::PyprojectToml;
2590    let file = Some(path.to_string());
2591
2592    // 1. Handle [tool.rumdl] and [tool.rumdl.global] sections
2593    if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
2594        && let Some(rumdl_table) = rumdl_config.as_table()
2595    {
2596        // Helper function to extract global config from a table
2597        let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
2598            // Extract global options from the given table
2599            if let Some(enable) = table.get("enable")
2600                && let Ok(values) = Vec::<String>::deserialize(enable.clone())
2601            {
2602                // Normalize rule names in the list
2603                let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2604                fragment
2605                    .global
2606                    .enable
2607                    .push_override(normalized_values, source, file.clone(), None);
2608            }
2609
2610            if let Some(disable) = table.get("disable")
2611                && let Ok(values) = Vec::<String>::deserialize(disable.clone())
2612            {
2613                // Re-enable normalization
2614                let normalized_values: Vec<String> = values.into_iter().map(|s| normalize_key(&s)).collect();
2615                fragment
2616                    .global
2617                    .disable
2618                    .push_override(normalized_values, source, file.clone(), None);
2619            }
2620
2621            if let Some(include) = table.get("include")
2622                && let Ok(values) = Vec::<String>::deserialize(include.clone())
2623            {
2624                fragment
2625                    .global
2626                    .include
2627                    .push_override(values, source, file.clone(), None);
2628            }
2629
2630            if let Some(exclude) = table.get("exclude")
2631                && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
2632            {
2633                fragment
2634                    .global
2635                    .exclude
2636                    .push_override(values, source, file.clone(), None);
2637            }
2638
2639            if let Some(respect_gitignore) = table
2640                .get("respect-gitignore")
2641                .or_else(|| table.get("respect_gitignore"))
2642                && let Ok(value) = bool::deserialize(respect_gitignore.clone())
2643            {
2644                fragment
2645                    .global
2646                    .respect_gitignore
2647                    .push_override(value, source, file.clone(), None);
2648            }
2649
2650            if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
2651                && let Ok(value) = bool::deserialize(force_exclude.clone())
2652            {
2653                fragment
2654                    .global
2655                    .force_exclude
2656                    .push_override(value, source, file.clone(), None);
2657            }
2658
2659            if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
2660                && let Ok(value) = String::deserialize(output_format.clone())
2661            {
2662                if fragment.global.output_format.is_none() {
2663                    fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
2664                } else {
2665                    fragment
2666                        .global
2667                        .output_format
2668                        .as_mut()
2669                        .unwrap()
2670                        .push_override(value, source, file.clone(), None);
2671                }
2672            }
2673
2674            if let Some(fixable) = table.get("fixable")
2675                && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
2676            {
2677                let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2678                fragment
2679                    .global
2680                    .fixable
2681                    .push_override(normalized_values, source, file.clone(), None);
2682            }
2683
2684            if let Some(unfixable) = table.get("unfixable")
2685                && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
2686            {
2687                let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2688                fragment
2689                    .global
2690                    .unfixable
2691                    .push_override(normalized_values, source, file.clone(), None);
2692            }
2693
2694            if let Some(flavor) = table.get("flavor")
2695                && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
2696            {
2697                fragment.global.flavor.push_override(value, source, file.clone(), None);
2698            }
2699
2700            // Handle line-length special case - this should set the global line_length
2701            if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
2702                && let Ok(value) = u64::deserialize(line_length.clone())
2703            {
2704                fragment
2705                    .global
2706                    .line_length
2707                    .push_override(value, source, file.clone(), None);
2708
2709                // Also add to MD013 rule config for backward compatibility
2710                let norm_md013_key = normalize_key("MD013");
2711                let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
2712                let norm_line_length_key = normalize_key("line-length");
2713                let sv = rule_entry
2714                    .values
2715                    .entry(norm_line_length_key)
2716                    .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
2717                sv.push_override(line_length.clone(), source, file.clone(), None);
2718            }
2719
2720            if let Some(cache_dir) = table.get("cache-dir").or_else(|| table.get("cache_dir"))
2721                && let Ok(value) = String::deserialize(cache_dir.clone())
2722            {
2723                if fragment.global.cache_dir.is_none() {
2724                    fragment.global.cache_dir = Some(SourcedValue::new(value.clone(), source));
2725                } else {
2726                    fragment
2727                        .global
2728                        .cache_dir
2729                        .as_mut()
2730                        .unwrap()
2731                        .push_override(value, source, file.clone(), None);
2732                }
2733            }
2734        };
2735
2736        // First, check for [tool.rumdl.global] section
2737        if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
2738            extract_global_config(&mut fragment, global_table);
2739        }
2740
2741        // Also extract global options from [tool.rumdl] directly (for flat structure)
2742        extract_global_config(&mut fragment, rumdl_table);
2743
2744        // --- Extract per-file-ignores configurations ---
2745        // Check both hyphenated and underscored versions for compatibility
2746        let per_file_ignores_key = rumdl_table
2747            .get("per-file-ignores")
2748            .or_else(|| rumdl_table.get("per_file_ignores"));
2749
2750        if let Some(per_file_ignores_value) = per_file_ignores_key
2751            && let Some(per_file_table) = per_file_ignores_value.as_table()
2752        {
2753            let mut per_file_map = HashMap::new();
2754            for (pattern, rules_value) in per_file_table {
2755                if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
2756                    let normalized_rules = rules.into_iter().map(|s| normalize_key(&s)).collect();
2757                    per_file_map.insert(pattern.clone(), normalized_rules);
2758                } else {
2759                    log::warn!(
2760                        "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {rules_value:?}"
2761                    );
2762                }
2763            }
2764            fragment
2765                .per_file_ignores
2766                .push_override(per_file_map, source, file.clone(), None);
2767        }
2768
2769        // --- Extract rule-specific configurations ---
2770        for (key, value) in rumdl_table {
2771            let norm_rule_key = normalize_key(key);
2772
2773            // Skip keys already handled as global or special cases
2774            if [
2775                "enable",
2776                "disable",
2777                "include",
2778                "exclude",
2779                "respect_gitignore",
2780                "respect-gitignore", // Added kebab-case here too
2781                "force_exclude",
2782                "force-exclude",
2783                "line_length",
2784                "line-length",
2785                "output_format",
2786                "output-format",
2787                "fixable",
2788                "unfixable",
2789                "per-file-ignores",
2790                "per_file_ignores",
2791                "global",
2792                "flavor",
2793                "cache_dir",
2794                "cache-dir",
2795            ]
2796            .contains(&norm_rule_key.as_str())
2797            {
2798                continue;
2799            }
2800
2801            // Explicitly check if the key looks like a rule name (e.g., starts with 'md')
2802            // AND if the value is actually a TOML table before processing as rule config.
2803            // This prevents misinterpreting other top-level keys under [tool.rumdl]
2804            let norm_rule_key_upper = norm_rule_key.to_ascii_uppercase();
2805            if norm_rule_key_upper.len() == 5
2806                && norm_rule_key_upper.starts_with("MD")
2807                && norm_rule_key_upper[2..].chars().all(|c| c.is_ascii_digit())
2808                && value.is_table()
2809            {
2810                if let Some(rule_config_table) = value.as_table() {
2811                    // Get the entry for this rule (e.g., "md013")
2812                    let rule_entry = fragment.rules.entry(norm_rule_key_upper).or_default();
2813                    for (rk, rv) in rule_config_table {
2814                        let norm_rk = normalize_key(rk); // Normalize the config key itself
2815
2816                        let toml_val = rv.clone();
2817
2818                        let sv = rule_entry
2819                            .values
2820                            .entry(norm_rk.clone())
2821                            .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2822                        sv.push_override(toml_val, source, file.clone(), None);
2823                    }
2824                }
2825            } else {
2826                // Key is not a global/special key, doesn't start with 'md', or isn't a table.
2827                // Track unknown keys under [tool.rumdl] for validation
2828                fragment
2829                    .unknown_keys
2830                    .push(("[tool.rumdl]".to_string(), key.to_string(), Some(path.to_string())));
2831            }
2832        }
2833    }
2834
2835    // 2. Handle [tool.rumdl.MDxxx] sections as rule-specific config (nested under [tool])
2836    if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
2837        for (key, value) in tool_table.iter() {
2838            if let Some(rule_name) = key.strip_prefix("rumdl.") {
2839                let norm_rule_name = normalize_key(rule_name);
2840                if norm_rule_name.len() == 5
2841                    && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2842                    && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2843                    && let Some(rule_table) = value.as_table()
2844                {
2845                    let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2846                    for (rk, rv) in rule_table {
2847                        let norm_rk = normalize_key(rk);
2848                        let toml_val = rv.clone();
2849                        let sv = rule_entry
2850                            .values
2851                            .entry(norm_rk.clone())
2852                            .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2853                        sv.push_override(toml_val, source, file.clone(), None);
2854                    }
2855                } else if rule_name.to_ascii_uppercase().starts_with("MD") {
2856                    // Track unknown rule sections like [tool.rumdl.MD999]
2857                    fragment.unknown_keys.push((
2858                        format!("[tool.rumdl.{rule_name}]"),
2859                        String::new(),
2860                        Some(path.to_string()),
2861                    ));
2862                }
2863            }
2864        }
2865    }
2866
2867    // 3. Handle [tool.rumdl.MDxxx] sections as top-level keys (e.g., [tool.rumdl.MD007])
2868    if let Some(doc_table) = doc.as_table() {
2869        for (key, value) in doc_table.iter() {
2870            if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
2871                let norm_rule_name = normalize_key(rule_name);
2872                if norm_rule_name.len() == 5
2873                    && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2874                    && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2875                    && let Some(rule_table) = value.as_table()
2876                {
2877                    let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2878                    for (rk, rv) in rule_table {
2879                        let norm_rk = normalize_key(rk);
2880                        let toml_val = rv.clone();
2881                        let sv = rule_entry
2882                            .values
2883                            .entry(norm_rk.clone())
2884                            .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2885                        sv.push_override(toml_val, source, file.clone(), None);
2886                    }
2887                } else if rule_name.to_ascii_uppercase().starts_with("MD") {
2888                    // Track unknown rule sections like [tool.rumdl.MD999]
2889                    fragment.unknown_keys.push((
2890                        format!("[tool.rumdl.{rule_name}]"),
2891                        String::new(),
2892                        Some(path.to_string()),
2893                    ));
2894                }
2895            }
2896        }
2897    }
2898
2899    // Only return Some(fragment) if any config was found
2900    let has_any = !fragment.global.enable.value.is_empty()
2901        || !fragment.global.disable.value.is_empty()
2902        || !fragment.global.include.value.is_empty()
2903        || !fragment.global.exclude.value.is_empty()
2904        || !fragment.global.fixable.value.is_empty()
2905        || !fragment.global.unfixable.value.is_empty()
2906        || fragment.global.output_format.is_some()
2907        || fragment.global.cache_dir.is_some()
2908        || !fragment.per_file_ignores.value.is_empty()
2909        || !fragment.rules.is_empty();
2910    if has_any { Ok(Some(fragment)) } else { Ok(None) }
2911}
2912
2913/// Parses rumdl.toml / .rumdl.toml content.
2914fn parse_rumdl_toml(content: &str, path: &str, source: ConfigSource) -> Result<SourcedConfigFragment, ConfigError> {
2915    let doc = content
2916        .parse::<DocumentMut>()
2917        .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2918    let mut fragment = SourcedConfigFragment::default();
2919    // source parameter provided by caller
2920    let file = Some(path.to_string());
2921
2922    // Define known rules before the loop
2923    let all_rules = rules::all_rules(&Config::default());
2924    let registry = RuleRegistry::from_rules(&all_rules);
2925    let known_rule_names: BTreeSet<String> = registry
2926        .rule_names()
2927        .into_iter()
2928        .map(|s| s.to_ascii_uppercase())
2929        .collect();
2930
2931    // Handle [global] section
2932    if let Some(global_item) = doc.get("global")
2933        && let Some(global_table) = global_item.as_table()
2934    {
2935        for (key, value_item) in global_table.iter() {
2936            let norm_key = normalize_key(key);
2937            match norm_key.as_str() {
2938                "enable" | "disable" | "include" | "exclude" => {
2939                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2940                        // Corrected: Iterate directly over the Formatted<Array>
2941                        let values: Vec<String> = formatted_array
2942                                .iter()
2943                                .filter_map(|item| item.as_str()) // Extract strings
2944                                .map(|s| s.to_string())
2945                                .collect();
2946
2947                        // Normalize rule names for enable/disable
2948                        let final_values = if norm_key == "enable" || norm_key == "disable" {
2949                            // Corrected: Pass &str to normalize_key
2950                            values.into_iter().map(|s| normalize_key(&s)).collect()
2951                        } else {
2952                            values
2953                        };
2954
2955                        match norm_key.as_str() {
2956                            "enable" => fragment
2957                                .global
2958                                .enable
2959                                .push_override(final_values, source, file.clone(), None),
2960                            "disable" => {
2961                                fragment
2962                                    .global
2963                                    .disable
2964                                    .push_override(final_values, source, file.clone(), None)
2965                            }
2966                            "include" => {
2967                                fragment
2968                                    .global
2969                                    .include
2970                                    .push_override(final_values, source, file.clone(), None)
2971                            }
2972                            "exclude" => {
2973                                fragment
2974                                    .global
2975                                    .exclude
2976                                    .push_override(final_values, source, file.clone(), None)
2977                            }
2978                            _ => unreachable!("Outer match guarantees only enable/disable/include/exclude"),
2979                        }
2980                    } else {
2981                        log::warn!(
2982                            "[WARN] Expected array for global key '{}' in {}, found {}",
2983                            key,
2984                            path,
2985                            value_item.type_name()
2986                        );
2987                    }
2988                }
2989                "respect_gitignore" | "respect-gitignore" => {
2990                    // Handle both cases
2991                    if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2992                        let val = *formatted_bool.value();
2993                        fragment
2994                            .global
2995                            .respect_gitignore
2996                            .push_override(val, source, file.clone(), None);
2997                    } else {
2998                        log::warn!(
2999                            "[WARN] Expected boolean for global key '{}' in {}, found {}",
3000                            key,
3001                            path,
3002                            value_item.type_name()
3003                        );
3004                    }
3005                }
3006                "force_exclude" | "force-exclude" => {
3007                    // Handle both cases
3008                    if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
3009                        let val = *formatted_bool.value();
3010                        fragment
3011                            .global
3012                            .force_exclude
3013                            .push_override(val, source, file.clone(), None);
3014                    } else {
3015                        log::warn!(
3016                            "[WARN] Expected boolean for global key '{}' in {}, found {}",
3017                            key,
3018                            path,
3019                            value_item.type_name()
3020                        );
3021                    }
3022                }
3023                "line_length" | "line-length" => {
3024                    // Handle both cases
3025                    if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
3026                        let val = *formatted_int.value() as u64;
3027                        fragment
3028                            .global
3029                            .line_length
3030                            .push_override(val, source, file.clone(), None);
3031                    } else {
3032                        log::warn!(
3033                            "[WARN] Expected integer for global key '{}' in {}, found {}",
3034                            key,
3035                            path,
3036                            value_item.type_name()
3037                        );
3038                    }
3039                }
3040                "output_format" | "output-format" => {
3041                    // Handle both cases
3042                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3043                        let val = formatted_string.value().clone();
3044                        if fragment.global.output_format.is_none() {
3045                            fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
3046                        } else {
3047                            fragment.global.output_format.as_mut().unwrap().push_override(
3048                                val,
3049                                source,
3050                                file.clone(),
3051                                None,
3052                            );
3053                        }
3054                    } else {
3055                        log::warn!(
3056                            "[WARN] Expected string for global key '{}' in {}, found {}",
3057                            key,
3058                            path,
3059                            value_item.type_name()
3060                        );
3061                    }
3062                }
3063                "cache_dir" | "cache-dir" => {
3064                    // Handle both cases
3065                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3066                        let val = formatted_string.value().clone();
3067                        if fragment.global.cache_dir.is_none() {
3068                            fragment.global.cache_dir = Some(SourcedValue::new(val.clone(), source));
3069                        } else {
3070                            fragment
3071                                .global
3072                                .cache_dir
3073                                .as_mut()
3074                                .unwrap()
3075                                .push_override(val, source, file.clone(), None);
3076                        }
3077                    } else {
3078                        log::warn!(
3079                            "[WARN] Expected string for global key '{}' in {}, found {}",
3080                            key,
3081                            path,
3082                            value_item.type_name()
3083                        );
3084                    }
3085                }
3086                "fixable" => {
3087                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3088                        let values: Vec<String> = formatted_array
3089                            .iter()
3090                            .filter_map(|item| item.as_str())
3091                            .map(normalize_key)
3092                            .collect();
3093                        fragment
3094                            .global
3095                            .fixable
3096                            .push_override(values, source, file.clone(), None);
3097                    } else {
3098                        log::warn!(
3099                            "[WARN] Expected array for global key '{}' in {}, found {}",
3100                            key,
3101                            path,
3102                            value_item.type_name()
3103                        );
3104                    }
3105                }
3106                "unfixable" => {
3107                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3108                        let values: Vec<String> = formatted_array
3109                            .iter()
3110                            .filter_map(|item| item.as_str())
3111                            .map(normalize_key)
3112                            .collect();
3113                        fragment
3114                            .global
3115                            .unfixable
3116                            .push_override(values, source, file.clone(), None);
3117                    } else {
3118                        log::warn!(
3119                            "[WARN] Expected array for global key '{}' in {}, found {}",
3120                            key,
3121                            path,
3122                            value_item.type_name()
3123                        );
3124                    }
3125                }
3126                "flavor" => {
3127                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3128                        let val = formatted_string.value();
3129                        if let Ok(flavor) = MarkdownFlavor::from_str(val) {
3130                            fragment.global.flavor.push_override(flavor, source, file.clone(), None);
3131                        } else {
3132                            log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
3133                        }
3134                    } else {
3135                        log::warn!(
3136                            "[WARN] Expected string for global key '{}' in {}, found {}",
3137                            key,
3138                            path,
3139                            value_item.type_name()
3140                        );
3141                    }
3142                }
3143                _ => {
3144                    // Track unknown global keys for validation
3145                    fragment
3146                        .unknown_keys
3147                        .push(("[global]".to_string(), key.to_string(), Some(path.to_string())));
3148                    log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
3149                }
3150            }
3151        }
3152    }
3153
3154    // Handle [per-file-ignores] section
3155    if let Some(per_file_item) = doc.get("per-file-ignores")
3156        && let Some(per_file_table) = per_file_item.as_table()
3157    {
3158        let mut per_file_map = HashMap::new();
3159        for (pattern, value_item) in per_file_table.iter() {
3160            if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3161                let rules: Vec<String> = formatted_array
3162                    .iter()
3163                    .filter_map(|item| item.as_str())
3164                    .map(normalize_key)
3165                    .collect();
3166                per_file_map.insert(pattern.to_string(), rules);
3167            } else {
3168                let type_name = value_item.type_name();
3169                log::warn!(
3170                    "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {type_name}"
3171                );
3172            }
3173        }
3174        fragment
3175            .per_file_ignores
3176            .push_override(per_file_map, source, file.clone(), None);
3177    }
3178
3179    // Rule-specific: all other top-level tables
3180    for (key, item) in doc.iter() {
3181        let norm_rule_name = key.to_ascii_uppercase();
3182
3183        // Skip known special sections
3184        if key == "global" || key == "per-file-ignores" {
3185            continue;
3186        }
3187
3188        // Track unknown rule sections (like [MD999])
3189        if !known_rule_names.contains(&norm_rule_name) {
3190            // Only track if it looks like a rule section (starts with MD or is uppercase)
3191            if norm_rule_name.starts_with("MD") || key.chars().all(|c| c.is_uppercase() || c.is_numeric()) {
3192                fragment
3193                    .unknown_keys
3194                    .push((format!("[{key}]"), String::new(), Some(path.to_string())));
3195            }
3196            continue;
3197        }
3198
3199        if let Some(tbl) = item.as_table() {
3200            let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
3201            for (rk, rv_item) in tbl.iter() {
3202                let norm_rk = normalize_key(rk);
3203                let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
3204                    Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
3205                    Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
3206                    Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
3207                    Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
3208                    Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
3209                    Some(toml_edit::Value::Array(formatted_array)) => {
3210                        // Convert toml_edit Array to toml::Value::Array
3211                        let mut values = Vec::new();
3212                        for item in formatted_array.iter() {
3213                            match item {
3214                                toml_edit::Value::String(formatted) => {
3215                                    values.push(toml::Value::String(formatted.value().clone()))
3216                                }
3217                                toml_edit::Value::Integer(formatted) => {
3218                                    values.push(toml::Value::Integer(*formatted.value()))
3219                                }
3220                                toml_edit::Value::Float(formatted) => {
3221                                    values.push(toml::Value::Float(*formatted.value()))
3222                                }
3223                                toml_edit::Value::Boolean(formatted) => {
3224                                    values.push(toml::Value::Boolean(*formatted.value()))
3225                                }
3226                                toml_edit::Value::Datetime(formatted) => {
3227                                    values.push(toml::Value::Datetime(*formatted.value()))
3228                                }
3229                                _ => {
3230                                    log::warn!(
3231                                        "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
3232                                    );
3233                                }
3234                            }
3235                        }
3236                        Some(toml::Value::Array(values))
3237                    }
3238                    Some(toml_edit::Value::InlineTable(_)) => {
3239                        log::warn!(
3240                            "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
3241                        );
3242                        None
3243                    }
3244                    None => {
3245                        log::warn!(
3246                            "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
3247                        );
3248                        None
3249                    }
3250                };
3251                if let Some(toml_val) = maybe_toml_val {
3252                    let sv = rule_entry
3253                        .values
3254                        .entry(norm_rk.clone())
3255                        .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
3256                    sv.push_override(toml_val, source, file.clone(), None);
3257                }
3258            }
3259        } else if item.is_value() {
3260            log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
3261        }
3262    }
3263
3264    Ok(fragment)
3265}
3266
3267/// Loads and converts a markdownlint config file (.json or .yaml) into a SourcedConfigFragment.
3268fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
3269    // Use the unified loader from markdownlint_config.rs
3270    let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
3271        .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
3272    Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
3273}
3274
3275#[cfg(test)]
3276#[path = "config_intelligent_merge_tests.rs"]
3277mod config_intelligent_merge_tests;