Skip to main content

rumdl_lib/
config.rs

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