Skip to main content

rumdl_lib/
config.rs

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