Skip to main content

rumdl_lib/config/
loading.rs

1use std::collections::BTreeMap;
2use std::marker::PhantomData;
3use std::path::Path;
4use std::sync::{Arc, OnceLock};
5
6use super::flavor::ConfigLoaded;
7use super::flavor::ConfigValidated;
8use super::parsers;
9use super::registry::RuleRegistry;
10use super::source_tracking::{
11    ConfigSource, ConfigValidationWarning, SourcedConfig, SourcedConfigFragment, SourcedGlobalConfig, SourcedValue,
12};
13use super::types::{Config, ConfigError, GlobalConfig, MARKDOWNLINT_CONFIG_FILES, RuleConfig};
14use super::validation::validate_config_sourced_internal;
15
16impl SourcedConfig<ConfigLoaded> {
17    /// Merges another SourcedConfigFragment into this SourcedConfig.
18    /// Uses source precedence to determine which values take effect.
19    pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
20        // Merge global config
21        // Enable uses replace semantics (project can enforce rules)
22        self.global.enable.merge_override(
23            fragment.global.enable.value,
24            fragment.global.enable.source,
25            fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
26            fragment.global.enable.overrides.first().and_then(|o| o.line),
27        );
28
29        // Disable uses union semantics (user can add to project disables)
30        self.global.disable.merge_union(
31            fragment.global.disable.value,
32            fragment.global.disable.source,
33            fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
34            fragment.global.disable.overrides.first().and_then(|o| o.line),
35        );
36
37        // Extend-enable uses union semantics (additive across config levels)
38        self.global.extend_enable.merge_union(
39            fragment.global.extend_enable.value,
40            fragment.global.extend_enable.source,
41            fragment
42                .global
43                .extend_enable
44                .overrides
45                .first()
46                .and_then(|o| o.file.clone()),
47            fragment.global.extend_enable.overrides.first().and_then(|o| o.line),
48        );
49
50        // Extend-disable uses union semantics (additive across config levels)
51        self.global.extend_disable.merge_union(
52            fragment.global.extend_disable.value,
53            fragment.global.extend_disable.source,
54            fragment
55                .global
56                .extend_disable
57                .overrides
58                .first()
59                .and_then(|o| o.file.clone()),
60            fragment.global.extend_disable.overrides.first().and_then(|o| o.line),
61        );
62
63        // Conflict resolution: Enable overrides disable
64        // Remove any rules from disable that appear in enable
65        self.global
66            .disable
67            .value
68            .retain(|rule| !self.global.enable.value.contains(rule));
69        self.global.include.merge_override(
70            fragment.global.include.value,
71            fragment.global.include.source,
72            fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
73            fragment.global.include.overrides.first().and_then(|o| o.line),
74        );
75        self.global.exclude.merge_override(
76            fragment.global.exclude.value,
77            fragment.global.exclude.source,
78            fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
79            fragment.global.exclude.overrides.first().and_then(|o| o.line),
80        );
81        self.global.respect_gitignore.merge_override(
82            fragment.global.respect_gitignore.value,
83            fragment.global.respect_gitignore.source,
84            fragment
85                .global
86                .respect_gitignore
87                .overrides
88                .first()
89                .and_then(|o| o.file.clone()),
90            fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
91        );
92        self.global.line_length.merge_override(
93            fragment.global.line_length.value,
94            fragment.global.line_length.source,
95            fragment
96                .global
97                .line_length
98                .overrides
99                .first()
100                .and_then(|o| o.file.clone()),
101            fragment.global.line_length.overrides.first().and_then(|o| o.line),
102        );
103        self.global.fixable.merge_override(
104            fragment.global.fixable.value,
105            fragment.global.fixable.source,
106            fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
107            fragment.global.fixable.overrides.first().and_then(|o| o.line),
108        );
109        self.global.unfixable.merge_override(
110            fragment.global.unfixable.value,
111            fragment.global.unfixable.source,
112            fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
113            fragment.global.unfixable.overrides.first().and_then(|o| o.line),
114        );
115
116        // Merge flavor
117        self.global.flavor.merge_override(
118            fragment.global.flavor.value,
119            fragment.global.flavor.source,
120            fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
121            fragment.global.flavor.overrides.first().and_then(|o| o.line),
122        );
123
124        // Merge force_exclude
125        self.global.force_exclude.merge_override(
126            fragment.global.force_exclude.value,
127            fragment.global.force_exclude.source,
128            fragment
129                .global
130                .force_exclude
131                .overrides
132                .first()
133                .and_then(|o| o.file.clone()),
134            fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
135        );
136
137        // Merge output_format if present
138        if let Some(output_format_fragment) = fragment.global.output_format {
139            if let Some(ref mut output_format) = self.global.output_format {
140                output_format.merge_override(
141                    output_format_fragment.value,
142                    output_format_fragment.source,
143                    output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
144                    output_format_fragment.overrides.first().and_then(|o| o.line),
145                );
146            } else {
147                self.global.output_format = Some(output_format_fragment);
148            }
149        }
150
151        // Merge cache_dir if present
152        if let Some(cache_dir_fragment) = fragment.global.cache_dir {
153            if let Some(ref mut cache_dir) = self.global.cache_dir {
154                cache_dir.merge_override(
155                    cache_dir_fragment.value,
156                    cache_dir_fragment.source,
157                    cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
158                    cache_dir_fragment.overrides.first().and_then(|o| o.line),
159                );
160            } else {
161                self.global.cache_dir = Some(cache_dir_fragment);
162            }
163        }
164
165        // Merge cache if not default (only override when explicitly set)
166        if fragment.global.cache.source != ConfigSource::Default {
167            self.global.cache.merge_override(
168                fragment.global.cache.value,
169                fragment.global.cache.source,
170                fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
171                fragment.global.cache.overrides.first().and_then(|o| o.line),
172            );
173        }
174
175        // Merge per_file_ignores
176        self.per_file_ignores.merge_override(
177            fragment.per_file_ignores.value,
178            fragment.per_file_ignores.source,
179            fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
180            fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
181        );
182
183        // Merge per_file_flavor
184        self.per_file_flavor.merge_override(
185            fragment.per_file_flavor.value,
186            fragment.per_file_flavor.source,
187            fragment.per_file_flavor.overrides.first().and_then(|o| o.file.clone()),
188            fragment.per_file_flavor.overrides.first().and_then(|o| o.line),
189        );
190
191        // Merge code_block_tools
192        self.code_block_tools.merge_override(
193            fragment.code_block_tools.value,
194            fragment.code_block_tools.source,
195            fragment.code_block_tools.overrides.first().and_then(|o| o.file.clone()),
196            fragment.code_block_tools.overrides.first().and_then(|o| o.line),
197        );
198
199        // Merge rule configs
200        for (rule_name, rule_fragment) in fragment.rules {
201            let norm_rule_name = rule_name.to_ascii_uppercase(); // Normalize to uppercase for case-insensitivity
202            let rule_entry = self.rules.entry(norm_rule_name).or_default();
203
204            // Merge severity if present in fragment
205            if let Some(severity_fragment) = rule_fragment.severity {
206                if let Some(ref mut existing_severity) = rule_entry.severity {
207                    existing_severity.merge_override(
208                        severity_fragment.value,
209                        severity_fragment.source,
210                        severity_fragment.overrides.first().and_then(|o| o.file.clone()),
211                        severity_fragment.overrides.first().and_then(|o| o.line),
212                    );
213                } else {
214                    rule_entry.severity = Some(severity_fragment);
215                }
216            }
217
218            // Merge values
219            for (key, sourced_value_fragment) in rule_fragment.values {
220                let sv_entry = rule_entry
221                    .values
222                    .entry(key.clone())
223                    .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
224                let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
225                let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
226                sv_entry.merge_override(
227                    sourced_value_fragment.value,  // Use the value from the fragment
228                    sourced_value_fragment.source, // Use the source from the fragment
229                    file_from_fragment,            // Pass the file path from the fragment override
230                    line_from_fragment,            // Pass the line number from the fragment override
231                );
232            }
233        }
234
235        // Merge unknown_keys from fragment
236        for (section, key, file_path) in fragment.unknown_keys {
237            // Deduplicate: only add if not already present
238            if !self.unknown_keys.iter().any(|(s, k, _)| s == &section && k == &key) {
239                self.unknown_keys.push((section, key, file_path));
240            }
241        }
242    }
243
244    /// Load and merge configurations from files and CLI overrides.
245    pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
246        Self::load_with_discovery(config_path, cli_overrides, false)
247    }
248
249    /// Finds project root by walking up from start_dir looking for .git directory.
250    /// Falls back to start_dir if no .git found.
251    fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
252        // Convert relative paths to absolute to ensure correct traversal
253        let mut current = if start_dir.is_relative() {
254            std::env::current_dir()
255                .map(|cwd| cwd.join(start_dir))
256                .unwrap_or_else(|_| start_dir.to_path_buf())
257        } else {
258            start_dir.to_path_buf()
259        };
260        const MAX_DEPTH: usize = 100;
261
262        for _ in 0..MAX_DEPTH {
263            if current.join(".git").exists() {
264                log::debug!("[rumdl-config] Found .git at: {}", current.display());
265                return current;
266            }
267
268            match current.parent() {
269                Some(parent) => current = parent.to_path_buf(),
270                None => break,
271            }
272        }
273
274        // No .git found, use start_dir as project root
275        log::debug!(
276            "[rumdl-config] No .git found, using config location as project root: {}",
277            start_dir.display()
278        );
279        start_dir.to_path_buf()
280    }
281
282    /// Discover configuration file by traversing up the directory tree.
283    /// Returns the first configuration file found.
284    /// Discovers config file and returns both the config path and project root.
285    /// Returns: (config_file_path, project_root_path)
286    /// Project root is the directory containing .git, or config parent as fallback.
287    fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
288        use std::env;
289
290        const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
291        const MAX_DEPTH: usize = 100; // Prevent infinite traversal
292
293        let start_dir = match env::current_dir() {
294            Ok(dir) => dir,
295            Err(e) => {
296                log::debug!("[rumdl-config] Failed to get current directory: {e}");
297                return None;
298            }
299        };
300
301        let mut current_dir = start_dir.clone();
302        let mut depth = 0;
303        let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
304
305        loop {
306            if depth >= MAX_DEPTH {
307                log::debug!("[rumdl-config] Maximum traversal depth reached");
308                break;
309            }
310
311            log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
312
313            // Check for config files in order of precedence (only if not already found)
314            if found_config.is_none() {
315                for config_name in CONFIG_FILES {
316                    let config_path = current_dir.join(config_name);
317
318                    if config_path.exists() {
319                        // For pyproject.toml, verify it contains [tool.rumdl] section
320                        if *config_name == "pyproject.toml" {
321                            if let Ok(content) = std::fs::read_to_string(&config_path) {
322                                if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
323                                    log::debug!("[rumdl-config] Found config file: {}", config_path.display());
324                                    // Store config, but continue looking for .git
325                                    found_config = Some((config_path.clone(), current_dir.clone()));
326                                    break;
327                                }
328                                log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
329                                continue;
330                            }
331                        } else {
332                            log::debug!("[rumdl-config] Found config file: {}", config_path.display());
333                            // Store config, but continue looking for .git
334                            found_config = Some((config_path.clone(), current_dir.clone()));
335                            break;
336                        }
337                    }
338                }
339            }
340
341            // Check for .git directory (stop boundary)
342            if current_dir.join(".git").exists() {
343                log::debug!("[rumdl-config] Stopping at .git directory");
344                break;
345            }
346
347            // Move to parent directory
348            match current_dir.parent() {
349                Some(parent) => {
350                    current_dir = parent.to_owned();
351                    depth += 1;
352                }
353                None => {
354                    log::debug!("[rumdl-config] Reached filesystem root");
355                    break;
356                }
357            }
358        }
359
360        // If config found, determine project root by walking up from config location
361        if let Some((config_path, config_dir)) = found_config {
362            let project_root = Self::find_project_root_from(&config_dir);
363            return Some((config_path, project_root));
364        }
365
366        None
367    }
368
369    /// Discover markdownlint configuration file by traversing up the directory tree.
370    /// Similar to discover_config_upward but for .markdownlint.yaml/json files.
371    /// Returns the path to the config file if found.
372    fn discover_markdownlint_config_upward() -> Option<std::path::PathBuf> {
373        use std::env;
374
375        const MAX_DEPTH: usize = 100;
376
377        let start_dir = match env::current_dir() {
378            Ok(dir) => dir,
379            Err(e) => {
380                log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
381                return None;
382            }
383        };
384
385        let mut current_dir = start_dir.clone();
386        let mut depth = 0;
387
388        loop {
389            if depth >= MAX_DEPTH {
390                log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
391                break;
392            }
393
394            log::debug!(
395                "[rumdl-config] Searching for markdownlint config in: {}",
396                current_dir.display()
397            );
398
399            // Check for markdownlint config files in order of precedence
400            for config_name in MARKDOWNLINT_CONFIG_FILES {
401                let config_path = current_dir.join(config_name);
402                if config_path.exists() {
403                    log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
404                    return Some(config_path);
405                }
406            }
407
408            // Check for .git directory (stop boundary)
409            if current_dir.join(".git").exists() {
410                log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
411                break;
412            }
413
414            // Move to parent directory
415            match current_dir.parent() {
416                Some(parent) => {
417                    current_dir = parent.to_owned();
418                    depth += 1;
419                }
420                None => {
421                    log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
422                    break;
423                }
424            }
425        }
426
427        None
428    }
429
430    /// Internal implementation that accepts config directory for testing
431    fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
432        let config_dir = config_dir.join("rumdl");
433
434        // Check for config files in precedence order (same as project discovery)
435        const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
436
437        log::debug!(
438            "[rumdl-config] Checking for user configuration in: {}",
439            config_dir.display()
440        );
441
442        for filename in USER_CONFIG_FILES {
443            let config_path = config_dir.join(filename);
444
445            if config_path.exists() {
446                // For pyproject.toml, verify it contains [tool.rumdl] section
447                if *filename == "pyproject.toml" {
448                    if let Ok(content) = std::fs::read_to_string(&config_path) {
449                        if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
450                            log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
451                            return Some(config_path);
452                        }
453                        log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
454                        continue;
455                    }
456                } else {
457                    log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
458                    return Some(config_path);
459                }
460            }
461        }
462
463        log::debug!(
464            "[rumdl-config] No user configuration found in: {}",
465            config_dir.display()
466        );
467        None
468    }
469
470    /// Discover user-level configuration file from platform-specific config directory.
471    /// Returns the first configuration file found in the user config directory.
472    #[cfg(feature = "native")]
473    fn user_configuration_path() -> Option<std::path::PathBuf> {
474        use etcetera::{BaseStrategy, choose_base_strategy};
475
476        match choose_base_strategy() {
477            Ok(strategy) => {
478                let config_dir = strategy.config_dir();
479                Self::user_configuration_path_impl(&config_dir)
480            }
481            Err(e) => {
482                log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
483                None
484            }
485        }
486    }
487
488    /// Stub for WASM builds - user config not supported
489    #[cfg(not(feature = "native"))]
490    fn user_configuration_path() -> Option<std::path::PathBuf> {
491        None
492    }
493
494    /// Load an explicit config file (standalone, no user config merging)
495    fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
496        let path_obj = Path::new(path);
497        let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
498        let path_str = path.to_string();
499
500        log::debug!("[rumdl-config] Loading explicit config file: {filename}");
501
502        // Find project root by walking up from config location looking for .git
503        if let Some(config_parent) = path_obj.parent() {
504            let project_root = Self::find_project_root_from(config_parent);
505            log::debug!(
506                "[rumdl-config] Project root (from explicit config): {}",
507                project_root.display()
508            );
509            sourced_config.project_root = Some(project_root);
510        }
511
512        // Known markdownlint config files
513        const MARKDOWNLINT_FILENAMES: &[&str] = &[".markdownlint.json", ".markdownlint.yaml", ".markdownlint.yml"];
514
515        if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
516            let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
517                source: e,
518                path: path_str.clone(),
519            })?;
520            if filename == "pyproject.toml" {
521                if let Some(fragment) = parsers::parse_pyproject_toml(&content, &path_str)? {
522                    sourced_config.merge(fragment);
523                    sourced_config.loaded_files.push(path_str);
524                }
525            } else {
526                let fragment = parsers::parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
527                sourced_config.merge(fragment);
528                sourced_config.loaded_files.push(path_str);
529            }
530        } else if MARKDOWNLINT_FILENAMES.contains(&filename)
531            || path_str.ends_with(".json")
532            || path_str.ends_with(".jsonc")
533            || path_str.ends_with(".yaml")
534            || path_str.ends_with(".yml")
535        {
536            // Parse as markdownlint config (JSON/YAML)
537            let fragment = parsers::load_from_markdownlint(&path_str)?;
538            sourced_config.merge(fragment);
539            sourced_config.loaded_files.push(path_str);
540        } else {
541            // Try TOML only
542            let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
543                source: e,
544                path: path_str.clone(),
545            })?;
546            let fragment = parsers::parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
547            sourced_config.merge(fragment);
548            sourced_config.loaded_files.push(path_str);
549        }
550
551        Ok(())
552    }
553
554    /// Load user config as fallback when no project config exists
555    fn load_user_config_as_fallback(
556        sourced_config: &mut Self,
557        user_config_dir: Option<&Path>,
558    ) -> Result<(), ConfigError> {
559        let user_config_path = if let Some(dir) = user_config_dir {
560            Self::user_configuration_path_impl(dir)
561        } else {
562            Self::user_configuration_path()
563        };
564
565        if let Some(user_config_path) = user_config_path {
566            let path_str = user_config_path.display().to_string();
567            let filename = user_config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
568
569            log::debug!("[rumdl-config] Loading user config as fallback: {path_str}");
570
571            if filename == "pyproject.toml" {
572                let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
573                    source: e,
574                    path: path_str.clone(),
575                })?;
576                if let Some(fragment) = parsers::parse_pyproject_toml(&content, &path_str)? {
577                    sourced_config.merge(fragment);
578                    sourced_config.loaded_files.push(path_str);
579                }
580            } else {
581                let content = std::fs::read_to_string(&user_config_path).map_err(|e| ConfigError::IoError {
582                    source: e,
583                    path: path_str.clone(),
584                })?;
585                let fragment = parsers::parse_rumdl_toml(&content, &path_str, ConfigSource::UserConfig)?;
586                sourced_config.merge(fragment);
587                sourced_config.loaded_files.push(path_str);
588            }
589        } else {
590            log::debug!("[rumdl-config] No user configuration file found");
591        }
592
593        Ok(())
594    }
595
596    /// Internal implementation that accepts user config directory for testing
597    #[doc(hidden)]
598    pub fn load_with_discovery_impl(
599        config_path: Option<&str>,
600        cli_overrides: Option<&SourcedGlobalConfig>,
601        skip_auto_discovery: bool,
602        user_config_dir: Option<&Path>,
603    ) -> Result<Self, ConfigError> {
604        use std::env;
605        log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
606
607        let mut sourced_config = SourcedConfig::default();
608
609        // Ruff model: Project config is standalone, user config is fallback only
610        //
611        // Priority order:
612        // 1. If explicit config path provided → use ONLY that (standalone)
613        // 2. Else if project config discovered → use ONLY that (standalone)
614        // 3. Else if user config exists → use it as fallback
615        // 4. CLI overrides always apply last
616        //
617        // This ensures project configs are reproducible across machines and
618        // CI/local runs behave identically.
619
620        // Explicit config path always takes precedence
621        if let Some(path) = config_path {
622            // Explicit config path provided - use ONLY this config (standalone)
623            log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
624            Self::load_explicit_config(&mut sourced_config, path)?;
625        } else if skip_auto_discovery {
626            log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
627            // No config loading, just apply CLI overrides at the end
628        } else {
629            // No explicit path - try auto-discovery
630            log::debug!("[rumdl-config] No explicit config_path, searching default locations");
631
632            // Try to discover project config first
633            if let Some((config_file, project_root)) = Self::discover_config_upward() {
634                // Project config found - use ONLY this (standalone, no user config)
635                let path_str = config_file.display().to_string();
636                let filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
637
638                log::debug!("[rumdl-config] Found project config: {path_str}");
639                log::debug!("[rumdl-config] Project root: {}", project_root.display());
640
641                sourced_config.project_root = Some(project_root);
642
643                if filename == "pyproject.toml" {
644                    let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
645                        source: e,
646                        path: path_str.clone(),
647                    })?;
648                    if let Some(fragment) = parsers::parse_pyproject_toml(&content, &path_str)? {
649                        sourced_config.merge(fragment);
650                        sourced_config.loaded_files.push(path_str);
651                    }
652                } else if filename == ".rumdl.toml" || filename == "rumdl.toml" {
653                    let content = std::fs::read_to_string(&config_file).map_err(|e| ConfigError::IoError {
654                        source: e,
655                        path: path_str.clone(),
656                    })?;
657                    let fragment = parsers::parse_rumdl_toml(&content, &path_str, ConfigSource::ProjectConfig)?;
658                    sourced_config.merge(fragment);
659                    sourced_config.loaded_files.push(path_str);
660                }
661            } else {
662                // No rumdl project config - try markdownlint config
663                log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
664
665                if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward() {
666                    let path_str = markdownlint_path.display().to_string();
667                    log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
668                    match parsers::load_from_markdownlint(&path_str) {
669                        Ok(fragment) => {
670                            sourced_config.merge(fragment);
671                            sourced_config.loaded_files.push(path_str);
672                        }
673                        Err(_e) => {
674                            log::debug!("[rumdl-config] Failed to load markdownlint config, trying user config");
675                            Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
676                        }
677                    }
678                } else {
679                    // No project config at all - use user config as fallback
680                    log::debug!("[rumdl-config] No project config found, using user config as fallback");
681                    Self::load_user_config_as_fallback(&mut sourced_config, user_config_dir)?;
682                }
683            }
684        }
685
686        // Apply CLI overrides (highest precedence)
687        if let Some(cli) = cli_overrides {
688            sourced_config
689                .global
690                .enable
691                .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
692            sourced_config
693                .global
694                .disable
695                .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
696            sourced_config
697                .global
698                .exclude
699                .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
700            sourced_config
701                .global
702                .include
703                .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
704            sourced_config.global.respect_gitignore.merge_override(
705                cli.respect_gitignore.value,
706                ConfigSource::Cli,
707                None,
708                None,
709            );
710            sourced_config
711                .global
712                .fixable
713                .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
714            sourced_config
715                .global
716                .unfixable
717                .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
718            // No rule-specific CLI overrides implemented yet
719        }
720
721        // Unknown keys are now collected during parsing and validated via validate_config_sourced()
722
723        Ok(sourced_config)
724    }
725
726    /// Load and merge configurations from files and CLI overrides.
727    /// If skip_auto_discovery is true, only explicit config paths are loaded.
728    pub fn load_with_discovery(
729        config_path: Option<&str>,
730        cli_overrides: Option<&SourcedGlobalConfig>,
731        skip_auto_discovery: bool,
732    ) -> Result<Self, ConfigError> {
733        Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
734    }
735
736    /// Validate the configuration against a rule registry.
737    ///
738    /// This method transitions the config from `ConfigLoaded` to `ConfigValidated` state,
739    /// enabling conversion to `Config`. Validation warnings are stored in the config
740    /// and can be displayed to the user.
741    ///
742    /// # Example
743    ///
744    /// ```ignore
745    /// let loaded = SourcedConfig::load_with_discovery(path, None, false)?;
746    /// let validated = loaded.validate(&registry)?;
747    /// let config: Config = validated.into();
748    /// ```
749    pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
750        let warnings = validate_config_sourced_internal(&self, registry);
751
752        Ok(SourcedConfig {
753            global: self.global,
754            per_file_ignores: self.per_file_ignores,
755            per_file_flavor: self.per_file_flavor,
756            code_block_tools: self.code_block_tools,
757            rules: self.rules,
758            loaded_files: self.loaded_files,
759            unknown_keys: self.unknown_keys,
760            project_root: self.project_root,
761            validation_warnings: warnings,
762            _state: PhantomData,
763        })
764    }
765
766    /// Validate and convert to Config in one step (convenience method).
767    ///
768    /// This combines `validate()` and `into()` for callers who want the
769    /// validation warnings separately.
770    pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
771        let validated = self.validate(registry)?;
772        let warnings = validated.validation_warnings.clone();
773        Ok((validated.into(), warnings))
774    }
775
776    /// Skip validation and convert directly to ConfigValidated state.
777    ///
778    /// # Safety
779    ///
780    /// This method bypasses validation. Use only when:
781    /// - You've already validated via `validate_config_sourced()`
782    /// - You're in test code that doesn't need validation
783    /// - You're migrating legacy code and will add proper validation later
784    ///
785    /// Prefer `validate()` for new code.
786    pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
787        SourcedConfig {
788            global: self.global,
789            per_file_ignores: self.per_file_ignores,
790            per_file_flavor: self.per_file_flavor,
791            code_block_tools: self.code_block_tools,
792            rules: self.rules,
793            loaded_files: self.loaded_files,
794            unknown_keys: self.unknown_keys,
795            project_root: self.project_root,
796            validation_warnings: Vec::new(),
797            _state: PhantomData,
798        }
799    }
800}
801
802/// Convert a validated configuration to the final Config type.
803///
804/// This implementation only exists for `SourcedConfig<ConfigValidated>`,
805/// ensuring that validation must occur before conversion.
806impl From<SourcedConfig<ConfigValidated>> for Config {
807    fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
808        let mut rules = BTreeMap::new();
809        for (rule_name, sourced_rule_cfg) in sourced.rules {
810            // Normalize rule name to uppercase for case-insensitive lookup
811            let normalized_rule_name = rule_name.to_ascii_uppercase();
812            let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
813            let mut values = BTreeMap::new();
814            for (key, sourced_val) in sourced_rule_cfg.values {
815                values.insert(key, sourced_val.value);
816            }
817            rules.insert(normalized_rule_name, RuleConfig { severity, values });
818        }
819        // Enable is "explicit" if it was set by something other than the Default source
820        let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
821
822        #[allow(deprecated)]
823        let global = GlobalConfig {
824            enable: sourced.global.enable.value,
825            disable: sourced.global.disable.value,
826            exclude: sourced.global.exclude.value,
827            include: sourced.global.include.value,
828            respect_gitignore: sourced.global.respect_gitignore.value,
829            line_length: sourced.global.line_length.value,
830            output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
831            fixable: sourced.global.fixable.value,
832            unfixable: sourced.global.unfixable.value,
833            flavor: sourced.global.flavor.value,
834            force_exclude: sourced.global.force_exclude.value,
835            cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
836            cache: sourced.global.cache.value,
837            extend_enable: sourced.global.extend_enable.value,
838            extend_disable: sourced.global.extend_disable.value,
839            enable_is_explicit,
840        };
841
842        // Backward compatibility bridge: per-rule `enabled = true` → extend_enable
843        // For opt-in rules (MD060, MD063, MD072, MD073), if the per-rule config
844        // has `enabled = true`, add the rule to extend_enable and emit a warning.
845        let opt_in_with_enabled = ["MD060", "MD063", "MD072", "MD073"];
846        let mut global = global;
847        for rule_name in opt_in_with_enabled {
848            if let Some(rule_cfg) = rules.get(rule_name)
849                && let Some(toml::Value::Boolean(true)) = rule_cfg.values.get("enabled")
850                && !global.extend_enable.contains(&rule_name.to_string())
851            {
852                log::warn!(
853                    "[DEPRECATED] [{rule_name}] enabled = true is deprecated. \
854                     Use `extend-enable = [\"{rule_name}\"]` in [global] instead.",
855                );
856                global.extend_enable.push(rule_name.to_string());
857            }
858        }
859
860        Config {
861            global,
862            per_file_ignores: sourced.per_file_ignores.value,
863            per_file_flavor: sourced.per_file_flavor.value,
864            code_block_tools: sourced.code_block_tools.value,
865            rules,
866            project_root: sourced.project_root,
867            per_file_ignores_cache: Arc::new(OnceLock::new()),
868            per_file_flavor_cache: Arc::new(OnceLock::new()),
869        }
870    }
871}