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