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 lazy_static::lazy_static;
8use log;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::collections::{BTreeSet, HashMap};
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)]
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    // Future flavors can be added here when they have actual implementation differences
31    // Planned: GFM (GitHub Flavored Markdown) - for GitHub-specific features like tables, strikethrough
32    // Planned: CommonMark - for strict CommonMark compliance
33}
34
35impl fmt::Display for MarkdownFlavor {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            MarkdownFlavor::Standard => write!(f, "standard"),
39            MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
40        }
41    }
42}
43
44impl FromStr for MarkdownFlavor {
45    type Err = String;
46
47    fn from_str(s: &str) -> Result<Self, Self::Err> {
48        match s.to_lowercase().as_str() {
49            "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
50            "mkdocs" => Ok(MarkdownFlavor::MkDocs),
51            // Accept but warn about unimplemented flavors
52            "gfm" | "github" => {
53                eprintln!("Warning: GFM flavor not yet implemented, using standard");
54                Ok(MarkdownFlavor::Standard)
55            }
56            "commonmark" => {
57                eprintln!("Warning: CommonMark flavor not yet implemented, using standard");
58                Ok(MarkdownFlavor::Standard)
59            }
60            _ => Err(format!("Unknown markdown flavor: {s}")),
61        }
62    }
63}
64
65lazy_static! {
66    // Map common markdownlint config keys to rumdl rule names
67    static ref MARKDOWNLINT_KEY_MAP: HashMap<&'static str, &'static str> = {
68        let mut m = HashMap::new();
69        // Add mappings based on common markdownlint config names
70        // From https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.jsonc
71        m.insert("ul-style", "md004");
72        m.insert("code-block-style", "md046");
73        m.insert("ul-indent", "md007"); // Example
74        m.insert("line-length", "md013"); // Example of a common one that might be top-level
75        // Add more mappings as needed based on markdownlint schema or observed usage
76        m
77    };
78}
79
80/// Normalizes configuration keys (rule names, option names) to lowercase kebab-case.
81pub fn normalize_key(key: &str) -> String {
82    // If the key looks like a rule name (e.g., MD013), uppercase it
83    if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
84        key.to_ascii_uppercase()
85    } else {
86        key.replace('_', "-").to_ascii_lowercase()
87    }
88}
89
90/// Represents a rule-specific configuration
91#[derive(Debug, Serialize, Deserialize, Default, PartialEq)]
92pub struct RuleConfig {
93    /// Configuration values for the rule
94    #[serde(flatten)]
95    pub values: BTreeMap<String, toml::Value>,
96}
97
98/// Represents the complete configuration loaded from rumdl.toml
99#[derive(Debug, Serialize, Deserialize, Default, PartialEq)]
100pub struct Config {
101    /// Global configuration options
102    #[serde(default)]
103    pub global: GlobalConfig,
104
105    /// Rule-specific configurations
106    #[serde(flatten)]
107    pub rules: BTreeMap<String, RuleConfig>,
108}
109
110impl Config {
111    /// Check if the Markdown flavor is set to MkDocs
112    pub fn is_mkdocs_flavor(&self) -> bool {
113        self.global.flavor == MarkdownFlavor::MkDocs
114    }
115
116    // Future methods for when GFM and CommonMark are implemented:
117    // pub fn is_gfm_flavor(&self) -> bool
118    // pub fn is_commonmark_flavor(&self) -> bool
119
120    /// Get the configured Markdown flavor
121    pub fn markdown_flavor(&self) -> MarkdownFlavor {
122        self.global.flavor
123    }
124
125    /// Legacy method for backwards compatibility - redirects to is_mkdocs_flavor
126    pub fn is_mkdocs_project(&self) -> bool {
127        self.is_mkdocs_flavor()
128    }
129}
130
131/// Global configuration options
132#[derive(Debug, Serialize, Deserialize, PartialEq)]
133#[serde(default)]
134pub struct GlobalConfig {
135    /// Enabled rules
136    #[serde(default)]
137    pub enable: Vec<String>,
138
139    /// Disabled rules
140    #[serde(default)]
141    pub disable: Vec<String>,
142
143    /// Files to exclude
144    #[serde(default)]
145    pub exclude: Vec<String>,
146
147    /// Files to include
148    #[serde(default)]
149    pub include: Vec<String>,
150
151    /// Respect .gitignore files when scanning directories
152    #[serde(default = "default_respect_gitignore")]
153    pub respect_gitignore: bool,
154
155    /// Global line length setting (used by MD013 and other rules if not overridden)
156    #[serde(default = "default_line_length")]
157    pub line_length: u64,
158
159    /// Output format for linting results (e.g., "text", "json", "pylint", etc.)
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub output_format: Option<String>,
162
163    /// Rules that are allowed to be fixed when --fix is used
164    /// If specified, only these rules will be fixed
165    #[serde(default)]
166    pub fixable: Vec<String>,
167
168    /// Rules that should never be fixed, even when --fix is used
169    /// Takes precedence over fixable
170    #[serde(default)]
171    pub unfixable: Vec<String>,
172
173    /// Markdown flavor/dialect to use (mkdocs, gfm, commonmark, etc.)
174    /// When set, adjusts parsing and validation rules for that specific Markdown variant
175    #[serde(default)]
176    pub flavor: MarkdownFlavor,
177
178    /// Whether to enforce exclude and extend-exclude patterns even for paths that are passed explicitly.
179    /// By default (false), rumdl will lint any paths passed in directly, even if they would typically be excluded.
180    /// Setting this to true will cause rumdl to respect exclusions unequivocally.
181    /// This is useful for pre-commit, which explicitly passes all changed files.
182    #[serde(default)]
183    pub force_exclude: bool,
184}
185
186fn default_respect_gitignore() -> bool {
187    true
188}
189
190fn default_line_length() -> u64 {
191    80
192}
193
194// Add the Default impl
195impl Default for GlobalConfig {
196    fn default() -> Self {
197        Self {
198            enable: Vec::new(),
199            disable: Vec::new(),
200            exclude: Vec::new(),
201            include: Vec::new(),
202            respect_gitignore: true,
203            line_length: 80,
204            output_format: None,
205            fixable: Vec::new(),
206            unfixable: Vec::new(),
207            flavor: MarkdownFlavor::default(),
208            force_exclude: false,
209        }
210    }
211}
212
213const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
214    ".markdownlint.json",
215    ".markdownlint.jsonc",
216    ".markdownlint.yaml",
217    ".markdownlint.yml",
218    "markdownlint.json",
219    "markdownlint.jsonc",
220    "markdownlint.yaml",
221    "markdownlint.yml",
222];
223
224/// Create a default configuration file at the specified path
225pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
226    // Check if file already exists
227    if Path::new(path).exists() {
228        return Err(ConfigError::FileExists { path: path.to_string() });
229    }
230
231    // Default configuration content
232    let default_config = r#"# rumdl configuration file
233
234# Global configuration options
235[global]
236# List of rules to disable (uncomment and modify as needed)
237# disable = ["MD013", "MD033"]
238
239# List of rules to enable exclusively (if provided, only these rules will run)
240# enable = ["MD001", "MD003", "MD004"]
241
242# List of file/directory patterns to include for linting (if provided, only these will be linted)
243# include = [
244#    "docs/*.md",
245#    "src/**/*.md",
246#    "README.md"
247# ]
248
249# List of file/directory patterns to exclude from linting
250exclude = [
251    # Common directories to exclude
252    ".git",
253    ".github",
254    "node_modules",
255    "vendor",
256    "dist",
257    "build",
258
259    # Specific files or patterns
260    "CHANGELOG.md",
261    "LICENSE.md",
262]
263
264# Respect .gitignore files when scanning directories (default: true)
265respect_gitignore = true
266
267# Markdown flavor/dialect (uncomment to enable)
268# Options: mkdocs, gfm, commonmark
269# flavor = "mkdocs"
270
271# Rule-specific configurations (uncomment and modify as needed)
272
273# [MD003]
274# style = "atx"  # Heading style (atx, atx_closed, setext)
275
276# [MD004]
277# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
278
279# [MD007]
280# indent = 4  # Unordered list indentation
281
282# [MD013]
283# line_length = 100  # Line length
284# code_blocks = false  # Exclude code blocks from line length check
285# tables = false  # Exclude tables from line length check
286# headings = true  # Include headings in line length check
287
288# [MD044]
289# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
290# code_blocks_excluded = true  # Exclude code blocks from proper name check
291"#;
292
293    // Write the default configuration to the file
294    match fs::write(path, default_config) {
295        Ok(_) => Ok(()),
296        Err(err) => Err(ConfigError::IoError {
297            source: err,
298            path: path.to_string(),
299        }),
300    }
301}
302
303/// Errors that can occur when loading configuration
304#[derive(Debug, thiserror::Error)]
305pub enum ConfigError {
306    /// Failed to read the configuration file
307    #[error("Failed to read config file at {path}: {source}")]
308    IoError { source: io::Error, path: String },
309
310    /// Failed to parse the configuration content (TOML or JSON)
311    #[error("Failed to parse config: {0}")]
312    ParseError(String),
313
314    /// Configuration file already exists
315    #[error("Configuration file already exists at {path}")]
316    FileExists { path: String },
317}
318
319/// Get a rule-specific configuration value
320/// Automatically tries both the original key and normalized variants (kebab-case ↔ snake_case)
321/// for better markdownlint compatibility
322pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
323    let norm_rule_name = rule_name.to_ascii_uppercase(); // Use uppercase for lookup
324
325    let rule_config = config.rules.get(&norm_rule_name)?;
326
327    // Try multiple key variants to support both underscore and kebab-case formats
328    let key_variants = [
329        key.to_string(),       // Original key as provided
330        normalize_key(key),    // Normalized key (lowercase, kebab-case)
331        key.replace('-', "_"), // Convert kebab-case to snake_case
332        key.replace('_', "-"), // Convert snake_case to kebab-case
333    ];
334
335    // Try each variant until we find a match
336    for variant in &key_variants {
337        if let Some(value) = rule_config.values.get(variant)
338            && let Ok(result) = T::deserialize(value.clone())
339        {
340            return Some(result);
341        }
342    }
343
344    None
345}
346
347/// Generate default rumdl configuration for pyproject.toml
348pub fn generate_pyproject_config() -> String {
349    let config_content = r#"
350[tool.rumdl]
351# Global configuration options
352line-length = 100
353disable = []
354exclude = [
355    # Common directories to exclude
356    ".git",
357    ".github",
358    "node_modules",
359    "vendor",
360    "dist",
361    "build",
362]
363respect-gitignore = true
364
365# Rule-specific configurations (uncomment and modify as needed)
366
367# [tool.rumdl.MD003]
368# style = "atx"  # Heading style (atx, atx_closed, setext)
369
370# [tool.rumdl.MD004]
371# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
372
373# [tool.rumdl.MD007]
374# indent = 4  # Unordered list indentation
375
376# [tool.rumdl.MD013]
377# line_length = 100  # Line length
378# code_blocks = false  # Exclude code blocks from line length check
379# tables = false  # Exclude tables from line length check
380# headings = true  # Include headings in line length check
381
382# [tool.rumdl.MD044]
383# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
384# code_blocks_excluded = true  # Exclude code blocks from proper name check
385"#;
386
387    config_content.to_string()
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use std::fs;
394    use tempfile::tempdir;
395
396    #[test]
397    fn test_flavor_loading() {
398        let temp_dir = tempdir().unwrap();
399        let config_path = temp_dir.path().join(".rumdl.toml");
400        let config_content = r#"
401[global]
402flavor = "mkdocs"
403disable = ["MD001"]
404"#;
405        fs::write(&config_path, config_content).unwrap();
406
407        // Load the config
408        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
409        let config: Config = sourced.into();
410
411        // Check that flavor was loaded
412        assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
413        assert!(config.is_mkdocs_flavor());
414        assert!(config.is_mkdocs_project()); // Test backwards compatibility
415        assert_eq!(config.global.disable, vec!["MD001".to_string()]);
416    }
417
418    #[test]
419    fn test_pyproject_toml_root_level_config() {
420        let temp_dir = tempdir().unwrap();
421        let config_path = temp_dir.path().join("pyproject.toml");
422
423        // Create a test pyproject.toml with root-level configuration
424        let content = r#"
425[tool.rumdl]
426line-length = 120
427disable = ["MD033"]
428enable = ["MD001", "MD004"]
429include = ["docs/*.md"]
430exclude = ["node_modules"]
431respect-gitignore = true
432        "#;
433
434        fs::write(&config_path, content).unwrap();
435
436        // Load the config with skip_auto_discovery to avoid environment config files
437        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
438        let config: Config = sourced.into(); // Convert to plain config for assertions
439
440        // Check global settings
441        assert_eq!(config.global.disable, vec!["MD033".to_string()]);
442        assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
443        // Should now contain only the configured pattern since auto-discovery is disabled
444        assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
445        assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
446        assert!(config.global.respect_gitignore);
447
448        // Check line-length was correctly added to MD013
449        let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
450        assert_eq!(line_length, Some(120));
451    }
452
453    #[test]
454    fn test_pyproject_toml_snake_case_and_kebab_case() {
455        let temp_dir = tempdir().unwrap();
456        let config_path = temp_dir.path().join("pyproject.toml");
457
458        // Test with both kebab-case and snake_case variants
459        let content = r#"
460[tool.rumdl]
461line-length = 150
462respect_gitignore = true
463        "#;
464
465        fs::write(&config_path, content).unwrap();
466
467        // Load the config with skip_auto_discovery to avoid environment config files
468        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
469        let config: Config = sourced.into(); // Convert to plain config for assertions
470
471        // Check settings were correctly loaded
472        assert!(config.global.respect_gitignore);
473        let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
474        assert_eq!(line_length, Some(150));
475    }
476
477    #[test]
478    fn test_md013_key_normalization_in_rumdl_toml() {
479        let temp_dir = tempdir().unwrap();
480        let config_path = temp_dir.path().join(".rumdl.toml");
481        let config_content = r#"
482[MD013]
483line_length = 111
484line-length = 222
485"#;
486        fs::write(&config_path, config_content).unwrap();
487        // Load the config with skip_auto_discovery to avoid environment config files
488        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
489        let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
490        // Now we should only get the explicitly configured key
491        let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
492        assert_eq!(keys, vec!["line-length"]);
493        let val = &rule_cfg.values["line-length"].value;
494        assert_eq!(val.as_integer(), Some(222));
495        // get_rule_config_value should retrieve the value for both snake_case and kebab-case
496        let config: Config = sourced.clone().into();
497        let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
498        let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
499        assert_eq!(v1, Some(222));
500        assert_eq!(v2, Some(222));
501    }
502
503    #[test]
504    fn test_md013_section_case_insensitivity() {
505        let temp_dir = tempdir().unwrap();
506        let config_path = temp_dir.path().join(".rumdl.toml");
507        let config_content = r#"
508[md013]
509line-length = 101
510
511[Md013]
512line-length = 102
513
514[MD013]
515line-length = 103
516"#;
517        fs::write(&config_path, config_content).unwrap();
518        // Load the config with skip_auto_discovery to avoid environment config files
519        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
520        let config: Config = sourced.clone().into();
521        // Only the last section should win, and be present
522        let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
523        let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
524        assert_eq!(keys, vec!["line-length"]);
525        let val = &rule_cfg.values["line-length"].value;
526        assert_eq!(val.as_integer(), Some(103));
527        let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
528        assert_eq!(v, Some(103));
529    }
530
531    #[test]
532    fn test_md013_key_snake_and_kebab_case() {
533        let temp_dir = tempdir().unwrap();
534        let config_path = temp_dir.path().join(".rumdl.toml");
535        let config_content = r#"
536[MD013]
537line_length = 201
538line-length = 202
539"#;
540        fs::write(&config_path, config_content).unwrap();
541        // Load the config with skip_auto_discovery to avoid environment config files
542        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
543        let config: Config = sourced.clone().into();
544        let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
545        let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
546        assert_eq!(keys, vec!["line-length"]);
547        let val = &rule_cfg.values["line-length"].value;
548        assert_eq!(val.as_integer(), Some(202));
549        let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
550        let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
551        assert_eq!(v1, Some(202));
552        assert_eq!(v2, Some(202));
553    }
554
555    #[test]
556    fn test_unknown_rule_section_is_ignored() {
557        let temp_dir = tempdir().unwrap();
558        let config_path = temp_dir.path().join(".rumdl.toml");
559        let config_content = r#"
560[MD999]
561foo = 1
562bar = 2
563[MD013]
564line-length = 303
565"#;
566        fs::write(&config_path, config_content).unwrap();
567        // Load the config with skip_auto_discovery to avoid environment config files
568        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
569        let config: Config = sourced.clone().into();
570        // MD999 should not be present
571        assert!(!sourced.rules.contains_key("MD999"));
572        // MD013 should be present and correct
573        let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
574        assert_eq!(v, Some(303));
575    }
576
577    #[test]
578    fn test_invalid_toml_syntax() {
579        let temp_dir = tempdir().unwrap();
580        let config_path = temp_dir.path().join(".rumdl.toml");
581
582        // Invalid TOML with unclosed string
583        let config_content = r#"
584[MD013]
585line-length = "unclosed string
586"#;
587        fs::write(&config_path, config_content).unwrap();
588
589        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
590        assert!(result.is_err());
591        match result.unwrap_err() {
592            ConfigError::ParseError(msg) => {
593                // The actual error message from toml parser might vary
594                assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
595            }
596            _ => panic!("Expected ParseError"),
597        }
598    }
599
600    #[test]
601    fn test_wrong_type_for_config_value() {
602        let temp_dir = tempdir().unwrap();
603        let config_path = temp_dir.path().join(".rumdl.toml");
604
605        // line-length should be a number, not a string
606        let config_content = r#"
607[MD013]
608line-length = "not a number"
609"#;
610        fs::write(&config_path, config_content).unwrap();
611
612        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
613        let config: Config = sourced.into();
614
615        // The value should be loaded as a string, not converted
616        let rule_config = config.rules.get("MD013").unwrap();
617        let value = rule_config.values.get("line-length").unwrap();
618        assert!(matches!(value, toml::Value::String(_)));
619    }
620
621    #[test]
622    fn test_empty_config_file() {
623        let temp_dir = tempdir().unwrap();
624        let config_path = temp_dir.path().join(".rumdl.toml");
625
626        // Empty file
627        fs::write(&config_path, "").unwrap();
628
629        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
630        let config: Config = sourced.into();
631
632        // Should have default values
633        assert_eq!(config.global.line_length, 80);
634        assert!(config.global.respect_gitignore);
635        assert!(config.rules.is_empty());
636    }
637
638    #[test]
639    fn test_malformed_pyproject_toml() {
640        let temp_dir = tempdir().unwrap();
641        let config_path = temp_dir.path().join("pyproject.toml");
642
643        // Missing closing bracket
644        let content = r#"
645[tool.rumdl
646line-length = 120
647"#;
648        fs::write(&config_path, content).unwrap();
649
650        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
651        assert!(result.is_err());
652    }
653
654    #[test]
655    fn test_conflicting_config_values() {
656        let temp_dir = tempdir().unwrap();
657        let config_path = temp_dir.path().join(".rumdl.toml");
658
659        // Both enable and disable the same rule - these need to be in a global section
660        let config_content = r#"
661[global]
662enable = ["MD013"]
663disable = ["MD013"]
664"#;
665        fs::write(&config_path, config_content).unwrap();
666
667        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
668        let config: Config = sourced.into();
669
670        // Both should be present - resolution happens at runtime
671        assert!(config.global.enable.contains(&"MD013".to_string()));
672        assert!(config.global.disable.contains(&"MD013".to_string()));
673    }
674
675    #[test]
676    fn test_invalid_rule_names() {
677        let temp_dir = tempdir().unwrap();
678        let config_path = temp_dir.path().join(".rumdl.toml");
679
680        let config_content = r#"
681[global]
682enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
683disable = ["MD-001", "MD_002"]
684"#;
685        fs::write(&config_path, config_content).unwrap();
686
687        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
688        let config: Config = sourced.into();
689
690        // All values should be preserved as-is
691        assert_eq!(config.global.enable.len(), 4);
692        assert_eq!(config.global.disable.len(), 2);
693    }
694
695    #[test]
696    fn test_deeply_nested_config() {
697        let temp_dir = tempdir().unwrap();
698        let config_path = temp_dir.path().join(".rumdl.toml");
699
700        // This should be ignored as we don't support nested tables within rule configs
701        let config_content = r#"
702[MD013]
703line-length = 100
704[MD013.nested]
705value = 42
706"#;
707        fs::write(&config_path, config_content).unwrap();
708
709        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
710        let config: Config = sourced.into();
711
712        let rule_config = config.rules.get("MD013").unwrap();
713        assert_eq!(
714            rule_config.values.get("line-length").unwrap(),
715            &toml::Value::Integer(100)
716        );
717        // Nested table should not be present
718        assert!(!rule_config.values.contains_key("nested"));
719    }
720
721    #[test]
722    fn test_unicode_in_config() {
723        let temp_dir = tempdir().unwrap();
724        let config_path = temp_dir.path().join(".rumdl.toml");
725
726        let config_content = r#"
727[global]
728include = ["文档/*.md", "ドキュメント/*.md"]
729exclude = ["测试/*", "🚀/*"]
730
731[MD013]
732line-length = 80
733message = "行太长了 🚨"
734"#;
735        fs::write(&config_path, config_content).unwrap();
736
737        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
738        let config: Config = sourced.into();
739
740        assert_eq!(config.global.include.len(), 2);
741        assert_eq!(config.global.exclude.len(), 2);
742        assert!(config.global.include[0].contains("文档"));
743        assert!(config.global.exclude[1].contains("🚀"));
744
745        let rule_config = config.rules.get("MD013").unwrap();
746        let message = rule_config.values.get("message").unwrap();
747        if let toml::Value::String(s) = message {
748            assert!(s.contains("行太长了"));
749            assert!(s.contains("🚨"));
750        }
751    }
752
753    #[test]
754    fn test_extremely_long_values() {
755        let temp_dir = tempdir().unwrap();
756        let config_path = temp_dir.path().join(".rumdl.toml");
757
758        let long_string = "a".repeat(10000);
759        let config_content = format!(
760            r#"
761[global]
762exclude = ["{long_string}"]
763
764[MD013]
765line-length = 999999999
766"#
767        );
768
769        fs::write(&config_path, config_content).unwrap();
770
771        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
772        let config: Config = sourced.into();
773
774        assert_eq!(config.global.exclude[0].len(), 10000);
775        let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
776        assert_eq!(line_length, Some(999999999));
777    }
778
779    #[test]
780    fn test_config_with_comments() {
781        let temp_dir = tempdir().unwrap();
782        let config_path = temp_dir.path().join(".rumdl.toml");
783
784        let config_content = r#"
785[global]
786# This is a comment
787enable = ["MD001"] # Enable MD001
788# disable = ["MD002"] # This is commented out
789
790[MD013] # Line length rule
791line-length = 100 # Set to 100 characters
792# ignored = true # This setting is commented out
793"#;
794        fs::write(&config_path, config_content).unwrap();
795
796        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
797        let config: Config = sourced.into();
798
799        assert_eq!(config.global.enable, vec!["MD001"]);
800        assert!(config.global.disable.is_empty()); // Commented out
801
802        let rule_config = config.rules.get("MD013").unwrap();
803        assert_eq!(rule_config.values.len(), 1); // Only line-length
804        assert!(!rule_config.values.contains_key("ignored"));
805    }
806
807    #[test]
808    fn test_arrays_in_rule_config() {
809        let temp_dir = tempdir().unwrap();
810        let config_path = temp_dir.path().join(".rumdl.toml");
811
812        let config_content = r#"
813[MD002]
814levels = [1, 2, 3]
815tags = ["important", "critical"]
816mixed = [1, "two", true]
817"#;
818        fs::write(&config_path, config_content).unwrap();
819
820        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
821        let config: Config = sourced.into();
822
823        // Arrays should now be properly parsed
824        let rule_config = config.rules.get("MD002").expect("MD002 config should exist");
825
826        // Check that arrays are present and correctly parsed
827        assert!(rule_config.values.contains_key("levels"));
828        assert!(rule_config.values.contains_key("tags"));
829        assert!(rule_config.values.contains_key("mixed"));
830
831        // Verify array contents
832        if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
833            assert_eq!(levels.len(), 3);
834            assert_eq!(levels[0], toml::Value::Integer(1));
835            assert_eq!(levels[1], toml::Value::Integer(2));
836            assert_eq!(levels[2], toml::Value::Integer(3));
837        } else {
838            panic!("levels should be an array");
839        }
840
841        if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
842            assert_eq!(tags.len(), 2);
843            assert_eq!(tags[0], toml::Value::String("important".to_string()));
844            assert_eq!(tags[1], toml::Value::String("critical".to_string()));
845        } else {
846            panic!("tags should be an array");
847        }
848
849        if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
850            assert_eq!(mixed.len(), 3);
851            assert_eq!(mixed[0], toml::Value::Integer(1));
852            assert_eq!(mixed[1], toml::Value::String("two".to_string()));
853            assert_eq!(mixed[2], toml::Value::Boolean(true));
854        } else {
855            panic!("mixed should be an array");
856        }
857    }
858
859    #[test]
860    fn test_normalize_key_edge_cases() {
861        // Rule names
862        assert_eq!(normalize_key("MD001"), "MD001");
863        assert_eq!(normalize_key("md001"), "MD001");
864        assert_eq!(normalize_key("Md001"), "MD001");
865        assert_eq!(normalize_key("mD001"), "MD001");
866
867        // Non-rule names
868        assert_eq!(normalize_key("line_length"), "line-length");
869        assert_eq!(normalize_key("line-length"), "line-length");
870        assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
871        assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
872
873        // Edge cases
874        assert_eq!(normalize_key("MD"), "md"); // Too short to be a rule
875        assert_eq!(normalize_key("MD00"), "md00"); // Too short
876        assert_eq!(normalize_key("MD0001"), "md0001"); // Too long
877        assert_eq!(normalize_key("MDabc"), "mdabc"); // Non-digit
878        assert_eq!(normalize_key("MD00a"), "md00a"); // Partial digit
879        assert_eq!(normalize_key(""), "");
880        assert_eq!(normalize_key("_"), "-");
881        assert_eq!(normalize_key("___"), "---");
882    }
883
884    #[test]
885    fn test_missing_config_file() {
886        let temp_dir = tempdir().unwrap();
887        let config_path = temp_dir.path().join("nonexistent.toml");
888
889        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
890        assert!(result.is_err());
891        match result.unwrap_err() {
892            ConfigError::IoError { .. } => {}
893            _ => panic!("Expected IoError for missing file"),
894        }
895    }
896
897    #[test]
898    #[cfg(unix)]
899    fn test_permission_denied_config() {
900        use std::os::unix::fs::PermissionsExt;
901
902        let temp_dir = tempdir().unwrap();
903        let config_path = temp_dir.path().join(".rumdl.toml");
904
905        fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
906
907        // Remove read permissions
908        let mut perms = fs::metadata(&config_path).unwrap().permissions();
909        perms.set_mode(0o000);
910        fs::set_permissions(&config_path, perms).unwrap();
911
912        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
913
914        // Restore permissions for cleanup
915        let mut perms = fs::metadata(&config_path).unwrap().permissions();
916        perms.set_mode(0o644);
917        fs::set_permissions(&config_path, perms).unwrap();
918
919        assert!(result.is_err());
920        match result.unwrap_err() {
921            ConfigError::IoError { .. } => {}
922            _ => panic!("Expected IoError for permission denied"),
923        }
924    }
925
926    #[test]
927    fn test_circular_reference_detection() {
928        // This test is more conceptual since TOML doesn't support circular references
929        // But we test that deeply nested structures don't cause stack overflow
930        let temp_dir = tempdir().unwrap();
931        let config_path = temp_dir.path().join(".rumdl.toml");
932
933        let mut config_content = String::from("[MD001]\n");
934        for i in 0..100 {
935            config_content.push_str(&format!("key{i} = {i}\n"));
936        }
937
938        fs::write(&config_path, config_content).unwrap();
939
940        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
941        let config: Config = sourced.into();
942
943        let rule_config = config.rules.get("MD001").unwrap();
944        assert_eq!(rule_config.values.len(), 100);
945    }
946
947    #[test]
948    fn test_special_toml_values() {
949        let temp_dir = tempdir().unwrap();
950        let config_path = temp_dir.path().join(".rumdl.toml");
951
952        let config_content = r#"
953[MD001]
954infinity = inf
955neg_infinity = -inf
956not_a_number = nan
957datetime = 1979-05-27T07:32:00Z
958local_date = 1979-05-27
959local_time = 07:32:00
960"#;
961        fs::write(&config_path, config_content).unwrap();
962
963        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
964        let config: Config = sourced.into();
965
966        // Some values might not be parsed due to parser limitations
967        if let Some(rule_config) = config.rules.get("MD001") {
968            // Check special float values if present
969            if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
970                assert!(f.is_infinite() && f.is_sign_positive());
971            }
972            if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
973                assert!(f.is_infinite() && f.is_sign_negative());
974            }
975            if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
976                assert!(f.is_nan());
977            }
978
979            // Check datetime values if present
980            if let Some(val) = rule_config.values.get("datetime") {
981                assert!(matches!(val, toml::Value::Datetime(_)));
982            }
983            // Note: local_date and local_time might not be parsed by the current implementation
984        }
985    }
986
987    #[test]
988    fn test_default_config_passes_validation() {
989        use crate::rules;
990
991        let temp_dir = tempdir().unwrap();
992        let config_path = temp_dir.path().join(".rumdl.toml");
993        let config_path_str = config_path.to_str().unwrap();
994
995        // Create the default config using the same function that `rumdl init` uses
996        create_default_config(config_path_str).unwrap();
997
998        // Load it back as a SourcedConfig
999        let sourced =
1000            SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
1001
1002        // Create the rule registry
1003        let all_rules = rules::all_rules(&Config::default());
1004        let registry = RuleRegistry::from_rules(&all_rules);
1005
1006        // Validate the config
1007        let warnings = validate_config_sourced(&sourced, &registry);
1008
1009        // The default config should have no warnings
1010        if !warnings.is_empty() {
1011            for warning in &warnings {
1012                eprintln!("Config validation warning: {}", warning.message);
1013                if let Some(rule) = &warning.rule {
1014                    eprintln!("  Rule: {rule}");
1015                }
1016                if let Some(key) = &warning.key {
1017                    eprintln!("  Key: {key}");
1018                }
1019            }
1020        }
1021        assert!(
1022            warnings.is_empty(),
1023            "Default config from rumdl init should pass validation without warnings"
1024        );
1025    }
1026}
1027
1028#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1029pub enum ConfigSource {
1030    Default,
1031    RumdlToml,
1032    PyprojectToml,
1033    Cli,
1034    /// Value was loaded from a markdownlint config file (e.g. .markdownlint.json, .markdownlint.yaml)
1035    Markdownlint,
1036}
1037
1038#[derive(Debug, Clone)]
1039pub struct ConfigOverride<T> {
1040    pub value: T,
1041    pub source: ConfigSource,
1042    pub file: Option<String>,
1043    pub line: Option<usize>,
1044}
1045
1046#[derive(Debug, Clone)]
1047pub struct SourcedValue<T> {
1048    pub value: T,
1049    pub source: ConfigSource,
1050    pub overrides: Vec<ConfigOverride<T>>,
1051}
1052
1053impl<T: Clone> SourcedValue<T> {
1054    pub fn new(value: T, source: ConfigSource) -> Self {
1055        Self {
1056            value: value.clone(),
1057            source,
1058            overrides: vec![ConfigOverride {
1059                value,
1060                source,
1061                file: None,
1062                line: None,
1063            }],
1064        }
1065    }
1066
1067    /// Merges a new override into this SourcedValue based on source precedence.
1068    /// If the new source has higher or equal precedence, the value and source are updated,
1069    /// and the new override is added to the history.
1070    pub fn merge_override(
1071        &mut self,
1072        new_value: T,
1073        new_source: ConfigSource,
1074        new_file: Option<String>,
1075        new_line: Option<usize>,
1076    ) {
1077        // Helper function to get precedence, defined locally or globally
1078        fn source_precedence(src: ConfigSource) -> u8 {
1079            match src {
1080                ConfigSource::Default => 0,
1081                ConfigSource::PyprojectToml => 1,
1082                ConfigSource::Markdownlint => 2,
1083                ConfigSource::RumdlToml => 3,
1084                ConfigSource::Cli => 4,
1085            }
1086        }
1087
1088        if source_precedence(new_source) >= source_precedence(self.source) {
1089            self.value = new_value.clone();
1090            self.source = new_source;
1091            self.overrides.push(ConfigOverride {
1092                value: new_value,
1093                source: new_source,
1094                file: new_file,
1095                line: new_line,
1096            });
1097        }
1098    }
1099
1100    pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
1101        // This is essentially merge_override without the precedence check
1102        // We might consolidate these later, but keep separate for now during refactor
1103        self.value = value.clone();
1104        self.source = source;
1105        self.overrides.push(ConfigOverride {
1106            value,
1107            source,
1108            file,
1109            line,
1110        });
1111    }
1112}
1113
1114#[derive(Debug, Clone)]
1115pub struct SourcedGlobalConfig {
1116    pub enable: SourcedValue<Vec<String>>,
1117    pub disable: SourcedValue<Vec<String>>,
1118    pub exclude: SourcedValue<Vec<String>>,
1119    pub include: SourcedValue<Vec<String>>,
1120    pub respect_gitignore: SourcedValue<bool>,
1121    pub line_length: SourcedValue<u64>,
1122    pub output_format: Option<SourcedValue<String>>,
1123    pub fixable: SourcedValue<Vec<String>>,
1124    pub unfixable: SourcedValue<Vec<String>>,
1125    pub flavor: SourcedValue<MarkdownFlavor>,
1126    pub force_exclude: SourcedValue<bool>,
1127}
1128
1129impl Default for SourcedGlobalConfig {
1130    fn default() -> Self {
1131        SourcedGlobalConfig {
1132            enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1133            disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1134            exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
1135            include: SourcedValue::new(Vec::new(), ConfigSource::Default),
1136            respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
1137            line_length: SourcedValue::new(80, ConfigSource::Default),
1138            output_format: None,
1139            fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1140            unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
1141            flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
1142            force_exclude: SourcedValue::new(false, ConfigSource::Default),
1143        }
1144    }
1145}
1146
1147#[derive(Debug, Default, Clone)]
1148pub struct SourcedRuleConfig {
1149    pub values: BTreeMap<String, SourcedValue<toml::Value>>,
1150}
1151
1152/// Represents configuration loaded from a single source file, with provenance.
1153/// Used as an intermediate step before merging into the final SourcedConfig.
1154#[derive(Debug, Default, Clone)]
1155pub struct SourcedConfigFragment {
1156    pub global: SourcedGlobalConfig,
1157    pub rules: BTreeMap<String, SourcedRuleConfig>,
1158    // Note: Does not include loaded_files or unknown_keys, as those are tracked globally.
1159}
1160
1161#[derive(Debug, Default, Clone)]
1162pub struct SourcedConfig {
1163    pub global: SourcedGlobalConfig,
1164    pub rules: BTreeMap<String, SourcedRuleConfig>,
1165    pub loaded_files: Vec<String>,
1166    pub unknown_keys: Vec<(String, String)>, // (section, key)
1167}
1168
1169impl SourcedConfig {
1170    /// Merges another SourcedConfigFragment into this SourcedConfig.
1171    /// Uses source precedence to determine which values take effect.
1172    fn merge(&mut self, fragment: SourcedConfigFragment) {
1173        // Merge global config
1174        self.global.enable.merge_override(
1175            fragment.global.enable.value,
1176            fragment.global.enable.source,
1177            fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
1178            fragment.global.enable.overrides.first().and_then(|o| o.line),
1179        );
1180        self.global.disable.merge_override(
1181            fragment.global.disable.value,
1182            fragment.global.disable.source,
1183            fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
1184            fragment.global.disable.overrides.first().and_then(|o| o.line),
1185        );
1186        self.global.include.merge_override(
1187            fragment.global.include.value,
1188            fragment.global.include.source,
1189            fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
1190            fragment.global.include.overrides.first().and_then(|o| o.line),
1191        );
1192        self.global.exclude.merge_override(
1193            fragment.global.exclude.value,
1194            fragment.global.exclude.source,
1195            fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
1196            fragment.global.exclude.overrides.first().and_then(|o| o.line),
1197        );
1198        self.global.respect_gitignore.merge_override(
1199            fragment.global.respect_gitignore.value,
1200            fragment.global.respect_gitignore.source,
1201            fragment
1202                .global
1203                .respect_gitignore
1204                .overrides
1205                .first()
1206                .and_then(|o| o.file.clone()),
1207            fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
1208        );
1209        self.global.line_length.merge_override(
1210            fragment.global.line_length.value,
1211            fragment.global.line_length.source,
1212            fragment
1213                .global
1214                .line_length
1215                .overrides
1216                .first()
1217                .and_then(|o| o.file.clone()),
1218            fragment.global.line_length.overrides.first().and_then(|o| o.line),
1219        );
1220        self.global.fixable.merge_override(
1221            fragment.global.fixable.value,
1222            fragment.global.fixable.source,
1223            fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
1224            fragment.global.fixable.overrides.first().and_then(|o| o.line),
1225        );
1226        self.global.unfixable.merge_override(
1227            fragment.global.unfixable.value,
1228            fragment.global.unfixable.source,
1229            fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
1230            fragment.global.unfixable.overrides.first().and_then(|o| o.line),
1231        );
1232
1233        // Merge flavor
1234        self.global.flavor.merge_override(
1235            fragment.global.flavor.value,
1236            fragment.global.flavor.source,
1237            fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
1238            fragment.global.flavor.overrides.first().and_then(|o| o.line),
1239        );
1240
1241        // Merge force_exclude
1242        self.global.force_exclude.merge_override(
1243            fragment.global.force_exclude.value,
1244            fragment.global.force_exclude.source,
1245            fragment
1246                .global
1247                .force_exclude
1248                .overrides
1249                .first()
1250                .and_then(|o| o.file.clone()),
1251            fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
1252        );
1253
1254        // Merge output_format if present
1255        if let Some(output_format_fragment) = fragment.global.output_format {
1256            if let Some(ref mut output_format) = self.global.output_format {
1257                output_format.merge_override(
1258                    output_format_fragment.value,
1259                    output_format_fragment.source,
1260                    output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
1261                    output_format_fragment.overrides.first().and_then(|o| o.line),
1262                );
1263            } else {
1264                self.global.output_format = Some(output_format_fragment);
1265            }
1266        }
1267
1268        // Merge rule configs
1269        for (rule_name, rule_fragment) in fragment.rules {
1270            let norm_rule_name = rule_name.to_ascii_uppercase(); // Normalize to uppercase for case-insensitivity
1271            let rule_entry = self.rules.entry(norm_rule_name).or_default();
1272            for (key, sourced_value_fragment) in rule_fragment.values {
1273                let sv_entry = rule_entry
1274                    .values
1275                    .entry(key.clone())
1276                    .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
1277                let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
1278                let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
1279                sv_entry.merge_override(
1280                    sourced_value_fragment.value,  // Use the value from the fragment
1281                    sourced_value_fragment.source, // Use the source from the fragment
1282                    file_from_fragment,            // Pass the file path from the fragment override
1283                    line_from_fragment,            // Pass the line number from the fragment override
1284                );
1285            }
1286        }
1287    }
1288
1289    /// Load and merge configurations from files and CLI overrides.
1290    pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
1291        Self::load_with_discovery(config_path, cli_overrides, false)
1292    }
1293
1294    /// Discover configuration file by traversing up the directory tree.
1295    /// Returns the first configuration file found.
1296    fn discover_config_upward() -> Option<std::path::PathBuf> {
1297        use std::env;
1298
1299        const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
1300        const MAX_DEPTH: usize = 100; // Prevent infinite traversal
1301
1302        let start_dir = match env::current_dir() {
1303            Ok(dir) => dir,
1304            Err(e) => {
1305                log::debug!("[rumdl-config] Failed to get current directory: {e}");
1306                return None;
1307            }
1308        };
1309
1310        let mut current_dir = start_dir.clone();
1311        let mut depth = 0;
1312
1313        loop {
1314            if depth >= MAX_DEPTH {
1315                log::debug!("[rumdl-config] Maximum traversal depth reached");
1316                break;
1317            }
1318
1319            log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
1320
1321            // Check for config files in order of precedence
1322            for config_name in CONFIG_FILES {
1323                let config_path = current_dir.join(config_name);
1324
1325                if config_path.exists() {
1326                    // For pyproject.toml, verify it contains [tool.rumdl] section
1327                    if *config_name == "pyproject.toml" {
1328                        if let Ok(content) = std::fs::read_to_string(&config_path) {
1329                            if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1330                                log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1331                                return Some(config_path);
1332                            }
1333                            log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
1334                            continue;
1335                        }
1336                    } else {
1337                        log::debug!("[rumdl-config] Found config file: {}", config_path.display());
1338                        return Some(config_path);
1339                    }
1340                }
1341            }
1342
1343            // Check for .git directory (stop boundary)
1344            if current_dir.join(".git").exists() {
1345                log::debug!("[rumdl-config] Stopping at .git directory");
1346                break;
1347            }
1348
1349            // Move to parent directory
1350            match current_dir.parent() {
1351                Some(parent) => {
1352                    current_dir = parent.to_owned();
1353                    depth += 1;
1354                }
1355                None => {
1356                    log::debug!("[rumdl-config] Reached filesystem root");
1357                    break;
1358                }
1359            }
1360        }
1361
1362        None
1363    }
1364
1365    /// Discover user-level configuration file from platform-specific config directory.
1366    /// Returns the first configuration file found in the user config directory.
1367    fn user_configuration_path() -> Option<std::path::PathBuf> {
1368        use etcetera::{BaseStrategy, choose_base_strategy};
1369
1370        match choose_base_strategy() {
1371            Ok(strategy) => {
1372                let config_dir = strategy.config_dir().join("rumdl");
1373
1374                // Check for config files in precedence order (same as project discovery)
1375                const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
1376
1377                log::debug!(
1378                    "[rumdl-config] Checking for user configuration in: {}",
1379                    config_dir.display()
1380                );
1381
1382                for filename in USER_CONFIG_FILES {
1383                    let config_path = config_dir.join(filename);
1384
1385                    if config_path.exists() {
1386                        // For pyproject.toml, verify it contains [tool.rumdl] section
1387                        if *filename == "pyproject.toml" {
1388                            if let Ok(content) = std::fs::read_to_string(&config_path) {
1389                                if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1390                                    log::debug!(
1391                                        "[rumdl-config] Found user configuration at: {}",
1392                                        config_path.display()
1393                                    );
1394                                    return Some(config_path);
1395                                }
1396                                log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
1397                                continue;
1398                            }
1399                        } else {
1400                            log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
1401                            return Some(config_path);
1402                        }
1403                    }
1404                }
1405
1406                log::debug!(
1407                    "[rumdl-config] No user configuration found in: {}",
1408                    config_dir.display()
1409                );
1410                None
1411            }
1412            Err(e) => {
1413                log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
1414                None
1415            }
1416        }
1417    }
1418
1419    /// Load and merge configurations from files and CLI overrides.
1420    /// If skip_auto_discovery is true, only explicit config paths are loaded.
1421    pub fn load_with_discovery(
1422        config_path: Option<&str>,
1423        cli_overrides: Option<&SourcedGlobalConfig>,
1424        skip_auto_discovery: bool,
1425    ) -> Result<Self, ConfigError> {
1426        use std::env;
1427        log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
1428        if config_path.is_none() {
1429            if skip_auto_discovery {
1430                log::debug!("[rumdl-config] Skipping auto-discovery due to --no-config flag");
1431            } else {
1432                log::debug!("[rumdl-config] No explicit config_path provided, will search default locations");
1433            }
1434        } else {
1435            log::debug!("[rumdl-config] Explicit config_path provided: {config_path:?}");
1436        }
1437        let mut sourced_config = SourcedConfig::default();
1438
1439        // 1. Load explicit config path if provided
1440        if let Some(path) = config_path {
1441            let path_obj = Path::new(path);
1442            let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
1443            log::debug!("[rumdl-config] Trying to load config file: {filename}");
1444            let path_str = path.to_string();
1445
1446            // Known markdownlint config files
1447            const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
1448
1449            if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
1450                let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1451                    source: e,
1452                    path: path_str.clone(),
1453                })?;
1454                if filename == "pyproject.toml" {
1455                    if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1456                        sourced_config.merge(fragment);
1457                        sourced_config.loaded_files.push(path_str.clone());
1458                    }
1459                } else {
1460                    let fragment = parse_rumdl_toml(&content, &path_str)?;
1461                    sourced_config.merge(fragment);
1462                    sourced_config.loaded_files.push(path_str.clone());
1463                }
1464            } else if MARKDOWNLINT_FILENAMES.contains(&filename)
1465                || path_str.ends_with(".json")
1466                || path_str.ends_with(".jsonc")
1467                || path_str.ends_with(".yaml")
1468                || path_str.ends_with(".yml")
1469            {
1470                // Parse as markdownlint config (JSON/YAML)
1471                let fragment = load_from_markdownlint(&path_str)?;
1472                sourced_config.merge(fragment);
1473                sourced_config.loaded_files.push(path_str.clone());
1474                // markdownlint is fallback only
1475            } else {
1476                // Try TOML only
1477                let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
1478                    source: e,
1479                    path: path_str.clone(),
1480                })?;
1481                let fragment = parse_rumdl_toml(&content, &path_str)?;
1482                sourced_config.merge(fragment);
1483                sourced_config.loaded_files.push(path_str.clone());
1484            }
1485        }
1486
1487        // Only perform auto-discovery if not skipped AND no explicit config path provided
1488        if !skip_auto_discovery && config_path.is_none() {
1489            // Step 1: Load user configuration first (as a base)
1490            if let Some(user_config_path) = Self::user_configuration_path() {
1491                let path_str = user_config_path.display().to_string();
1492                let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1493
1494                log::debug!("[rumdl-config] Loading user configuration file: {path_str}");
1495
1496                if filename == "pyproject.toml" {
1497                    let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
1498                        source: e,
1499                        path: path_str.clone(),
1500                    })?;
1501                    if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1502                        sourced_config.merge(fragment);
1503                        sourced_config.loaded_files.push(path_str);
1504                    }
1505                } else {
1506                    let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
1507                        source: e,
1508                        path: path_str.clone(),
1509                    })?;
1510                    let fragment = parse_rumdl_toml(&content, &path_str)?;
1511                    sourced_config.merge(fragment);
1512                    sourced_config.loaded_files.push(path_str);
1513                }
1514            } else {
1515                log::debug!("[rumdl-config] No user configuration file found");
1516            }
1517
1518            // Step 2: Look for project configuration files (override user config)
1519            if let Some(config_file) = Self::discover_config_upward() {
1520                let path_str = config_file.display().to_string();
1521                let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
1522
1523                log::debug!("[rumdl-config] Loading discovered config file: {path_str}");
1524
1525                if filename == "pyproject.toml" {
1526                    let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1527                        source: e,
1528                        path: path_str.clone(),
1529                    })?;
1530                    if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
1531                        sourced_config.merge(fragment);
1532                        sourced_config.loaded_files.push(path_str);
1533                    }
1534                } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
1535                    let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
1536                        source: e,
1537                        path: path_str.clone(),
1538                    })?;
1539                    let fragment = parse_rumdl_toml(&content, &path_str)?;
1540                    sourced_config.merge(fragment);
1541                    sourced_config.loaded_files.push(path_str);
1542                }
1543            } else {
1544                log::debug!("[rumdl-config] No configuration file found via upward traversal");
1545
1546                // Step 3: If no project config found, fallback to markdownlint config in current directory
1547                let mut found_markdownlint = false;
1548                for filename in MARKDOWNLINT_CONFIG_FILES {
1549                    if std::path::Path::new(filename).exists() {
1550                        match load_from_markdownlint(filename) {
1551                            Ok(fragment) => {
1552                                sourced_config.merge(fragment);
1553                                sourced_config.loaded_files.push(filename.to_string());
1554                                found_markdownlint = true;
1555                                break; // Load only the first one found
1556                            }
1557                            Err(_e) => {
1558                                // Log error but continue (it's just a fallback)
1559                            }
1560                        }
1561                    }
1562                }
1563
1564                if !found_markdownlint {
1565                    log::debug!("[rumdl-config] No markdownlint configuration file found");
1566                }
1567            }
1568        }
1569
1570        // 5. Apply CLI overrides (highest precedence)
1571        if let Some(cli) = cli_overrides {
1572            sourced_config
1573                .global
1574                .enable
1575                .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
1576            sourced_config
1577                .global
1578                .disable
1579                .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
1580            sourced_config
1581                .global
1582                .exclude
1583                .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
1584            sourced_config
1585                .global
1586                .include
1587                .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
1588            sourced_config.global.respect_gitignore.merge_override(
1589                cli.respect_gitignore.value,
1590                ConfigSource::Cli,
1591                None,
1592                None,
1593            );
1594            sourced_config
1595                .global
1596                .fixable
1597                .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
1598            sourced_config
1599                .global
1600                .unfixable
1601                .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
1602            // No rule-specific CLI overrides implemented yet
1603        }
1604
1605        // TODO: Handle unknown keys collected during parsing/merging
1606
1607        Ok(sourced_config)
1608    }
1609}
1610
1611impl From<SourcedConfig> for Config {
1612    fn from(sourced: SourcedConfig) -> Self {
1613        let mut rules = BTreeMap::new();
1614        for (rule_name, sourced_rule_cfg) in sourced.rules {
1615            // Normalize rule name to uppercase for case-insensitive lookup
1616            let normalized_rule_name = rule_name.to_ascii_uppercase();
1617            let mut values = BTreeMap::new();
1618            for (key, sourced_val) in sourced_rule_cfg.values {
1619                values.insert(key, sourced_val.value);
1620            }
1621            rules.insert(normalized_rule_name, RuleConfig { values });
1622        }
1623        let global = GlobalConfig {
1624            enable: sourced.global.enable.value,
1625            disable: sourced.global.disable.value,
1626            exclude: sourced.global.exclude.value,
1627            include: sourced.global.include.value,
1628            respect_gitignore: sourced.global.respect_gitignore.value,
1629            line_length: sourced.global.line_length.value,
1630            output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1631            fixable: sourced.global.fixable.value,
1632            unfixable: sourced.global.unfixable.value,
1633            flavor: sourced.global.flavor.value,
1634            force_exclude: sourced.global.force_exclude.value,
1635        };
1636        Config { global, rules }
1637    }
1638}
1639
1640/// Registry of all known rules and their config schemas
1641pub struct RuleRegistry {
1642    /// Map of rule name (e.g. "MD013") to set of valid config keys and their TOML value types
1643    pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
1644    /// Map of rule name to config key aliases
1645    pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
1646}
1647
1648impl RuleRegistry {
1649    /// Build a registry from a list of rules
1650    pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
1651        let mut rule_schemas = std::collections::BTreeMap::new();
1652        let mut rule_aliases = std::collections::BTreeMap::new();
1653
1654        for rule in rules {
1655            let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
1656                let norm_name = normalize_key(&name); // Normalize the name from default_config_section
1657                rule_schemas.insert(norm_name.clone(), table);
1658                norm_name
1659            } else {
1660                let norm_name = normalize_key(rule.name()); // Normalize the name from rule.name()
1661                rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
1662                norm_name
1663            };
1664
1665            // Store aliases if the rule provides them
1666            if let Some(aliases) = rule.config_aliases() {
1667                rule_aliases.insert(norm_name, aliases);
1668            }
1669        }
1670
1671        RuleRegistry {
1672            rule_schemas,
1673            rule_aliases,
1674        }
1675    }
1676
1677    /// Get all known rule names
1678    pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
1679        self.rule_schemas.keys().cloned().collect()
1680    }
1681
1682    /// Get the valid configuration keys for a rule, including both original and normalized variants
1683    pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
1684        self.rule_schemas.get(rule).map(|schema| {
1685            let mut all_keys = std::collections::BTreeSet::new();
1686
1687            // Add original keys from schema
1688            for key in schema.keys() {
1689                all_keys.insert(key.clone());
1690            }
1691
1692            // Add normalized variants for markdownlint compatibility
1693            for key in schema.keys() {
1694                // Add kebab-case variant
1695                all_keys.insert(key.replace('_', "-"));
1696                // Add snake_case variant
1697                all_keys.insert(key.replace('-', "_"));
1698                // Add normalized variant
1699                all_keys.insert(normalize_key(key));
1700            }
1701
1702            // Add any aliases defined by the rule
1703            if let Some(aliases) = self.rule_aliases.get(rule) {
1704                for alias_key in aliases.keys() {
1705                    all_keys.insert(alias_key.clone());
1706                    // Also add normalized variants of the alias
1707                    all_keys.insert(alias_key.replace('_', "-"));
1708                    all_keys.insert(alias_key.replace('-', "_"));
1709                    all_keys.insert(normalize_key(alias_key));
1710                }
1711            }
1712
1713            all_keys
1714        })
1715    }
1716
1717    /// Get the expected value type for a rule's configuration key, trying variants
1718    pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
1719        if let Some(schema) = self.rule_schemas.get(rule) {
1720            // Check if this key is an alias
1721            if let Some(aliases) = self.rule_aliases.get(rule)
1722                && let Some(canonical_key) = aliases.get(key)
1723            {
1724                // Use the canonical key for schema lookup
1725                if let Some(value) = schema.get(canonical_key) {
1726                    return Some(value);
1727                }
1728            }
1729
1730            // Try the original key
1731            if let Some(value) = schema.get(key) {
1732                return Some(value);
1733            }
1734
1735            // Try key variants
1736            let key_variants = [
1737                key.replace('-', "_"), // Convert kebab-case to snake_case
1738                key.replace('_', "-"), // Convert snake_case to kebab-case
1739                normalize_key(key),    // Normalized key (lowercase, kebab-case)
1740            ];
1741
1742            for variant in &key_variants {
1743                if let Some(value) = schema.get(variant) {
1744                    return Some(value);
1745                }
1746            }
1747        }
1748        None
1749    }
1750}
1751
1752/// Represents a config validation warning or error
1753#[derive(Debug, Clone)]
1754pub struct ConfigValidationWarning {
1755    pub message: String,
1756    pub rule: Option<String>,
1757    pub key: Option<String>,
1758}
1759
1760/// Validate a loaded config against the rule registry, using SourcedConfig for unknown key tracking
1761pub fn validate_config_sourced(sourced: &SourcedConfig, registry: &RuleRegistry) -> Vec<ConfigValidationWarning> {
1762    let mut warnings = Vec::new();
1763    let known_rules = registry.rule_names();
1764    // 1. Unknown rules
1765    for rule in sourced.rules.keys() {
1766        if !known_rules.contains(rule) {
1767            warnings.push(ConfigValidationWarning {
1768                message: format!("Unknown rule in config: {rule}"),
1769                rule: Some(rule.clone()),
1770                key: None,
1771            });
1772        }
1773    }
1774    // 2. Unknown options and type mismatches
1775    for (rule, rule_cfg) in &sourced.rules {
1776        if let Some(valid_keys) = registry.config_keys_for(rule) {
1777            for key in rule_cfg.values.keys() {
1778                if !valid_keys.contains(key) {
1779                    warnings.push(ConfigValidationWarning {
1780                        message: format!("Unknown option for rule {rule}: {key}"),
1781                        rule: Some(rule.clone()),
1782                        key: Some(key.clone()),
1783                    });
1784                } else {
1785                    // Type check: compare type of value to type of default
1786                    if let Some(expected) = registry.expected_value_for(rule, key) {
1787                        let actual = &rule_cfg.values[key].value;
1788                        if !toml_value_type_matches(expected, actual) {
1789                            warnings.push(ConfigValidationWarning {
1790                                message: format!(
1791                                    "Type mismatch for {}.{}: expected {}, got {}",
1792                                    rule,
1793                                    key,
1794                                    toml_type_name(expected),
1795                                    toml_type_name(actual)
1796                                ),
1797                                rule: Some(rule.clone()),
1798                                key: Some(key.clone()),
1799                            });
1800                        }
1801                    }
1802                }
1803            }
1804        }
1805    }
1806    // 3. Unknown global options (from unknown_keys)
1807    for (section, key) in &sourced.unknown_keys {
1808        if section.contains("[global]") {
1809            warnings.push(ConfigValidationWarning {
1810                message: format!("Unknown global option: {key}"),
1811                rule: None,
1812                key: Some(key.clone()),
1813            });
1814        }
1815    }
1816    warnings
1817}
1818
1819fn toml_type_name(val: &toml::Value) -> &'static str {
1820    match val {
1821        toml::Value::String(_) => "string",
1822        toml::Value::Integer(_) => "integer",
1823        toml::Value::Float(_) => "float",
1824        toml::Value::Boolean(_) => "boolean",
1825        toml::Value::Array(_) => "array",
1826        toml::Value::Table(_) => "table",
1827        toml::Value::Datetime(_) => "datetime",
1828    }
1829}
1830
1831fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
1832    use toml::Value::*;
1833    match (expected, actual) {
1834        (String(_), String(_)) => true,
1835        (Integer(_), Integer(_)) => true,
1836        (Float(_), Float(_)) => true,
1837        (Boolean(_), Boolean(_)) => true,
1838        (Array(_), Array(_)) => true,
1839        (Table(_), Table(_)) => true,
1840        (Datetime(_), Datetime(_)) => true,
1841        // Allow integer for float
1842        (Float(_), Integer(_)) => true,
1843        _ => false,
1844    }
1845}
1846
1847/// Parses pyproject.toml content and extracts the [tool.rumdl] section if present.
1848fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
1849    let doc: toml::Value =
1850        toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
1851    let mut fragment = SourcedConfigFragment::default();
1852    let source = ConfigSource::PyprojectToml;
1853    let file = Some(path.to_string());
1854
1855    // 1. Handle [tool.rumdl] and [tool.rumdl.global] sections
1856    if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
1857        && let Some(rumdl_table) = rumdl_config.as_table()
1858    {
1859        // Helper function to extract global config from a table
1860        let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
1861            // Extract global options from the given table
1862            if let Some(enable) = table.get("enable")
1863                && let Ok(values) = Vec::<String>::deserialize(enable.clone())
1864            {
1865                // Normalize rule names in the list
1866                let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1867                fragment
1868                    .global
1869                    .enable
1870                    .push_override(normalized_values, source, file.clone(), None);
1871            }
1872
1873            if let Some(disable) = table.get("disable")
1874                && let Ok(values) = Vec::<String>::deserialize(disable.clone())
1875            {
1876                // Re-enable normalization
1877                let normalized_values: Vec<String> = values.into_iter().map(|s| normalize_key(&s)).collect();
1878                fragment
1879                    .global
1880                    .disable
1881                    .push_override(normalized_values, source, file.clone(), None);
1882            }
1883
1884            if let Some(include) = table.get("include")
1885                && let Ok(values) = Vec::<String>::deserialize(include.clone())
1886            {
1887                fragment
1888                    .global
1889                    .include
1890                    .push_override(values, source, file.clone(), None);
1891            }
1892
1893            if let Some(exclude) = table.get("exclude")
1894                && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
1895            {
1896                fragment
1897                    .global
1898                    .exclude
1899                    .push_override(values, source, file.clone(), None);
1900            }
1901
1902            if let Some(respect_gitignore) = table
1903                .get("respect-gitignore")
1904                .or_else(|| table.get("respect_gitignore"))
1905                && let Ok(value) = bool::deserialize(respect_gitignore.clone())
1906            {
1907                fragment
1908                    .global
1909                    .respect_gitignore
1910                    .push_override(value, source, file.clone(), None);
1911            }
1912
1913            if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
1914                && let Ok(value) = bool::deserialize(force_exclude.clone())
1915            {
1916                fragment
1917                    .global
1918                    .force_exclude
1919                    .push_override(value, source, file.clone(), None);
1920            }
1921
1922            if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
1923                && let Ok(value) = String::deserialize(output_format.clone())
1924            {
1925                if fragment.global.output_format.is_none() {
1926                    fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
1927                } else {
1928                    fragment
1929                        .global
1930                        .output_format
1931                        .as_mut()
1932                        .unwrap()
1933                        .push_override(value, source, file.clone(), None);
1934                }
1935            }
1936
1937            if let Some(fixable) = table.get("fixable")
1938                && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
1939            {
1940                let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1941                fragment
1942                    .global
1943                    .fixable
1944                    .push_override(normalized_values, source, file.clone(), None);
1945            }
1946
1947            if let Some(unfixable) = table.get("unfixable")
1948                && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
1949            {
1950                let normalized_values = values.into_iter().map(|s| normalize_key(&s)).collect();
1951                fragment
1952                    .global
1953                    .unfixable
1954                    .push_override(normalized_values, source, file.clone(), None);
1955            }
1956
1957            if let Some(flavor) = table.get("flavor")
1958                && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
1959            {
1960                fragment.global.flavor.push_override(value, source, file.clone(), None);
1961            }
1962
1963            // Handle line-length special case - this should set the global line_length
1964            if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
1965                && let Ok(value) = u64::deserialize(line_length.clone())
1966            {
1967                fragment
1968                    .global
1969                    .line_length
1970                    .push_override(value, source, file.clone(), None);
1971
1972                // Also add to MD013 rule config for backward compatibility
1973                let norm_md013_key = normalize_key("MD013");
1974                let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
1975                let norm_line_length_key = normalize_key("line-length");
1976                let sv = rule_entry
1977                    .values
1978                    .entry(norm_line_length_key)
1979                    .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
1980                sv.push_override(line_length.clone(), source, file.clone(), None);
1981            }
1982        };
1983
1984        // First, check for [tool.rumdl.global] section
1985        if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
1986            extract_global_config(&mut fragment, global_table);
1987        }
1988
1989        // Also extract global options from [tool.rumdl] directly (for flat structure)
1990        extract_global_config(&mut fragment, rumdl_table);
1991
1992        // --- Extract rule-specific configurations ---
1993        for (key, value) in rumdl_table {
1994            let norm_rule_key = normalize_key(key);
1995
1996            // Skip keys already handled as global or special cases
1997            if [
1998                "enable",
1999                "disable",
2000                "include",
2001                "exclude",
2002                "respect_gitignore",
2003                "respect-gitignore", // Added kebab-case here too
2004                "force_exclude",
2005                "force-exclude",
2006                "line_length",
2007                "line-length",
2008                "output_format",
2009                "output-format",
2010                "fixable",
2011                "unfixable",
2012            ]
2013            .contains(&norm_rule_key.as_str())
2014            {
2015                continue;
2016            }
2017
2018            // Explicitly check if the key looks like a rule name (e.g., starts with 'md')
2019            // AND if the value is actually a TOML table before processing as rule config.
2020            // This prevents misinterpreting other top-level keys under [tool.rumdl]
2021            let norm_rule_key_upper = norm_rule_key.to_ascii_uppercase();
2022            if norm_rule_key_upper.len() == 5
2023                && norm_rule_key_upper.starts_with("MD")
2024                && norm_rule_key_upper[2..].chars().all(|c| c.is_ascii_digit())
2025                && value.is_table()
2026            {
2027                if let Some(rule_config_table) = value.as_table() {
2028                    // Get the entry for this rule (e.g., "md013")
2029                    let rule_entry = fragment.rules.entry(norm_rule_key_upper).or_default();
2030                    for (rk, rv) in rule_config_table {
2031                        let norm_rk = normalize_key(rk); // Normalize the config key itself
2032
2033                        let toml_val = rv.clone();
2034
2035                        let sv = rule_entry
2036                            .values
2037                            .entry(norm_rk.clone())
2038                            .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2039                        sv.push_override(toml_val, source, file.clone(), None);
2040                    }
2041                }
2042            } else {
2043                // Key is not a global/special key, doesn't start with 'md', or isn't a table.
2044                // TODO: Track unknown keys/sections if necessary for validation later.
2045                // eprintln!("[DEBUG parse_pyproject] Skipping key '{}' as it's not a recognized rule table.", key);
2046            }
2047        }
2048    }
2049
2050    // 2. Handle [tool.rumdl.MDxxx] sections as rule-specific config (nested under [tool])
2051    if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
2052        for (key, value) in tool_table.iter() {
2053            if let Some(rule_name) = key.strip_prefix("rumdl.") {
2054                let norm_rule_name = normalize_key(rule_name);
2055                if norm_rule_name.len() == 5
2056                    && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2057                    && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2058                    && let Some(rule_table) = value.as_table()
2059                {
2060                    let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2061                    for (rk, rv) in rule_table {
2062                        let norm_rk = normalize_key(rk);
2063                        let toml_val = rv.clone();
2064                        let sv = rule_entry
2065                            .values
2066                            .entry(norm_rk.clone())
2067                            .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2068                        sv.push_override(toml_val, source, file.clone(), None);
2069                    }
2070                }
2071            }
2072        }
2073    }
2074
2075    // 3. Handle [tool.rumdl.MDxxx] sections as top-level keys (e.g., [tool.rumdl.MD007])
2076    if let Some(doc_table) = doc.as_table() {
2077        for (key, value) in doc_table.iter() {
2078            if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
2079                let norm_rule_name = normalize_key(rule_name);
2080                if norm_rule_name.len() == 5
2081                    && norm_rule_name.to_ascii_uppercase().starts_with("MD")
2082                    && norm_rule_name[2..].chars().all(|c| c.is_ascii_digit())
2083                    && let Some(rule_table) = value.as_table()
2084                {
2085                    let rule_entry = fragment.rules.entry(norm_rule_name.to_ascii_uppercase()).or_default();
2086                    for (rk, rv) in rule_table {
2087                        let norm_rk = normalize_key(rk);
2088                        let toml_val = rv.clone();
2089                        let sv = rule_entry
2090                            .values
2091                            .entry(norm_rk.clone())
2092                            .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
2093                        sv.push_override(toml_val, source, file.clone(), None);
2094                    }
2095                }
2096            }
2097        }
2098    }
2099
2100    // Only return Some(fragment) if any config was found
2101    let has_any = !fragment.global.enable.value.is_empty()
2102        || !fragment.global.disable.value.is_empty()
2103        || !fragment.global.include.value.is_empty()
2104        || !fragment.global.exclude.value.is_empty()
2105        || !fragment.global.fixable.value.is_empty()
2106        || !fragment.global.unfixable.value.is_empty()
2107        || fragment.global.output_format.is_some()
2108        || !fragment.rules.is_empty();
2109    if has_any { Ok(Some(fragment)) } else { Ok(None) }
2110}
2111
2112/// Parses rumdl.toml / .rumdl.toml content.
2113fn parse_rumdl_toml(content: &str, path: &str) -> Result<SourcedConfigFragment, ConfigError> {
2114    let doc = content
2115        .parse::<DocumentMut>()
2116        .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
2117    let mut fragment = SourcedConfigFragment::default();
2118    let source = ConfigSource::RumdlToml;
2119    let file = Some(path.to_string());
2120
2121    // Define known rules before the loop
2122    let all_rules = rules::all_rules(&Config::default());
2123    let registry = RuleRegistry::from_rules(&all_rules);
2124    let known_rule_names: BTreeSet<String> = registry
2125        .rule_names()
2126        .into_iter()
2127        .map(|s| s.to_ascii_uppercase())
2128        .collect();
2129
2130    // Handle [global] section
2131    if let Some(global_item) = doc.get("global")
2132        && let Some(global_table) = global_item.as_table()
2133    {
2134        for (key, value_item) in global_table.iter() {
2135            let norm_key = normalize_key(key);
2136            match norm_key.as_str() {
2137                "enable" | "disable" | "include" | "exclude" => {
2138                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2139                        // Corrected: Iterate directly over the Formatted<Array>
2140                        let values: Vec<String> = formatted_array
2141                                .iter()
2142                                .filter_map(|item| item.as_str()) // Extract strings
2143                                .map(|s| s.to_string())
2144                                .collect();
2145
2146                        // Normalize rule names for enable/disable
2147                        let final_values = if norm_key == "enable" || norm_key == "disable" {
2148                            // Corrected: Pass &str to normalize_key
2149                            values.into_iter().map(|s| normalize_key(&s)).collect()
2150                        } else {
2151                            values
2152                        };
2153
2154                        match norm_key.as_str() {
2155                            "enable" => fragment
2156                                .global
2157                                .enable
2158                                .push_override(final_values, source, file.clone(), None),
2159                            "disable" => {
2160                                fragment
2161                                    .global
2162                                    .disable
2163                                    .push_override(final_values, source, file.clone(), None)
2164                            }
2165                            "include" => {
2166                                fragment
2167                                    .global
2168                                    .include
2169                                    .push_override(final_values, source, file.clone(), None)
2170                            }
2171                            "exclude" => {
2172                                fragment
2173                                    .global
2174                                    .exclude
2175                                    .push_override(final_values, source, file.clone(), None)
2176                            }
2177                            _ => unreachable!(), // Should not happen due to outer match
2178                        }
2179                    } else {
2180                        log::warn!(
2181                            "[WARN] Expected array for global key '{}' in {}, found {}",
2182                            key,
2183                            path,
2184                            value_item.type_name()
2185                        );
2186                    }
2187                }
2188                "respect_gitignore" | "respect-gitignore" => {
2189                    // Handle both cases
2190                    if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2191                        let val = *formatted_bool.value();
2192                        fragment
2193                            .global
2194                            .respect_gitignore
2195                            .push_override(val, source, file.clone(), None);
2196                    } else {
2197                        log::warn!(
2198                            "[WARN] Expected boolean for global key '{}' in {}, found {}",
2199                            key,
2200                            path,
2201                            value_item.type_name()
2202                        );
2203                    }
2204                }
2205                "force_exclude" | "force-exclude" => {
2206                    // Handle both cases
2207                    if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
2208                        let val = *formatted_bool.value();
2209                        fragment
2210                            .global
2211                            .force_exclude
2212                            .push_override(val, source, file.clone(), None);
2213                    } else {
2214                        log::warn!(
2215                            "[WARN] Expected boolean for global key '{}' in {}, found {}",
2216                            key,
2217                            path,
2218                            value_item.type_name()
2219                        );
2220                    }
2221                }
2222                "line_length" | "line-length" => {
2223                    // Handle both cases
2224                    if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
2225                        let val = *formatted_int.value() as u64;
2226                        fragment
2227                            .global
2228                            .line_length
2229                            .push_override(val, source, file.clone(), None);
2230                    } else {
2231                        log::warn!(
2232                            "[WARN] Expected integer for global key '{}' in {}, found {}",
2233                            key,
2234                            path,
2235                            value_item.type_name()
2236                        );
2237                    }
2238                }
2239                "output_format" | "output-format" => {
2240                    // Handle both cases
2241                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
2242                        let val = formatted_string.value().clone();
2243                        if fragment.global.output_format.is_none() {
2244                            fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
2245                        } else {
2246                            fragment.global.output_format.as_mut().unwrap().push_override(
2247                                val,
2248                                source,
2249                                file.clone(),
2250                                None,
2251                            );
2252                        }
2253                    } else {
2254                        log::warn!(
2255                            "[WARN] Expected string for global key '{}' in {}, found {}",
2256                            key,
2257                            path,
2258                            value_item.type_name()
2259                        );
2260                    }
2261                }
2262                "fixable" => {
2263                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2264                        let values: Vec<String> = formatted_array
2265                            .iter()
2266                            .filter_map(|item| item.as_str())
2267                            .map(normalize_key)
2268                            .collect();
2269                        fragment
2270                            .global
2271                            .fixable
2272                            .push_override(values, source, file.clone(), None);
2273                    } else {
2274                        log::warn!(
2275                            "[WARN] Expected array for global key '{}' in {}, found {}",
2276                            key,
2277                            path,
2278                            value_item.type_name()
2279                        );
2280                    }
2281                }
2282                "unfixable" => {
2283                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
2284                        let values: Vec<String> = formatted_array
2285                            .iter()
2286                            .filter_map(|item| item.as_str())
2287                            .map(normalize_key)
2288                            .collect();
2289                        fragment
2290                            .global
2291                            .unfixable
2292                            .push_override(values, source, file.clone(), None);
2293                    } else {
2294                        log::warn!(
2295                            "[WARN] Expected array for global key '{}' in {}, found {}",
2296                            key,
2297                            path,
2298                            value_item.type_name()
2299                        );
2300                    }
2301                }
2302                "flavor" => {
2303                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
2304                        let val = formatted_string.value();
2305                        if let Ok(flavor) = MarkdownFlavor::from_str(val) {
2306                            fragment.global.flavor.push_override(flavor, source, file.clone(), None);
2307                        } else {
2308                            log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
2309                        }
2310                    } else {
2311                        log::warn!(
2312                            "[WARN] Expected string for global key '{}' in {}, found {}",
2313                            key,
2314                            path,
2315                            value_item.type_name()
2316                        );
2317                    }
2318                }
2319                _ => {
2320                    // Add to unknown_keys for potential validation later
2321                    // fragment.unknown_keys.push(("[global]".to_string(), key.to_string()));
2322                    log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
2323                }
2324            }
2325        }
2326    }
2327
2328    // Rule-specific: all other top-level tables
2329    for (key, item) in doc.iter() {
2330        let norm_rule_name = key.to_ascii_uppercase();
2331        if !known_rule_names.contains(&norm_rule_name) {
2332            continue;
2333        }
2334        if let Some(tbl) = item.as_table() {
2335            let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
2336            for (rk, rv_item) in tbl.iter() {
2337                let norm_rk = normalize_key(rk);
2338                let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
2339                    Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
2340                    Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
2341                    Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
2342                    Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
2343                    Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
2344                    Some(toml_edit::Value::Array(formatted_array)) => {
2345                        // Convert toml_edit Array to toml::Value::Array
2346                        let mut values = Vec::new();
2347                        for item in formatted_array.iter() {
2348                            match item {
2349                                toml_edit::Value::String(formatted) => {
2350                                    values.push(toml::Value::String(formatted.value().clone()))
2351                                }
2352                                toml_edit::Value::Integer(formatted) => {
2353                                    values.push(toml::Value::Integer(*formatted.value()))
2354                                }
2355                                toml_edit::Value::Float(formatted) => {
2356                                    values.push(toml::Value::Float(*formatted.value()))
2357                                }
2358                                toml_edit::Value::Boolean(formatted) => {
2359                                    values.push(toml::Value::Boolean(*formatted.value()))
2360                                }
2361                                toml_edit::Value::Datetime(formatted) => {
2362                                    values.push(toml::Value::Datetime(*formatted.value()))
2363                                }
2364                                _ => {
2365                                    log::warn!(
2366                                        "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
2367                                    );
2368                                }
2369                            }
2370                        }
2371                        Some(toml::Value::Array(values))
2372                    }
2373                    Some(toml_edit::Value::InlineTable(_)) => {
2374                        log::warn!(
2375                            "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
2376                        );
2377                        None
2378                    }
2379                    None => {
2380                        log::warn!(
2381                            "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
2382                        );
2383                        None
2384                    }
2385                };
2386                if let Some(toml_val) = maybe_toml_val {
2387                    let sv = rule_entry
2388                        .values
2389                        .entry(norm_rk.clone())
2390                        .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
2391                    sv.push_override(toml_val, source, file.clone(), None);
2392                }
2393            }
2394        } else if item.is_value() {
2395            log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
2396        }
2397    }
2398
2399    Ok(fragment)
2400}
2401
2402/// Loads and converts a markdownlint config file (.json or .yaml) into a SourcedConfigFragment.
2403fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
2404    // Use the unified loader from markdownlint_config.rs
2405    let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
2406        .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
2407    Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
2408}