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    fn user_configuration_path() -> Option<std::path::PathBuf> {
1984        use etcetera::{BaseStrategy, choose_base_strategy};
1985
1986        match choose_base_strategy() {
1987            Ok(strategy) => {
1988                let config_dir = strategy.config_dir();
1989                Self::user_configuration_path_impl(&config_dir)
1990            }
1991            Err(e) => {
1992                log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
1993                None
1994            }
1995        }
1996    }
1997
1998    /// Internal implementation that accepts user config directory for testing
1999    #[doc(hidden)]
2000    pub fn load_with_discovery_impl(
2001        config_path: Option<&str>,
2002        cli_overrides: Option<&SourcedGlobalConfig>,
2003        skip_auto_discovery: bool,
2004        user_config_dir: Option<&Path>,
2005    ) -> Result<Self, ConfigError> {
2006        use std::env;
2007        log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
2008        if config_path.is_none() {
2009            if skip_auto_discovery {
2010                log::debug!("[rumdl-config] Skipping auto-discovery due to --no-config flag");
2011            } else {
2012                log::debug!("[rumdl-config] No explicit config_path provided, will search default locations");
2013            }
2014        } else {
2015            log::debug!("[rumdl-config] Explicit config_path provided: {config_path:?}");
2016        }
2017        let mut sourced_config = SourcedConfig::default();
2018
2019        // 1. Always load user configuration first (unless auto-discovery is disabled)
2020        // User config serves as the base layer that project configs build upon
2021        if !skip_auto_discovery {
2022            let user_config_path = if let Some(dir) = user_config_dir {
2023                Self::user_configuration_path_impl(dir)
2024            } else {
2025                Self::user_configuration_path()
2026            };
2027
2028            if let Some(user_config_path) = user_config_path {
2029                let path_str = user_config_path.display().to_string();
2030                let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
2031
2032                log::debug!("[rumdl-config] Loading user configuration file: {path_str}");
2033
2034                if filename == "pyproject.toml" {
2035                    let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2036                        source: e,
2037                        path: path_str.clone(),
2038                    })?;
2039                    if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2040                        sourced_config.merge(fragment);
2041                        sourced_config.loaded_files.push(path_str);
2042                    }
2043                } else {
2044                    let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
2045                        source: e,
2046                        path: path_str.clone(),
2047                    })?;
2048                    let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
2049                    sourced_config.merge(fragment);
2050                    sourced_config.loaded_files.push(path_str);
2051                }
2052            } else {
2053                log::debug!("[rumdl-config] No user configuration file found");
2054            }
2055        }
2056
2057        // 2. Load explicit config path if provided (overrides user config)
2058        if let Some(path) = config_path {
2059            let path_obj = Path::new(path);
2060            let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
2061            log::debug!("[rumdl-config] Trying to load config file: {filename}");
2062            let path_str = path.to_string();
2063
2064            // Find project root by walking up from config location looking for .git
2065            if let Some(config_parent) = path_obj.parent() {
2066                let project_root = Self::find_project_root_from(config_parent);
2067                log::debug!(
2068                    "[rumdl-config] Project root (from explicit config): {}",
2069                    project_root.display()
2070                );
2071                sourced_config.project_root = Some(project_root);
2072            }
2073
2074            // Known markdownlint config files
2075            const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
2076
2077            if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
2078                let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2079                    source: e,
2080                    path: path_str.clone(),
2081                })?;
2082                if filename == "pyproject.toml" {
2083                    if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2084                        sourced_config.merge(fragment);
2085                        sourced_config.loaded_files.push(path_str.clone());
2086                    }
2087                } else {
2088                    let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2089                    sourced_config.merge(fragment);
2090                    sourced_config.loaded_files.push(path_str.clone());
2091                }
2092            } else if MARKDOWNLINT_FILENAMES.contains(&filename)
2093                || path_str.ends_with(".json")
2094                || path_str.ends_with(".jsonc")
2095                || path_str.ends_with(".yaml")
2096                || path_str.ends_with(".yml")
2097            {
2098                // Parse as markdownlint config (JSON/YAML)
2099                let fragment = load_from_markdownlint(&path_str)?;
2100                sourced_config.merge(fragment);
2101                sourced_config.loaded_files.push(path_str.clone());
2102                // markdownlint is fallback only
2103            } else {
2104                // Try TOML only
2105                let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
2106                    source: e,
2107                    path: path_str.clone(),
2108                })?;
2109                let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2110                sourced_config.merge(fragment);
2111                sourced_config.loaded_files.push(path_str.clone());
2112            }
2113        }
2114
2115        // 3. Perform auto-discovery for project config if not skipped AND no explicit config path
2116        if !skip_auto_discovery && config_path.is_none() {
2117            // Look for project configuration files (override user config)
2118            if let Some((config_file, project_root)) = Self::discover_config_upward() {
2119                let path_str = config_file.display().to_string();
2120                let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
2121
2122                log::debug!("[rumdl-config] Loading discovered config file: {path_str}");
2123                log::debug!("[rumdl-config] Project root: {}", project_root.display());
2124
2125                // Store project root for cache directory resolution
2126                sourced_config.project_root = Some(project_root);
2127
2128                if filename == "pyproject.toml" {
2129                    let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2130                        source: e,
2131                        path: path_str.clone(),
2132                    })?;
2133                    if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
2134                        sourced_config.merge(fragment);
2135                        sourced_config.loaded_files.push(path_str);
2136                    }
2137                } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
2138                    let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
2139                        source: e,
2140                        path: path_str.clone(),
2141                    })?;
2142                    let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
2143                    sourced_config.merge(fragment);
2144                    sourced_config.loaded_files.push(path_str);
2145                }
2146            } else {
2147                log::debug!("[rumdl-config] No configuration file found via upward traversal");
2148
2149                // If no project config found, fallback to markdownlint config in current directory
2150                let mut found_markdownlint = false;
2151                for filename in MARKDOWNLINT_CONFIG_FILES {
2152                    if std::path::Path::new(filename).exists() {
2153                        match load_from_markdownlint(filename) {
2154                            Ok(fragment) => {
2155                                sourced_config.merge(fragment);
2156                                sourced_config.loaded_files.push(filename.to_string());
2157                                found_markdownlint = true;
2158                                break; // Load only the first one found
2159                            }
2160                            Err(_e) => {
2161                                // Log error but continue (it's just a fallback)
2162                            }
2163                        }
2164                    }
2165                }
2166
2167                if !found_markdownlint {
2168                    log::debug!("[rumdl-config] No markdownlint configuration file found");
2169                }
2170            }
2171        }
2172
2173        // 4. Apply CLI overrides (highest precedence)
2174        if let Some(cli) = cli_overrides {
2175            sourced_config
2176                .global
2177                .enable
2178                .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
2179            sourced_config
2180                .global
2181                .disable
2182                .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
2183            sourced_config
2184                .global
2185                .exclude
2186                .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
2187            sourced_config
2188                .global
2189                .include
2190                .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
2191            sourced_config.global.respect_gitignore.merge_override(
2192                cli.respect_gitignore.value,
2193                ConfigSource::Cli,
2194                None,
2195                None,
2196            );
2197            sourced_config
2198                .global
2199                .fixable
2200                .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
2201            sourced_config
2202                .global
2203                .unfixable
2204                .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
2205            // No rule-specific CLI overrides implemented yet
2206        }
2207
2208        // Unknown keys are now collected during parsing and validated via validate_config_sourced()
2209
2210        Ok(sourced_config)
2211    }
2212
2213    /// Load and merge configurations from files and CLI overrides.
2214    /// If skip_auto_discovery is true, only explicit config paths are loaded.
2215    pub fn load_with_discovery(
2216        config_path: Option<&str>,
2217        cli_overrides: Option<&SourcedGlobalConfig>,
2218        skip_auto_discovery: bool,
2219    ) -> Result<Self, ConfigError> {
2220        Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
2221    }
2222}
2223
2224impl From<SourcedConfig> for Config {
2225    fn from(sourced: SourcedConfig) -> Self {
2226        let mut rules = BTreeMap::new();
2227        for (rule_name, sourced_rule_cfg) in sourced.rules {
2228            // Normalize rule name to uppercase for case-insensitive lookup
2229            let normalized_rule_name = rule_name.to_ascii_uppercase();
2230            let mut values = BTreeMap::new();
2231            for (key, sourced_val) in sourced_rule_cfg.values {
2232                values.insert(key, sourced_val.value);
2233            }
2234            rules.insert(normalized_rule_name, RuleConfig { values });
2235        }
2236        #[allow(deprecated)]
2237        let global = GlobalConfig {
2238            enable: sourced.global.enable.value,
2239            disable: sourced.global.disable.value,
2240            exclude: sourced.global.exclude.value,
2241            include: sourced.global.include.value,
2242            respect_gitignore: sourced.global.respect_gitignore.value,
2243            line_length: sourced.global.line_length.value,
2244            output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
2245            fixable: sourced.global.fixable.value,
2246            unfixable: sourced.global.unfixable.value,
2247            flavor: sourced.global.flavor.value,
2248            force_exclude: sourced.global.force_exclude.value,
2249            cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
2250        };
2251        Config {
2252            global,
2253            per_file_ignores: sourced.per_file_ignores.value,
2254            rules,
2255        }
2256    }
2257}
2258
2259/// Registry of all known rules and their config schemas
2260pub struct RuleRegistry {
2261    /// Map of rule name (e.g. "MD013") to set of valid config keys and their TOML value types
2262    pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
2263    /// Map of rule name to config key aliases
2264    pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
2265}
2266
2267impl RuleRegistry {
2268    /// Build a registry from a list of rules
2269    pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
2270        let mut rule_schemas = std::collections::BTreeMap::new();
2271        let mut rule_aliases = std::collections::BTreeMap::new();
2272
2273        for rule in rules {
2274            let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
2275                let norm_name = normalize_key(&name); // Normalize the name from default_config_section
2276                rule_schemas.insert(norm_name.clone(), table);
2277                norm_name
2278            } else {
2279                let norm_name = normalize_key(rule.name()); // Normalize the name from rule.name()
2280                rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
2281                norm_name
2282            };
2283
2284            // Store aliases if the rule provides them
2285            if let Some(aliases) = rule.config_aliases() {
2286                rule_aliases.insert(norm_name, aliases);
2287            }
2288        }
2289
2290        RuleRegistry {
2291            rule_schemas,
2292            rule_aliases,
2293        }
2294    }
2295
2296    /// Get all known rule names
2297    pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
2298        self.rule_schemas.keys().cloned().collect()
2299    }
2300
2301    /// Get the valid configuration keys for a rule, including both original and normalized variants
2302    pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
2303        self.rule_schemas.get(rule).map(|schema| {
2304            let mut all_keys = std::collections::BTreeSet::new();
2305
2306            // Add original keys from schema
2307            for key in schema.keys() {
2308                all_keys.insert(key.clone());
2309            }
2310
2311            // Add normalized variants for markdownlint compatibility
2312            for key in schema.keys() {
2313                // Add kebab-case variant
2314                all_keys.insert(key.replace('_', "-"));
2315                // Add snake_case variant
2316                all_keys.insert(key.replace('-', "_"));
2317                // Add normalized variant
2318                all_keys.insert(normalize_key(key));
2319            }
2320
2321            // Add any aliases defined by the rule
2322            if let Some(aliases) = self.rule_aliases.get(rule) {
2323                for alias_key in aliases.keys() {
2324                    all_keys.insert(alias_key.clone());
2325                    // Also add normalized variants of the alias
2326                    all_keys.insert(alias_key.replace('_', "-"));
2327                    all_keys.insert(alias_key.replace('-', "_"));
2328                    all_keys.insert(normalize_key(alias_key));
2329                }
2330            }
2331
2332            all_keys
2333        })
2334    }
2335
2336    /// Get the expected value type for a rule's configuration key, trying variants
2337    pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
2338        if let Some(schema) = self.rule_schemas.get(rule) {
2339            // Check if this key is an alias
2340            if let Some(aliases) = self.rule_aliases.get(rule)
2341                && let Some(canonical_key) = aliases.get(key)
2342            {
2343                // Use the canonical key for schema lookup
2344                if let Some(value) = schema.get(canonical_key) {
2345                    return Some(value);
2346                }
2347            }
2348
2349            // Try the original key
2350            if let Some(value) = schema.get(key) {
2351                return Some(value);
2352            }
2353
2354            // Try key variants
2355            let key_variants = [
2356                key.replace('-', "_"), // Convert kebab-case to snake_case
2357                key.replace('_', "-"), // Convert snake_case to kebab-case
2358                normalize_key(key),    // Normalized key (lowercase, kebab-case)
2359            ];
2360
2361            for variant in &key_variants {
2362                if let Some(value) = schema.get(variant) {
2363                    return Some(value);
2364                }
2365            }
2366        }
2367        None
2368    }
2369}
2370
2371/// Represents a config validation warning or error
2372#[derive(Debug, Clone)]
2373pub struct ConfigValidationWarning {
2374    pub message: String,
2375    pub rule: Option<String>,
2376    pub key: Option<String>,
2377}
2378
2379/// Validate a loaded config against the rule registry, using SourcedConfig for unknown key tracking
2380pub fn validate_config_sourced(sourced: &SourcedConfig, registry: &RuleRegistry) -> Vec<ConfigValidationWarning> {
2381    let mut warnings = Vec::new();
2382    let known_rules = registry.rule_names();
2383    // 1. Unknown rules
2384    for rule in sourced.rules.keys() {
2385        if !known_rules.contains(rule) {
2386            warnings.push(ConfigValidationWarning {
2387                message: format!("Unknown rule in config: {rule}"),
2388                rule: Some(rule.clone()),
2389                key: None,
2390            });
2391        }
2392    }
2393    // 2. Unknown options and type mismatches
2394    for (rule, rule_cfg) in &sourced.rules {
2395        if let Some(valid_keys) = registry.config_keys_for(rule) {
2396            for key in rule_cfg.values.keys() {
2397                if !valid_keys.contains(key) {
2398                    let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
2399                    let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
2400                        format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
2401                    } else {
2402                        format!("Unknown option for rule {rule}: {key}")
2403                    };
2404                    warnings.push(ConfigValidationWarning {
2405                        message,
2406                        rule: Some(rule.clone()),
2407                        key: Some(key.clone()),
2408                    });
2409                } else {
2410                    // Type check: compare type of value to type of default
2411                    if let Some(expected) = registry.expected_value_for(rule, key) {
2412                        let actual = &rule_cfg.values[key].value;
2413                        if !toml_value_type_matches(expected, actual) {
2414                            warnings.push(ConfigValidationWarning {
2415                                message: format!(
2416                                    "Type mismatch for {}.{}: expected {}, got {}",
2417                                    rule,
2418                                    key,
2419                                    toml_type_name(expected),
2420                                    toml_type_name(actual)
2421                                ),
2422                                rule: Some(rule.clone()),
2423                                key: Some(key.clone()),
2424                            });
2425                        }
2426                    }
2427                }
2428            }
2429        }
2430    }
2431    // 3. Unknown global options (from unknown_keys)
2432    let known_global_keys = vec![
2433        "enable".to_string(),
2434        "disable".to_string(),
2435        "include".to_string(),
2436        "exclude".to_string(),
2437        "respect-gitignore".to_string(),
2438        "line-length".to_string(),
2439        "fixable".to_string(),
2440        "unfixable".to_string(),
2441        "flavor".to_string(),
2442        "force-exclude".to_string(),
2443        "output-format".to_string(),
2444        "cache-dir".to_string(),
2445    ];
2446
2447    for (section, key, file_path) in &sourced.unknown_keys {
2448        if section.contains("[global]") || section.contains("[tool.rumdl]") {
2449            let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
2450                if let Some(path) = file_path {
2451                    format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
2452                } else {
2453                    format!("Unknown global option: {key} (did you mean: {suggestion}?)")
2454                }
2455            } else if let Some(path) = file_path {
2456                format!("Unknown global option in {path}: {key}")
2457            } else {
2458                format!("Unknown global option: {key}")
2459            };
2460            warnings.push(ConfigValidationWarning {
2461                message,
2462                rule: None,
2463                key: Some(key.clone()),
2464            });
2465        } else if !key.is_empty() {
2466            // This is an unknown rule section (key is empty means it's a section header)
2467            // No suggestions for rule names - just warn
2468            continue;
2469        } else {
2470            // Unknown rule section
2471            let message = if let Some(path) = file_path {
2472                format!(
2473                    "Unknown rule in {path}: {}",
2474                    section.trim_matches(|c| c == '[' || c == ']')
2475                )
2476            } else {
2477                format!(
2478                    "Unknown rule in config: {}",
2479                    section.trim_matches(|c| c == '[' || c == ']')
2480                )
2481            };
2482            warnings.push(ConfigValidationWarning {
2483                message,
2484                rule: None,
2485                key: None,
2486            });
2487        }
2488    }
2489    warnings
2490}
2491
2492fn toml_type_name(val: &toml::Value) -> &'static str {
2493    match val {
2494        toml::Value::String(_) => "string",
2495        toml::Value::Integer(_) => "integer",
2496        toml::Value::Float(_) => "float",
2497        toml::Value::Boolean(_) => "boolean",
2498        toml::Value::Array(_) => "array",
2499        toml::Value::Table(_) => "table",
2500        toml::Value::Datetime(_) => "datetime",
2501    }
2502}
2503
2504/// Calculate Levenshtein distance between two strings (simple implementation)
2505fn levenshtein_distance(s1: &str, s2: &str) -> usize {
2506    let len1 = s1.len();
2507    let len2 = s2.len();
2508
2509    if len1 == 0 {
2510        return len2;
2511    }
2512    if len2 == 0 {
2513        return len1;
2514    }
2515
2516    let s1_chars: Vec<char> = s1.chars().collect();
2517    let s2_chars: Vec<char> = s2.chars().collect();
2518
2519    let mut prev_row: Vec<usize> = (0..=len2).collect();
2520    let mut curr_row = vec![0; len2 + 1];
2521
2522    for i in 1..=len1 {
2523        curr_row[0] = i;
2524        for j in 1..=len2 {
2525            let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
2526            curr_row[j] = (prev_row[j] + 1)          // deletion
2527                .min(curr_row[j - 1] + 1)            // insertion
2528                .min(prev_row[j - 1] + cost); // substitution
2529        }
2530        std::mem::swap(&mut prev_row, &mut curr_row);
2531    }
2532
2533    prev_row[len2]
2534}
2535
2536/// Suggest a similar key from a list of valid keys using fuzzy matching
2537fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
2538    let unknown_lower = unknown.to_lowercase();
2539    let max_distance = 2.max(unknown.len() / 3); // Allow up to 2 edits or 30% of string length
2540
2541    let mut best_match: Option<(String, usize)> = None;
2542
2543    for valid in valid_keys {
2544        let valid_lower = valid.to_lowercase();
2545        let distance = levenshtein_distance(&unknown_lower, &valid_lower);
2546
2547        if distance <= max_distance {
2548            if let Some((_, best_dist)) = &best_match {
2549                if distance < *best_dist {
2550                    best_match = Some((valid.clone(), distance));
2551                }
2552            } else {
2553                best_match = Some((valid.clone(), distance));
2554            }
2555        }
2556    }
2557
2558    best_match.map(|(key, _)| key)
2559}
2560
2561fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
2562    use toml::Value::*;
2563    match (expected, actual) {
2564        (String(_), String(_)) => true,
2565        (Integer(_), Integer(_)) => true,
2566        (Float(_), Float(_)) => true,
2567        (Boolean(_), Boolean(_)) => true,
2568        (Array(_), Array(_)) => true,
2569        (Table(_), Table(_)) => true,
2570        (Datetime(_), Datetime(_)) => true,
2571        // Allow integer for float
2572        (Float(_), Integer(_)) => true,
2573        _ => false,
2574    }
2575}
2576
2577/// Parses pyproject.toml content and extracts the [tool.rumdl] section if present.
2578fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
2579    let doc: toml::Value =
2580        toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2581    let mut fragment = SourcedConfigFragment::default();
2582    let source = ConfigSource::PyprojectToml;
2583    let file = Some(path.to_string());
2584
2585    // 1. Handle [tool.rumdl] and [tool.rumdl.global] sections
2586    if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
2587        && let Some(rumdl_table) = rumdl_config.as_table()
2588    {
2589        // Helper function to extract global config from a table
2590        let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
2591            // Extract global options from the given table
2592            if let Some(enable) = table.get("enable")
2593                && let Ok(values) = Vec::<String>::deserialize(enable.clone())
2594            {
2595                // Normalize rule names in the list
2596                let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2597                fragment
2598                    .global
2599                    .enable
2600                    .push_override(normalized_values, source, file.clone(), None);
2601            }
2602
2603            if let Some(disable) = table.get("disable")
2604                && let Ok(values) = Vec::<String>::deserialize(disable.clone())
2605            {
2606                // Re-enable normalization
2607                let normalized_values: Vec<String> = values.into_iter().map(|s| normalize_key(&s)).collect();
2608                fragment
2609                    .global
2610                    .disable
2611                    .push_override(normalized_values, source, file.clone(), None);
2612            }
2613
2614            if let Some(include) = table.get("include")
2615                && let Ok(values) = Vec::<String>::deserialize(include.clone())
2616            {
2617                fragment
2618                    .global
2619                    .include
2620                    .push_override(values, source, file.clone(), None);
2621            }
2622
2623            if let Some(exclude) = table.get("exclude")
2624                && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
2625            {
2626                fragment
2627                    .global
2628                    .exclude
2629                    .push_override(values, source, file.clone(), None);
2630            }
2631
2632            if let Some(respect_gitignore) = table
2633                .get("respect-gitignore")
2634                .or_else(|| table.get("respect_gitignore"))
2635                && let Ok(value) = bool::deserialize(respect_gitignore.clone())
2636            {
2637                fragment
2638                    .global
2639                    .respect_gitignore
2640                    .push_override(value, source, file.clone(), None);
2641            }
2642
2643            if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
2644                && let Ok(value) = bool::deserialize(force_exclude.clone())
2645            {
2646                fragment
2647                    .global
2648                    .force_exclude
2649                    .push_override(value, source, file.clone(), None);
2650            }
2651
2652            if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
2653                && let Ok(value) = String::deserialize(output_format.clone())
2654            {
2655                if fragment.global.output_format.is_none() {
2656                    fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
2657                } else {
2658                    fragment
2659                        .global
2660                        .output_format
2661                        .as_mut()
2662                        .unwrap()
2663                        .push_override(value, source, file.clone(), None);
2664                }
2665            }
2666
2667            if let Some(fixable) = table.get("fixable")
2668                && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
2669            {
2670                let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2671                fragment
2672                    .global
2673                    .fixable
2674                    .push_override(normalized_values, source, file.clone(), None);
2675            }
2676
2677            if let Some(unfixable) = table.get("unfixable")
2678                && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
2679            {
2680                let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
2681                fragment
2682                    .global
2683                    .unfixable
2684                    .push_override(normalized_values, source, file.clone(), None);
2685            }
2686
2687            if let Some(flavor) = table.get("flavor")
2688                && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
2689            {
2690                fragment.global.flavor.push_override(value, source, file.clone(), None);
2691            }
2692
2693            // Handle line-length special case - this should set the global line_length
2694            if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
2695                && let Ok(value) = u64::deserialize(line_length.clone())
2696            {
2697                fragment
2698                    .global
2699                    .line_length
2700                    .push_override(value, source, file.clone(), None);
2701
2702                // Also add to MD013 rule config for backward compatibility
2703                let norm_md013_key = normalize_key("MD013");
2704                let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
2705                let norm_line_length_key = normalize_key("line-length");
2706                let sv = rule_entry
2707                    .values
2708                    .entry(norm_line_length_key)
2709                    .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
2710                sv.push_override(line_length.clone(), source, file.clone(), None);
2711            }
2712
2713            if let Some(cache_dir) = table.get("cache-dir").or_else(|| table.get("cache_dir"))
2714                && let Ok(value) = String::deserialize(cache_dir.clone())
2715            {
2716                if fragment.global.cache_dir.is_none() {
2717                    fragment.global.cache_dir = Some(SourcedValue::new(value.clone(), source));
2718                } else {
2719                    fragment
2720                        .global
2721                        .cache_dir
2722                        .as_mut()
2723                        .unwrap()
2724                        .push_override(value, source, file.clone(), None);
2725                }
2726            }
2727        };
2728
2729        // First, check for [tool.rumdl.global] section
2730        if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
2731            extract_global_config(&mut fragment, global_table);
2732        }
2733
2734        // Also extract global options from [tool.rumdl] directly (for flat structure)
2735        extract_global_config(&mut fragment, rumdl_table);
2736
2737        // --- Extract per-file-ignores configurations ---
2738        // Check both hyphenated and underscored versions for compatibility
2739        let per_file_ignores_key = rumdl_table
2740            .get("per-file-ignores")
2741            .or_else(|| rumdl_table.get("per_file_ignores"));
2742
2743        if let Some(per_file_ignores_value) = per_file_ignores_key
2744            && let Some(per_file_table) = per_file_ignores_value.as_table()
2745        {
2746            let mut per_file_map = HashMap::new();
2747            for (pattern, rules_value) in per_file_table {
2748                if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
2749                    let normalized_rules = rules.into_iter().map(|s| normalize_key(&s)).collect();
2750                    per_file_map.insert(pattern.clone(), normalized_rules);
2751                } else {
2752                    log::warn!(
2753                        "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {rules_value:?}"
2754                    );
2755                }
2756            }
2757            fragment
2758                .per_file_ignores
2759                .push_override(per_file_map, source, file.clone(), None);
2760        }
2761
2762        // --- Extract rule-specific configurations ---
2763        for (key, value) in rumdl_table {
2764            let norm_rule_key = normalize_key(key);
2765
2766            // Skip keys already handled as global or special cases
2767            if [
2768                "enable",
2769                "disable",
2770                "include",
2771                "exclude",
2772                "respect_gitignore",
2773                "respect-gitignore", // Added kebab-case here too
2774                "force_exclude",
2775                "force-exclude",
2776                "line_length",
2777                "line-length",
2778                "output_format",
2779                "output-format",
2780                "fixable",
2781                "unfixable",
2782                "per-file-ignores",
2783                "per_file_ignores",
2784                "global",
2785                "flavor",
2786                "cache_dir",
2787                "cache-dir",
2788            ]
2789            .contains(&norm_rule_key.as_str())
2790            {
2791                continue;
2792            }
2793
2794            // Explicitly check if the key looks like a rule name (e.g., starts with 'md')
2795            // AND if the value is actually a TOML table before processing as rule config.
2796            // This prevents misinterpreting other top-level keys under [tool.rumdl]
2797            let norm_rule_key_upper = norm_rule_key.to_ascii_uppercase();
2798            if norm_rule_key_upper.len() == 5
2799                && norm_rule_key_upper.starts_with("MD")
2800                && norm_rule_key_upper[2..].chars().all(|c| c.is_ascii_digit())
2801                && value.is_table()
2802            {
2803                if let Some(rule_config_table) = value.as_table() {
2804                    // Get the entry for this rule (e.g., "md013")
2805                    let rule_entry = fragment.rules.entry(norm_rule_key_upper).or_default();
2806                    for (rk, rv) in rule_config_table {
2807                        let norm_rk = normalize_key(rk); // Normalize the config key itself
2808
2809                        let toml_val = rv.clone();
2810
2811                        let sv = rule_entry
2812                            .values
2813                            .entry(norm_rk.clone())
2814                            .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2815                        sv.push_override(toml_val, source, file.clone(), None);
2816                    }
2817                }
2818            } else {
2819                // Key is not a global/special key, doesn't start with 'md', or isn't a table.
2820                // Track unknown keys under [tool.rumdl] for validation
2821                fragment
2822                    .unknown_keys
2823                    .push(("[tool.rumdl]".to_string(), key.to_string(), Some(path.to_string())));
2824            }
2825        }
2826    }
2827
2828    // 2. Handle [tool.rumdl.MDxxx] sections as rule-specific config (nested under [tool])
2829    if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
2830        for (key, value) in tool_table.iter() {
2831            if let Some(rule_name) = key.strip_prefix("rumdl.") {
2832                let norm_rule_name = normalize_key(rule_name);
2833                if norm_rule_name.len() == 5
2834                    && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2835                    && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2836                    && let Some(rule_table) = value.as_table()
2837                {
2838                    let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2839                    for (rk, rv) in rule_table {
2840                        let norm_rk = normalize_key(rk);
2841                        let toml_val = rv.clone();
2842                        let sv = rule_entry
2843                            .values
2844                            .entry(norm_rk.clone())
2845                            .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2846                        sv.push_override(toml_val, source, file.clone(), None);
2847                    }
2848                } else if rule_name.to_ascii_uppercase().starts_with("MD") {
2849                    // Track unknown rule sections like [tool.rumdl.MD999]
2850                    fragment.unknown_keys.push((
2851                        format!("[tool.rumdl.{rule_name}]"),
2852                        String::new(),
2853                        Some(path.to_string()),
2854                    ));
2855                }
2856            }
2857        }
2858    }
2859
2860    // 3. Handle [tool.rumdl.MDxxx] sections as top-level keys (e.g., [tool.rumdl.MD007])
2861    if let Some(doc_table) = doc.as_table() {
2862        for (key, value) in doc_table.iter() {
2863            if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
2864                let norm_rule_name = normalize_key(rule_name);
2865                if norm_rule_name.len() == 5
2866                    && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2867                    && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2868                    && let Some(rule_table) = value.as_table()
2869                {
2870                    let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2871                    for (rk, rv) in rule_table {
2872                        let norm_rk = normalize_key(rk);
2873                        let toml_val = rv.clone();
2874                        let sv = rule_entry
2875                            .values
2876                            .entry(norm_rk.clone())
2877                            .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2878                        sv.push_override(toml_val, source, file.clone(), None);
2879                    }
2880                } else if rule_name.to_ascii_uppercase().starts_with("MD") {
2881                    // Track unknown rule sections like [tool.rumdl.MD999]
2882                    fragment.unknown_keys.push((
2883                        format!("[tool.rumdl.{rule_name}]"),
2884                        String::new(),
2885                        Some(path.to_string()),
2886                    ));
2887                }
2888            }
2889        }
2890    }
2891
2892    // Only return Some(fragment) if any config was found
2893    let has_any = !fragment.global.enable.value.is_empty()
2894        || !fragment.global.disable.value.is_empty()
2895        || !fragment.global.include.value.is_empty()
2896        || !fragment.global.exclude.value.is_empty()
2897        || !fragment.global.fixable.value.is_empty()
2898        || !fragment.global.unfixable.value.is_empty()
2899        || fragment.global.output_format.is_some()
2900        || fragment.global.cache_dir.is_some()
2901        || !fragment.per_file_ignores.value.is_empty()
2902        || !fragment.rules.is_empty();
2903    if has_any { Ok(Some(fragment)) } else { Ok(None) }
2904}
2905
2906/// Parses rumdl.toml / .rumdl.toml content.
2907fn parse_rumdl_toml(content: &str, path: &str, source: ConfigSource) -> Result<SourcedConfigFragment, ConfigError> {
2908    let doc = content
2909        .parse::<DocumentMut>()
2910        .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2911    let mut fragment = SourcedConfigFragment::default();
2912    // source parameter provided by caller
2913    let file = Some(path.to_string());
2914
2915    // Define known rules before the loop
2916    let all_rules = rules::all_rules(&Config::default());
2917    let registry = RuleRegistry::from_rules(&all_rules);
2918    let known_rule_names: BTreeSet<String> = registry
2919        .rule_names()
2920        .into_iter()
2921        .map(|s| s.to_ascii_uppercase())
2922        .collect();
2923
2924    // Handle [global] section
2925    if let Some(global_item) = doc.get("global")
2926        && let Some(global_table) = global_item.as_table()
2927    {
2928        for (key, value_item) in global_table.iter() {
2929            let norm_key = normalize_key(key);
2930            match norm_key.as_str() {
2931                "enable" | "disable" | "include" | "exclude" => {
2932                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2933                        // Corrected: Iterate directly over the Formatted<Array>
2934                        let values: Vec<String> = formatted_array
2935                                .iter()
2936                                .filter_map(|item| item.as_str()) // Extract strings
2937                                .map(|s| s.to_string())
2938                                .collect();
2939
2940                        // Normalize rule names for enable/disable
2941                        let final_values = if norm_key == "enable" || norm_key == "disable" {
2942                            // Corrected: Pass &str to normalize_key
2943                            values.into_iter().map(|s| normalize_key(&s)).collect()
2944                        } else {
2945                            values
2946                        };
2947
2948                        match norm_key.as_str() {
2949                            "enable" => fragment
2950                                .global
2951                                .enable
2952                                .push_override(final_values, source, file.clone(), None),
2953                            "disable" => {
2954                                fragment
2955                                    .global
2956                                    .disable
2957                                    .push_override(final_values, source, file.clone(), None)
2958                            }
2959                            "include" => {
2960                                fragment
2961                                    .global
2962                                    .include
2963                                    .push_override(final_values, source, file.clone(), None)
2964                            }
2965                            "exclude" => {
2966                                fragment
2967                                    .global
2968                                    .exclude
2969                                    .push_override(final_values, source, file.clone(), None)
2970                            }
2971                            _ => unreachable!("Outer match guarantees only enable/disable/include/exclude"),
2972                        }
2973                    } else {
2974                        log::warn!(
2975                            "[WARN] Expected array for global key '{}' in {}, found {}",
2976                            key,
2977                            path,
2978                            value_item.type_name()
2979                        );
2980                    }
2981                }
2982                "respect_gitignore" | "respect-gitignore" => {
2983                    // Handle both cases
2984                    if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2985                        let val = *formatted_bool.value();
2986                        fragment
2987                            .global
2988                            .respect_gitignore
2989                            .push_override(val, source, file.clone(), None);
2990                    } else {
2991                        log::warn!(
2992                            "[WARN] Expected boolean for global key '{}' in {}, found {}",
2993                            key,
2994                            path,
2995                            value_item.type_name()
2996                        );
2997                    }
2998                }
2999                "force_exclude" | "force-exclude" => {
3000                    // Handle both cases
3001                    if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
3002                        let val = *formatted_bool.value();
3003                        fragment
3004                            .global
3005                            .force_exclude
3006                            .push_override(val, source, file.clone(), None);
3007                    } else {
3008                        log::warn!(
3009                            "[WARN] Expected boolean for global key '{}' in {}, found {}",
3010                            key,
3011                            path,
3012                            value_item.type_name()
3013                        );
3014                    }
3015                }
3016                "line_length" | "line-length" => {
3017                    // Handle both cases
3018                    if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
3019                        let val = *formatted_int.value() as u64;
3020                        fragment
3021                            .global
3022                            .line_length
3023                            .push_override(val, source, file.clone(), None);
3024                    } else {
3025                        log::warn!(
3026                            "[WARN] Expected integer for global key '{}' in {}, found {}",
3027                            key,
3028                            path,
3029                            value_item.type_name()
3030                        );
3031                    }
3032                }
3033                "output_format" | "output-format" => {
3034                    // Handle both cases
3035                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3036                        let val = formatted_string.value().clone();
3037                        if fragment.global.output_format.is_none() {
3038                            fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
3039                        } else {
3040                            fragment.global.output_format.as_mut().unwrap().push_override(
3041                                val,
3042                                source,
3043                                file.clone(),
3044                                None,
3045                            );
3046                        }
3047                    } else {
3048                        log::warn!(
3049                            "[WARN] Expected string for global key '{}' in {}, found {}",
3050                            key,
3051                            path,
3052                            value_item.type_name()
3053                        );
3054                    }
3055                }
3056                "cache_dir" | "cache-dir" => {
3057                    // Handle both cases
3058                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3059                        let val = formatted_string.value().clone();
3060                        if fragment.global.cache_dir.is_none() {
3061                            fragment.global.cache_dir = Some(SourcedValue::new(val.clone(), source));
3062                        } else {
3063                            fragment
3064                                .global
3065                                .cache_dir
3066                                .as_mut()
3067                                .unwrap()
3068                                .push_override(val, source, file.clone(), None);
3069                        }
3070                    } else {
3071                        log::warn!(
3072                            "[WARN] Expected string for global key '{}' in {}, found {}",
3073                            key,
3074                            path,
3075                            value_item.type_name()
3076                        );
3077                    }
3078                }
3079                "fixable" => {
3080                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3081                        let values: Vec<String> = formatted_array
3082                            .iter()
3083                            .filter_map(|item| item.as_str())
3084                            .map(normalize_key)
3085                            .collect();
3086                        fragment
3087                            .global
3088                            .fixable
3089                            .push_override(values, source, file.clone(), None);
3090                    } else {
3091                        log::warn!(
3092                            "[WARN] Expected array for global key '{}' in {}, found {}",
3093                            key,
3094                            path,
3095                            value_item.type_name()
3096                        );
3097                    }
3098                }
3099                "unfixable" => {
3100                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3101                        let values: Vec<String> = formatted_array
3102                            .iter()
3103                            .filter_map(|item| item.as_str())
3104                            .map(normalize_key)
3105                            .collect();
3106                        fragment
3107                            .global
3108                            .unfixable
3109                            .push_override(values, source, file.clone(), None);
3110                    } else {
3111                        log::warn!(
3112                            "[WARN] Expected array for global key '{}' in {}, found {}",
3113                            key,
3114                            path,
3115                            value_item.type_name()
3116                        );
3117                    }
3118                }
3119                "flavor" => {
3120                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
3121                        let val = formatted_string.value();
3122                        if let Ok(flavor) = MarkdownFlavor::from_str(val) {
3123                            fragment.global.flavor.push_override(flavor, source, file.clone(), None);
3124                        } else {
3125                            log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
3126                        }
3127                    } else {
3128                        log::warn!(
3129                            "[WARN] Expected string for global key '{}' in {}, found {}",
3130                            key,
3131                            path,
3132                            value_item.type_name()
3133                        );
3134                    }
3135                }
3136                _ => {
3137                    // Track unknown global keys for validation
3138                    fragment
3139                        .unknown_keys
3140                        .push(("[global]".to_string(), key.to_string(), Some(path.to_string())));
3141                    log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
3142                }
3143            }
3144        }
3145    }
3146
3147    // Handle [per-file-ignores] section
3148    if let Some(per_file_item) = doc.get("per-file-ignores")
3149        && let Some(per_file_table) = per_file_item.as_table()
3150    {
3151        let mut per_file_map = HashMap::new();
3152        for (pattern, value_item) in per_file_table.iter() {
3153            if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
3154                let rules: Vec<String> = formatted_array
3155                    .iter()
3156                    .filter_map(|item| item.as_str())
3157                    .map(normalize_key)
3158                    .collect();
3159                per_file_map.insert(pattern.to_string(), rules);
3160            } else {
3161                let type_name = value_item.type_name();
3162                log::warn!(
3163                    "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {type_name}"
3164                );
3165            }
3166        }
3167        fragment
3168            .per_file_ignores
3169            .push_override(per_file_map, source, file.clone(), None);
3170    }
3171
3172    // Rule-specific: all other top-level tables
3173    for (key, item) in doc.iter() {
3174        let norm_rule_name = key.to_ascii_uppercase();
3175
3176        // Skip known special sections
3177        if key == "global" || key == "per-file-ignores" {
3178            continue;
3179        }
3180
3181        // Track unknown rule sections (like [MD999])
3182        if !known_rule_names.contains(&norm_rule_name) {
3183            // Only track if it looks like a rule section (starts with MD or is uppercase)
3184            if norm_rule_name.starts_with("MD") || key.chars().all(|c| c.is_uppercase() || c.is_numeric()) {
3185                fragment
3186                    .unknown_keys
3187                    .push((format!("[{key}]"), String::new(), Some(path.to_string())));
3188            }
3189            continue;
3190        }
3191
3192        if let Some(tbl) = item.as_table() {
3193            let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
3194            for (rk, rv_item) in tbl.iter() {
3195                let norm_rk = normalize_key(rk);
3196                let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
3197                    Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
3198                    Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
3199                    Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
3200                    Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
3201                    Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
3202                    Some(toml_edit::Value::Array(formatted_array)) => {
3203                        // Convert toml_edit Array to toml::Value::Array
3204                        let mut values = Vec::new();
3205                        for item in formatted_array.iter() {
3206                            match item {
3207                                toml_edit::Value::String(formatted) => {
3208                                    values.push(toml::Value::String(formatted.value().clone()))
3209                                }
3210                                toml_edit::Value::Integer(formatted) => {
3211                                    values.push(toml::Value::Integer(*formatted.value()))
3212                                }
3213                                toml_edit::Value::Float(formatted) => {
3214                                    values.push(toml::Value::Float(*formatted.value()))
3215                                }
3216                                toml_edit::Value::Boolean(formatted) => {
3217                                    values.push(toml::Value::Boolean(*formatted.value()))
3218                                }
3219                                toml_edit::Value::Datetime(formatted) => {
3220                                    values.push(toml::Value::Datetime(*formatted.value()))
3221                                }
3222                                _ => {
3223                                    log::warn!(
3224                                        "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
3225                                    );
3226                                }
3227                            }
3228                        }
3229                        Some(toml::Value::Array(values))
3230                    }
3231                    Some(toml_edit::Value::InlineTable(_)) => {
3232                        log::warn!(
3233                            "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
3234                        );
3235                        None
3236                    }
3237                    None => {
3238                        log::warn!(
3239                            "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
3240                        );
3241                        None
3242                    }
3243                };
3244                if let Some(toml_val) = maybe_toml_val {
3245                    let sv = rule_entry
3246                        .values
3247                        .entry(norm_rk.clone())
3248                        .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
3249                    sv.push_override(toml_val, source, file.clone(), None);
3250                }
3251            }
3252        } else if item.is_value() {
3253            log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
3254        }
3255    }
3256
3257    Ok(fragment)
3258}
3259
3260/// Loads and converts a markdownlint config file (.json or .yaml) into a SourcedConfigFragment.
3261fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
3262    // Use the unified loader from markdownlint_config.rs
3263    let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
3264        .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
3265    Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
3266}
3267
3268#[cfg(test)]
3269#[path = "config_intelligent_merge_tests.rs"]
3270mod config_intelligent_merge_tests;