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