rumdl_lib/
config.rs

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