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