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