rumdl_lib/
config.rs

1//!
2//! This module defines configuration structures, loading logic, and provenance tracking for rumdl.
3//! Supports TOML, pyproject.toml, and markdownlint config formats, and provides merging and override logic.
4
5use crate::rule::Rule;
6use crate::rules;
7use crate::types::LineLength;
8use indexmap::IndexMap;
9use log;
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::collections::{HashMap, HashSet};
13use std::fmt;
14use std::fs;
15use std::io;
16use std::marker::PhantomData;
17use std::path::Path;
18use std::str::FromStr;
19use toml_edit::DocumentMut;
20
21// ============================================================================
22// Typestate markers for configuration pipeline
23// ============================================================================
24
25/// Marker type for configuration that has been loaded but not yet validated.
26/// This is the initial state after `load_with_discovery()`.
27#[derive(Debug, Clone, Copy, Default)]
28pub struct ConfigLoaded;
29
30/// Marker type for configuration that has been validated.
31/// Only validated configs can be converted to `Config`.
32#[derive(Debug, Clone, Copy, Default)]
33pub struct ConfigValidated;
34
35/// Markdown flavor/dialect enumeration
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema)]
37#[serde(rename_all = "lowercase")]
38pub enum MarkdownFlavor {
39    /// Standard Markdown without flavor-specific adjustments
40    #[serde(rename = "standard", alias = "none", alias = "")]
41    #[default]
42    Standard,
43    /// MkDocs flavor with auto-reference support
44    #[serde(rename = "mkdocs")]
45    MkDocs,
46    /// MDX flavor with JSX and ESM support (.mdx files)
47    #[serde(rename = "mdx")]
48    MDX,
49    /// Quarto/RMarkdown flavor for scientific publishing (.qmd, .Rmd files)
50    #[serde(rename = "quarto")]
51    Quarto,
52    // Future flavors can be added here when they have actual implementation differences
53    // Planned: GFM (GitHub Flavored Markdown) - for GitHub-specific features like tables, strikethrough
54    // Planned: CommonMark - for strict CommonMark compliance
55}
56
57impl fmt::Display for MarkdownFlavor {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            MarkdownFlavor::Standard => write!(f, "standard"),
61            MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
62            MarkdownFlavor::MDX => write!(f, "mdx"),
63            MarkdownFlavor::Quarto => write!(f, "quarto"),
64        }
65    }
66}
67
68impl FromStr for MarkdownFlavor {
69    type Err = String;
70
71    fn from_str(s: &str) -> Result<Self, Self::Err> {
72        match s.to_lowercase().as_str() {
73            "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
74            "mkdocs" => Ok(MarkdownFlavor::MkDocs),
75            "mdx" => Ok(MarkdownFlavor::MDX),
76            "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
77            // GFM and CommonMark are aliases for Standard since the base parser
78            // (pulldown-cmark) already supports GFM extensions (tables, task lists,
79            // strikethrough, autolinks, etc.) which are a superset of CommonMark
80            "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
81            _ => Err(format!("Unknown markdown flavor: {s}")),
82        }
83    }
84}
85
86impl MarkdownFlavor {
87    /// Detect flavor from file extension
88    pub fn from_extension(ext: &str) -> Self {
89        match ext.to_lowercase().as_str() {
90            "mdx" => Self::MDX,
91            "qmd" => Self::Quarto,
92            "rmd" => Self::Quarto,
93            _ => Self::Standard,
94        }
95    }
96
97    /// Detect flavor from file path
98    pub fn from_path(path: &std::path::Path) -> Self {
99        path.extension()
100            .and_then(|e| e.to_str())
101            .map(Self::from_extension)
102            .unwrap_or(Self::Standard)
103    }
104
105    /// Check if this flavor supports ESM imports/exports (MDX-specific)
106    pub fn supports_esm_blocks(self) -> bool {
107        matches!(self, Self::MDX)
108    }
109
110    /// Check if this flavor supports JSX components (MDX-specific)
111    pub fn supports_jsx(self) -> bool {
112        matches!(self, Self::MDX)
113    }
114
115    /// Check if this flavor supports auto-references (MkDocs-specific)
116    pub fn supports_auto_references(self) -> bool {
117        matches!(self, Self::MkDocs)
118    }
119
120    /// Get a human-readable name for this flavor
121    pub fn name(self) -> &'static str {
122        match self {
123            Self::Standard => "Standard",
124            Self::MkDocs => "MkDocs",
125            Self::MDX => "MDX",
126            Self::Quarto => "Quarto",
127        }
128    }
129}
130
131/// Normalizes configuration keys (rule names, option names) to lowercase kebab-case.
132pub fn normalize_key(key: &str) -> String {
133    // If the key looks like a rule name (e.g., MD013), uppercase it
134    if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
135        key.to_ascii_uppercase()
136    } else {
137        key.replace('_', "-").to_ascii_lowercase()
138    }
139}
140
141/// Represents a rule-specific configuration
142#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
143pub struct RuleConfig {
144    /// Severity override for this rule (Error, Warning, or Info)
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub severity: Option<crate::rule::Severity>,
147
148    /// Configuration values for the rule
149    #[serde(flatten)]
150    #[schemars(schema_with = "arbitrary_value_schema")]
151    pub values: BTreeMap<String, toml::Value>,
152}
153
154/// Generate a JSON schema for arbitrary configuration values
155fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
156    schemars::json_schema!({
157        "type": "object",
158        "additionalProperties": true
159    })
160}
161
162/// Represents the complete configuration loaded from rumdl.toml
163#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
164#[schemars(
165    description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
166)]
167pub struct Config {
168    /// Global configuration options
169    #[serde(default)]
170    pub global: GlobalConfig,
171
172    /// Per-file rule ignores: maps file patterns to lists of rules to ignore
173    /// Example: { "README.md": ["MD033"], "docs/**/*.md": ["MD013"] }
174    #[serde(default, rename = "per-file-ignores")]
175    pub per_file_ignores: HashMap<String, Vec<String>>,
176
177    /// Per-file flavor overrides: maps file patterns to Markdown flavors
178    /// Example: { "docs/**/*.md": MkDocs, "**/*.mdx": MDX }
179    /// Uses IndexMap to preserve config file order for "first match wins" semantics
180    #[serde(default, rename = "per-file-flavor")]
181    #[schemars(with = "HashMap<String, MarkdownFlavor>")]
182    pub per_file_flavor: IndexMap<String, MarkdownFlavor>,
183
184    /// Rule-specific configurations (e.g., MD013, MD007, MD044)
185    /// Each rule section can contain options specific to that rule.
186    ///
187    /// Common examples:
188    /// - MD013: line_length, code_blocks, tables, headings
189    /// - MD007: indent
190    /// - MD003: style ("atx", "atx_closed", "setext")
191    /// - MD044: names (array of proper names to check)
192    ///
193    /// See https://github.com/rvben/rumdl for full rule documentation.
194    #[serde(flatten)]
195    pub rules: BTreeMap<String, RuleConfig>,
196
197    /// Project root directory, used for resolving relative paths in per-file-ignores
198    #[serde(skip)]
199    pub project_root: Option<std::path::PathBuf>,
200}
201
202impl Config {
203    /// Check if the Markdown flavor is set to MkDocs
204    pub fn is_mkdocs_flavor(&self) -> bool {
205        self.global.flavor == MarkdownFlavor::MkDocs
206    }
207
208    // Future methods for when GFM and CommonMark are implemented:
209    // pub fn is_gfm_flavor(&self) -> bool
210    // pub fn is_commonmark_flavor(&self) -> bool
211
212    /// Get the configured Markdown flavor
213    pub fn markdown_flavor(&self) -> MarkdownFlavor {
214        self.global.flavor
215    }
216
217    /// Legacy method for backwards compatibility - redirects to is_mkdocs_flavor
218    pub fn is_mkdocs_project(&self) -> bool {
219        self.is_mkdocs_flavor()
220    }
221
222    /// Get the severity override for a specific rule, if configured
223    pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
224        self.rules.get(rule_name).and_then(|r| r.severity)
225    }
226
227    /// Get the set of rules that should be ignored for a specific file based on per-file-ignores configuration
228    /// Returns a HashSet of rule names (uppercase, e.g., "MD033") that match the given file path
229    pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
230        use globset::{Glob, GlobSetBuilder};
231
232        let mut ignored_rules = HashSet::new();
233
234        if self.per_file_ignores.is_empty() {
235            return ignored_rules;
236        }
237
238        // Normalize the file path to be relative to project_root for pattern matching
239        // This ensures patterns like ".github/file.md" work with absolute paths
240        let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
241            if let Ok(canonical_path) = file_path.canonicalize() {
242                if let Ok(canonical_root) = root.canonicalize() {
243                    if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
244                        std::borrow::Cow::Owned(relative.to_path_buf())
245                    } else {
246                        std::borrow::Cow::Borrowed(file_path)
247                    }
248                } else {
249                    std::borrow::Cow::Borrowed(file_path)
250                }
251            } else {
252                std::borrow::Cow::Borrowed(file_path)
253            }
254        } else {
255            std::borrow::Cow::Borrowed(file_path)
256        };
257
258        // Build a globset for efficient matching
259        let mut builder = GlobSetBuilder::new();
260        let mut pattern_to_rules: Vec<(usize, &Vec<String>)> = Vec::new();
261
262        for (idx, (pattern, rules)) in self.per_file_ignores.iter().enumerate() {
263            if let Ok(glob) = Glob::new(pattern) {
264                builder.add(glob);
265                pattern_to_rules.push((idx, rules));
266            } else {
267                log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
268            }
269        }
270
271        let globset = match builder.build() {
272            Ok(gs) => gs,
273            Err(e) => {
274                log::error!("Failed to build globset for per-file-ignores: {e}");
275                return ignored_rules;
276            }
277        };
278
279        // Match the file path against all patterns
280        for match_idx in globset.matches(path_for_matching.as_ref()) {
281            if let Some((_, rules)) = pattern_to_rules.get(match_idx) {
282                for rule in rules.iter() {
283                    // Normalize rule names to uppercase (MD033, md033 -> MD033)
284                    ignored_rules.insert(normalize_key(rule));
285                }
286            }
287        }
288
289        ignored_rules
290    }
291
292    /// Get the MarkdownFlavor for a specific file based on per-file-flavor configuration.
293    /// Returns the first matching pattern's flavor, or falls back to global flavor,
294    /// or auto-detects from extension, or defaults to Standard.
295    pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
296        use globset::GlobBuilder;
297
298        // If no per-file patterns, use fallback logic
299        if self.per_file_flavor.is_empty() {
300            return self.resolve_flavor_fallback(file_path);
301        }
302
303        // Normalize path for matching (same logic as get_ignored_rules_for_file)
304        let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
305            if let Ok(canonical_path) = file_path.canonicalize() {
306                if let Ok(canonical_root) = root.canonicalize() {
307                    if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
308                        std::borrow::Cow::Owned(relative.to_path_buf())
309                    } else {
310                        std::borrow::Cow::Borrowed(file_path)
311                    }
312                } else {
313                    std::borrow::Cow::Borrowed(file_path)
314                }
315            } else {
316                std::borrow::Cow::Borrowed(file_path)
317            }
318        } else {
319            std::borrow::Cow::Borrowed(file_path)
320        };
321
322        // Iterate in config order and return first match (IndexMap preserves order)
323        for (pattern, flavor) in &self.per_file_flavor {
324            // Use GlobBuilder with literal_separator(true) for standard glob semantics
325            // where * does NOT match path separators (only ** does)
326            if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
327                let matcher = glob.compile_matcher();
328                if matcher.is_match(path_for_matching.as_ref()) {
329                    return *flavor;
330                }
331            } else {
332                log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
333            }
334        }
335
336        // No pattern matched, use fallback
337        self.resolve_flavor_fallback(file_path)
338    }
339
340    /// Fallback flavor resolution: global flavor → auto-detect → Standard
341    fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
342        // If global flavor is explicitly set to non-Standard, use it
343        if self.global.flavor != MarkdownFlavor::Standard {
344            return self.global.flavor;
345        }
346        // Auto-detect from extension
347        MarkdownFlavor::from_path(file_path)
348    }
349}
350
351/// Global configuration options
352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
353#[serde(default, rename_all = "kebab-case")]
354pub struct GlobalConfig {
355    /// Enabled rules
356    #[serde(default)]
357    pub enable: Vec<String>,
358
359    /// Disabled rules
360    #[serde(default)]
361    pub disable: Vec<String>,
362
363    /// Files to exclude
364    #[serde(default)]
365    pub exclude: Vec<String>,
366
367    /// Files to include
368    #[serde(default)]
369    pub include: Vec<String>,
370
371    /// Respect .gitignore files when scanning directories
372    #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
373    pub respect_gitignore: bool,
374
375    /// Global line length setting (used by MD013 and other rules if not overridden)
376    #[serde(default, alias = "line_length")]
377    pub line_length: LineLength,
378
379    /// Output format for linting results (e.g., "text", "json", "pylint", etc.)
380    #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
381    pub output_format: Option<String>,
382
383    /// Rules that are allowed to be fixed when --fix is used
384    /// If specified, only these rules will be fixed
385    #[serde(default)]
386    pub fixable: Vec<String>,
387
388    /// Rules that should never be fixed, even when --fix is used
389    /// Takes precedence over fixable
390    #[serde(default)]
391    pub unfixable: Vec<String>,
392
393    /// Markdown flavor/dialect to use (mkdocs, gfm, commonmark, etc.)
394    /// When set, adjusts parsing and validation rules for that specific Markdown variant
395    #[serde(default)]
396    pub flavor: MarkdownFlavor,
397
398    /// [DEPRECATED] Whether to enforce exclude patterns for explicitly passed paths.
399    /// This option is deprecated as of v0.0.156 and has no effect.
400    /// Exclude patterns are now always respected, even for explicitly provided files.
401    /// This prevents duplication between rumdl config and tool configs like pre-commit.
402    #[serde(default, alias = "force_exclude")]
403    #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
404    pub force_exclude: bool,
405
406    /// Directory to store cache files (default: .rumdl_cache)
407    /// Can also be set via --cache-dir CLI flag or RUMDL_CACHE_DIR environment variable
408    #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
409    pub cache_dir: Option<String>,
410
411    /// Whether caching is enabled (default: true)
412    /// Can also be disabled via --no-cache CLI flag
413    #[serde(default = "default_true")]
414    pub cache: bool,
415}
416
417fn default_respect_gitignore() -> bool {
418    true
419}
420
421fn default_true() -> bool {
422    true
423}
424
425// Add the Default impl
426impl Default for GlobalConfig {
427    #[allow(deprecated)]
428    fn default() -> Self {
429        Self {
430            enable: Vec::new(),
431            disable: Vec::new(),
432            exclude: Vec::new(),
433            include: Vec::new(),
434            respect_gitignore: true,
435            line_length: LineLength::default(),
436            output_format: None,
437            fixable: Vec::new(),
438            unfixable: Vec::new(),
439            flavor: MarkdownFlavor::default(),
440            force_exclude: false,
441            cache_dir: None,
442            cache: true,
443        }
444    }
445}
446
447const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
448    ".markdownlint.json",
449    ".markdownlint.jsonc",
450    ".markdownlint.yaml",
451    ".markdownlint.yml",
452    "markdownlint.json",
453    "markdownlint.jsonc",
454    "markdownlint.yaml",
455    "markdownlint.yml",
456];
457
458/// Create a default configuration file at the specified path
459pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
460    // Check if file already exists
461    if Path::new(path).exists() {
462        return Err(ConfigError::FileExists { path: path.to_string() });
463    }
464
465    // Default configuration content
466    let default_config = r#"# rumdl configuration file
467
468# Global configuration options
469[global]
470# List of rules to disable (uncomment and modify as needed)
471# disable = ["MD013", "MD033"]
472
473# List of rules to enable exclusively (if provided, only these rules will run)
474# enable = ["MD001", "MD003", "MD004"]
475
476# List of file/directory patterns to include for linting (if provided, only these will be linted)
477# include = [
478#    "docs/*.md",
479#    "src/**/*.md",
480#    "README.md"
481# ]
482
483# List of file/directory patterns to exclude from linting
484exclude = [
485    # Common directories to exclude
486    ".git",
487    ".github",
488    "node_modules",
489    "vendor",
490    "dist",
491    "build",
492
493    # Specific files or patterns
494    "CHANGELOG.md",
495    "LICENSE.md",
496]
497
498# Respect .gitignore files when scanning directories (default: true)
499respect-gitignore = true
500
501# Markdown flavor/dialect (uncomment to enable)
502# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
503# flavor = "mkdocs"
504
505# Rule-specific configurations (uncomment and modify as needed)
506
507# [MD003]
508# style = "atx"  # Heading style (atx, atx_closed, setext)
509
510# [MD004]
511# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
512
513# [MD007]
514# indent = 4  # Unordered list indentation
515
516# [MD013]
517# line-length = 100  # Line length
518# code-blocks = false  # Exclude code blocks from line length check
519# tables = false  # Exclude tables from line length check
520# headings = true  # Include headings in line length check
521
522# [MD044]
523# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
524# code-blocks = false  # Check code blocks for proper names (default: false, skips code blocks)
525"#;
526
527    // Write the default configuration to the file
528    match fs::write(path, default_config) {
529        Ok(_) => Ok(()),
530        Err(err) => Err(ConfigError::IoError {
531            source: err,
532            path: path.to_string(),
533        }),
534    }
535}
536
537/// Errors that can occur when loading configuration
538#[derive(Debug, thiserror::Error)]
539pub enum ConfigError {
540    /// Failed to read the configuration file
541    #[error("Failed to read config file at {path}: {source}")]
542    IoError { source: io::Error, path: String },
543
544    /// Failed to parse the configuration content (TOML or JSON)
545    #[error("Failed to parse config: {0}")]
546    ParseError(String),
547
548    /// Configuration file already exists
549    #[error("Configuration file already exists at {path}")]
550    FileExists { path: String },
551}
552
553/// Get a rule-specific configuration value
554/// Automatically tries both the original key and normalized variants (kebab-case ↔ snake_case)
555/// for better markdownlint compatibility
556pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
557    let norm_rule_name = rule_name.to_ascii_uppercase(); // Use uppercase for lookup
558
559    let rule_config = config.rules.get(&norm_rule_name)?;
560
561    // Try multiple key variants to support both underscore and kebab-case formats
562    let key_variants = [
563        key.to_string(),       // Original key as provided
564        normalize_key(key),    // Normalized key (lowercase, kebab-case)
565        key.replace('-', "_"), // Convert kebab-case to snake_case
566        key.replace('_', "-"), // Convert snake_case to kebab-case
567    ];
568
569    // Try each variant until we find a match
570    for variant in &key_variants {
571        if let Some(value) = rule_config.values.get(variant)
572            && let Ok(result) = T::deserialize(value.clone())
573        {
574            return Some(result);
575        }
576    }
577
578    None
579}
580
581/// Generate default rumdl configuration for pyproject.toml
582pub fn generate_pyproject_config() -> String {
583    let config_content = r#"
584[tool.rumdl]
585# Global configuration options
586line-length = 100
587disable = []
588exclude = [
589    # Common directories to exclude
590    ".git",
591    ".github",
592    "node_modules",
593    "vendor",
594    "dist",
595    "build",
596]
597respect-gitignore = true
598
599# Rule-specific configurations (uncomment and modify as needed)
600
601# [tool.rumdl.MD003]
602# style = "atx"  # Heading style (atx, atx_closed, setext)
603
604# [tool.rumdl.MD004]
605# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
606
607# [tool.rumdl.MD007]
608# indent = 4  # Unordered list indentation
609
610# [tool.rumdl.MD013]
611# line-length = 100  # Line length
612# code-blocks = false  # Exclude code blocks from line length check
613# tables = false  # Exclude tables from line length check
614# headings = true  # Include headings in line length check
615
616# [tool.rumdl.MD044]
617# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
618# code-blocks = false  # Check code blocks for proper names (default: false, skips code blocks)
619"#;
620
621    config_content.to_string()
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use std::fs;
628    use tempfile::tempdir;
629
630    #[test]
631    fn test_flavor_loading() {
632        let temp_dir = tempdir().unwrap();
633        let config_path = temp_dir.path().join(".rumdl.toml");
634        let config_content = r#"
635[global]
636flavor = "mkdocs"
637disable = ["MD001"]
638"#;
639        fs::write(&config_path, config_content).unwrap();
640
641        // Load the config
642        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
643        let config: Config = sourced.into_validated_unchecked().into();
644
645        // Check that flavor was loaded
646        assert_eq!(config.global.flavor, MarkdownFlavor::MkDocs);
647        assert!(config.is_mkdocs_flavor());
648        assert!(config.is_mkdocs_project()); // Test backwards compatibility
649        assert_eq!(config.global.disable, vec!["MD001".to_string()]);
650    }
651
652    #[test]
653    fn test_pyproject_toml_root_level_config() {
654        let temp_dir = tempdir().unwrap();
655        let config_path = temp_dir.path().join("pyproject.toml");
656
657        // Create a test pyproject.toml with root-level configuration
658        let content = r#"
659[tool.rumdl]
660line-length = 120
661disable = ["MD033"]
662enable = ["MD001", "MD004"]
663include = ["docs/*.md"]
664exclude = ["node_modules"]
665respect-gitignore = true
666        "#;
667
668        fs::write(&config_path, content).unwrap();
669
670        // Load the config with skip_auto_discovery to avoid environment config files
671        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
672        let config: Config = sourced.into_validated_unchecked().into(); // Convert to plain config for assertions
673
674        // Check global settings
675        assert_eq!(config.global.disable, vec!["MD033".to_string()]);
676        assert_eq!(config.global.enable, vec!["MD001".to_string(), "MD004".to_string()]);
677        // Should now contain only the configured pattern since auto-discovery is disabled
678        assert_eq!(config.global.include, vec!["docs/*.md".to_string()]);
679        assert_eq!(config.global.exclude, vec!["node_modules".to_string()]);
680        assert!(config.global.respect_gitignore);
681
682        // Check line-length was correctly added to MD013
683        let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
684        assert_eq!(line_length, Some(120));
685    }
686
687    #[test]
688    fn test_pyproject_toml_snake_case_and_kebab_case() {
689        let temp_dir = tempdir().unwrap();
690        let config_path = temp_dir.path().join("pyproject.toml");
691
692        // Test with both kebab-case and snake_case variants
693        let content = r#"
694[tool.rumdl]
695line-length = 150
696respect_gitignore = true
697        "#;
698
699        fs::write(&config_path, content).unwrap();
700
701        // Load the config with skip_auto_discovery to avoid environment config files
702        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
703        let config: Config = sourced.into_validated_unchecked().into(); // Convert to plain config for assertions
704
705        // Check settings were correctly loaded
706        assert!(config.global.respect_gitignore);
707        let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
708        assert_eq!(line_length, Some(150));
709    }
710
711    #[test]
712    fn test_md013_key_normalization_in_rumdl_toml() {
713        let temp_dir = tempdir().unwrap();
714        let config_path = temp_dir.path().join(".rumdl.toml");
715        let config_content = r#"
716[MD013]
717line_length = 111
718line-length = 222
719"#;
720        fs::write(&config_path, config_content).unwrap();
721        // Load the config with skip_auto_discovery to avoid environment config files
722        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
723        let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
724        // Now we should only get the explicitly configured key
725        let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
726        assert_eq!(keys, vec!["line-length"]);
727        let val = &rule_cfg.values["line-length"].value;
728        assert_eq!(val.as_integer(), Some(222));
729        // get_rule_config_value should retrieve the value for both snake_case and kebab-case
730        let config: Config = sourced.clone().into_validated_unchecked().into();
731        let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
732        let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
733        assert_eq!(v1, Some(222));
734        assert_eq!(v2, Some(222));
735    }
736
737    #[test]
738    fn test_md013_section_case_insensitivity() {
739        let temp_dir = tempdir().unwrap();
740        let config_path = temp_dir.path().join(".rumdl.toml");
741        let config_content = r#"
742[md013]
743line-length = 101
744
745[Md013]
746line-length = 102
747
748[MD013]
749line-length = 103
750"#;
751        fs::write(&config_path, config_content).unwrap();
752        // Load the config with skip_auto_discovery to avoid environment config files
753        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
754        let config: Config = sourced.clone().into_validated_unchecked().into();
755        // Only the last section should win, and be present
756        let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
757        let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
758        assert_eq!(keys, vec!["line-length"]);
759        let val = &rule_cfg.values["line-length"].value;
760        assert_eq!(val.as_integer(), Some(103));
761        let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
762        assert_eq!(v, Some(103));
763    }
764
765    #[test]
766    fn test_md013_key_snake_and_kebab_case() {
767        let temp_dir = tempdir().unwrap();
768        let config_path = temp_dir.path().join(".rumdl.toml");
769        let config_content = r#"
770[MD013]
771line_length = 201
772line-length = 202
773"#;
774        fs::write(&config_path, config_content).unwrap();
775        // Load the config with skip_auto_discovery to avoid environment config files
776        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
777        let config: Config = sourced.clone().into_validated_unchecked().into();
778        let rule_cfg = sourced.rules.get("MD013").expect("MD013 rule config should exist");
779        let keys: Vec<_> = rule_cfg.values.keys().cloned().collect();
780        assert_eq!(keys, vec!["line-length"]);
781        let val = &rule_cfg.values["line-length"].value;
782        assert_eq!(val.as_integer(), Some(202));
783        let v1 = get_rule_config_value::<usize>(&config, "MD013", "line_length");
784        let v2 = get_rule_config_value::<usize>(&config, "MD013", "line-length");
785        assert_eq!(v1, Some(202));
786        assert_eq!(v2, Some(202));
787    }
788
789    #[test]
790    fn test_unknown_rule_section_is_ignored() {
791        let temp_dir = tempdir().unwrap();
792        let config_path = temp_dir.path().join(".rumdl.toml");
793        let config_content = r#"
794[MD999]
795foo = 1
796bar = 2
797[MD013]
798line-length = 303
799"#;
800        fs::write(&config_path, config_content).unwrap();
801        // Load the config with skip_auto_discovery to avoid environment config files
802        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
803        let config: Config = sourced.clone().into_validated_unchecked().into();
804        // MD999 should not be present
805        assert!(!sourced.rules.contains_key("MD999"));
806        // MD013 should be present and correct
807        let v = get_rule_config_value::<usize>(&config, "MD013", "line-length");
808        assert_eq!(v, Some(303));
809    }
810
811    #[test]
812    fn test_invalid_toml_syntax() {
813        let temp_dir = tempdir().unwrap();
814        let config_path = temp_dir.path().join(".rumdl.toml");
815
816        // Invalid TOML with unclosed string
817        let config_content = r#"
818[MD013]
819line-length = "unclosed string
820"#;
821        fs::write(&config_path, config_content).unwrap();
822
823        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
824        assert!(result.is_err());
825        match result.unwrap_err() {
826            ConfigError::ParseError(msg) => {
827                // The actual error message from toml parser might vary
828                assert!(msg.contains("expected") || msg.contains("invalid") || msg.contains("unterminated"));
829            }
830            _ => panic!("Expected ParseError"),
831        }
832    }
833
834    #[test]
835    fn test_wrong_type_for_config_value() {
836        let temp_dir = tempdir().unwrap();
837        let config_path = temp_dir.path().join(".rumdl.toml");
838
839        // line-length should be a number, not a string
840        let config_content = r#"
841[MD013]
842line-length = "not a number"
843"#;
844        fs::write(&config_path, config_content).unwrap();
845
846        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
847        let config: Config = sourced.into_validated_unchecked().into();
848
849        // The value should be loaded as a string, not converted
850        let rule_config = config.rules.get("MD013").unwrap();
851        let value = rule_config.values.get("line-length").unwrap();
852        assert!(matches!(value, toml::Value::String(_)));
853    }
854
855    #[test]
856    fn test_empty_config_file() {
857        let temp_dir = tempdir().unwrap();
858        let config_path = temp_dir.path().join(".rumdl.toml");
859
860        // Empty file
861        fs::write(&config_path, "").unwrap();
862
863        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
864        let config: Config = sourced.into_validated_unchecked().into();
865
866        // Should have default values
867        assert_eq!(config.global.line_length.get(), 80);
868        assert!(config.global.respect_gitignore);
869        assert!(config.rules.is_empty());
870    }
871
872    #[test]
873    fn test_malformed_pyproject_toml() {
874        let temp_dir = tempdir().unwrap();
875        let config_path = temp_dir.path().join("pyproject.toml");
876
877        // Missing closing bracket
878        let content = r#"
879[tool.rumdl
880line-length = 120
881"#;
882        fs::write(&config_path, content).unwrap();
883
884        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
885        assert!(result.is_err());
886    }
887
888    #[test]
889    fn test_conflicting_config_values() {
890        let temp_dir = tempdir().unwrap();
891        let config_path = temp_dir.path().join(".rumdl.toml");
892
893        // Both enable and disable the same rule - these need to be in a global section
894        let config_content = r#"
895[global]
896enable = ["MD013"]
897disable = ["MD013"]
898"#;
899        fs::write(&config_path, config_content).unwrap();
900
901        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
902        let config: Config = sourced.into_validated_unchecked().into();
903
904        // Conflict resolution: enable wins over disable
905        assert!(config.global.enable.contains(&"MD013".to_string()));
906        assert!(!config.global.disable.contains(&"MD013".to_string()));
907    }
908
909    #[test]
910    fn test_invalid_rule_names() {
911        let temp_dir = tempdir().unwrap();
912        let config_path = temp_dir.path().join(".rumdl.toml");
913
914        let config_content = r#"
915[global]
916enable = ["MD001", "NOT_A_RULE", "md002", "12345"]
917disable = ["MD-001", "MD_002"]
918"#;
919        fs::write(&config_path, config_content).unwrap();
920
921        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
922        let config: Config = sourced.into_validated_unchecked().into();
923
924        // All values should be preserved as-is
925        assert_eq!(config.global.enable.len(), 4);
926        assert_eq!(config.global.disable.len(), 2);
927    }
928
929    #[test]
930    fn test_deeply_nested_config() {
931        let temp_dir = tempdir().unwrap();
932        let config_path = temp_dir.path().join(".rumdl.toml");
933
934        // This should be ignored as we don't support nested tables within rule configs
935        let config_content = r#"
936[MD013]
937line-length = 100
938[MD013.nested]
939value = 42
940"#;
941        fs::write(&config_path, config_content).unwrap();
942
943        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
944        let config: Config = sourced.into_validated_unchecked().into();
945
946        let rule_config = config.rules.get("MD013").unwrap();
947        assert_eq!(
948            rule_config.values.get("line-length").unwrap(),
949            &toml::Value::Integer(100)
950        );
951        // Nested table should not be present
952        assert!(!rule_config.values.contains_key("nested"));
953    }
954
955    #[test]
956    fn test_unicode_in_config() {
957        let temp_dir = tempdir().unwrap();
958        let config_path = temp_dir.path().join(".rumdl.toml");
959
960        let config_content = r#"
961[global]
962include = ["文档/*.md", "ドキュメント/*.md"]
963exclude = ["测试/*", "🚀/*"]
964
965[MD013]
966line-length = 80
967message = "行太长了 🚨"
968"#;
969        fs::write(&config_path, config_content).unwrap();
970
971        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
972        let config: Config = sourced.into_validated_unchecked().into();
973
974        assert_eq!(config.global.include.len(), 2);
975        assert_eq!(config.global.exclude.len(), 2);
976        assert!(config.global.include[0].contains("文档"));
977        assert!(config.global.exclude[1].contains("🚀"));
978
979        let rule_config = config.rules.get("MD013").unwrap();
980        let message = rule_config.values.get("message").unwrap();
981        if let toml::Value::String(s) = message {
982            assert!(s.contains("行太长了"));
983            assert!(s.contains("🚨"));
984        }
985    }
986
987    #[test]
988    fn test_extremely_long_values() {
989        let temp_dir = tempdir().unwrap();
990        let config_path = temp_dir.path().join(".rumdl.toml");
991
992        let long_string = "a".repeat(10000);
993        let config_content = format!(
994            r#"
995[global]
996exclude = ["{long_string}"]
997
998[MD013]
999line-length = 999999999
1000"#
1001        );
1002
1003        fs::write(&config_path, config_content).unwrap();
1004
1005        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1006        let config: Config = sourced.into_validated_unchecked().into();
1007
1008        assert_eq!(config.global.exclude[0].len(), 10000);
1009        let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length");
1010        assert_eq!(line_length, Some(999999999));
1011    }
1012
1013    #[test]
1014    fn test_config_with_comments() {
1015        let temp_dir = tempdir().unwrap();
1016        let config_path = temp_dir.path().join(".rumdl.toml");
1017
1018        let config_content = r#"
1019[global]
1020# This is a comment
1021enable = ["MD001"] # Enable MD001
1022# disable = ["MD002"] # This is commented out
1023
1024[MD013] # Line length rule
1025line-length = 100 # Set to 100 characters
1026# ignored = true # This setting is commented out
1027"#;
1028        fs::write(&config_path, config_content).unwrap();
1029
1030        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1031        let config: Config = sourced.into_validated_unchecked().into();
1032
1033        assert_eq!(config.global.enable, vec!["MD001"]);
1034        assert!(config.global.disable.is_empty()); // Commented out
1035
1036        let rule_config = config.rules.get("MD013").unwrap();
1037        assert_eq!(rule_config.values.len(), 1); // Only line-length
1038        assert!(!rule_config.values.contains_key("ignored"));
1039    }
1040
1041    #[test]
1042    fn test_arrays_in_rule_config() {
1043        let temp_dir = tempdir().unwrap();
1044        let config_path = temp_dir.path().join(".rumdl.toml");
1045
1046        let config_content = r#"
1047[MD003]
1048levels = [1, 2, 3]
1049tags = ["important", "critical"]
1050mixed = [1, "two", true]
1051"#;
1052        fs::write(&config_path, config_content).unwrap();
1053
1054        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1055        let config: Config = sourced.into_validated_unchecked().into();
1056
1057        // Arrays should now be properly parsed
1058        let rule_config = config.rules.get("MD003").expect("MD003 config should exist");
1059
1060        // Check that arrays are present and correctly parsed
1061        assert!(rule_config.values.contains_key("levels"));
1062        assert!(rule_config.values.contains_key("tags"));
1063        assert!(rule_config.values.contains_key("mixed"));
1064
1065        // Verify array contents
1066        if let Some(toml::Value::Array(levels)) = rule_config.values.get("levels") {
1067            assert_eq!(levels.len(), 3);
1068            assert_eq!(levels[0], toml::Value::Integer(1));
1069            assert_eq!(levels[1], toml::Value::Integer(2));
1070            assert_eq!(levels[2], toml::Value::Integer(3));
1071        } else {
1072            panic!("levels should be an array");
1073        }
1074
1075        if let Some(toml::Value::Array(tags)) = rule_config.values.get("tags") {
1076            assert_eq!(tags.len(), 2);
1077            assert_eq!(tags[0], toml::Value::String("important".to_string()));
1078            assert_eq!(tags[1], toml::Value::String("critical".to_string()));
1079        } else {
1080            panic!("tags should be an array");
1081        }
1082
1083        if let Some(toml::Value::Array(mixed)) = rule_config.values.get("mixed") {
1084            assert_eq!(mixed.len(), 3);
1085            assert_eq!(mixed[0], toml::Value::Integer(1));
1086            assert_eq!(mixed[1], toml::Value::String("two".to_string()));
1087            assert_eq!(mixed[2], toml::Value::Boolean(true));
1088        } else {
1089            panic!("mixed should be an array");
1090        }
1091    }
1092
1093    #[test]
1094    fn test_normalize_key_edge_cases() {
1095        // Rule names
1096        assert_eq!(normalize_key("MD001"), "MD001");
1097        assert_eq!(normalize_key("md001"), "MD001");
1098        assert_eq!(normalize_key("Md001"), "MD001");
1099        assert_eq!(normalize_key("mD001"), "MD001");
1100
1101        // Non-rule names
1102        assert_eq!(normalize_key("line_length"), "line-length");
1103        assert_eq!(normalize_key("line-length"), "line-length");
1104        assert_eq!(normalize_key("LINE_LENGTH"), "line-length");
1105        assert_eq!(normalize_key("respect_gitignore"), "respect-gitignore");
1106
1107        // Edge cases
1108        assert_eq!(normalize_key("MD"), "md"); // Too short to be a rule
1109        assert_eq!(normalize_key("MD00"), "md00"); // Too short
1110        assert_eq!(normalize_key("MD0001"), "md0001"); // Too long
1111        assert_eq!(normalize_key("MDabc"), "mdabc"); // Non-digit
1112        assert_eq!(normalize_key("MD00a"), "md00a"); // Partial digit
1113        assert_eq!(normalize_key(""), "");
1114        assert_eq!(normalize_key("_"), "-");
1115        assert_eq!(normalize_key("___"), "---");
1116    }
1117
1118    #[test]
1119    fn test_missing_config_file() {
1120        let temp_dir = tempdir().unwrap();
1121        let config_path = temp_dir.path().join("nonexistent.toml");
1122
1123        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1124        assert!(result.is_err());
1125        match result.unwrap_err() {
1126            ConfigError::IoError { .. } => {}
1127            _ => panic!("Expected IoError for missing file"),
1128        }
1129    }
1130
1131    #[test]
1132    #[cfg(unix)]
1133    fn test_permission_denied_config() {
1134        use std::os::unix::fs::PermissionsExt;
1135
1136        let temp_dir = tempdir().unwrap();
1137        let config_path = temp_dir.path().join(".rumdl.toml");
1138
1139        fs::write(&config_path, "enable = [\"MD001\"]").unwrap();
1140
1141        // Remove read permissions
1142        let mut perms = fs::metadata(&config_path).unwrap().permissions();
1143        perms.set_mode(0o000);
1144        fs::set_permissions(&config_path, perms).unwrap();
1145
1146        let result = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true);
1147
1148        // Restore permissions for cleanup
1149        let mut perms = fs::metadata(&config_path).unwrap().permissions();
1150        perms.set_mode(0o644);
1151        fs::set_permissions(&config_path, perms).unwrap();
1152
1153        assert!(result.is_err());
1154        match result.unwrap_err() {
1155            ConfigError::IoError { .. } => {}
1156            _ => panic!("Expected IoError for permission denied"),
1157        }
1158    }
1159
1160    #[test]
1161    fn test_circular_reference_detection() {
1162        // This test is more conceptual since TOML doesn't support circular references
1163        // But we test that deeply nested structures don't cause stack overflow
1164        let temp_dir = tempdir().unwrap();
1165        let config_path = temp_dir.path().join(".rumdl.toml");
1166
1167        let mut config_content = String::from("[MD001]\n");
1168        for i in 0..100 {
1169            config_content.push_str(&format!("key{i} = {i}\n"));
1170        }
1171
1172        fs::write(&config_path, config_content).unwrap();
1173
1174        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1175        let config: Config = sourced.into_validated_unchecked().into();
1176
1177        let rule_config = config.rules.get("MD001").unwrap();
1178        assert_eq!(rule_config.values.len(), 100);
1179    }
1180
1181    #[test]
1182    fn test_special_toml_values() {
1183        let temp_dir = tempdir().unwrap();
1184        let config_path = temp_dir.path().join(".rumdl.toml");
1185
1186        let config_content = r#"
1187[MD001]
1188infinity = inf
1189neg_infinity = -inf
1190not_a_number = nan
1191datetime = 1979-05-27T07:32:00Z
1192local_date = 1979-05-27
1193local_time = 07:32:00
1194"#;
1195        fs::write(&config_path, config_content).unwrap();
1196
1197        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1198        let config: Config = sourced.into_validated_unchecked().into();
1199
1200        // Some values might not be parsed due to parser limitations
1201        if let Some(rule_config) = config.rules.get("MD001") {
1202            // Check special float values if present
1203            if let Some(toml::Value::Float(f)) = rule_config.values.get("infinity") {
1204                assert!(f.is_infinite() && f.is_sign_positive());
1205            }
1206            if let Some(toml::Value::Float(f)) = rule_config.values.get("neg_infinity") {
1207                assert!(f.is_infinite() && f.is_sign_negative());
1208            }
1209            if let Some(toml::Value::Float(f)) = rule_config.values.get("not_a_number") {
1210                assert!(f.is_nan());
1211            }
1212
1213            // Check datetime values if present
1214            if let Some(val) = rule_config.values.get("datetime") {
1215                assert!(matches!(val, toml::Value::Datetime(_)));
1216            }
1217            // Note: local_date and local_time might not be parsed by the current implementation
1218        }
1219    }
1220
1221    #[test]
1222    fn test_default_config_passes_validation() {
1223        use crate::rules;
1224
1225        let temp_dir = tempdir().unwrap();
1226        let config_path = temp_dir.path().join(".rumdl.toml");
1227        let config_path_str = config_path.to_str().unwrap();
1228
1229        // Create the default config using the same function that `rumdl init` uses
1230        create_default_config(config_path_str).unwrap();
1231
1232        // Load it back as a SourcedConfig
1233        let sourced =
1234            SourcedConfig::load(Some(config_path_str), None).expect("Default config should load successfully");
1235
1236        // Create the rule registry
1237        let all_rules = rules::all_rules(&Config::default());
1238        let registry = RuleRegistry::from_rules(&all_rules);
1239
1240        // Validate the config
1241        let warnings = validate_config_sourced(&sourced, &registry);
1242
1243        // The default config should have no warnings
1244        if !warnings.is_empty() {
1245            for warning in &warnings {
1246                eprintln!("Config validation warning: {}", warning.message);
1247                if let Some(rule) = &warning.rule {
1248                    eprintln!("  Rule: {rule}");
1249                }
1250                if let Some(key) = &warning.key {
1251                    eprintln!("  Key: {key}");
1252                }
1253            }
1254        }
1255        assert!(
1256            warnings.is_empty(),
1257            "Default config from rumdl init should pass validation without warnings"
1258        );
1259    }
1260
1261    #[test]
1262    fn test_per_file_ignores_config_parsing() {
1263        let temp_dir = tempdir().unwrap();
1264        let config_path = temp_dir.path().join(".rumdl.toml");
1265        let config_content = r#"
1266[per-file-ignores]
1267"README.md" = ["MD033"]
1268"docs/**/*.md" = ["MD013", "MD033"]
1269"test/*.md" = ["MD041"]
1270"#;
1271        fs::write(&config_path, config_content).unwrap();
1272
1273        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1274        let config: Config = sourced.into_validated_unchecked().into();
1275
1276        // Verify per-file-ignores was loaded
1277        assert_eq!(config.per_file_ignores.len(), 3);
1278        assert_eq!(
1279            config.per_file_ignores.get("README.md"),
1280            Some(&vec!["MD033".to_string()])
1281        );
1282        assert_eq!(
1283            config.per_file_ignores.get("docs/**/*.md"),
1284            Some(&vec!["MD013".to_string(), "MD033".to_string()])
1285        );
1286        assert_eq!(
1287            config.per_file_ignores.get("test/*.md"),
1288            Some(&vec!["MD041".to_string()])
1289        );
1290    }
1291
1292    #[test]
1293    fn test_per_file_ignores_glob_matching() {
1294        use std::path::PathBuf;
1295
1296        let temp_dir = tempdir().unwrap();
1297        let config_path = temp_dir.path().join(".rumdl.toml");
1298        let config_content = r#"
1299[per-file-ignores]
1300"README.md" = ["MD033"]
1301"docs/**/*.md" = ["MD013"]
1302"**/test_*.md" = ["MD041"]
1303"#;
1304        fs::write(&config_path, config_content).unwrap();
1305
1306        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1307        let config: Config = sourced.into_validated_unchecked().into();
1308
1309        // Test exact match
1310        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1311        assert!(ignored.contains("MD033"));
1312        assert_eq!(ignored.len(), 1);
1313
1314        // Test glob pattern matching
1315        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1316        assert!(ignored.contains("MD013"));
1317        assert_eq!(ignored.len(), 1);
1318
1319        // Test recursive glob pattern
1320        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("tests/fixtures/test_example.md"));
1321        assert!(ignored.contains("MD041"));
1322        assert_eq!(ignored.len(), 1);
1323
1324        // Test non-matching path
1325        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("other/file.md"));
1326        assert!(ignored.is_empty());
1327    }
1328
1329    #[test]
1330    fn test_per_file_ignores_pyproject_toml() {
1331        let temp_dir = tempdir().unwrap();
1332        let config_path = temp_dir.path().join("pyproject.toml");
1333        let config_content = r#"
1334[tool.rumdl]
1335[tool.rumdl.per-file-ignores]
1336"README.md" = ["MD033", "MD013"]
1337"generated/*.md" = ["MD041"]
1338"#;
1339        fs::write(&config_path, config_content).unwrap();
1340
1341        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1342        let config: Config = sourced.into_validated_unchecked().into();
1343
1344        // Verify per-file-ignores was loaded from pyproject.toml
1345        assert_eq!(config.per_file_ignores.len(), 2);
1346        assert_eq!(
1347            config.per_file_ignores.get("README.md"),
1348            Some(&vec!["MD033".to_string(), "MD013".to_string()])
1349        );
1350        assert_eq!(
1351            config.per_file_ignores.get("generated/*.md"),
1352            Some(&vec!["MD041".to_string()])
1353        );
1354    }
1355
1356    #[test]
1357    fn test_per_file_ignores_multiple_patterns_match() {
1358        use std::path::PathBuf;
1359
1360        let temp_dir = tempdir().unwrap();
1361        let config_path = temp_dir.path().join(".rumdl.toml");
1362        let config_content = r#"
1363[per-file-ignores]
1364"docs/**/*.md" = ["MD013"]
1365"**/api/*.md" = ["MD033"]
1366"docs/api/overview.md" = ["MD041"]
1367"#;
1368        fs::write(&config_path, config_content).unwrap();
1369
1370        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1371        let config: Config = sourced.into_validated_unchecked().into();
1372
1373        // File matches multiple patterns - should get union of all rules
1374        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("docs/api/overview.md"));
1375        assert_eq!(ignored.len(), 3);
1376        assert!(ignored.contains("MD013"));
1377        assert!(ignored.contains("MD033"));
1378        assert!(ignored.contains("MD041"));
1379    }
1380
1381    #[test]
1382    fn test_per_file_ignores_rule_name_normalization() {
1383        use std::path::PathBuf;
1384
1385        let temp_dir = tempdir().unwrap();
1386        let config_path = temp_dir.path().join(".rumdl.toml");
1387        let config_content = r#"
1388[per-file-ignores]
1389"README.md" = ["md033", "MD013", "Md041"]
1390"#;
1391        fs::write(&config_path, config_content).unwrap();
1392
1393        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1394        let config: Config = sourced.into_validated_unchecked().into();
1395
1396        // All rule names should be normalized to uppercase
1397        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1398        assert_eq!(ignored.len(), 3);
1399        assert!(ignored.contains("MD033"));
1400        assert!(ignored.contains("MD013"));
1401        assert!(ignored.contains("MD041"));
1402    }
1403
1404    #[test]
1405    fn test_per_file_ignores_invalid_glob_pattern() {
1406        use std::path::PathBuf;
1407
1408        let temp_dir = tempdir().unwrap();
1409        let config_path = temp_dir.path().join(".rumdl.toml");
1410        let config_content = r#"
1411[per-file-ignores]
1412"[invalid" = ["MD033"]
1413"valid/*.md" = ["MD013"]
1414"#;
1415        fs::write(&config_path, config_content).unwrap();
1416
1417        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1418        let config: Config = sourced.into_validated_unchecked().into();
1419
1420        // Invalid pattern should be skipped, valid pattern should work
1421        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("valid/test.md"));
1422        assert!(ignored.contains("MD013"));
1423
1424        // Invalid pattern should not cause issues
1425        let ignored2 = config.get_ignored_rules_for_file(&PathBuf::from("[invalid"));
1426        assert!(ignored2.is_empty());
1427    }
1428
1429    #[test]
1430    fn test_per_file_ignores_empty_section() {
1431        use std::path::PathBuf;
1432
1433        let temp_dir = tempdir().unwrap();
1434        let config_path = temp_dir.path().join(".rumdl.toml");
1435        let config_content = r#"
1436[global]
1437disable = ["MD001"]
1438
1439[per-file-ignores]
1440"#;
1441        fs::write(&config_path, config_content).unwrap();
1442
1443        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1444        let config: Config = sourced.into_validated_unchecked().into();
1445
1446        // Empty per-file-ignores should work fine
1447        assert_eq!(config.per_file_ignores.len(), 0);
1448        let ignored = config.get_ignored_rules_for_file(&PathBuf::from("README.md"));
1449        assert!(ignored.is_empty());
1450    }
1451
1452    #[test]
1453    fn test_per_file_ignores_with_underscores_in_pyproject() {
1454        let temp_dir = tempdir().unwrap();
1455        let config_path = temp_dir.path().join("pyproject.toml");
1456        let config_content = r#"
1457[tool.rumdl]
1458[tool.rumdl.per_file_ignores]
1459"README.md" = ["MD033"]
1460"#;
1461        fs::write(&config_path, config_content).unwrap();
1462
1463        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1464        let config: Config = sourced.into_validated_unchecked().into();
1465
1466        // Should support both per-file-ignores and per_file_ignores
1467        assert_eq!(config.per_file_ignores.len(), 1);
1468        assert_eq!(
1469            config.per_file_ignores.get("README.md"),
1470            Some(&vec!["MD033".to_string()])
1471        );
1472    }
1473
1474    #[test]
1475    fn test_per_file_ignores_absolute_path_matching() {
1476        // Regression test for issue #208: per-file-ignores should work with absolute paths
1477        // This is critical for GitHub Actions which uses absolute paths like $GITHUB_WORKSPACE
1478        use std::path::PathBuf;
1479
1480        let temp_dir = tempdir().unwrap();
1481        let config_path = temp_dir.path().join(".rumdl.toml");
1482
1483        // Create a subdirectory and file to match against
1484        let github_dir = temp_dir.path().join(".github");
1485        fs::create_dir_all(&github_dir).unwrap();
1486        let test_file = github_dir.join("pull_request_template.md");
1487        fs::write(&test_file, "Test content").unwrap();
1488
1489        let config_content = r#"
1490[per-file-ignores]
1491".github/pull_request_template.md" = ["MD041"]
1492"docs/**/*.md" = ["MD013"]
1493"#;
1494        fs::write(&config_path, config_content).unwrap();
1495
1496        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1497        let config: Config = sourced.into_validated_unchecked().into();
1498
1499        // Test with absolute path (like GitHub Actions would use)
1500        let absolute_path = test_file.canonicalize().unwrap();
1501        let ignored = config.get_ignored_rules_for_file(&absolute_path);
1502        assert!(
1503            ignored.contains("MD041"),
1504            "Should match absolute path {absolute_path:?} against relative pattern"
1505        );
1506        assert_eq!(ignored.len(), 1);
1507
1508        // Also verify relative path still works
1509        let relative_path = PathBuf::from(".github/pull_request_template.md");
1510        let ignored = config.get_ignored_rules_for_file(&relative_path);
1511        assert!(ignored.contains("MD041"), "Should match relative path");
1512    }
1513
1514    // ==========================================
1515    // Per-File-Flavor Tests
1516    // ==========================================
1517
1518    #[test]
1519    fn test_per_file_flavor_config_parsing() {
1520        let temp_dir = tempdir().unwrap();
1521        let config_path = temp_dir.path().join(".rumdl.toml");
1522        let config_content = r#"
1523[per-file-flavor]
1524"docs/**/*.md" = "mkdocs"
1525"**/*.mdx" = "mdx"
1526"**/*.qmd" = "quarto"
1527"#;
1528        fs::write(&config_path, config_content).unwrap();
1529
1530        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1531        let config: Config = sourced.into_validated_unchecked().into();
1532
1533        // Verify per-file-flavor was loaded
1534        assert_eq!(config.per_file_flavor.len(), 3);
1535        assert_eq!(
1536            config.per_file_flavor.get("docs/**/*.md"),
1537            Some(&MarkdownFlavor::MkDocs)
1538        );
1539        assert_eq!(config.per_file_flavor.get("**/*.mdx"), Some(&MarkdownFlavor::MDX));
1540        assert_eq!(config.per_file_flavor.get("**/*.qmd"), Some(&MarkdownFlavor::Quarto));
1541    }
1542
1543    #[test]
1544    fn test_per_file_flavor_glob_matching() {
1545        use std::path::PathBuf;
1546
1547        let temp_dir = tempdir().unwrap();
1548        let config_path = temp_dir.path().join(".rumdl.toml");
1549        let config_content = r#"
1550[per-file-flavor]
1551"docs/**/*.md" = "mkdocs"
1552"**/*.mdx" = "mdx"
1553"components/**/*.md" = "mdx"
1554"#;
1555        fs::write(&config_path, config_content).unwrap();
1556
1557        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1558        let config: Config = sourced.into_validated_unchecked().into();
1559
1560        // Test mkdocs flavor for docs directory
1561        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/overview.md"));
1562        assert_eq!(flavor, MarkdownFlavor::MkDocs);
1563
1564        // Test mdx flavor for .mdx extension
1565        let flavor = config.get_flavor_for_file(&PathBuf::from("src/components/Button.mdx"));
1566        assert_eq!(flavor, MarkdownFlavor::MDX);
1567
1568        // Test mdx flavor for components directory
1569        let flavor = config.get_flavor_for_file(&PathBuf::from("components/Button/README.md"));
1570        assert_eq!(flavor, MarkdownFlavor::MDX);
1571
1572        // Test non-matching path falls back to standard
1573        let flavor = config.get_flavor_for_file(&PathBuf::from("README.md"));
1574        assert_eq!(flavor, MarkdownFlavor::Standard);
1575    }
1576
1577    #[test]
1578    fn test_per_file_flavor_pyproject_toml() {
1579        let temp_dir = tempdir().unwrap();
1580        let config_path = temp_dir.path().join("pyproject.toml");
1581        let config_content = r#"
1582[tool.rumdl]
1583[tool.rumdl.per-file-flavor]
1584"docs/**/*.md" = "mkdocs"
1585"**/*.mdx" = "mdx"
1586"#;
1587        fs::write(&config_path, config_content).unwrap();
1588
1589        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1590        let config: Config = sourced.into_validated_unchecked().into();
1591
1592        // Verify per-file-flavor was loaded from pyproject.toml
1593        assert_eq!(config.per_file_flavor.len(), 2);
1594        assert_eq!(
1595            config.per_file_flavor.get("docs/**/*.md"),
1596            Some(&MarkdownFlavor::MkDocs)
1597        );
1598        assert_eq!(config.per_file_flavor.get("**/*.mdx"), Some(&MarkdownFlavor::MDX));
1599    }
1600
1601    #[test]
1602    fn test_per_file_flavor_first_match_wins() {
1603        use std::path::PathBuf;
1604
1605        let temp_dir = tempdir().unwrap();
1606        let config_path = temp_dir.path().join(".rumdl.toml");
1607        // Order matters - first match wins (IndexMap preserves order)
1608        let config_content = r#"
1609[per-file-flavor]
1610"docs/internal/**/*.md" = "quarto"
1611"docs/**/*.md" = "mkdocs"
1612"**/*.md" = "standard"
1613"#;
1614        fs::write(&config_path, config_content).unwrap();
1615
1616        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1617        let config: Config = sourced.into_validated_unchecked().into();
1618
1619        // More specific pattern should match first
1620        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/internal/secret.md"));
1621        assert_eq!(flavor, MarkdownFlavor::Quarto);
1622
1623        // Less specific pattern for other docs
1624        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/public/readme.md"));
1625        assert_eq!(flavor, MarkdownFlavor::MkDocs);
1626
1627        // Fallback to least specific pattern
1628        let flavor = config.get_flavor_for_file(&PathBuf::from("other/file.md"));
1629        assert_eq!(flavor, MarkdownFlavor::Standard);
1630    }
1631
1632    #[test]
1633    fn test_per_file_flavor_overrides_global_flavor() {
1634        use std::path::PathBuf;
1635
1636        let temp_dir = tempdir().unwrap();
1637        let config_path = temp_dir.path().join(".rumdl.toml");
1638        let config_content = r#"
1639[global]
1640flavor = "mkdocs"
1641
1642[per-file-flavor]
1643"**/*.mdx" = "mdx"
1644"#;
1645        fs::write(&config_path, config_content).unwrap();
1646
1647        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1648        let config: Config = sourced.into_validated_unchecked().into();
1649
1650        // Per-file-flavor should override global flavor
1651        let flavor = config.get_flavor_for_file(&PathBuf::from("components/Button.mdx"));
1652        assert_eq!(flavor, MarkdownFlavor::MDX);
1653
1654        // Non-matching files should use global flavor
1655        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/readme.md"));
1656        assert_eq!(flavor, MarkdownFlavor::MkDocs);
1657    }
1658
1659    #[test]
1660    fn test_per_file_flavor_empty_map() {
1661        use std::path::PathBuf;
1662
1663        let temp_dir = tempdir().unwrap();
1664        let config_path = temp_dir.path().join(".rumdl.toml");
1665        let config_content = r#"
1666[global]
1667disable = ["MD001"]
1668
1669[per-file-flavor]
1670"#;
1671        fs::write(&config_path, config_content).unwrap();
1672
1673        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1674        let config: Config = sourced.into_validated_unchecked().into();
1675
1676        // Empty per-file-flavor should fall back to auto-detection
1677        let flavor = config.get_flavor_for_file(&PathBuf::from("README.md"));
1678        assert_eq!(flavor, MarkdownFlavor::Standard);
1679
1680        // MDX files should auto-detect
1681        let flavor = config.get_flavor_for_file(&PathBuf::from("test.mdx"));
1682        assert_eq!(flavor, MarkdownFlavor::MDX);
1683    }
1684
1685    #[test]
1686    fn test_per_file_flavor_with_underscores() {
1687        let temp_dir = tempdir().unwrap();
1688        let config_path = temp_dir.path().join("pyproject.toml");
1689        let config_content = r#"
1690[tool.rumdl]
1691[tool.rumdl.per_file_flavor]
1692"docs/**/*.md" = "mkdocs"
1693"#;
1694        fs::write(&config_path, config_content).unwrap();
1695
1696        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1697        let config: Config = sourced.into_validated_unchecked().into();
1698
1699        // Should support both per-file-flavor and per_file_flavor
1700        assert_eq!(config.per_file_flavor.len(), 1);
1701        assert_eq!(
1702            config.per_file_flavor.get("docs/**/*.md"),
1703            Some(&MarkdownFlavor::MkDocs)
1704        );
1705    }
1706
1707    #[test]
1708    fn test_per_file_flavor_absolute_path_matching() {
1709        use std::path::PathBuf;
1710
1711        let temp_dir = tempdir().unwrap();
1712        let config_path = temp_dir.path().join(".rumdl.toml");
1713
1714        // Create a subdirectory and file to match against
1715        let docs_dir = temp_dir.path().join("docs");
1716        fs::create_dir_all(&docs_dir).unwrap();
1717        let test_file = docs_dir.join("guide.md");
1718        fs::write(&test_file, "Test content").unwrap();
1719
1720        let config_content = r#"
1721[per-file-flavor]
1722"docs/**/*.md" = "mkdocs"
1723"#;
1724        fs::write(&config_path, config_content).unwrap();
1725
1726        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1727        let config: Config = sourced.into_validated_unchecked().into();
1728
1729        // Test with absolute path
1730        let absolute_path = test_file.canonicalize().unwrap();
1731        let flavor = config.get_flavor_for_file(&absolute_path);
1732        assert_eq!(
1733            flavor,
1734            MarkdownFlavor::MkDocs,
1735            "Should match absolute path {absolute_path:?} against relative pattern"
1736        );
1737
1738        // Also verify relative path still works
1739        let relative_path = PathBuf::from("docs/guide.md");
1740        let flavor = config.get_flavor_for_file(&relative_path);
1741        assert_eq!(flavor, MarkdownFlavor::MkDocs, "Should match relative path");
1742    }
1743
1744    #[test]
1745    fn test_per_file_flavor_all_flavors() {
1746        let temp_dir = tempdir().unwrap();
1747        let config_path = temp_dir.path().join(".rumdl.toml");
1748        let config_content = r#"
1749[per-file-flavor]
1750"standard/**/*.md" = "standard"
1751"mkdocs/**/*.md" = "mkdocs"
1752"mdx/**/*.md" = "mdx"
1753"quarto/**/*.md" = "quarto"
1754"#;
1755        fs::write(&config_path, config_content).unwrap();
1756
1757        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1758        let config: Config = sourced.into_validated_unchecked().into();
1759
1760        // All four flavors should be loadable
1761        assert_eq!(config.per_file_flavor.len(), 4);
1762        assert_eq!(
1763            config.per_file_flavor.get("standard/**/*.md"),
1764            Some(&MarkdownFlavor::Standard)
1765        );
1766        assert_eq!(
1767            config.per_file_flavor.get("mkdocs/**/*.md"),
1768            Some(&MarkdownFlavor::MkDocs)
1769        );
1770        assert_eq!(config.per_file_flavor.get("mdx/**/*.md"), Some(&MarkdownFlavor::MDX));
1771        assert_eq!(
1772            config.per_file_flavor.get("quarto/**/*.md"),
1773            Some(&MarkdownFlavor::Quarto)
1774        );
1775    }
1776
1777    #[test]
1778    fn test_per_file_flavor_invalid_glob_pattern() {
1779        use std::path::PathBuf;
1780
1781        let temp_dir = tempdir().unwrap();
1782        let config_path = temp_dir.path().join(".rumdl.toml");
1783        // Include an invalid glob pattern with unclosed bracket
1784        let config_content = r#"
1785[per-file-flavor]
1786"[invalid" = "mkdocs"
1787"valid/**/*.md" = "mdx"
1788"#;
1789        fs::write(&config_path, config_content).unwrap();
1790
1791        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1792        let config: Config = sourced.into_validated_unchecked().into();
1793
1794        // Invalid pattern should be skipped, valid pattern should still work
1795        let flavor = config.get_flavor_for_file(&PathBuf::from("valid/test.md"));
1796        assert_eq!(flavor, MarkdownFlavor::MDX);
1797
1798        // Non-matching should fall back to Standard
1799        let flavor = config.get_flavor_for_file(&PathBuf::from("other/test.md"));
1800        assert_eq!(flavor, MarkdownFlavor::Standard);
1801    }
1802
1803    #[test]
1804    fn test_per_file_flavor_paths_with_spaces() {
1805        use std::path::PathBuf;
1806
1807        let temp_dir = tempdir().unwrap();
1808        let config_path = temp_dir.path().join(".rumdl.toml");
1809        let config_content = r#"
1810[per-file-flavor]
1811"my docs/**/*.md" = "mkdocs"
1812"src/**/*.md" = "mdx"
1813"#;
1814        fs::write(&config_path, config_content).unwrap();
1815
1816        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1817        let config: Config = sourced.into_validated_unchecked().into();
1818
1819        // Paths with spaces should match
1820        let flavor = config.get_flavor_for_file(&PathBuf::from("my docs/guide.md"));
1821        assert_eq!(flavor, MarkdownFlavor::MkDocs);
1822
1823        // Regular path
1824        let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
1825        assert_eq!(flavor, MarkdownFlavor::MDX);
1826    }
1827
1828    #[test]
1829    fn test_per_file_flavor_deeply_nested_paths() {
1830        use std::path::PathBuf;
1831
1832        let temp_dir = tempdir().unwrap();
1833        let config_path = temp_dir.path().join(".rumdl.toml");
1834        let config_content = r#"
1835[per-file-flavor]
1836"a/b/c/d/e/**/*.md" = "quarto"
1837"a/b/**/*.md" = "mkdocs"
1838"**/*.md" = "standard"
1839"#;
1840        fs::write(&config_path, config_content).unwrap();
1841
1842        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1843        let config: Config = sourced.into_validated_unchecked().into();
1844
1845        // 5-level deep path should match most specific pattern first
1846        let flavor = config.get_flavor_for_file(&PathBuf::from("a/b/c/d/e/f/deep.md"));
1847        assert_eq!(flavor, MarkdownFlavor::Quarto);
1848
1849        // 3-level deep path
1850        let flavor = config.get_flavor_for_file(&PathBuf::from("a/b/c/test.md"));
1851        assert_eq!(flavor, MarkdownFlavor::MkDocs);
1852
1853        // Root level
1854        let flavor = config.get_flavor_for_file(&PathBuf::from("root.md"));
1855        assert_eq!(flavor, MarkdownFlavor::Standard);
1856    }
1857
1858    #[test]
1859    fn test_per_file_flavor_complex_overlapping_patterns() {
1860        use std::path::PathBuf;
1861
1862        let temp_dir = tempdir().unwrap();
1863        let config_path = temp_dir.path().join(".rumdl.toml");
1864        // Complex pattern order testing - tests that IndexMap preserves TOML order
1865        let config_content = r#"
1866[per-file-flavor]
1867"docs/api/*.md" = "mkdocs"
1868"docs/**/*.mdx" = "mdx"
1869"docs/**/*.md" = "quarto"
1870"**/*.md" = "standard"
1871"#;
1872        fs::write(&config_path, config_content).unwrap();
1873
1874        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1875        let config: Config = sourced.into_validated_unchecked().into();
1876
1877        // docs/api/*.md should match first
1878        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/reference.md"));
1879        assert_eq!(flavor, MarkdownFlavor::MkDocs);
1880
1881        // docs/api/nested/file.md should NOT match docs/api/*.md (no **), but match docs/**/*.md
1882        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/nested/file.md"));
1883        assert_eq!(flavor, MarkdownFlavor::Quarto);
1884
1885        // .mdx in docs should match docs/**/*.mdx
1886        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/components/Button.mdx"));
1887        assert_eq!(flavor, MarkdownFlavor::MDX);
1888
1889        // .md outside docs should match **/*.md
1890        let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
1891        assert_eq!(flavor, MarkdownFlavor::Standard);
1892    }
1893
1894    #[test]
1895    fn test_per_file_flavor_extension_detection_interaction() {
1896        use std::path::PathBuf;
1897
1898        let temp_dir = tempdir().unwrap();
1899        let config_path = temp_dir.path().join(".rumdl.toml");
1900        // Test that per-file-flavor pattern can override extension-based auto-detection
1901        let config_content = r#"
1902[per-file-flavor]
1903"legacy/**/*.mdx" = "standard"
1904"#;
1905        fs::write(&config_path, config_content).unwrap();
1906
1907        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1908        let config: Config = sourced.into_validated_unchecked().into();
1909
1910        // .mdx file in legacy dir should use pattern override (standard), not auto-detect (mdx)
1911        let flavor = config.get_flavor_for_file(&PathBuf::from("legacy/old.mdx"));
1912        assert_eq!(flavor, MarkdownFlavor::Standard);
1913
1914        // .mdx file elsewhere should auto-detect as MDX
1915        let flavor = config.get_flavor_for_file(&PathBuf::from("src/component.mdx"));
1916        assert_eq!(flavor, MarkdownFlavor::MDX);
1917    }
1918
1919    #[test]
1920    fn test_per_file_flavor_standard_alias_none() {
1921        use std::path::PathBuf;
1922
1923        let temp_dir = tempdir().unwrap();
1924        let config_path = temp_dir.path().join(".rumdl.toml");
1925        // Test that "none" works as alias for "standard"
1926        let config_content = r#"
1927[per-file-flavor]
1928"plain/**/*.md" = "none"
1929"#;
1930        fs::write(&config_path, config_content).unwrap();
1931
1932        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1933        let config: Config = sourced.into_validated_unchecked().into();
1934
1935        // "none" should resolve to Standard
1936        let flavor = config.get_flavor_for_file(&PathBuf::from("plain/test.md"));
1937        assert_eq!(flavor, MarkdownFlavor::Standard);
1938    }
1939
1940    #[test]
1941    fn test_per_file_flavor_brace_expansion() {
1942        use std::path::PathBuf;
1943
1944        let temp_dir = tempdir().unwrap();
1945        let config_path = temp_dir.path().join(".rumdl.toml");
1946        // Test brace expansion in glob patterns
1947        let config_content = r#"
1948[per-file-flavor]
1949"docs/**/*.{md,mdx}" = "mkdocs"
1950"#;
1951        fs::write(&config_path, config_content).unwrap();
1952
1953        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1954        let config: Config = sourced.into_validated_unchecked().into();
1955
1956        // Should match .md files
1957        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/guide.md"));
1958        assert_eq!(flavor, MarkdownFlavor::MkDocs);
1959
1960        // Should match .mdx files
1961        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/component.mdx"));
1962        assert_eq!(flavor, MarkdownFlavor::MkDocs);
1963    }
1964
1965    #[test]
1966    fn test_per_file_flavor_single_star_vs_double_star() {
1967        use std::path::PathBuf;
1968
1969        let temp_dir = tempdir().unwrap();
1970        let config_path = temp_dir.path().join(".rumdl.toml");
1971        // Test difference between * (single level) and ** (recursive)
1972        let config_content = r#"
1973[per-file-flavor]
1974"docs/*.md" = "mkdocs"
1975"src/**/*.md" = "mdx"
1976"#;
1977        fs::write(&config_path, config_content).unwrap();
1978
1979        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
1980        let config: Config = sourced.into_validated_unchecked().into();
1981
1982        // Single * matches only direct children
1983        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/README.md"));
1984        assert_eq!(flavor, MarkdownFlavor::MkDocs);
1985
1986        // Single * does NOT match nested files
1987        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/api/index.md"));
1988        assert_eq!(flavor, MarkdownFlavor::Standard); // fallback
1989
1990        // Double ** matches recursively
1991        let flavor = config.get_flavor_for_file(&PathBuf::from("src/components/Button.md"));
1992        assert_eq!(flavor, MarkdownFlavor::MDX);
1993
1994        let flavor = config.get_flavor_for_file(&PathBuf::from("src/README.md"));
1995        assert_eq!(flavor, MarkdownFlavor::MDX);
1996    }
1997
1998    #[test]
1999    fn test_per_file_flavor_question_mark_wildcard() {
2000        use std::path::PathBuf;
2001
2002        let temp_dir = tempdir().unwrap();
2003        let config_path = temp_dir.path().join(".rumdl.toml");
2004        // Test ? wildcard (matches single character)
2005        let config_content = r#"
2006[per-file-flavor]
2007"docs/v?.md" = "mkdocs"
2008"#;
2009        fs::write(&config_path, config_content).unwrap();
2010
2011        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2012        let config: Config = sourced.into_validated_unchecked().into();
2013
2014        // ? matches single character
2015        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v1.md"));
2016        assert_eq!(flavor, MarkdownFlavor::MkDocs);
2017
2018        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v2.md"));
2019        assert_eq!(flavor, MarkdownFlavor::MkDocs);
2020
2021        // ? does NOT match multiple characters
2022        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v10.md"));
2023        assert_eq!(flavor, MarkdownFlavor::Standard);
2024
2025        // ? does NOT match zero characters
2026        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/v.md"));
2027        assert_eq!(flavor, MarkdownFlavor::Standard);
2028    }
2029
2030    #[test]
2031    fn test_per_file_flavor_character_class() {
2032        use std::path::PathBuf;
2033
2034        let temp_dir = tempdir().unwrap();
2035        let config_path = temp_dir.path().join(".rumdl.toml");
2036        // Test character class [abc]
2037        let config_content = r#"
2038[per-file-flavor]
2039"docs/[abc].md" = "mkdocs"
2040"#;
2041        fs::write(&config_path, config_content).unwrap();
2042
2043        let sourced = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true).unwrap();
2044        let config: Config = sourced.into_validated_unchecked().into();
2045
2046        // Should match a, b, or c
2047        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/a.md"));
2048        assert_eq!(flavor, MarkdownFlavor::MkDocs);
2049
2050        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/b.md"));
2051        assert_eq!(flavor, MarkdownFlavor::MkDocs);
2052
2053        // Should NOT match d
2054        let flavor = config.get_flavor_for_file(&PathBuf::from("docs/d.md"));
2055        assert_eq!(flavor, MarkdownFlavor::Standard);
2056    }
2057
2058    #[test]
2059    fn test_generate_json_schema() {
2060        use schemars::schema_for;
2061        use std::env;
2062
2063        let schema = schema_for!(Config);
2064        let schema_json = serde_json::to_string_pretty(&schema).expect("Failed to serialize schema");
2065
2066        // Write schema to file if RUMDL_UPDATE_SCHEMA env var is set
2067        if env::var("RUMDL_UPDATE_SCHEMA").is_ok() {
2068            let schema_path = env::current_dir().unwrap().join("rumdl.schema.json");
2069            fs::write(&schema_path, &schema_json).expect("Failed to write schema file");
2070            println!("Schema written to: {}", schema_path.display());
2071        }
2072
2073        // Basic validation that schema was generated
2074        assert!(schema_json.contains("\"title\": \"Config\""));
2075        assert!(schema_json.contains("\"global\""));
2076        assert!(schema_json.contains("\"per-file-ignores\""));
2077    }
2078
2079    #[test]
2080    fn test_project_config_is_standalone() {
2081        // Ruff model: Project config is standalone, user config is NOT merged
2082        // This ensures reproducibility across machines and CI/local consistency
2083        let temp_dir = tempdir().unwrap();
2084
2085        // Create a fake user config directory
2086        // Note: user_configuration_path_impl adds /rumdl to the config dir
2087        let user_config_dir = temp_dir.path().join("user_config");
2088        let rumdl_config_dir = user_config_dir.join("rumdl");
2089        fs::create_dir_all(&rumdl_config_dir).unwrap();
2090        let user_config_path = rumdl_config_dir.join("rumdl.toml");
2091
2092        // User config disables MD013 and MD041
2093        let user_config_content = r#"
2094[global]
2095disable = ["MD013", "MD041"]
2096line-length = 100
2097"#;
2098        fs::write(&user_config_path, user_config_content).unwrap();
2099
2100        // Create a project config that enables MD001
2101        let project_config_path = temp_dir.path().join("project").join("pyproject.toml");
2102        fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
2103        let project_config_content = r#"
2104[tool.rumdl]
2105enable = ["MD001"]
2106"#;
2107        fs::write(&project_config_path, project_config_content).unwrap();
2108
2109        // Load config with explicit project path, passing user_config_dir
2110        let sourced = SourcedConfig::load_with_discovery_impl(
2111            Some(project_config_path.to_str().unwrap()),
2112            None,
2113            false,
2114            Some(&user_config_dir),
2115        )
2116        .unwrap();
2117
2118        let config: Config = sourced.into_validated_unchecked().into();
2119
2120        // User config settings should NOT be present (Ruff model: project is standalone)
2121        assert!(
2122            !config.global.disable.contains(&"MD013".to_string()),
2123            "User config should NOT be merged with project config"
2124        );
2125        assert!(
2126            !config.global.disable.contains(&"MD041".to_string()),
2127            "User config should NOT be merged with project config"
2128        );
2129
2130        // Project config settings should be applied
2131        assert!(
2132            config.global.enable.contains(&"MD001".to_string()),
2133            "Project config enabled rules should be applied"
2134        );
2135    }
2136
2137    #[test]
2138    fn test_user_config_as_fallback_when_no_project_config() {
2139        // Ruff model: User config is used as fallback when no project config exists
2140        use std::env;
2141
2142        let temp_dir = tempdir().unwrap();
2143        let original_dir = env::current_dir().unwrap();
2144
2145        // Create a fake user config directory
2146        let user_config_dir = temp_dir.path().join("user_config");
2147        let rumdl_config_dir = user_config_dir.join("rumdl");
2148        fs::create_dir_all(&rumdl_config_dir).unwrap();
2149        let user_config_path = rumdl_config_dir.join("rumdl.toml");
2150
2151        // User config with specific settings
2152        let user_config_content = r#"
2153[global]
2154disable = ["MD013", "MD041"]
2155line-length = 88
2156"#;
2157        fs::write(&user_config_path, user_config_content).unwrap();
2158
2159        // Create a project directory WITHOUT any config
2160        let project_dir = temp_dir.path().join("project_no_config");
2161        fs::create_dir_all(&project_dir).unwrap();
2162
2163        // Change to project directory
2164        env::set_current_dir(&project_dir).unwrap();
2165
2166        // Load config - should use user config as fallback
2167        let sourced = SourcedConfig::load_with_discovery_impl(None, None, false, Some(&user_config_dir)).unwrap();
2168
2169        let config: Config = sourced.into_validated_unchecked().into();
2170
2171        // User config should be loaded as fallback
2172        assert!(
2173            config.global.disable.contains(&"MD013".to_string()),
2174            "User config should be loaded as fallback when no project config"
2175        );
2176        assert!(
2177            config.global.disable.contains(&"MD041".to_string()),
2178            "User config should be loaded as fallback when no project config"
2179        );
2180        assert_eq!(
2181            config.global.line_length.get(),
2182            88,
2183            "User config line-length should be loaded as fallback"
2184        );
2185
2186        env::set_current_dir(original_dir).unwrap();
2187    }
2188
2189    #[test]
2190    fn test_typestate_validate_method() {
2191        use tempfile::tempdir;
2192
2193        let temp_dir = tempdir().expect("Failed to create temporary directory");
2194        let config_path = temp_dir.path().join("test.toml");
2195
2196        // Create config with an unknown rule option to trigger a validation warning
2197        let config_content = r#"
2198[global]
2199enable = ["MD001"]
2200
2201[MD013]
2202line_length = 80
2203unknown_option = true
2204"#;
2205        std::fs::write(&config_path, config_content).expect("Failed to write config");
2206
2207        // Load config - this returns SourcedConfig<ConfigLoaded>
2208        let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
2209            .expect("Should load config");
2210
2211        // Create a rule registry for validation
2212        let default_config = Config::default();
2213        let all_rules = crate::rules::all_rules(&default_config);
2214        let registry = RuleRegistry::from_rules(&all_rules);
2215
2216        // Validate - this transitions to SourcedConfig<ConfigValidated>
2217        let validated = loaded.validate(&registry).expect("Should validate config");
2218
2219        // Check that validation warnings were captured for the unknown option
2220        // Note: The validation checks rule options against the rule's schema
2221        let has_unknown_option_warning = validated
2222            .validation_warnings
2223            .iter()
2224            .any(|w| w.message.contains("unknown_option") || w.message.contains("Unknown option"));
2225
2226        // Print warnings for debugging if assertion fails
2227        if !has_unknown_option_warning {
2228            for w in &validated.validation_warnings {
2229                eprintln!("Warning: {}", w.message);
2230            }
2231        }
2232        assert!(
2233            has_unknown_option_warning,
2234            "Should have warning for unknown option. Got {} warnings: {:?}",
2235            validated.validation_warnings.len(),
2236            validated
2237                .validation_warnings
2238                .iter()
2239                .map(|w| &w.message)
2240                .collect::<Vec<_>>()
2241        );
2242
2243        // Now we can convert to Config (this would be a compile error with ConfigLoaded)
2244        let config: Config = validated.into();
2245
2246        // Verify the config values are correct
2247        assert!(config.global.enable.contains(&"MD001".to_string()));
2248    }
2249
2250    #[test]
2251    fn test_typestate_validate_into_convenience_method() {
2252        use tempfile::tempdir;
2253
2254        let temp_dir = tempdir().expect("Failed to create temporary directory");
2255        let config_path = temp_dir.path().join("test.toml");
2256
2257        let config_content = r#"
2258[global]
2259enable = ["MD022"]
2260
2261[MD022]
2262lines_above = 2
2263"#;
2264        std::fs::write(&config_path, config_content).expect("Failed to write config");
2265
2266        let loaded = SourcedConfig::load_with_discovery(Some(config_path.to_str().unwrap()), None, true)
2267            .expect("Should load config");
2268
2269        let default_config = Config::default();
2270        let all_rules = crate::rules::all_rules(&default_config);
2271        let registry = RuleRegistry::from_rules(&all_rules);
2272
2273        // Use the convenience method that validates and converts in one step
2274        let (config, warnings) = loaded.validate_into(&registry).expect("Should validate and convert");
2275
2276        // Should have no warnings for valid config
2277        assert!(warnings.is_empty(), "Should have no warnings for valid config");
2278
2279        // Config should be usable
2280        assert!(config.global.enable.contains(&"MD022".to_string()));
2281    }
2282
2283    #[test]
2284    fn test_resolve_rule_name_canonical() {
2285        // Canonical IDs should resolve to themselves
2286        assert_eq!(resolve_rule_name("MD001"), "MD001");
2287        assert_eq!(resolve_rule_name("MD013"), "MD013");
2288        assert_eq!(resolve_rule_name("MD069"), "MD069");
2289    }
2290
2291    #[test]
2292    fn test_resolve_rule_name_aliases() {
2293        // Aliases should resolve to canonical IDs
2294        assert_eq!(resolve_rule_name("heading-increment"), "MD001");
2295        assert_eq!(resolve_rule_name("line-length"), "MD013");
2296        assert_eq!(resolve_rule_name("no-bare-urls"), "MD034");
2297        assert_eq!(resolve_rule_name("ul-style"), "MD004");
2298    }
2299
2300    #[test]
2301    fn test_resolve_rule_name_case_insensitive() {
2302        // Case should not matter
2303        assert_eq!(resolve_rule_name("HEADING-INCREMENT"), "MD001");
2304        assert_eq!(resolve_rule_name("Heading-Increment"), "MD001");
2305        assert_eq!(resolve_rule_name("md001"), "MD001");
2306        assert_eq!(resolve_rule_name("MD001"), "MD001");
2307    }
2308
2309    #[test]
2310    fn test_resolve_rule_name_underscore_to_hyphen() {
2311        // Underscores should be converted to hyphens
2312        assert_eq!(resolve_rule_name("heading_increment"), "MD001");
2313        assert_eq!(resolve_rule_name("line_length"), "MD013");
2314        assert_eq!(resolve_rule_name("no_bare_urls"), "MD034");
2315    }
2316
2317    #[test]
2318    fn test_resolve_rule_name_unknown() {
2319        // Unknown names should fall back to normalization
2320        assert_eq!(resolve_rule_name("custom-rule"), "custom-rule");
2321        assert_eq!(resolve_rule_name("CUSTOM_RULE"), "custom-rule");
2322        assert_eq!(resolve_rule_name("md999"), "MD999"); // Looks like an MD rule
2323    }
2324
2325    #[test]
2326    fn test_resolve_rule_names_basic() {
2327        let result = resolve_rule_names("MD001,line-length,heading-increment");
2328        assert!(result.contains("MD001"));
2329        assert!(result.contains("MD013")); // line-length
2330        // Note: heading-increment also resolves to MD001, so set should contain MD001 and MD013
2331        assert_eq!(result.len(), 2);
2332    }
2333
2334    #[test]
2335    fn test_resolve_rule_names_with_whitespace() {
2336        let result = resolve_rule_names("  MD001 , line-length , MD034  ");
2337        assert!(result.contains("MD001"));
2338        assert!(result.contains("MD013"));
2339        assert!(result.contains("MD034"));
2340        assert_eq!(result.len(), 3);
2341    }
2342
2343    #[test]
2344    fn test_resolve_rule_names_empty_entries() {
2345        let result = resolve_rule_names("MD001,,MD013,");
2346        assert!(result.contains("MD001"));
2347        assert!(result.contains("MD013"));
2348        assert_eq!(result.len(), 2);
2349    }
2350
2351    #[test]
2352    fn test_resolve_rule_names_empty_string() {
2353        let result = resolve_rule_names("");
2354        assert!(result.is_empty());
2355    }
2356
2357    #[test]
2358    fn test_resolve_rule_names_mixed() {
2359        // Mix of canonical IDs, aliases, and unknown
2360        let result = resolve_rule_names("MD001,line-length,custom-rule");
2361        assert!(result.contains("MD001"));
2362        assert!(result.contains("MD013"));
2363        assert!(result.contains("custom-rule"));
2364        assert_eq!(result.len(), 3);
2365    }
2366
2367    // =========================================================================
2368    // Unit tests for is_valid_rule_name() and validate_cli_rule_names()
2369    // =========================================================================
2370
2371    #[test]
2372    fn test_is_valid_rule_name_canonical() {
2373        // Valid canonical rule IDs
2374        assert!(is_valid_rule_name("MD001"));
2375        assert!(is_valid_rule_name("MD013"));
2376        assert!(is_valid_rule_name("MD041"));
2377        assert!(is_valid_rule_name("MD069"));
2378
2379        // Case insensitive
2380        assert!(is_valid_rule_name("md001"));
2381        assert!(is_valid_rule_name("Md001"));
2382        assert!(is_valid_rule_name("mD001"));
2383    }
2384
2385    #[test]
2386    fn test_is_valid_rule_name_aliases() {
2387        // Valid aliases
2388        assert!(is_valid_rule_name("line-length"));
2389        assert!(is_valid_rule_name("heading-increment"));
2390        assert!(is_valid_rule_name("no-bare-urls"));
2391        assert!(is_valid_rule_name("ul-style"));
2392
2393        // Case insensitive
2394        assert!(is_valid_rule_name("LINE-LENGTH"));
2395        assert!(is_valid_rule_name("Line-Length"));
2396
2397        // Underscore variant
2398        assert!(is_valid_rule_name("line_length"));
2399        assert!(is_valid_rule_name("ul_style"));
2400    }
2401
2402    #[test]
2403    fn test_is_valid_rule_name_special_all() {
2404        assert!(is_valid_rule_name("all"));
2405        assert!(is_valid_rule_name("ALL"));
2406        assert!(is_valid_rule_name("All"));
2407        assert!(is_valid_rule_name("aLl"));
2408    }
2409
2410    #[test]
2411    fn test_is_valid_rule_name_invalid() {
2412        // Non-existent rules
2413        assert!(!is_valid_rule_name("MD000"));
2414        assert!(!is_valid_rule_name("MD002")); // gap in numbering
2415        assert!(!is_valid_rule_name("MD006")); // gap in numbering
2416        assert!(!is_valid_rule_name("MD999"));
2417        assert!(!is_valid_rule_name("MD100"));
2418
2419        // Invalid formats
2420        assert!(!is_valid_rule_name(""));
2421        assert!(!is_valid_rule_name("INVALID"));
2422        assert!(!is_valid_rule_name("not-a-rule"));
2423        assert!(!is_valid_rule_name("random-text"));
2424        assert!(!is_valid_rule_name("abc"));
2425
2426        // Edge cases
2427        assert!(!is_valid_rule_name("MD"));
2428        assert!(!is_valid_rule_name("MD1"));
2429        assert!(!is_valid_rule_name("MD12"));
2430    }
2431
2432    #[test]
2433    fn test_validate_cli_rule_names_valid() {
2434        // All valid - should return no warnings
2435        let warnings = validate_cli_rule_names(
2436            Some("MD001,MD013"),
2437            Some("line-length"),
2438            Some("heading-increment"),
2439            Some("all"),
2440        );
2441        assert!(warnings.is_empty(), "Expected no warnings for valid rules");
2442    }
2443
2444    #[test]
2445    fn test_validate_cli_rule_names_invalid() {
2446        // Invalid rule in --enable
2447        let warnings = validate_cli_rule_names(Some("abc"), None, None, None);
2448        assert_eq!(warnings.len(), 1);
2449        assert!(warnings[0].message.contains("Unknown rule in --enable: abc"));
2450
2451        // Invalid rule in --disable
2452        let warnings = validate_cli_rule_names(None, Some("xyz"), None, None);
2453        assert_eq!(warnings.len(), 1);
2454        assert!(warnings[0].message.contains("Unknown rule in --disable: xyz"));
2455
2456        // Invalid rule in --extend-enable
2457        let warnings = validate_cli_rule_names(None, None, Some("nonexistent"), None);
2458        assert_eq!(warnings.len(), 1);
2459        assert!(
2460            warnings[0]
2461                .message
2462                .contains("Unknown rule in --extend-enable: nonexistent")
2463        );
2464
2465        // Invalid rule in --extend-disable
2466        let warnings = validate_cli_rule_names(None, None, None, Some("fake-rule"));
2467        assert_eq!(warnings.len(), 1);
2468        assert!(
2469            warnings[0]
2470                .message
2471                .contains("Unknown rule in --extend-disable: fake-rule")
2472        );
2473    }
2474
2475    #[test]
2476    fn test_validate_cli_rule_names_mixed() {
2477        // Mix of valid and invalid
2478        let warnings = validate_cli_rule_names(Some("MD001,abc,MD003"), None, None, None);
2479        assert_eq!(warnings.len(), 1);
2480        assert!(warnings[0].message.contains("abc"));
2481    }
2482
2483    #[test]
2484    fn test_validate_cli_rule_names_suggestions() {
2485        // Typo should suggest correction
2486        let warnings = validate_cli_rule_names(Some("line-lenght"), None, None, None);
2487        assert_eq!(warnings.len(), 1);
2488        assert!(warnings[0].message.contains("did you mean"));
2489        assert!(warnings[0].message.contains("line-length"));
2490    }
2491
2492    #[test]
2493    fn test_validate_cli_rule_names_none() {
2494        // All None - should return no warnings
2495        let warnings = validate_cli_rule_names(None, None, None, None);
2496        assert!(warnings.is_empty());
2497    }
2498
2499    #[test]
2500    fn test_validate_cli_rule_names_empty_string() {
2501        // Empty strings should produce no warnings
2502        let warnings = validate_cli_rule_names(Some(""), Some(""), Some(""), Some(""));
2503        assert!(warnings.is_empty());
2504    }
2505
2506    #[test]
2507    fn test_validate_cli_rule_names_whitespace() {
2508        // Whitespace handling
2509        let warnings = validate_cli_rule_names(Some("  MD001  ,  MD013  "), None, None, None);
2510        assert!(warnings.is_empty(), "Whitespace should be trimmed");
2511    }
2512
2513    #[test]
2514    fn test_all_implemented_rules_have_aliases() {
2515        // This test ensures we don't forget to add aliases when adding new rules.
2516        // If this test fails, add the missing rule to RULE_ALIAS_MAP in config.rs
2517        // with both the canonical entry (e.g., "MD071" => "MD071") and an alias
2518        // (e.g., "BLANK-LINE-AFTER-FRONTMATTER" => "MD071").
2519
2520        // Get all implemented rules from the rules module
2521        let config = crate::config::Config::default();
2522        let all_rules = crate::rules::all_rules(&config);
2523
2524        let mut missing_rules = Vec::new();
2525        for rule in &all_rules {
2526            let rule_name = rule.name();
2527            // Check if the canonical entry exists in RULE_ALIAS_MAP
2528            if resolve_rule_name_alias(rule_name).is_none() {
2529                missing_rules.push(rule_name.to_string());
2530            }
2531        }
2532
2533        assert!(
2534            missing_rules.is_empty(),
2535            "The following rules are missing from RULE_ALIAS_MAP: {:?}\n\
2536             Add entries like:\n\
2537             - Canonical: \"{}\" => \"{}\"\n\
2538             - Alias: \"RULE-NAME-HERE\" => \"{}\"",
2539            missing_rules,
2540            missing_rules.first().unwrap_or(&"MDxxx".to_string()),
2541            missing_rules.first().unwrap_or(&"MDxxx".to_string()),
2542            missing_rules.first().unwrap_or(&"MDxxx".to_string()),
2543        );
2544    }
2545
2546    // ==================== to_relative_display_path Tests ====================
2547
2548    #[test]
2549    fn test_relative_path_in_cwd() {
2550        // Create a temp file in the current directory
2551        let cwd = std::env::current_dir().unwrap();
2552        let test_path = cwd.join("test_file.md");
2553        fs::write(&test_path, "test").unwrap();
2554
2555        let result = super::to_relative_display_path(test_path.to_str().unwrap());
2556
2557        // Should be relative (just the filename)
2558        assert_eq!(result, "test_file.md");
2559
2560        // Cleanup
2561        fs::remove_file(&test_path).unwrap();
2562    }
2563
2564    #[test]
2565    fn test_relative_path_in_subdirectory() {
2566        // Create a temp file in a subdirectory
2567        let cwd = std::env::current_dir().unwrap();
2568        let subdir = cwd.join("test_subdir_for_relative_path");
2569        fs::create_dir_all(&subdir).unwrap();
2570        let test_path = subdir.join("test_file.md");
2571        fs::write(&test_path, "test").unwrap();
2572
2573        let result = super::to_relative_display_path(test_path.to_str().unwrap());
2574
2575        // Should be relative path with subdirectory
2576        assert_eq!(result, "test_subdir_for_relative_path/test_file.md");
2577
2578        // Cleanup
2579        fs::remove_file(&test_path).unwrap();
2580        fs::remove_dir(&subdir).unwrap();
2581    }
2582
2583    #[test]
2584    fn test_relative_path_outside_cwd_returns_original() {
2585        // Use a path that's definitely outside CWD (root level)
2586        let outside_path = "/tmp/definitely_not_in_cwd_test.md";
2587
2588        let result = super::to_relative_display_path(outside_path);
2589
2590        // Can't make relative to CWD, should return original
2591        // (unless CWD happens to be /tmp, which is unlikely in tests)
2592        let cwd = std::env::current_dir().unwrap();
2593        if !cwd.starts_with("/tmp") {
2594            assert_eq!(result, outside_path);
2595        }
2596    }
2597
2598    #[test]
2599    fn test_relative_path_already_relative() {
2600        // Already relative path that doesn't exist
2601        let relative_path = "some/relative/path.md";
2602
2603        let result = super::to_relative_display_path(relative_path);
2604
2605        // Should return original since it can't be canonicalized
2606        assert_eq!(result, relative_path);
2607    }
2608
2609    #[test]
2610    fn test_relative_path_with_dot_components() {
2611        // Path with . and .. components
2612        let cwd = std::env::current_dir().unwrap();
2613        let test_path = cwd.join("test_dot_component.md");
2614        fs::write(&test_path, "test").unwrap();
2615
2616        // Create path with redundant ./
2617        let dotted_path = cwd.join(".").join("test_dot_component.md");
2618        let result = super::to_relative_display_path(dotted_path.to_str().unwrap());
2619
2620        // Should resolve to clean relative path
2621        assert_eq!(result, "test_dot_component.md");
2622
2623        // Cleanup
2624        fs::remove_file(&test_path).unwrap();
2625    }
2626
2627    #[test]
2628    fn test_relative_path_empty_string() {
2629        let result = super::to_relative_display_path("");
2630
2631        // Empty string should return empty string
2632        assert_eq!(result, "");
2633    }
2634}
2635
2636/// Configuration source with clear precedence hierarchy.
2637///
2638/// Precedence order (lower values override higher values):
2639/// - Default (0): Built-in defaults
2640/// - UserConfig (1): User-level ~/.config/rumdl/rumdl.toml
2641/// - PyprojectToml (2): Project-level pyproject.toml
2642/// - ProjectConfig (3): Project-level .rumdl.toml (most specific)
2643/// - Cli (4): Command-line flags (highest priority)
2644#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2645pub enum ConfigSource {
2646    /// Built-in default configuration
2647    Default,
2648    /// User-level configuration from ~/.config/rumdl/rumdl.toml
2649    UserConfig,
2650    /// Project-level configuration from pyproject.toml
2651    PyprojectToml,
2652    /// Project-level configuration from .rumdl.toml or rumdl.toml
2653    ProjectConfig,
2654    /// Command-line flags (highest precedence)
2655    Cli,
2656}
2657
2658#[derive(Debug, Clone)]
2659pub struct ConfigOverride<T> {
2660    pub value: T,
2661    pub source: ConfigSource,
2662    pub file: Option<String>,
2663    pub line: Option<usize>,
2664}
2665
2666#[derive(Debug, Clone)]
2667pub struct SourcedValue<T> {
2668    pub value: T,
2669    pub source: ConfigSource,
2670    pub overrides: Vec<ConfigOverride<T>>,
2671}
2672
2673impl<T: Clone> SourcedValue<T> {
2674    pub fn new(value: T, source: ConfigSource) -> Self {
2675        Self {
2676            value: value.clone(),
2677            source,
2678            overrides: vec![ConfigOverride {
2679                value,
2680                source,
2681                file: None,
2682                line: None,
2683            }],
2684        }
2685    }
2686
2687    /// Merges a new override into this SourcedValue based on source precedence.
2688    /// If the new source has higher or equal precedence, the value and source are updated,
2689    /// and the new override is added to the history.
2690    pub fn merge_override(
2691        &mut self,
2692        new_value: T,
2693        new_source: ConfigSource,
2694        new_file: Option<String>,
2695        new_line: Option<usize>,
2696    ) {
2697        // Helper function to get precedence, defined locally or globally
2698        fn source_precedence(src: ConfigSource) -> u8 {
2699            match src {
2700                ConfigSource::Default => 0,
2701                ConfigSource::UserConfig => 1,
2702                ConfigSource::PyprojectToml => 2,
2703                ConfigSource::ProjectConfig => 3,
2704                ConfigSource::Cli => 4,
2705            }
2706        }
2707
2708        if source_precedence(new_source) >= source_precedence(self.source) {
2709            self.value = new_value.clone();
2710            self.source = new_source;
2711            self.overrides.push(ConfigOverride {
2712                value: new_value,
2713                source: new_source,
2714                file: new_file,
2715                line: new_line,
2716            });
2717        }
2718    }
2719
2720    pub fn push_override(&mut self, value: T, source: ConfigSource, file: Option<String>, line: Option<usize>) {
2721        // This is essentially merge_override without the precedence check
2722        // We might consolidate these later, but keep separate for now during refactor
2723        self.value = value.clone();
2724        self.source = source;
2725        self.overrides.push(ConfigOverride {
2726            value,
2727            source,
2728            file,
2729            line,
2730        });
2731    }
2732}
2733
2734impl<T: Clone + Eq + std::hash::Hash> SourcedValue<Vec<T>> {
2735    /// Merges a new value using union semantics (for arrays like `disable`)
2736    /// Values from both sources are combined, with deduplication
2737    pub fn merge_union(
2738        &mut self,
2739        new_value: Vec<T>,
2740        new_source: ConfigSource,
2741        new_file: Option<String>,
2742        new_line: Option<usize>,
2743    ) {
2744        fn source_precedence(src: ConfigSource) -> u8 {
2745            match src {
2746                ConfigSource::Default => 0,
2747                ConfigSource::UserConfig => 1,
2748                ConfigSource::PyprojectToml => 2,
2749                ConfigSource::ProjectConfig => 3,
2750                ConfigSource::Cli => 4,
2751            }
2752        }
2753
2754        if source_precedence(new_source) >= source_precedence(self.source) {
2755            // Union: combine values from both sources with deduplication
2756            let mut combined = self.value.clone();
2757            for item in new_value.iter() {
2758                if !combined.contains(item) {
2759                    combined.push(item.clone());
2760                }
2761            }
2762
2763            self.value = combined;
2764            self.source = new_source;
2765            self.overrides.push(ConfigOverride {
2766                value: new_value,
2767                source: new_source,
2768                file: new_file,
2769                line: new_line,
2770            });
2771        }
2772    }
2773}
2774
2775#[derive(Debug, Clone)]
2776pub struct SourcedGlobalConfig {
2777    pub enable: SourcedValue<Vec<String>>,
2778    pub disable: SourcedValue<Vec<String>>,
2779    pub exclude: SourcedValue<Vec<String>>,
2780    pub include: SourcedValue<Vec<String>>,
2781    pub respect_gitignore: SourcedValue<bool>,
2782    pub line_length: SourcedValue<LineLength>,
2783    pub output_format: Option<SourcedValue<String>>,
2784    pub fixable: SourcedValue<Vec<String>>,
2785    pub unfixable: SourcedValue<Vec<String>>,
2786    pub flavor: SourcedValue<MarkdownFlavor>,
2787    pub force_exclude: SourcedValue<bool>,
2788    pub cache_dir: Option<SourcedValue<String>>,
2789    pub cache: SourcedValue<bool>,
2790}
2791
2792impl Default for SourcedGlobalConfig {
2793    fn default() -> Self {
2794        SourcedGlobalConfig {
2795            enable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2796            disable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2797            exclude: SourcedValue::new(Vec::new(), ConfigSource::Default),
2798            include: SourcedValue::new(Vec::new(), ConfigSource::Default),
2799            respect_gitignore: SourcedValue::new(true, ConfigSource::Default),
2800            line_length: SourcedValue::new(LineLength::default(), ConfigSource::Default),
2801            output_format: None,
2802            fixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2803            unfixable: SourcedValue::new(Vec::new(), ConfigSource::Default),
2804            flavor: SourcedValue::new(MarkdownFlavor::default(), ConfigSource::Default),
2805            force_exclude: SourcedValue::new(false, ConfigSource::Default),
2806            cache_dir: None,
2807            cache: SourcedValue::new(true, ConfigSource::Default),
2808        }
2809    }
2810}
2811
2812#[derive(Debug, Default, Clone)]
2813pub struct SourcedRuleConfig {
2814    pub severity: Option<SourcedValue<crate::rule::Severity>>,
2815    pub values: BTreeMap<String, SourcedValue<toml::Value>>,
2816}
2817
2818/// Represents configuration loaded from a single source file, with provenance.
2819/// Used as an intermediate step before merging into the final SourcedConfig.
2820#[derive(Debug, Clone)]
2821pub struct SourcedConfigFragment {
2822    pub global: SourcedGlobalConfig,
2823    pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
2824    pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
2825    pub rules: BTreeMap<String, SourcedRuleConfig>,
2826    pub unknown_keys: Vec<(String, String, Option<String>)>, // (section, key, file_path)
2827                                                             // Note: loaded_files is tracked globally in SourcedConfig.
2828}
2829
2830impl Default for SourcedConfigFragment {
2831    fn default() -> Self {
2832        Self {
2833            global: SourcedGlobalConfig::default(),
2834            per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
2835            per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
2836            rules: BTreeMap::new(),
2837            unknown_keys: Vec::new(),
2838        }
2839    }
2840}
2841
2842/// Configuration with provenance tracking for values.
2843///
2844/// The `State` type parameter encodes the validation state:
2845/// - `ConfigLoaded`: Config has been loaded but not validated
2846/// - `ConfigValidated`: Config has been validated and can be converted to `Config`
2847///
2848/// # Typestate Pattern
2849///
2850/// This uses the typestate pattern to ensure validation happens before conversion:
2851///
2852/// ```ignore
2853/// let loaded: SourcedConfig<ConfigLoaded> = SourcedConfig::load_with_discovery(...)?;
2854/// let validated: SourcedConfig<ConfigValidated> = loaded.validate(&registry)?;
2855/// let config: Config = validated.into();  // Only works on ConfigValidated!
2856/// ```
2857///
2858/// Attempting to convert a `ConfigLoaded` config directly to `Config` is a compile error.
2859#[derive(Debug, Clone)]
2860pub struct SourcedConfig<State = ConfigLoaded> {
2861    pub global: SourcedGlobalConfig,
2862    pub per_file_ignores: SourcedValue<HashMap<String, Vec<String>>>,
2863    pub per_file_flavor: SourcedValue<IndexMap<String, MarkdownFlavor>>,
2864    pub rules: BTreeMap<String, SourcedRuleConfig>,
2865    pub loaded_files: Vec<String>,
2866    pub unknown_keys: Vec<(String, String, Option<String>)>, // (section, key, file_path)
2867    /// Project root directory (parent of config file), used for resolving relative paths
2868    pub project_root: Option<std::path::PathBuf>,
2869    /// Validation warnings (populated after validate() is called)
2870    pub validation_warnings: Vec<ConfigValidationWarning>,
2871    /// Phantom data for the state type parameter
2872    _state: PhantomData<State>,
2873}
2874
2875impl Default for SourcedConfig<ConfigLoaded> {
2876    fn default() -> Self {
2877        Self {
2878            global: SourcedGlobalConfig::default(),
2879            per_file_ignores: SourcedValue::new(HashMap::new(), ConfigSource::Default),
2880            per_file_flavor: SourcedValue::new(IndexMap::new(), ConfigSource::Default),
2881            rules: BTreeMap::new(),
2882            loaded_files: Vec::new(),
2883            unknown_keys: Vec::new(),
2884            project_root: None,
2885            validation_warnings: Vec::new(),
2886            _state: PhantomData,
2887        }
2888    }
2889}
2890
2891impl SourcedConfig<ConfigLoaded> {
2892    /// Merges another SourcedConfigFragment into this SourcedConfig.
2893    /// Uses source precedence to determine which values take effect.
2894    fn merge(&mut self, fragment: SourcedConfigFragment) {
2895        // Merge global config
2896        // Enable uses replace semantics (project can enforce rules)
2897        self.global.enable.merge_override(
2898            fragment.global.enable.value,
2899            fragment.global.enable.source,
2900            fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
2901            fragment.global.enable.overrides.first().and_then(|o| o.line),
2902        );
2903
2904        // Disable uses union semantics (user can add to project disables)
2905        self.global.disable.merge_union(
2906            fragment.global.disable.value,
2907            fragment.global.disable.source,
2908            fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
2909            fragment.global.disable.overrides.first().and_then(|o| o.line),
2910        );
2911
2912        // Conflict resolution: Enable overrides disable
2913        // Remove any rules from disable that appear in enable
2914        self.global
2915            .disable
2916            .value
2917            .retain(|rule| !self.global.enable.value.contains(rule));
2918        self.global.include.merge_override(
2919            fragment.global.include.value,
2920            fragment.global.include.source,
2921            fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
2922            fragment.global.include.overrides.first().and_then(|o| o.line),
2923        );
2924        self.global.exclude.merge_override(
2925            fragment.global.exclude.value,
2926            fragment.global.exclude.source,
2927            fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
2928            fragment.global.exclude.overrides.first().and_then(|o| o.line),
2929        );
2930        self.global.respect_gitignore.merge_override(
2931            fragment.global.respect_gitignore.value,
2932            fragment.global.respect_gitignore.source,
2933            fragment
2934                .global
2935                .respect_gitignore
2936                .overrides
2937                .first()
2938                .and_then(|o| o.file.clone()),
2939            fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
2940        );
2941        self.global.line_length.merge_override(
2942            fragment.global.line_length.value,
2943            fragment.global.line_length.source,
2944            fragment
2945                .global
2946                .line_length
2947                .overrides
2948                .first()
2949                .and_then(|o| o.file.clone()),
2950            fragment.global.line_length.overrides.first().and_then(|o| o.line),
2951        );
2952        self.global.fixable.merge_override(
2953            fragment.global.fixable.value,
2954            fragment.global.fixable.source,
2955            fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
2956            fragment.global.fixable.overrides.first().and_then(|o| o.line),
2957        );
2958        self.global.unfixable.merge_override(
2959            fragment.global.unfixable.value,
2960            fragment.global.unfixable.source,
2961            fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
2962            fragment.global.unfixable.overrides.first().and_then(|o| o.line),
2963        );
2964
2965        // Merge flavor
2966        self.global.flavor.merge_override(
2967            fragment.global.flavor.value,
2968            fragment.global.flavor.source,
2969            fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
2970            fragment.global.flavor.overrides.first().and_then(|o| o.line),
2971        );
2972
2973        // Merge force_exclude
2974        self.global.force_exclude.merge_override(
2975            fragment.global.force_exclude.value,
2976            fragment.global.force_exclude.source,
2977            fragment
2978                .global
2979                .force_exclude
2980                .overrides
2981                .first()
2982                .and_then(|o| o.file.clone()),
2983            fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
2984        );
2985
2986        // Merge output_format if present
2987        if let Some(output_format_fragment) = fragment.global.output_format {
2988            if let Some(ref mut output_format) = self.global.output_format {
2989                output_format.merge_override(
2990                    output_format_fragment.value,
2991                    output_format_fragment.source,
2992                    output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
2993                    output_format_fragment.overrides.first().and_then(|o| o.line),
2994                );
2995            } else {
2996                self.global.output_format = Some(output_format_fragment);
2997            }
2998        }
2999
3000        // Merge cache_dir if present
3001        if let Some(cache_dir_fragment) = fragment.global.cache_dir {
3002            if let Some(ref mut cache_dir) = self.global.cache_dir {
3003                cache_dir.merge_override(
3004                    cache_dir_fragment.value,
3005                    cache_dir_fragment.source,
3006                    cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
3007                    cache_dir_fragment.overrides.first().and_then(|o| o.line),
3008                );
3009            } else {
3010                self.global.cache_dir = Some(cache_dir_fragment);
3011            }
3012        }
3013
3014        // Merge cache if not default (only override when explicitly set)
3015        if fragment.global.cache.source != ConfigSource::Default {
3016            self.global.cache.merge_override(
3017                fragment.global.cache.value,
3018                fragment.global.cache.source,
3019                fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
3020                fragment.global.cache.overrides.first().and_then(|o| o.line),
3021            );
3022        }
3023
3024        // Merge per_file_ignores
3025        self.per_file_ignores.merge_override(
3026            fragment.per_file_ignores.value,
3027            fragment.per_file_ignores.source,
3028            fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
3029            fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
3030        );
3031
3032        // Merge per_file_flavor
3033        self.per_file_flavor.merge_override(
3034            fragment.per_file_flavor.value,
3035            fragment.per_file_flavor.source,
3036            fragment.per_file_flavor.overrides.first().and_then(|o| o.file.clone()),
3037            fragment.per_file_flavor.overrides.first().and_then(|o| o.line),
3038        );
3039
3040        // Merge rule configs
3041        for (rule_name, rule_fragment) in fragment.rules {
3042            let norm_rule_name = rule_name.to_ascii_uppercase(); // Normalize to uppercase for case-insensitivity
3043            let rule_entry = self.rules.entry(norm_rule_name).or_default();
3044
3045            // Merge severity if present in fragment
3046            if let Some(severity_fragment) = rule_fragment.severity {
3047                if let Some(ref mut existing_severity) = rule_entry.severity {
3048                    existing_severity.merge_override(
3049                        severity_fragment.value,
3050                        severity_fragment.source,
3051                        severity_fragment.overrides.first().and_then(|o| o.file.clone()),
3052                        severity_fragment.overrides.first().and_then(|o| o.line),
3053                    );
3054                } else {
3055                    rule_entry.severity = Some(severity_fragment);
3056                }
3057            }
3058
3059            // Merge values
3060            for (key, sourced_value_fragment) in rule_fragment.values {
3061                let sv_entry = rule_entry
3062                    .values
3063                    .entry(key.clone())
3064                    .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
3065                let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
3066                let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
3067                sv_entry.merge_override(
3068                    sourced_value_fragment.value,  // Use the value from the fragment
3069                    sourced_value_fragment.source, // Use the source from the fragment
3070                    file_from_fragment,            // Pass the file path from the fragment override
3071                    line_from_fragment,            // Pass the line number from the fragment override
3072                );
3073            }
3074        }
3075
3076        // Merge unknown_keys from fragment
3077        for (section, key, file_path) in fragment.unknown_keys {
3078            // Deduplicate: only add if not already present
3079            if !self.unknown_keys.iter().any(|(s, k, _)| s == &section && k == &key) {
3080                self.unknown_keys.push((section, key, file_path));
3081            }
3082        }
3083    }
3084
3085    /// Load and merge configurations from files and CLI overrides.
3086    pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
3087        Self::load_with_discovery(config_path, cli_overrides, false)
3088    }
3089
3090    /// Finds project root by walking up from start_dir looking for .git directory.
3091    /// Falls back to start_dir if no .git found.
3092    fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
3093        // Convert relative paths to absolute to ensure correct traversal
3094        let mut current = if start_dir.is_relative() {
3095            std::env::current_dir()
3096                .map(|cwd| cwd.join(start_dir))
3097                .unwrap_or_else(|_| start_dir.to_path_buf())
3098        } else {
3099            start_dir.to_path_buf()
3100        };
3101        const MAX_DEPTH: usize = 100;
3102
3103        for _ in 0..MAX_DEPTH {
3104            if current.join(".git").exists() {
3105                log::debug!("[rumdl-config] Found .git at: {}", current.display());
3106                return current;
3107            }
3108
3109            match current.parent() {
3110                Some(parent) => current = parent.to_path_buf(),
3111                None => break,
3112            }
3113        }
3114
3115        // No .git found, use start_dir as project root
3116        log::debug!(
3117            "[rumdl-config] No .git found, using config location as project root: {}",
3118            start_dir.display()
3119        );
3120        start_dir.to_path_buf()
3121    }
3122
3123    /// Discover configuration file by traversing up the directory tree.
3124    /// Returns the first configuration file found.
3125    /// Discovers config file and returns both the config path and project root.
3126    /// Returns: (config_file_path, project_root_path)
3127    /// Project root is the directory containing .git, or config parent as fallback.
3128    fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
3129        use std::env;
3130
3131        const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
3132        const MAX_DEPTH: usize = 100; // Prevent infinite traversal
3133
3134        let start_dir = match env::current_dir() {
3135            Ok(dir) => dir,
3136            Err(e) => {
3137                log::debug!("[rumdl-config] Failed to get current directory: {e}");
3138                return None;
3139            }
3140        };
3141
3142        let mut current_dir = start_dir.clone();
3143        let mut depth = 0;
3144        let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
3145
3146        loop {
3147            if depth >= MAX_DEPTH {
3148                log::debug!("[rumdl-config] Maximum traversal depth reached");
3149                break;
3150            }
3151
3152            log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
3153
3154            // Check for config files in order of precedence (only if not already found)
3155            if found_config.is_none() {
3156                for config_name in CONFIG_FILES {
3157                    let config_path = current_dir.join(config_name);
3158
3159                    if config_path.exists() {
3160                        // For pyproject.toml, verify it contains [tool.rumdl] section
3161                        if *config_name == "pyproject.toml" {
3162                            if let Ok(content) = std::fs::read_to_string(&config_path) {
3163                                if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
3164                                    log::debug!("[rumdl-config] Found config file: {}", config_path.display());
3165                                    // Store config, but continue looking for .git
3166                                    found_config = Some((config_path.clone(), current_dir.clone()));
3167                                    break;
3168                                }
3169                                log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
3170                                continue;
3171                            }
3172                        } else {
3173                            log::debug!("[rumdl-config] Found config file: {}", config_path.display());
3174                            // Store config, but continue looking for .git
3175                            found_config = Some((config_path.clone(), current_dir.clone()));
3176                            break;
3177                        }
3178                    }
3179                }
3180            }
3181
3182            // Check for .git directory (stop boundary)
3183            if current_dir.join(".git").exists() {
3184                log::debug!("[rumdl-config] Stopping at .git directory");
3185                break;
3186            }
3187
3188            // Move to parent directory
3189            match current_dir.parent() {
3190                Some(parent) => {
3191                    current_dir = parent.to_owned();
3192                    depth += 1;
3193                }
3194                None => {
3195                    log::debug!("[rumdl-config] Reached filesystem root");
3196                    break;
3197                }
3198            }
3199        }
3200
3201        // If config found, determine project root by walking up from config location
3202        if let Some((config_path, config_dir)) = found_config {
3203            let project_root = Self::find_project_root_from(&config_dir);
3204            return Some((config_path, project_root));
3205        }
3206
3207        None
3208    }
3209
3210    /// Discover markdownlint configuration file by traversing up the directory tree.
3211    /// Similar to discover_config_upward but for .markdownlint.yaml/json files.
3212    /// Returns the path to the config file if found.
3213    fn discover_markdownlint_config_upward() -> Option<std::path::PathBuf> {
3214        use std::env;
3215
3216        const MAX_DEPTH: usize = 100;
3217
3218        let start_dir = match env::current_dir() {
3219            Ok(dir) => dir,
3220            Err(e) => {
3221                log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
3222                return None;
3223            }
3224        };
3225
3226        let mut current_dir = start_dir.clone();
3227        let mut depth = 0;
3228
3229        loop {
3230            if depth >= MAX_DEPTH {
3231                log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
3232                break;
3233            }
3234
3235            log::debug!(
3236                "[rumdl-config] Searching for markdownlint config in: {}",
3237                current_dir.display()
3238            );
3239
3240            // Check for markdownlint config files in order of precedence
3241            for config_name in MARKDOWNLINT_CONFIG_FILES {
3242                let config_path = current_dir.join(config_name);
3243                if config_path.exists() {
3244                    log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
3245                    return Some(config_path);
3246                }
3247            }
3248
3249            // Check for .git directory (stop boundary)
3250            if current_dir.join(".git").exists() {
3251                log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
3252                break;
3253            }
3254
3255            // Move to parent directory
3256            match current_dir.parent() {
3257                Some(parent) => {
3258                    current_dir = parent.to_owned();
3259                    depth += 1;
3260                }
3261                None => {
3262                    log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
3263                    break;
3264                }
3265            }
3266        }
3267
3268        None
3269    }
3270
3271    /// Internal implementation that accepts config directory for testing
3272    fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
3273        let config_dir = config_dir.join("rumdl");
3274
3275        // Check for config files in precedence order (same as project discovery)
3276        const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
3277
3278        log::debug!(
3279            "[rumdl-config] Checking for user configuration in: {}",
3280            config_dir.display()
3281        );
3282
3283        for filename in USER_CONFIG_FILES {
3284            let config_path = config_dir.join(filename);
3285
3286            if config_path.exists() {
3287                // For pyproject.toml, verify it contains [tool.rumdl] section
3288                if *filename == "pyproject.toml" {
3289                    if let Ok(content) = std::fs::read_to_string(&config_path) {
3290                        if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
3291                            log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
3292                            return Some(config_path);
3293                        }
3294                        log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
3295                        continue;
3296                    }
3297                } else {
3298                    log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
3299                    return Some(config_path);
3300                }
3301            }
3302        }
3303
3304        log::debug!(
3305            "[rumdl-config] No user configuration found in: {}",
3306            config_dir.display()
3307        );
3308        None
3309    }
3310
3311    /// Discover user-level configuration file from platform-specific config directory.
3312    /// Returns the first configuration file found in the user config directory.
3313    #[cfg(feature = "native")]
3314    fn user_configuration_path() -> Option<std::path::PathBuf> {
3315        use etcetera::{BaseStrategy, choose_base_strategy};
3316
3317        match choose_base_strategy() {
3318            Ok(strategy) => {
3319                let config_dir = strategy.config_dir();
3320                Self::user_configuration_path_impl(&config_dir)
3321            }
3322            Err(e) => {
3323                log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
3324                None
3325            }
3326        }
3327    }
3328
3329    /// Stub for WASM builds - user config not supported
3330    #[cfg(not(feature = "native"))]
3331    fn user_configuration_path() -> Option<std::path::PathBuf> {
3332        None
3333    }
3334
3335    /// Load an explicit config file (standalone, no user config merging)
3336    fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
3337        let path_obj = Path::new(path);
3338        let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
3339        let path_str = path.to_string();
3340
3341        log::debug!("[rumdl-config] Loading explicit config file: {filename}");
3342
3343        // Find project root by walking up from config location looking for .git
3344        if let Some(config_parent) = path_obj.parent() {
3345            let project_root = Self::find_project_root_from(config_parent);
3346            log::debug!(
3347                "[rumdl-config] Project root (from explicit config): {}",
3348                project_root.display()
3349            );
3350            sourced_config.project_root = Some(project_root);
3351        }
3352
3353        // Known markdownlint config files
3354        const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
3355
3356        if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
3357            let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
3358                source: e,
3359                path: path_str.clone(),
3360            })?;
3361            if filename == "pyproject.toml" {
3362                if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3363                    sourced_config.merge(fragment);
3364                    sourced_config.loaded_files.push(path_str);
3365                }
3366            } else {
3367                let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3368                sourced_config.merge(fragment);
3369                sourced_config.loaded_files.push(path_str);
3370            }
3371        } else if MARKDOWNLINT_FILENAMES.contains(&filename)
3372            || path_str.ends_with(".json")
3373            || path_str.ends_with(".jsonc")
3374            || path_str.ends_with(".yaml")
3375            || path_str.ends_with(".yml")
3376        {
3377            // Parse as markdownlint config (JSON/YAML)
3378            let fragment = load_from_markdownlint(&path_str)?;
3379            sourced_config.merge(fragment);
3380            sourced_config.loaded_files.push(path_str);
3381        } else {
3382            // Try TOML only
3383            let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
3384                source: e,
3385                path: path_str.clone(),
3386            })?;
3387            let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3388            sourced_config.merge(fragment);
3389            sourced_config.loaded_files.push(path_str);
3390        }
3391
3392        Ok(())
3393    }
3394
3395    /// Load user config as fallback when no project config exists
3396    fn load_user_config_as_fallback(
3397        sourced_config: &mut Self,
3398        user_config_dir: Option<&Path>,
3399    ) -> Result<(), ConfigError> {
3400        let user_config_path = if let Some(dir) = user_config_dir {
3401            Self::user_configuration_path_impl(dir)
3402        } else {
3403            Self::user_configuration_path()
3404        };
3405
3406        if let Some(user_config_path) = user_config_path {
3407            let path_str = user_config_path.display().to_string();
3408            let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
3409
3410            log::debug!("[rumdl-config] Loading user config as fallback: {path_str}");
3411
3412            if filename == "pyproject.toml" {
3413                let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
3414                    source: e,
3415                    path: path_str.clone(),
3416                })?;
3417                if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3418                    sourced_config.merge(fragment);
3419                    sourced_config.loaded_files.push(path_str);
3420                }
3421            } else {
3422                let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
3423                    source: e,
3424                    path: path_str.clone(),
3425                })?;
3426                let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
3427                sourced_config.merge(fragment);
3428                sourced_config.loaded_files.push(path_str);
3429            }
3430        } else {
3431            log::debug!("[rumdl-config] No user configuration file found");
3432        }
3433
3434        Ok(())
3435    }
3436
3437    /// Internal implementation that accepts user config directory for testing
3438    #[doc(hidden)]
3439    pub fn load_with_discovery_impl(
3440        config_path: Option<&str>,
3441        cli_overrides: Option<&SourcedGlobalConfig>,
3442        skip_auto_discovery: bool,
3443        user_config_dir: Option<&Path>,
3444    ) -> Result<Self, ConfigError> {
3445        use std::env;
3446        log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
3447
3448        let mut sourced_config = SourcedConfig::default();
3449
3450        // Ruff model: Project config is standalone, user config is fallback only
3451        //
3452        // Priority order:
3453        // 1. If explicit config path provided → use ONLY that (standalone)
3454        // 2. Else if project config discovered → use ONLY that (standalone)
3455        // 3. Else if user config exists → use it as fallback
3456        // 4. CLI overrides always apply last
3457        //
3458        // This ensures project configs are reproducible across machines and
3459        // CI/local runs behave identically.
3460
3461        // Explicit config path always takes precedence
3462        if let Some(path) = config_path {
3463            // Explicit config path provided - use ONLY this config (standalone)
3464            log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
3465            Self::load_explicit_config(&mut sourced_config, path)?;
3466        } else if skip_auto_discovery {
3467            log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
3468            // No config loading, just apply CLI overrides at the end
3469        } else {
3470            // No explicit path - try auto-discovery
3471            log::debug!("[rumdl-config] No explicit config_path, searching default locations");
3472
3473            // Try to discover project config first
3474            if let Some((config_file, project_root)) = Self::discover_config_upward() {
3475                // Project config found - use ONLY this (standalone, no user config)
3476                let path_str = config_file.display().to_string();
3477                let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
3478
3479                log::debug!("[rumdl-config] Found project config: {path_str}");
3480                log::debug!("[rumdl-config] Project root: {}", project_root.display());
3481
3482                sourced_config.project_root = Some(project_root);
3483
3484                if filename == "pyproject.toml" {
3485                    let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
3486                        source: e,
3487                        path: path_str.clone(),
3488                    })?;
3489                    if let Some(fragment) = parse_pyproject_toml(&content, &path_str)? {
3490                        sourced_config.merge(fragment);
3491                        sourced_config.loaded_files.push(path_str);
3492                    }
3493                } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
3494                    let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
3495                        source: e,
3496                        path: path_str.clone(),
3497                    })?;
3498                    let fragment = parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
3499                    sourced_config.merge(fragment);
3500                    sourced_config.loaded_files.push(path_str);
3501                }
3502            } else {
3503                // No rumdl project config - try markdownlint config
3504                log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
3505
3506                if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward() {
3507                    let path_str = markdownlint_path.display().to_string();
3508                    log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
3509                    match load_from_markdownlint(&path_str) {
3510                        Ok(fragment) => {
3511                            sourced_config.merge(fragment);
3512                            sourced_config.loaded_files.push(path_str);
3513                        }
3514                        Err(_e) => {
3515                            log::debug!("[rumdl-config] Failed to load markdownlint config, trying user config");
3516                            Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
3517                        }
3518                    }
3519                } else {
3520                    // No project config at all - use user config as fallback
3521                    log::debug!("[rumdl-config] No project config found, using user config as fallback");
3522                    Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
3523                }
3524            }
3525        }
3526
3527        // Apply CLI overrides (highest precedence)
3528        if let Some(cli) = cli_overrides {
3529            sourced_config
3530                .global
3531                .enable
3532                .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
3533            sourced_config
3534                .global
3535                .disable
3536                .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
3537            sourced_config
3538                .global
3539                .exclude
3540                .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
3541            sourced_config
3542                .global
3543                .include
3544                .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
3545            sourced_config.global.respect_gitignore.merge_override(
3546                cli.respect_gitignore.value,
3547                ConfigSource::Cli,
3548                None,
3549                None,
3550            );
3551            sourced_config
3552                .global
3553                .fixable
3554                .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
3555            sourced_config
3556                .global
3557                .unfixable
3558                .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
3559            // No rule-specific CLI overrides implemented yet
3560        }
3561
3562        // Unknown keys are now collected during parsing and validated via validate_config_sourced()
3563
3564        Ok(sourced_config)
3565    }
3566
3567    /// Load and merge configurations from files and CLI overrides.
3568    /// If skip_auto_discovery is true, only explicit config paths are loaded.
3569    pub fn load_with_discovery(
3570        config_path: Option<&str>,
3571        cli_overrides: Option<&SourcedGlobalConfig>,
3572        skip_auto_discovery: bool,
3573    ) -> Result<Self, ConfigError> {
3574        Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
3575    }
3576
3577    /// Validate the configuration against a rule registry.
3578    ///
3579    /// This method transitions the config from `ConfigLoaded` to `ConfigValidated` state,
3580    /// enabling conversion to `Config`. Validation warnings are stored in the config
3581    /// and can be displayed to the user.
3582    ///
3583    /// # Example
3584    ///
3585    /// ```ignore
3586    /// let loaded = SourcedConfig::load_with_discovery(path, None, false)?;
3587    /// let validated = loaded.validate(&registry)?;
3588    /// let config: Config = validated.into();
3589    /// ```
3590    pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
3591        let warnings = validate_config_sourced_internal(&self, registry);
3592
3593        Ok(SourcedConfig {
3594            global: self.global,
3595            per_file_ignores: self.per_file_ignores,
3596            per_file_flavor: self.per_file_flavor,
3597            rules: self.rules,
3598            loaded_files: self.loaded_files,
3599            unknown_keys: self.unknown_keys,
3600            project_root: self.project_root,
3601            validation_warnings: warnings,
3602            _state: PhantomData,
3603        })
3604    }
3605
3606    /// Validate and convert to Config in one step (convenience method).
3607    ///
3608    /// This combines `validate()` and `into()` for callers who want the
3609    /// validation warnings separately.
3610    pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
3611        let validated = self.validate(registry)?;
3612        let warnings = validated.validation_warnings.clone();
3613        Ok((validated.into(), warnings))
3614    }
3615
3616    /// Skip validation and convert directly to ConfigValidated state.
3617    ///
3618    /// # Safety
3619    ///
3620    /// This method bypasses validation. Use only when:
3621    /// - You've already validated via `validate_config_sourced()`
3622    /// - You're in test code that doesn't need validation
3623    /// - You're migrating legacy code and will add proper validation later
3624    ///
3625    /// Prefer `validate()` for new code.
3626    pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
3627        SourcedConfig {
3628            global: self.global,
3629            per_file_ignores: self.per_file_ignores,
3630            per_file_flavor: self.per_file_flavor,
3631            rules: self.rules,
3632            loaded_files: self.loaded_files,
3633            unknown_keys: self.unknown_keys,
3634            project_root: self.project_root,
3635            validation_warnings: Vec::new(),
3636            _state: PhantomData,
3637        }
3638    }
3639}
3640
3641/// Convert a validated configuration to the final Config type.
3642///
3643/// This implementation only exists for `SourcedConfig<ConfigValidated>`,
3644/// ensuring that validation must occur before conversion.
3645impl From<SourcedConfig<ConfigValidated>> for Config {
3646    fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
3647        let mut rules = BTreeMap::new();
3648        for (rule_name, sourced_rule_cfg) in sourced.rules {
3649            // Normalize rule name to uppercase for case-insensitive lookup
3650            let normalized_rule_name = rule_name.to_ascii_uppercase();
3651            let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
3652            let mut values = BTreeMap::new();
3653            for (key, sourced_val) in sourced_rule_cfg.values {
3654                values.insert(key, sourced_val.value);
3655            }
3656            rules.insert(normalized_rule_name, RuleConfig { severity, values });
3657        }
3658        #[allow(deprecated)]
3659        let global = GlobalConfig {
3660            enable: sourced.global.enable.value,
3661            disable: sourced.global.disable.value,
3662            exclude: sourced.global.exclude.value,
3663            include: sourced.global.include.value,
3664            respect_gitignore: sourced.global.respect_gitignore.value,
3665            line_length: sourced.global.line_length.value,
3666            output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
3667            fixable: sourced.global.fixable.value,
3668            unfixable: sourced.global.unfixable.value,
3669            flavor: sourced.global.flavor.value,
3670            force_exclude: sourced.global.force_exclude.value,
3671            cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
3672            cache: sourced.global.cache.value,
3673        };
3674        Config {
3675            global,
3676            per_file_ignores: sourced.per_file_ignores.value,
3677            per_file_flavor: sourced.per_file_flavor.value,
3678            rules,
3679            project_root: sourced.project_root,
3680        }
3681    }
3682}
3683
3684/// Registry of all known rules and their config schemas
3685pub struct RuleRegistry {
3686    /// Map of rule name (e.g. "MD013") to set of valid config keys and their TOML value types
3687    pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
3688    /// Map of rule name to config key aliases
3689    pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
3690}
3691
3692impl RuleRegistry {
3693    /// Build a registry from a list of rules
3694    pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
3695        let mut rule_schemas = std::collections::BTreeMap::new();
3696        let mut rule_aliases = std::collections::BTreeMap::new();
3697
3698        for rule in rules {
3699            let norm_name = if let Some((name, toml::Value::Table(table))) = rule.default_config_section() {
3700                let norm_name = normalize_key(&name); // Normalize the name from default_config_section
3701                rule_schemas.insert(norm_name.clone(), table);
3702                norm_name
3703            } else {
3704                let norm_name = normalize_key(rule.name()); // Normalize the name from rule.name()
3705                rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
3706                norm_name
3707            };
3708
3709            // Store aliases if the rule provides them
3710            if let Some(aliases) = rule.config_aliases() {
3711                rule_aliases.insert(norm_name, aliases);
3712            }
3713        }
3714
3715        RuleRegistry {
3716            rule_schemas,
3717            rule_aliases,
3718        }
3719    }
3720
3721    /// Get all known rule names
3722    pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
3723        self.rule_schemas.keys().cloned().collect()
3724    }
3725
3726    /// Get the valid configuration keys for a rule, including both original and normalized variants
3727    pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
3728        self.rule_schemas.get(rule).map(|schema| {
3729            let mut all_keys = std::collections::BTreeSet::new();
3730
3731            // Always allow 'severity' for any rule
3732            all_keys.insert("severity".to_string());
3733
3734            // Add original keys from schema
3735            for key in schema.keys() {
3736                all_keys.insert(key.clone());
3737            }
3738
3739            // Add normalized variants for markdownlint compatibility
3740            for key in schema.keys() {
3741                // Add kebab-case variant
3742                all_keys.insert(key.replace('_', "-"));
3743                // Add snake_case variant
3744                all_keys.insert(key.replace('-', "_"));
3745                // Add normalized variant
3746                all_keys.insert(normalize_key(key));
3747            }
3748
3749            // Add any aliases defined by the rule
3750            if let Some(aliases) = self.rule_aliases.get(rule) {
3751                for alias_key in aliases.keys() {
3752                    all_keys.insert(alias_key.clone());
3753                    // Also add normalized variants of the alias
3754                    all_keys.insert(alias_key.replace('_', "-"));
3755                    all_keys.insert(alias_key.replace('-', "_"));
3756                    all_keys.insert(normalize_key(alias_key));
3757                }
3758            }
3759
3760            all_keys
3761        })
3762    }
3763
3764    /// Get the expected value type for a rule's configuration key, trying variants
3765    pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
3766        if let Some(schema) = self.rule_schemas.get(rule) {
3767            // Check if this key is an alias
3768            if let Some(aliases) = self.rule_aliases.get(rule)
3769                && let Some(canonical_key) = aliases.get(key)
3770            {
3771                // Use the canonical key for schema lookup
3772                if let Some(value) = schema.get(canonical_key) {
3773                    return Some(value);
3774                }
3775            }
3776
3777            // Try the original key
3778            if let Some(value) = schema.get(key) {
3779                return Some(value);
3780            }
3781
3782            // Try key variants
3783            let key_variants = [
3784                key.replace('-', "_"), // Convert kebab-case to snake_case
3785                key.replace('_', "-"), // Convert snake_case to kebab-case
3786                normalize_key(key),    // Normalized key (lowercase, kebab-case)
3787            ];
3788
3789            for variant in &key_variants {
3790                if let Some(value) = schema.get(variant) {
3791                    return Some(value);
3792                }
3793            }
3794        }
3795        None
3796    }
3797
3798    /// Resolve any rule name (canonical or alias) to its canonical form
3799    /// Returns None if the rule name is not recognized
3800    ///
3801    /// Resolution order:
3802    /// 1. Direct canonical name match
3803    /// 2. Static aliases (built-in markdownlint aliases)
3804    pub fn resolve_rule_name(&self, name: &str) -> Option<String> {
3805        // Try normalized canonical name first
3806        let normalized = normalize_key(name);
3807        if self.rule_schemas.contains_key(&normalized) {
3808            return Some(normalized);
3809        }
3810
3811        // Try static alias resolution (O(1) perfect hash lookup)
3812        resolve_rule_name_alias(name).map(|s| s.to_string())
3813    }
3814}
3815
3816/// Compile-time perfect hash map for O(1) rule alias lookups
3817/// Uses phf for zero-cost abstraction - compiles to direct jumps
3818pub static RULE_ALIAS_MAP: phf::Map<&'static str, &'static str> = phf::phf_map! {
3819    // Canonical names (identity mapping for consistency)
3820    "MD001" => "MD001",
3821    "MD003" => "MD003",
3822    "MD004" => "MD004",
3823    "MD005" => "MD005",
3824    "MD007" => "MD007",
3825    "MD009" => "MD009",
3826    "MD010" => "MD010",
3827    "MD011" => "MD011",
3828    "MD012" => "MD012",
3829    "MD013" => "MD013",
3830    "MD014" => "MD014",
3831    "MD018" => "MD018",
3832    "MD019" => "MD019",
3833    "MD020" => "MD020",
3834    "MD021" => "MD021",
3835    "MD022" => "MD022",
3836    "MD023" => "MD023",
3837    "MD024" => "MD024",
3838    "MD025" => "MD025",
3839    "MD026" => "MD026",
3840    "MD027" => "MD027",
3841    "MD028" => "MD028",
3842    "MD029" => "MD029",
3843    "MD030" => "MD030",
3844    "MD031" => "MD031",
3845    "MD032" => "MD032",
3846    "MD033" => "MD033",
3847    "MD034" => "MD034",
3848    "MD035" => "MD035",
3849    "MD036" => "MD036",
3850    "MD037" => "MD037",
3851    "MD038" => "MD038",
3852    "MD039" => "MD039",
3853    "MD040" => "MD040",
3854    "MD041" => "MD041",
3855    "MD042" => "MD042",
3856    "MD043" => "MD043",
3857    "MD044" => "MD044",
3858    "MD045" => "MD045",
3859    "MD046" => "MD046",
3860    "MD047" => "MD047",
3861    "MD048" => "MD048",
3862    "MD049" => "MD049",
3863    "MD050" => "MD050",
3864    "MD051" => "MD051",
3865    "MD052" => "MD052",
3866    "MD053" => "MD053",
3867    "MD054" => "MD054",
3868    "MD055" => "MD055",
3869    "MD056" => "MD056",
3870    "MD057" => "MD057",
3871    "MD058" => "MD058",
3872    "MD059" => "MD059",
3873    "MD060" => "MD060",
3874    "MD061" => "MD061",
3875    "MD062" => "MD062",
3876    "MD063" => "MD063",
3877    "MD064" => "MD064",
3878    "MD065" => "MD065",
3879    "MD066" => "MD066",
3880    "MD067" => "MD067",
3881    "MD068" => "MD068",
3882    "MD069" => "MD069",
3883    "MD070" => "MD070",
3884    "MD071" => "MD071",
3885    "MD072" => "MD072",
3886
3887    // Aliases (hyphen format)
3888    "HEADING-INCREMENT" => "MD001",
3889    "HEADING-STYLE" => "MD003",
3890    "UL-STYLE" => "MD004",
3891    "LIST-INDENT" => "MD005",
3892    "UL-INDENT" => "MD007",
3893    "NO-TRAILING-SPACES" => "MD009",
3894    "NO-HARD-TABS" => "MD010",
3895    "NO-REVERSED-LINKS" => "MD011",
3896    "NO-MULTIPLE-BLANKS" => "MD012",
3897    "LINE-LENGTH" => "MD013",
3898    "COMMANDS-SHOW-OUTPUT" => "MD014",
3899    "NO-MISSING-SPACE-ATX" => "MD018",
3900    "NO-MULTIPLE-SPACE-ATX" => "MD019",
3901    "NO-MISSING-SPACE-CLOSED-ATX" => "MD020",
3902    "NO-MULTIPLE-SPACE-CLOSED-ATX" => "MD021",
3903    "BLANKS-AROUND-HEADINGS" => "MD022",
3904    "HEADING-START-LEFT" => "MD023",
3905    "NO-DUPLICATE-HEADING" => "MD024",
3906    "SINGLE-TITLE" => "MD025",
3907    "SINGLE-H1" => "MD025",
3908    "NO-TRAILING-PUNCTUATION" => "MD026",
3909    "NO-MULTIPLE-SPACE-BLOCKQUOTE" => "MD027",
3910    "NO-BLANKS-BLOCKQUOTE" => "MD028",
3911    "OL-PREFIX" => "MD029",
3912    "LIST-MARKER-SPACE" => "MD030",
3913    "BLANKS-AROUND-FENCES" => "MD031",
3914    "BLANKS-AROUND-LISTS" => "MD032",
3915    "NO-INLINE-HTML" => "MD033",
3916    "NO-BARE-URLS" => "MD034",
3917    "HR-STYLE" => "MD035",
3918    "NO-EMPHASIS-AS-HEADING" => "MD036",
3919    "NO-SPACE-IN-EMPHASIS" => "MD037",
3920    "NO-SPACE-IN-CODE" => "MD038",
3921    "NO-SPACE-IN-LINKS" => "MD039",
3922    "FENCED-CODE-LANGUAGE" => "MD040",
3923    "FIRST-LINE-HEADING" => "MD041",
3924    "FIRST-LINE-H1" => "MD041",
3925    "NO-EMPTY-LINKS" => "MD042",
3926    "REQUIRED-HEADINGS" => "MD043",
3927    "PROPER-NAMES" => "MD044",
3928    "NO-ALT-TEXT" => "MD045",
3929    "CODE-BLOCK-STYLE" => "MD046",
3930    "SINGLE-TRAILING-NEWLINE" => "MD047",
3931    "CODE-FENCE-STYLE" => "MD048",
3932    "EMPHASIS-STYLE" => "MD049",
3933    "STRONG-STYLE" => "MD050",
3934    "LINK-FRAGMENTS" => "MD051",
3935    "REFERENCE-LINKS-IMAGES" => "MD052",
3936    "LINK-IMAGE-REFERENCE-DEFINITIONS" => "MD053",
3937    "LINK-IMAGE-STYLE" => "MD054",
3938    "TABLE-PIPE-STYLE" => "MD055",
3939    "TABLE-COLUMN-COUNT" => "MD056",
3940    "EXISTING-RELATIVE-LINKS" => "MD057",
3941    "BLANKS-AROUND-TABLES" => "MD058",
3942    "TABLE-CELL-ALIGNMENT" => "MD059",
3943    "TABLE-FORMAT" => "MD060",
3944    "FORBIDDEN-TERMS" => "MD061",
3945    "LINK-DESTINATION-WHITESPACE" => "MD062",
3946    "HEADING-CAPITALIZATION" => "MD063",
3947    "NO-MULTIPLE-CONSECUTIVE-SPACES" => "MD064",
3948    "BLANKS-AROUND-HORIZONTAL-RULES" => "MD065",
3949    "FOOTNOTE-VALIDATION" => "MD066",
3950    "FOOTNOTE-DEFINITION-ORDER" => "MD067",
3951    "EMPTY-FOOTNOTE-DEFINITION" => "MD068",
3952    "NO-DUPLICATE-LIST-MARKERS" => "MD069",
3953    "NESTED-CODE-FENCE" => "MD070",
3954    "BLANK-LINE-AFTER-FRONTMATTER" => "MD071",
3955    "FRONTMATTER-KEY-SORT" => "MD072",
3956};
3957
3958/// Resolve a rule name alias to its canonical form with O(1) perfect hash lookup
3959/// Converts rule aliases (like "ul-style", "line-length") to canonical IDs (like "MD004", "MD013")
3960/// Returns None if the rule name is not recognized
3961pub fn resolve_rule_name_alias(key: &str) -> Option<&'static str> {
3962    // Normalize: uppercase and replace underscores with hyphens
3963    let normalized_key = key.to_ascii_uppercase().replace('_', "-");
3964
3965    // O(1) perfect hash lookup
3966    RULE_ALIAS_MAP.get(normalized_key.as_str()).copied()
3967}
3968
3969/// Resolves a rule name to its canonical ID, supporting both rule IDs and aliases.
3970/// Returns the canonical ID (e.g., "MD001") for any valid input:
3971/// - "MD001" → "MD001" (canonical)
3972/// - "heading-increment" → "MD001" (alias)
3973/// - "HEADING_INCREMENT" → "MD001" (case-insensitive, underscore variant)
3974///
3975/// For unknown names, falls back to normalization (uppercase for MDxxx pattern, otherwise kebab-case).
3976pub fn resolve_rule_name(name: &str) -> String {
3977    resolve_rule_name_alias(name)
3978        .map(|s| s.to_string())
3979        .unwrap_or_else(|| normalize_key(name))
3980}
3981
3982/// Resolves a comma-separated list of rule names to canonical IDs.
3983/// Handles CLI input like "MD001,line-length,heading-increment".
3984/// Empty entries and whitespace are filtered out.
3985pub fn resolve_rule_names(input: &str) -> std::collections::HashSet<String> {
3986    input
3987        .split(',')
3988        .map(|s| s.trim())
3989        .filter(|s| !s.is_empty())
3990        .map(resolve_rule_name)
3991        .collect()
3992}
3993
3994/// Validates rule names from CLI flags against the known rule set.
3995/// Returns warnings for unknown rules with "did you mean" suggestions.
3996///
3997/// This provides consistent validation between config files and CLI flags.
3998/// Unknown rules are warned about but don't cause failures.
3999pub fn validate_cli_rule_names(
4000    enable: Option<&str>,
4001    disable: Option<&str>,
4002    extend_enable: Option<&str>,
4003    extend_disable: Option<&str>,
4004) -> Vec<ConfigValidationWarning> {
4005    let mut warnings = Vec::new();
4006    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4007
4008    let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
4009        for name in input.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
4010            // Check for special "all" value (case-insensitive)
4011            if name.eq_ignore_ascii_case("all") {
4012                continue;
4013            }
4014            if resolve_rule_name_alias(name).is_none() {
4015                let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
4016                    let formatted = if suggestion.starts_with("MD") {
4017                        suggestion
4018                    } else {
4019                        suggestion.to_lowercase()
4020                    };
4021                    format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
4022                } else {
4023                    format!("Unknown rule in {flag_name}: {name}")
4024                };
4025                warnings.push(ConfigValidationWarning {
4026                    message,
4027                    rule: Some(name.to_string()),
4028                    key: None,
4029                });
4030            }
4031        }
4032    };
4033
4034    if let Some(e) = enable {
4035        validate_list(e, "--enable", &mut warnings);
4036    }
4037    if let Some(d) = disable {
4038        validate_list(d, "--disable", &mut warnings);
4039    }
4040    if let Some(ee) = extend_enable {
4041        validate_list(ee, "--extend-enable", &mut warnings);
4042    }
4043    if let Some(ed) = extend_disable {
4044        validate_list(ed, "--extend-disable", &mut warnings);
4045    }
4046
4047    warnings
4048}
4049
4050/// Checks if a rule name (or alias) is valid.
4051/// Returns true if the name resolves to a known rule.
4052/// Handles the special "all" value and all aliases.
4053pub fn is_valid_rule_name(name: &str) -> bool {
4054    // Check for special "all" value (case-insensitive)
4055    if name.eq_ignore_ascii_case("all") {
4056        return true;
4057    }
4058    resolve_rule_name_alias(name).is_some()
4059}
4060
4061/// Represents a config validation warning or error
4062#[derive(Debug, Clone)]
4063pub struct ConfigValidationWarning {
4064    pub message: String,
4065    pub rule: Option<String>,
4066    pub key: Option<String>,
4067}
4068
4069/// Internal validation function that works with any SourcedConfig state.
4070/// This is used by both the public `validate_config_sourced` and the typestate `validate()` method.
4071fn validate_config_sourced_internal<S>(
4072    sourced: &SourcedConfig<S>,
4073    registry: &RuleRegistry,
4074) -> Vec<ConfigValidationWarning> {
4075    let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
4076
4077    // Validate enable/disable arrays in [global] section
4078    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4079
4080    for rule_name in &sourced.global.enable.value {
4081        if !is_valid_rule_name(rule_name) {
4082            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4083                let formatted = if suggestion.starts_with("MD") {
4084                    suggestion
4085                } else {
4086                    suggestion.to_lowercase()
4087                };
4088                format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
4089            } else {
4090                format!("Unknown rule in global.enable: {rule_name}")
4091            };
4092            warnings.push(ConfigValidationWarning {
4093                message,
4094                rule: Some(rule_name.clone()),
4095                key: None,
4096            });
4097        }
4098    }
4099
4100    for rule_name in &sourced.global.disable.value {
4101        if !is_valid_rule_name(rule_name) {
4102            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4103                let formatted = if suggestion.starts_with("MD") {
4104                    suggestion
4105                } else {
4106                    suggestion.to_lowercase()
4107                };
4108                format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
4109            } else {
4110                format!("Unknown rule in global.disable: {rule_name}")
4111            };
4112            warnings.push(ConfigValidationWarning {
4113                message,
4114                rule: Some(rule_name.clone()),
4115                key: None,
4116            });
4117        }
4118    }
4119
4120    warnings
4121}
4122
4123/// Core validation implementation that doesn't depend on SourcedConfig type parameter.
4124fn validate_config_sourced_impl(
4125    rules: &BTreeMap<String, SourcedRuleConfig>,
4126    unknown_keys: &[(String, String, Option<String>)],
4127    registry: &RuleRegistry,
4128) -> Vec<ConfigValidationWarning> {
4129    let mut warnings = Vec::new();
4130    let known_rules = registry.rule_names();
4131    // 1. Unknown rules
4132    for rule in rules.keys() {
4133        if !known_rules.contains(rule) {
4134            // Include both canonical names AND aliases for fuzzy matching
4135            let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4136            let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
4137                // Convert alias suggestions to lowercase for better UX (MD001 stays uppercase, ul-style becomes lowercase)
4138                let formatted_suggestion = if suggestion.starts_with("MD") {
4139                    suggestion
4140                } else {
4141                    suggestion.to_lowercase()
4142                };
4143                format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
4144            } else {
4145                format!("Unknown rule in config: {rule}")
4146            };
4147            warnings.push(ConfigValidationWarning {
4148                message,
4149                rule: Some(rule.clone()),
4150                key: None,
4151            });
4152        }
4153    }
4154    // 2. Unknown options and type mismatches
4155    for (rule, rule_cfg) in rules {
4156        if let Some(valid_keys) = registry.config_keys_for(rule) {
4157            for key in rule_cfg.values.keys() {
4158                if !valid_keys.contains(key) {
4159                    let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
4160                    let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
4161                        format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
4162                    } else {
4163                        format!("Unknown option for rule {rule}: {key}")
4164                    };
4165                    warnings.push(ConfigValidationWarning {
4166                        message,
4167                        rule: Some(rule.clone()),
4168                        key: Some(key.clone()),
4169                    });
4170                } else {
4171                    // Type check: compare type of value to type of default
4172                    if let Some(expected) = registry.expected_value_for(rule, key) {
4173                        let actual = &rule_cfg.values[key].value;
4174                        if !toml_value_type_matches(expected, actual) {
4175                            warnings.push(ConfigValidationWarning {
4176                                message: format!(
4177                                    "Type mismatch for {}.{}: expected {}, got {}",
4178                                    rule,
4179                                    key,
4180                                    toml_type_name(expected),
4181                                    toml_type_name(actual)
4182                                ),
4183                                rule: Some(rule.clone()),
4184                                key: Some(key.clone()),
4185                            });
4186                        }
4187                    }
4188                }
4189            }
4190        }
4191    }
4192    // 3. Unknown global options (from unknown_keys)
4193    let known_global_keys = vec![
4194        "enable".to_string(),
4195        "disable".to_string(),
4196        "include".to_string(),
4197        "exclude".to_string(),
4198        "respect-gitignore".to_string(),
4199        "line-length".to_string(),
4200        "fixable".to_string(),
4201        "unfixable".to_string(),
4202        "flavor".to_string(),
4203        "force-exclude".to_string(),
4204        "output-format".to_string(),
4205        "cache-dir".to_string(),
4206        "cache".to_string(),
4207    ];
4208
4209    for (section, key, file_path) in unknown_keys {
4210        // Convert file path to relative for cleaner output
4211        let display_path = file_path.as_ref().map(|p| to_relative_display_path(p));
4212
4213        if section.contains("[global]") || section.contains("[tool.rumdl]") {
4214            let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
4215                if let Some(ref path) = display_path {
4216                    format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
4217                } else {
4218                    format!("Unknown global option: {key} (did you mean: {suggestion}?)")
4219                }
4220            } else if let Some(ref path) = display_path {
4221                format!("Unknown global option in {path}: {key}")
4222            } else {
4223                format!("Unknown global option: {key}")
4224            };
4225            warnings.push(ConfigValidationWarning {
4226                message,
4227                rule: None,
4228                key: Some(key.clone()),
4229            });
4230        } else if !key.is_empty() {
4231            // This is an unknown rule section (key is empty means it's a section header)
4232            continue;
4233        } else {
4234            // Unknown rule section - suggest similar rule names
4235            let rule_name = section.trim_matches(|c| c == '[' || c == ']');
4236            let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
4237            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
4238                // Convert alias suggestions to lowercase for better UX (MD001 stays uppercase, ul-style becomes lowercase)
4239                let formatted_suggestion = if suggestion.starts_with("MD") {
4240                    suggestion
4241                } else {
4242                    suggestion.to_lowercase()
4243                };
4244                if let Some(ref path) = display_path {
4245                    format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
4246                } else {
4247                    format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
4248                }
4249            } else if let Some(ref path) = display_path {
4250                format!("Unknown rule in {path}: {rule_name}")
4251            } else {
4252                format!("Unknown rule in config: {rule_name}")
4253            };
4254            warnings.push(ConfigValidationWarning {
4255                message,
4256                rule: None,
4257                key: None,
4258            });
4259        }
4260    }
4261    warnings
4262}
4263
4264/// Convert a file path to a display-friendly relative path.
4265///
4266/// Tries to make the path relative to the current working directory.
4267/// If that fails, returns the original path unchanged.
4268fn to_relative_display_path(path: &str) -> String {
4269    let file_path = Path::new(path);
4270
4271    // Try to make relative to CWD
4272    if let Ok(cwd) = std::env::current_dir() {
4273        // Try with canonicalized paths first (handles symlinks)
4274        if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
4275            && let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
4276        {
4277            return relative.to_string_lossy().to_string();
4278        }
4279
4280        // Fall back to non-canonicalized comparison
4281        if let Ok(relative) = file_path.strip_prefix(&cwd) {
4282            return relative.to_string_lossy().to_string();
4283        }
4284    }
4285
4286    // Return original if we can't make it relative
4287    path.to_string()
4288}
4289
4290/// Validate a loaded config against the rule registry, using SourcedConfig for unknown key tracking.
4291///
4292/// This is the legacy API that works with `SourcedConfig<ConfigLoaded>`.
4293/// For new code, prefer using `sourced.validate(&registry)` which returns a
4294/// `SourcedConfig<ConfigValidated>` that can be converted to `Config`.
4295pub fn validate_config_sourced(
4296    sourced: &SourcedConfig<ConfigLoaded>,
4297    registry: &RuleRegistry,
4298) -> Vec<ConfigValidationWarning> {
4299    validate_config_sourced_internal(sourced, registry)
4300}
4301
4302/// Validate a config that has already been validated (no-op, returns stored warnings).
4303///
4304/// This exists for API consistency - validated configs already have their warnings stored.
4305pub fn validate_config_sourced_validated(
4306    sourced: &SourcedConfig<ConfigValidated>,
4307    _registry: &RuleRegistry,
4308) -> Vec<ConfigValidationWarning> {
4309    sourced.validation_warnings.clone()
4310}
4311
4312fn toml_type_name(val: &toml::Value) -> &'static str {
4313    match val {
4314        toml::Value::String(_) => "string",
4315        toml::Value::Integer(_) => "integer",
4316        toml::Value::Float(_) => "float",
4317        toml::Value::Boolean(_) => "boolean",
4318        toml::Value::Array(_) => "array",
4319        toml::Value::Table(_) => "table",
4320        toml::Value::Datetime(_) => "datetime",
4321    }
4322}
4323
4324/// Calculate Levenshtein distance between two strings (simple implementation)
4325fn levenshtein_distance(s1: &str, s2: &str) -> usize {
4326    let len1 = s1.len();
4327    let len2 = s2.len();
4328
4329    if len1 == 0 {
4330        return len2;
4331    }
4332    if len2 == 0 {
4333        return len1;
4334    }
4335
4336    let s1_chars: Vec<char> = s1.chars().collect();
4337    let s2_chars: Vec<char> = s2.chars().collect();
4338
4339    let mut prev_row: Vec<usize> = (0..=len2).collect();
4340    let mut curr_row = vec![0; len2 + 1];
4341
4342    for i in 1..=len1 {
4343        curr_row[0] = i;
4344        for j in 1..=len2 {
4345            let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
4346            curr_row[j] = (prev_row[j] + 1)          // deletion
4347                .min(curr_row[j - 1] + 1)            // insertion
4348                .min(prev_row[j - 1] + cost); // substitution
4349        }
4350        std::mem::swap(&mut prev_row, &mut curr_row);
4351    }
4352
4353    prev_row[len2]
4354}
4355
4356/// Suggest a similar key from a list of valid keys using fuzzy matching
4357pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
4358    let unknown_lower = unknown.to_lowercase();
4359    let max_distance = 2.max(unknown.len() / 3); // Allow up to 2 edits or 30% of string length
4360
4361    let mut best_match: Option<(String, usize)> = None;
4362
4363    for valid in valid_keys {
4364        let valid_lower = valid.to_lowercase();
4365        let distance = levenshtein_distance(&unknown_lower, &valid_lower);
4366
4367        if distance <= max_distance {
4368            if let Some((_, best_dist)) = &best_match {
4369                if distance < *best_dist {
4370                    best_match = Some((valid.clone(), distance));
4371                }
4372            } else {
4373                best_match = Some((valid.clone(), distance));
4374            }
4375        }
4376    }
4377
4378    best_match.map(|(key, _)| key)
4379}
4380
4381fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
4382    use toml::Value::*;
4383    match (expected, actual) {
4384        (String(_), String(_)) => true,
4385        (Integer(_), Integer(_)) => true,
4386        (Float(_), Float(_)) => true,
4387        (Boolean(_), Boolean(_)) => true,
4388        (Array(_), Array(_)) => true,
4389        (Table(_), Table(_)) => true,
4390        (Datetime(_), Datetime(_)) => true,
4391        // Allow integer for float
4392        (Float(_), Integer(_)) => true,
4393        _ => false,
4394    }
4395}
4396
4397/// Parses pyproject.toml content and extracts the [tool.rumdl] section if present.
4398fn parse_pyproject_toml(content: &str, path: &str) -> Result<Option<SourcedConfigFragment>, ConfigError> {
4399    let doc: toml::Value =
4400        toml::from_str(content).map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
4401    let mut fragment = SourcedConfigFragment::default();
4402    let source = ConfigSource::PyprojectToml;
4403    let file = Some(path.to_string());
4404
4405    // Create rule registry for alias resolution
4406    let all_rules = rules::all_rules(&Config::default());
4407    let registry = RuleRegistry::from_rules(&all_rules);
4408
4409    // 1. Handle [tool.rumdl] and [tool.rumdl.global] sections
4410    if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
4411        && let Some(rumdl_table) = rumdl_config.as_table()
4412    {
4413        // Helper function to extract global config from a table
4414        let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
4415            // Extract global options from the given table
4416            if let Some(enable) = table.get("enable")
4417                && let Ok(values) = Vec::<String>::deserialize(enable.clone())
4418            {
4419                // Resolve rule name aliases (e.g., "ul-style" -> "MD004")
4420                let normalized_values = values
4421                    .into_iter()
4422                    .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4423                    .collect();
4424                fragment
4425                    .global
4426                    .enable
4427                    .push_override(normalized_values, source, file.clone(), None);
4428            }
4429
4430            if let Some(disable) = table.get("disable")
4431                && let Ok(values) = Vec::<String>::deserialize(disable.clone())
4432            {
4433                // Resolve rule name aliases
4434                let normalized_values: Vec<String> = values
4435                    .into_iter()
4436                    .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4437                    .collect();
4438                fragment
4439                    .global
4440                    .disable
4441                    .push_override(normalized_values, source, file.clone(), None);
4442            }
4443
4444            if let Some(include) = table.get("include")
4445                && let Ok(values) = Vec::<String>::deserialize(include.clone())
4446            {
4447                fragment
4448                    .global
4449                    .include
4450                    .push_override(values, source, file.clone(), None);
4451            }
4452
4453            if let Some(exclude) = table.get("exclude")
4454                && let Ok(values) = Vec::<String>::deserialize(exclude.clone())
4455            {
4456                fragment
4457                    .global
4458                    .exclude
4459                    .push_override(values, source, file.clone(), None);
4460            }
4461
4462            if let Some(respect_gitignore) = table
4463                .get("respect-gitignore")
4464                .or_else(|| table.get("respect_gitignore"))
4465                && let Ok(value) = bool::deserialize(respect_gitignore.clone())
4466            {
4467                fragment
4468                    .global
4469                    .respect_gitignore
4470                    .push_override(value, source, file.clone(), None);
4471            }
4472
4473            if let Some(force_exclude) = table.get("force-exclude").or_else(|| table.get("force_exclude"))
4474                && let Ok(value) = bool::deserialize(force_exclude.clone())
4475            {
4476                fragment
4477                    .global
4478                    .force_exclude
4479                    .push_override(value, source, file.clone(), None);
4480            }
4481
4482            if let Some(output_format) = table.get("output-format").or_else(|| table.get("output_format"))
4483                && let Ok(value) = String::deserialize(output_format.clone())
4484            {
4485                if fragment.global.output_format.is_none() {
4486                    fragment.global.output_format = Some(SourcedValue::new(value.clone(), source));
4487                } else {
4488                    fragment
4489                        .global
4490                        .output_format
4491                        .as_mut()
4492                        .unwrap()
4493                        .push_override(value, source, file.clone(), None);
4494                }
4495            }
4496
4497            if let Some(fixable) = table.get("fixable")
4498                && let Ok(values) = Vec::<String>::deserialize(fixable.clone())
4499            {
4500                let normalized_values = values
4501                    .into_iter()
4502                    .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4503                    .collect();
4504                fragment
4505                    .global
4506                    .fixable
4507                    .push_override(normalized_values, source, file.clone(), None);
4508            }
4509
4510            if let Some(unfixable) = table.get("unfixable")
4511                && let Ok(values) = Vec::<String>::deserialize(unfixable.clone())
4512            {
4513                let normalized_values = values
4514                    .into_iter()
4515                    .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4516                    .collect();
4517                fragment
4518                    .global
4519                    .unfixable
4520                    .push_override(normalized_values, source, file.clone(), None);
4521            }
4522
4523            if let Some(flavor) = table.get("flavor")
4524                && let Ok(value) = MarkdownFlavor::deserialize(flavor.clone())
4525            {
4526                fragment.global.flavor.push_override(value, source, file.clone(), None);
4527            }
4528
4529            // Handle line-length special case - this should set the global line_length
4530            if let Some(line_length) = table.get("line-length").or_else(|| table.get("line_length"))
4531                && let Ok(value) = u64::deserialize(line_length.clone())
4532            {
4533                fragment
4534                    .global
4535                    .line_length
4536                    .push_override(LineLength::new(value as usize), source, file.clone(), None);
4537
4538                // Also add to MD013 rule config for backward compatibility
4539                let norm_md013_key = normalize_key("MD013");
4540                let rule_entry = fragment.rules.entry(norm_md013_key).or_default();
4541                let norm_line_length_key = normalize_key("line-length");
4542                let sv = rule_entry
4543                    .values
4544                    .entry(norm_line_length_key)
4545                    .or_insert_with(|| SourcedValue::new(line_length.clone(), ConfigSource::Default));
4546                sv.push_override(line_length.clone(), source, file.clone(), None);
4547            }
4548
4549            if let Some(cache_dir) = table.get("cache-dir").or_else(|| table.get("cache_dir"))
4550                && let Ok(value) = String::deserialize(cache_dir.clone())
4551            {
4552                if fragment.global.cache_dir.is_none() {
4553                    fragment.global.cache_dir = Some(SourcedValue::new(value.clone(), source));
4554                } else {
4555                    fragment
4556                        .global
4557                        .cache_dir
4558                        .as_mut()
4559                        .unwrap()
4560                        .push_override(value, source, file.clone(), None);
4561                }
4562            }
4563
4564            if let Some(cache) = table.get("cache")
4565                && let Ok(value) = bool::deserialize(cache.clone())
4566            {
4567                fragment.global.cache.push_override(value, source, file.clone(), None);
4568            }
4569        };
4570
4571        // First, check for [tool.rumdl.global] section
4572        if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
4573            extract_global_config(&mut fragment, global_table);
4574        }
4575
4576        // Also extract global options from [tool.rumdl] directly (for flat structure)
4577        extract_global_config(&mut fragment, rumdl_table);
4578
4579        // --- Extract per-file-ignores configurations ---
4580        // Check both hyphenated and underscored versions for compatibility
4581        let per_file_ignores_key = rumdl_table
4582            .get("per-file-ignores")
4583            .or_else(|| rumdl_table.get("per_file_ignores"));
4584
4585        if let Some(per_file_ignores_value) = per_file_ignores_key
4586            && let Some(per_file_table) = per_file_ignores_value.as_table()
4587        {
4588            let mut per_file_map = HashMap::new();
4589            for (pattern, rules_value) in per_file_table {
4590                if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
4591                    let normalized_rules = rules
4592                        .into_iter()
4593                        .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4594                        .collect();
4595                    per_file_map.insert(pattern.clone(), normalized_rules);
4596                } else {
4597                    log::warn!(
4598                        "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {rules_value:?}"
4599                    );
4600                }
4601            }
4602            fragment
4603                .per_file_ignores
4604                .push_override(per_file_map, source, file.clone(), None);
4605        }
4606
4607        // --- Extract per-file-flavor configurations ---
4608        // Check both hyphenated and underscored versions for compatibility
4609        let per_file_flavor_key = rumdl_table
4610            .get("per-file-flavor")
4611            .or_else(|| rumdl_table.get("per_file_flavor"));
4612
4613        if let Some(per_file_flavor_value) = per_file_flavor_key
4614            && let Some(per_file_table) = per_file_flavor_value.as_table()
4615        {
4616            let mut per_file_map = IndexMap::new();
4617            for (pattern, flavor_value) in per_file_table {
4618                if let Ok(flavor) = MarkdownFlavor::deserialize(flavor_value.clone()) {
4619                    per_file_map.insert(pattern.clone(), flavor);
4620                } else {
4621                    log::warn!(
4622                        "[WARN] Invalid flavor for per-file-flavor pattern '{pattern}' in {path}, found {flavor_value:?}. Valid values: standard, mkdocs, mdx, quarto"
4623                    );
4624                }
4625            }
4626            fragment
4627                .per_file_flavor
4628                .push_override(per_file_map, source, file.clone(), None);
4629        }
4630
4631        // --- Extract rule-specific configurations ---
4632        for (key, value) in rumdl_table {
4633            let norm_rule_key = normalize_key(key);
4634
4635            // Skip keys already handled as global or special cases
4636            // Note: Only skip these if they're NOT tables (rule sections are tables)
4637            let is_global_key = [
4638                "enable",
4639                "disable",
4640                "include",
4641                "exclude",
4642                "respect_gitignore",
4643                "respect-gitignore",
4644                "force_exclude",
4645                "force-exclude",
4646                "output_format",
4647                "output-format",
4648                "fixable",
4649                "unfixable",
4650                "per-file-ignores",
4651                "per_file_ignores",
4652                "per-file-flavor",
4653                "per_file_flavor",
4654                "global",
4655                "flavor",
4656                "cache_dir",
4657                "cache-dir",
4658                "cache",
4659            ]
4660            .contains(&norm_rule_key.as_str());
4661
4662            // Special handling for line-length: could be global config OR rule section
4663            let is_line_length_global =
4664                (norm_rule_key == "line-length" || norm_rule_key == "line_length") && !value.is_table();
4665
4666            if is_global_key || is_line_length_global {
4667                continue;
4668            }
4669
4670            // Try to resolve as a rule name (handles both canonical names and aliases)
4671            if let Some(resolved_rule_name) = registry.resolve_rule_name(key)
4672                && value.is_table()
4673                && let Some(rule_config_table) = value.as_table()
4674            {
4675                let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4676                for (rk, rv) in rule_config_table {
4677                    let norm_rk = normalize_key(rk);
4678
4679                    // Special handling for severity - store in rule_entry.severity
4680                    if norm_rk == "severity" {
4681                        if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4682                            if rule_entry.severity.is_none() {
4683                                rule_entry.severity = Some(SourcedValue::new(severity, source));
4684                            } else {
4685                                rule_entry.severity.as_mut().unwrap().push_override(
4686                                    severity,
4687                                    source,
4688                                    file.clone(),
4689                                    None,
4690                                );
4691                            }
4692                        }
4693                        continue; // Skip regular value processing for severity
4694                    }
4695
4696                    let toml_val = rv.clone();
4697
4698                    let sv = rule_entry
4699                        .values
4700                        .entry(norm_rk.clone())
4701                        .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
4702                    sv.push_override(toml_val, source, file.clone(), None);
4703                }
4704            } else if registry.resolve_rule_name(key).is_none() {
4705                // Key is not a global/special key and not a recognized rule name
4706                // Track unknown keys under [tool.rumdl] for validation
4707                fragment
4708                    .unknown_keys
4709                    .push(("[tool.rumdl]".to_string(), key.to_string(), Some(path.to_string())));
4710            }
4711        }
4712    }
4713
4714    // 2. Handle [tool.rumdl.MDxxx] sections as rule-specific config (nested under [tool])
4715    if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
4716        for (key, value) in tool_table.iter() {
4717            if let Some(rule_name) = key.strip_prefix("rumdl.") {
4718                // Try to resolve as a rule name (handles both canonical names and aliases)
4719                if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
4720                    if let Some(rule_table) = value.as_table() {
4721                        let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4722                        for (rk, rv) in rule_table {
4723                            let norm_rk = normalize_key(rk);
4724
4725                            // Special handling for severity - store in rule_entry.severity
4726                            if norm_rk == "severity" {
4727                                if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4728                                    if rule_entry.severity.is_none() {
4729                                        rule_entry.severity = Some(SourcedValue::new(severity, source));
4730                                    } else {
4731                                        rule_entry.severity.as_mut().unwrap().push_override(
4732                                            severity,
4733                                            source,
4734                                            file.clone(),
4735                                            None,
4736                                        );
4737                                    }
4738                                }
4739                                continue; // Skip regular value processing for severity
4740                            }
4741
4742                            let toml_val = rv.clone();
4743                            let sv = rule_entry
4744                                .values
4745                                .entry(norm_rk.clone())
4746                                .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
4747                            sv.push_override(toml_val, source, file.clone(), None);
4748                        }
4749                    }
4750                } else if rule_name.to_ascii_uppercase().starts_with("MD")
4751                    || rule_name.chars().any(|c| c.is_alphabetic())
4752                {
4753                    // Track unknown rule sections like [tool.rumdl.MD999] or [tool.rumdl.unknown-rule]
4754                    fragment.unknown_keys.push((
4755                        format!("[tool.rumdl.{rule_name}]"),
4756                        String::new(),
4757                        Some(path.to_string()),
4758                    ));
4759                }
4760            }
4761        }
4762    }
4763
4764    // 3. Handle [tool.rumdl.MDxxx] sections as top-level keys (e.g., [tool.rumdl.MD007] or [tool.rumdl.line-length])
4765    if let Some(doc_table) = doc.as_table() {
4766        for (key, value) in doc_table.iter() {
4767            if let Some(rule_name) = key.strip_prefix("tool.rumdl.") {
4768                // Try to resolve as a rule name (handles both canonical names and aliases)
4769                if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
4770                    if let Some(rule_table) = value.as_table() {
4771                        let rule_entry = fragment.rules.entry(resolved_rule_name.clone()).or_default();
4772                        for (rk, rv) in rule_table {
4773                            let norm_rk = normalize_key(rk);
4774
4775                            // Special handling for severity - store in rule_entry.severity
4776                            if norm_rk == "severity" {
4777                                if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
4778                                    if rule_entry.severity.is_none() {
4779                                        rule_entry.severity = Some(SourcedValue::new(severity, source));
4780                                    } else {
4781                                        rule_entry.severity.as_mut().unwrap().push_override(
4782                                            severity,
4783                                            source,
4784                                            file.clone(),
4785                                            None,
4786                                        );
4787                                    }
4788                                }
4789                                continue; // Skip regular value processing for severity
4790                            }
4791
4792                            let toml_val = rv.clone();
4793                            let sv = rule_entry
4794                                .values
4795                                .entry(norm_rk.clone())
4796                                .or_insert_with(|| SourcedValue::new(toml_val.clone(), source));
4797                            sv.push_override(toml_val, source, file.clone(), None);
4798                        }
4799                    }
4800                } else if rule_name.to_ascii_uppercase().starts_with("MD")
4801                    || rule_name.chars().any(|c| c.is_alphabetic())
4802                {
4803                    // Track unknown rule sections like [tool.rumdl.MD999] or [tool.rumdl.unknown-rule]
4804                    fragment.unknown_keys.push((
4805                        format!("[tool.rumdl.{rule_name}]"),
4806                        String::new(),
4807                        Some(path.to_string()),
4808                    ));
4809                }
4810            }
4811        }
4812    }
4813
4814    // Only return Some(fragment) if any config was found
4815    let has_any = !fragment.global.enable.value.is_empty()
4816        || !fragment.global.disable.value.is_empty()
4817        || !fragment.global.include.value.is_empty()
4818        || !fragment.global.exclude.value.is_empty()
4819        || !fragment.global.fixable.value.is_empty()
4820        || !fragment.global.unfixable.value.is_empty()
4821        || fragment.global.output_format.is_some()
4822        || fragment.global.cache_dir.is_some()
4823        || !fragment.global.cache.value
4824        || !fragment.per_file_ignores.value.is_empty()
4825        || !fragment.per_file_flavor.value.is_empty()
4826        || !fragment.rules.is_empty();
4827    if has_any { Ok(Some(fragment)) } else { Ok(None) }
4828}
4829
4830/// Parses rumdl.toml / .rumdl.toml content.
4831fn parse_rumdl_toml(content: &str, path: &str, source: ConfigSource) -> Result<SourcedConfigFragment, ConfigError> {
4832    let doc = content
4833        .parse::<DocumentMut>()
4834        .map_err(|e| ConfigError::ParseError(format!("{path}: Failed to parse TOML: {e}")))?;
4835    let mut fragment = SourcedConfigFragment::default();
4836    // source parameter provided by caller
4837    let file = Some(path.to_string());
4838
4839    // Define known rules before the loop
4840    let all_rules = rules::all_rules(&Config::default());
4841    let registry = RuleRegistry::from_rules(&all_rules);
4842
4843    // Handle [global] section
4844    if let Some(global_item) = doc.get("global")
4845        && let Some(global_table) = global_item.as_table()
4846    {
4847        for (key, value_item) in global_table.iter() {
4848            let norm_key = normalize_key(key);
4849            match norm_key.as_str() {
4850                "enable" | "disable" | "include" | "exclude" => {
4851                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
4852                        // Corrected: Iterate directly over the Formatted<Array>
4853                        let values: Vec<String> = formatted_array
4854                                .iter()
4855                                .filter_map(|item| item.as_str()) // Extract strings
4856                                .map(|s| s.to_string())
4857                                .collect();
4858
4859                        // Resolve rule name aliases for enable/disable (e.g., "ul-style" -> "MD004")
4860                        let final_values = if norm_key == "enable" || norm_key == "disable" {
4861                            values
4862                                .into_iter()
4863                                .map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
4864                                .collect()
4865                        } else {
4866                            values
4867                        };
4868
4869                        match norm_key.as_str() {
4870                            "enable" => fragment
4871                                .global
4872                                .enable
4873                                .push_override(final_values, source, file.clone(), None),
4874                            "disable" => {
4875                                fragment
4876                                    .global
4877                                    .disable
4878                                    .push_override(final_values, source, file.clone(), None)
4879                            }
4880                            "include" => {
4881                                fragment
4882                                    .global
4883                                    .include
4884                                    .push_override(final_values, source, file.clone(), None)
4885                            }
4886                            "exclude" => {
4887                                fragment
4888                                    .global
4889                                    .exclude
4890                                    .push_override(final_values, source, file.clone(), None)
4891                            }
4892                            _ => unreachable!("Outer match guarantees only enable/disable/include/exclude"),
4893                        }
4894                    } else {
4895                        log::warn!(
4896                            "[WARN] Expected array for global key '{}' in {}, found {}",
4897                            key,
4898                            path,
4899                            value_item.type_name()
4900                        );
4901                    }
4902                }
4903                "respect_gitignore" | "respect-gitignore" => {
4904                    // Handle both cases
4905                    if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
4906                        let val = *formatted_bool.value();
4907                        fragment
4908                            .global
4909                            .respect_gitignore
4910                            .push_override(val, source, file.clone(), None);
4911                    } else {
4912                        log::warn!(
4913                            "[WARN] Expected boolean for global key '{}' in {}, found {}",
4914                            key,
4915                            path,
4916                            value_item.type_name()
4917                        );
4918                    }
4919                }
4920                "force_exclude" | "force-exclude" => {
4921                    // Handle both cases
4922                    if let Some(toml_edit::Value::Boolean(formatted_bool)) = value_item.as_value() {
4923                        let val = *formatted_bool.value();
4924                        fragment
4925                            .global
4926                            .force_exclude
4927                            .push_override(val, source, file.clone(), None);
4928                    } else {
4929                        log::warn!(
4930                            "[WARN] Expected boolean for global key '{}' in {}, found {}",
4931                            key,
4932                            path,
4933                            value_item.type_name()
4934                        );
4935                    }
4936                }
4937                "line_length" | "line-length" => {
4938                    // Handle both cases
4939                    if let Some(toml_edit::Value::Integer(formatted_int)) = value_item.as_value() {
4940                        let val = LineLength::new(*formatted_int.value() as usize);
4941                        fragment
4942                            .global
4943                            .line_length
4944                            .push_override(val, source, file.clone(), None);
4945                    } else {
4946                        log::warn!(
4947                            "[WARN] Expected integer for global key '{}' in {}, found {}",
4948                            key,
4949                            path,
4950                            value_item.type_name()
4951                        );
4952                    }
4953                }
4954                "output_format" | "output-format" => {
4955                    // Handle both cases
4956                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4957                        let val = formatted_string.value().clone();
4958                        if fragment.global.output_format.is_none() {
4959                            fragment.global.output_format = Some(SourcedValue::new(val.clone(), source));
4960                        } else {
4961                            fragment.global.output_format.as_mut().unwrap().push_override(
4962                                val,
4963                                source,
4964                                file.clone(),
4965                                None,
4966                            );
4967                        }
4968                    } else {
4969                        log::warn!(
4970                            "[WARN] Expected string for global key '{}' in {}, found {}",
4971                            key,
4972                            path,
4973                            value_item.type_name()
4974                        );
4975                    }
4976                }
4977                "cache_dir" | "cache-dir" => {
4978                    // Handle both cases
4979                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
4980                        let val = formatted_string.value().clone();
4981                        if fragment.global.cache_dir.is_none() {
4982                            fragment.global.cache_dir = Some(SourcedValue::new(val.clone(), source));
4983                        } else {
4984                            fragment
4985                                .global
4986                                .cache_dir
4987                                .as_mut()
4988                                .unwrap()
4989                                .push_override(val, source, file.clone(), None);
4990                        }
4991                    } else {
4992                        log::warn!(
4993                            "[WARN] Expected string for global key '{}' in {}, found {}",
4994                            key,
4995                            path,
4996                            value_item.type_name()
4997                        );
4998                    }
4999                }
5000                "cache" => {
5001                    if let Some(toml_edit::Value::Boolean(b)) = value_item.as_value() {
5002                        let val = *b.value();
5003                        fragment.global.cache.push_override(val, source, file.clone(), None);
5004                    } else {
5005                        log::warn!(
5006                            "[WARN] Expected boolean for global key '{}' in {}, found {}",
5007                            key,
5008                            path,
5009                            value_item.type_name()
5010                        );
5011                    }
5012                }
5013                "fixable" => {
5014                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5015                        let values: Vec<String> = formatted_array
5016                            .iter()
5017                            .filter_map(|item| item.as_str())
5018                            .map(normalize_key)
5019                            .collect();
5020                        fragment
5021                            .global
5022                            .fixable
5023                            .push_override(values, source, file.clone(), None);
5024                    } else {
5025                        log::warn!(
5026                            "[WARN] Expected array for global key '{}' in {}, found {}",
5027                            key,
5028                            path,
5029                            value_item.type_name()
5030                        );
5031                    }
5032                }
5033                "unfixable" => {
5034                    if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5035                        let values: Vec<String> = formatted_array
5036                            .iter()
5037                            .filter_map(|item| item.as_str())
5038                            .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
5039                            .collect();
5040                        fragment
5041                            .global
5042                            .unfixable
5043                            .push_override(values, source, file.clone(), None);
5044                    } else {
5045                        log::warn!(
5046                            "[WARN] Expected array for global key '{}' in {}, found {}",
5047                            key,
5048                            path,
5049                            value_item.type_name()
5050                        );
5051                    }
5052                }
5053                "flavor" => {
5054                    if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
5055                        let val = formatted_string.value();
5056                        if let Ok(flavor) = MarkdownFlavor::from_str(val) {
5057                            fragment.global.flavor.push_override(flavor, source, file.clone(), None);
5058                        } else {
5059                            log::warn!("[WARN] Unknown markdown flavor '{val}' in {path}");
5060                        }
5061                    } else {
5062                        log::warn!(
5063                            "[WARN] Expected string for global key '{}' in {}, found {}",
5064                            key,
5065                            path,
5066                            value_item.type_name()
5067                        );
5068                    }
5069                }
5070                _ => {
5071                    // Track unknown global keys for validation
5072                    fragment
5073                        .unknown_keys
5074                        .push(("[global]".to_string(), key.to_string(), Some(path.to_string())));
5075                    log::warn!("[WARN] Unknown key in [global] section of {path}: {key}");
5076                }
5077            }
5078        }
5079    }
5080
5081    // Handle [per-file-ignores] section
5082    if let Some(per_file_item) = doc.get("per-file-ignores")
5083        && let Some(per_file_table) = per_file_item.as_table()
5084    {
5085        let mut per_file_map = HashMap::new();
5086        for (pattern, value_item) in per_file_table.iter() {
5087            if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
5088                let rules: Vec<String> = formatted_array
5089                    .iter()
5090                    .filter_map(|item| item.as_str())
5091                    .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
5092                    .collect();
5093                per_file_map.insert(pattern.to_string(), rules);
5094            } else {
5095                let type_name = value_item.type_name();
5096                log::warn!(
5097                    "[WARN] Expected array for per-file-ignores pattern '{pattern}' in {path}, found {type_name}"
5098                );
5099            }
5100        }
5101        fragment
5102            .per_file_ignores
5103            .push_override(per_file_map, source, file.clone(), None);
5104    }
5105
5106    // Handle [per-file-flavor] section
5107    if let Some(per_file_item) = doc.get("per-file-flavor")
5108        && let Some(per_file_table) = per_file_item.as_table()
5109    {
5110        let mut per_file_map = IndexMap::new();
5111        for (pattern, value_item) in per_file_table.iter() {
5112            if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
5113                let flavor_str = formatted_string.value();
5114                match MarkdownFlavor::deserialize(toml::Value::String(flavor_str.to_string())) {
5115                    Ok(flavor) => {
5116                        per_file_map.insert(pattern.to_string(), flavor);
5117                    }
5118                    Err(_) => {
5119                        log::warn!(
5120                            "[WARN] Invalid flavor '{flavor_str}' for pattern '{pattern}' in {path}. Valid values: standard, mkdocs, mdx, quarto"
5121                        );
5122                    }
5123                }
5124            } else {
5125                let type_name = value_item.type_name();
5126                log::warn!(
5127                    "[WARN] Expected string for per-file-flavor pattern '{pattern}' in {path}, found {type_name}"
5128                );
5129            }
5130        }
5131        fragment
5132            .per_file_flavor
5133            .push_override(per_file_map, source, file.clone(), None);
5134    }
5135
5136    // Rule-specific: all other top-level tables
5137    for (key, item) in doc.iter() {
5138        // Skip known special sections
5139        if key == "global" || key == "per-file-ignores" || key == "per-file-flavor" {
5140            continue;
5141        }
5142
5143        // Resolve rule name (handles both canonical names like "MD004" and aliases like "ul-style")
5144        let norm_rule_name = if let Some(resolved) = registry.resolve_rule_name(key) {
5145            resolved
5146        } else {
5147            // Unknown rule - always track it for validation and suggestions
5148            fragment
5149                .unknown_keys
5150                .push((format!("[{key}]"), String::new(), Some(path.to_string())));
5151            continue;
5152        };
5153
5154        if let Some(tbl) = item.as_table() {
5155            let rule_entry = fragment.rules.entry(norm_rule_name.clone()).or_default();
5156            for (rk, rv_item) in tbl.iter() {
5157                let norm_rk = normalize_key(rk);
5158
5159                // Special handling for severity - store in rule_entry.severity
5160                if norm_rk == "severity" {
5161                    if let Some(toml_edit::Value::String(formatted_string)) = rv_item.as_value() {
5162                        let severity_str = formatted_string.value();
5163                        match crate::rule::Severity::deserialize(toml::Value::String(severity_str.to_string())) {
5164                            Ok(severity) => {
5165                                if rule_entry.severity.is_none() {
5166                                    rule_entry.severity = Some(SourcedValue::new(severity, source));
5167                                } else {
5168                                    rule_entry.severity.as_mut().unwrap().push_override(
5169                                        severity,
5170                                        source,
5171                                        file.clone(),
5172                                        None,
5173                                    );
5174                                }
5175                            }
5176                            Err(_) => {
5177                                log::warn!(
5178                                    "[WARN] Invalid severity '{severity_str}' for rule {norm_rule_name} in {path}. Valid values: error, warning"
5179                                );
5180                            }
5181                        }
5182                    }
5183                    continue; // Skip regular value processing for severity
5184                }
5185
5186                let maybe_toml_val: Option<toml::Value> = match rv_item.as_value() {
5187                    Some(toml_edit::Value::String(formatted)) => Some(toml::Value::String(formatted.value().clone())),
5188                    Some(toml_edit::Value::Integer(formatted)) => Some(toml::Value::Integer(*formatted.value())),
5189                    Some(toml_edit::Value::Float(formatted)) => Some(toml::Value::Float(*formatted.value())),
5190                    Some(toml_edit::Value::Boolean(formatted)) => Some(toml::Value::Boolean(*formatted.value())),
5191                    Some(toml_edit::Value::Datetime(formatted)) => Some(toml::Value::Datetime(*formatted.value())),
5192                    Some(toml_edit::Value::Array(formatted_array)) => {
5193                        // Convert toml_edit Array to toml::Value::Array
5194                        let mut values = Vec::new();
5195                        for item in formatted_array.iter() {
5196                            match item {
5197                                toml_edit::Value::String(formatted) => {
5198                                    values.push(toml::Value::String(formatted.value().clone()))
5199                                }
5200                                toml_edit::Value::Integer(formatted) => {
5201                                    values.push(toml::Value::Integer(*formatted.value()))
5202                                }
5203                                toml_edit::Value::Float(formatted) => {
5204                                    values.push(toml::Value::Float(*formatted.value()))
5205                                }
5206                                toml_edit::Value::Boolean(formatted) => {
5207                                    values.push(toml::Value::Boolean(*formatted.value()))
5208                                }
5209                                toml_edit::Value::Datetime(formatted) => {
5210                                    values.push(toml::Value::Datetime(*formatted.value()))
5211                                }
5212                                _ => {
5213                                    log::warn!(
5214                                        "[WARN] Skipping unsupported array element type in key '{norm_rule_name}.{norm_rk}' in {path}"
5215                                    );
5216                                }
5217                            }
5218                        }
5219                        Some(toml::Value::Array(values))
5220                    }
5221                    Some(toml_edit::Value::InlineTable(_)) => {
5222                        log::warn!(
5223                            "[WARN] Skipping inline table value for key '{norm_rule_name}.{norm_rk}' in {path}. Table conversion not yet fully implemented in parser."
5224                        );
5225                        None
5226                    }
5227                    None => {
5228                        log::warn!(
5229                            "[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {path}. Expected simple value."
5230                        );
5231                        None
5232                    }
5233                };
5234                if let Some(toml_val) = maybe_toml_val {
5235                    let sv = rule_entry
5236                        .values
5237                        .entry(norm_rk.clone())
5238                        .or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
5239                    sv.push_override(toml_val, source, file.clone(), None);
5240                }
5241            }
5242        } else if item.is_value() {
5243            log::warn!("[WARN] Ignoring top-level value key in {path}: '{key}'. Expected a table like [{key}].");
5244        }
5245    }
5246
5247    Ok(fragment)
5248}
5249
5250/// Loads and converts a markdownlint config file (.json or .yaml) into a SourcedConfigFragment.
5251fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
5252    // Use the unified loader from markdownlint_config.rs
5253    let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
5254        .map_err(|e| ConfigError::ParseError(format!("{path}: {e}")))?;
5255    Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
5256}
5257
5258#[cfg(test)]
5259#[path = "config_intelligent_merge_tests.rs"]
5260mod config_intelligent_merge_tests;