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