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