Skip to main content

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