Skip to main content

rumdl_lib/config/
loading.rs

1use indexmap::IndexSet;
2use std::collections::BTreeMap;
3use std::marker::PhantomData;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, OnceLock};
6
7use super::flavor::ConfigLoaded;
8use super::flavor::ConfigValidated;
9use super::parsers;
10use super::registry::RuleRegistry;
11use super::source_tracking::{
12    ConfigSource, ConfigValidationWarning, SourcedConfig, SourcedConfigFragment, SourcedGlobalConfig, SourcedValue,
13};
14use super::types::{Config, ConfigError, GlobalConfig, MARKDOWNLINT_CONFIG_FILES, RUMDL_CONFIG_FILES, RuleConfig};
15use super::validation::validate_config_sourced_internal;
16
17/// Maximum depth for extends chains to prevent runaway recursion
18const MAX_EXTENDS_DEPTH: usize = 10;
19
20/// Cheap pre-filter for whether a `pyproject.toml` declares rumdl config.
21///
22/// Matches the flat section header `[tool.rumdl]` as well as dotted sections
23/// like `[tool.rumdl.MD013]` or `[tool.rumdl.rules.MD007]` (which are valid on
24/// their own, without a flat header). Requiring the leading `[` avoids matching
25/// a bare `tool.rumdl` in prose or dependency names; a literal `[tool.rumdl...`
26/// inside a comment or string would still match, but the subsequent parse
27/// handles that gracefully.
28fn pyproject_declares_rumdl_config(content: &str) -> bool {
29    content.contains("[tool.rumdl]") || content.contains("[tool.rumdl.")
30}
31
32/// Resolve an `extends` path relative to the config file that contains it.
33///
34/// - `~/` prefix: expanded to home directory
35/// - Relative paths: resolved against the config file's parent directory
36/// - Absolute paths: used as-is
37fn resolve_extends_path(extends_value: &str, config_file_path: &Path) -> PathBuf {
38    if let Some(suffix) = extends_value.strip_prefix("~/") {
39        // Expand tilde to home directory
40        #[cfg(feature = "native")]
41        {
42            use etcetera::{BaseStrategy, choose_base_strategy};
43            let home = choose_base_strategy().map_or_else(|_| PathBuf::from("~"), |s| s.home_dir().to_path_buf());
44            home.join(suffix)
45        }
46        #[cfg(not(feature = "native"))]
47        {
48            let _ = suffix;
49            PathBuf::from(extends_value)
50        }
51    } else {
52        let path = PathBuf::from(extends_value);
53        if path.is_absolute() {
54            path
55        } else {
56            // Resolve relative to config file's directory
57            let config_dir = config_file_path.parent().unwrap_or(Path::new("."));
58            config_dir.join(extends_value)
59        }
60    }
61}
62
63/// Determine ConfigSource from a config filename.
64fn source_from_filename(filename: &str) -> ConfigSource {
65    if filename == "pyproject.toml" {
66        ConfigSource::PyprojectToml
67    } else {
68        ConfigSource::ProjectConfig
69    }
70}
71
72/// The rumdl-native config files that actually exist in `dir`, in precedence order.
73///
74/// Walks `RUMDL_CONFIG_FILES` (the single source of truth for discovery) joined onto
75/// `dir`, so `.config/rumdl.toml` is recognised at the same level as `.rumdl.toml`.
76/// `pyproject.toml` counts only when it declares `[tool.rumdl]`. markdownlint configs
77/// are intentionally excluded: they are a separate fallback tier, not a same-tool
78/// collision, and projects routinely keep one around while migrating.
79pub(crate) fn rumdl_configs_in_dir(dir: &Path) -> Vec<PathBuf> {
80    RUMDL_CONFIG_FILES
81        .iter()
82        .map(|name| dir.join(name))
83        .filter(|path| {
84            if !path.exists() {
85                return false;
86            }
87            if path.file_name().and_then(|n| n.to_str()) == Some("pyproject.toml") {
88                std::fs::read_to_string(path).is_ok_and(|content| pyproject_declares_rumdl_config(&content))
89            } else {
90                true
91            }
92        })
93        .collect()
94}
95
96/// A directory holding more than one rumdl-native config file.
97///
98/// `winner` is the file discovery uses (highest precedence); `shadowed` are the
99/// silently-ignored siblings. Having both `.rumdl.toml` and `rumdl.toml` (or either
100/// plus a `[tool.rumdl]` in `pyproject.toml`) in one directory is redundant by
101/// construction and a common footgun: editing the shadowed file appears to do
102/// nothing. Resolution is unchanged (the dot file still wins, matching Ruff); this
103/// type only lets callers surface the collision.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub(crate) struct ShadowedConfigs {
106    pub dir: PathBuf,
107    pub winner: PathBuf,
108    pub shadowed: Vec<PathBuf>,
109}
110
111/// Detect rumdl-native config files that shadow each other in `dir`.
112///
113/// Returns `None` unless two or more rumdl-native configs coexist at this directory
114/// level (markdownlint files and configs in other directories never count). The
115/// highest-precedence file is the `winner`; the rest are silently `shadowed`.
116pub(crate) fn detect_shadowed_configs(dir: &Path) -> Option<ShadowedConfigs> {
117    let mut configs = rumdl_configs_in_dir(dir);
118    if configs.len() < 2 {
119        return None;
120    }
121    let winner = configs.remove(0);
122    Some(ShadowedConfigs {
123        dir: dir.to_path_buf(),
124        winner,
125        shadowed: configs,
126    })
127}
128
129/// Format a shadowed-config collision as a single user-facing warning line.
130///
131/// The directory is named once; the winner and shadowed files are shown relative
132/// to it (e.g. `.rumdl.toml`, `.config/rumdl.toml`) rather than repeating the full
133/// directory in every path. Paths are normalized to forward slashes on Windows for
134/// stable, copy-pasteable output; non-UTF-8 components degrade lossily rather than
135/// panicking.
136pub(crate) fn format_shadow_warning(shadow: &ShadowedConfigs) -> String {
137    let norm = |s: String| if cfg!(windows) { s.replace('\\', "/") } else { s };
138    let rel = |path: &Path| {
139        let relative = path.strip_prefix(&shadow.dir).unwrap_or(path);
140        norm(relative.to_string_lossy().into_owned())
141    };
142    let shadowed = shadow.shadowed.iter().map(|p| rel(p)).collect::<Vec<_>>().join(", ");
143    format!(
144        "multiple rumdl config files in {}: using {}, ignoring {}",
145        norm(shadow.dir.to_string_lossy().into_owned()),
146        rel(&shadow.winner),
147        shadowed,
148    )
149}
150
151/// Load a config file (and any base configs it extends) into a SourcedConfig.
152///
153/// This function handles the recursive `extends` chain:
154/// 1. Parse the config file into a fragment
155/// 2. If the fragment has `extends`, recursively load the base config first
156/// 3. Merge the base config, then merge this fragment on top
157fn load_config_with_extends(
158    sourced_config: &mut SourcedConfig<ConfigLoaded>,
159    config_file_path: &Path,
160    visited: &mut IndexSet<PathBuf>,
161    chain_source: ConfigSource,
162) -> Result<(), ConfigError> {
163    // Canonicalize the path for circular reference detection
164    let canonical = config_file_path
165        .canonicalize()
166        .unwrap_or_else(|_| config_file_path.to_path_buf());
167
168    // Check for circular references
169    if visited.contains(&canonical) {
170        let chain: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
171        return Err(ConfigError::CircularExtends {
172            path: config_file_path.display().to_string(),
173            chain,
174        });
175    }
176
177    // Check depth limit
178    if visited.len() >= MAX_EXTENDS_DEPTH {
179        return Err(ConfigError::ExtendsDepthExceeded {
180            path: config_file_path.display().to_string(),
181            max_depth: MAX_EXTENDS_DEPTH,
182        });
183    }
184
185    // Mark as visited
186    visited.insert(canonical);
187
188    let path_str = config_file_path.display().to_string();
189    let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
190
191    // Read and parse the config file
192    let content = std::fs::read_to_string(config_file_path).map_err(|e| ConfigError::IoError {
193        source: e,
194        path: path_str.clone(),
195    })?;
196
197    let fragment = if filename == "pyproject.toml" {
198        match parsers::parse_pyproject_toml(&content, &path_str, chain_source)? {
199            Some(f) => f,
200            None => return Ok(()), // No [tool.rumdl] section
201        }
202    } else {
203        parsers::parse_rumdl_toml(&content, &path_str, chain_source)?
204    };
205
206    // If this fragment has `extends`, load the base config first
207    if let Some(ref extends_value) = fragment.extends {
208        let base_path = resolve_extends_path(extends_value, config_file_path);
209
210        if !base_path.exists() {
211            return Err(ConfigError::ExtendsNotFound {
212                path: base_path.display().to_string(),
213                from: path_str.clone(),
214            });
215        }
216
217        log::debug!(
218            "[rumdl-config] Config {} extends {}, loading base first",
219            path_str,
220            base_path.display()
221        );
222
223        // Recursively load the base config
224        load_config_with_extends(sourced_config, &base_path, visited, chain_source)?;
225    }
226
227    // Merge this fragment on top (base config was already merged if present)
228    // Strip the `extends` field since it's been consumed
229    let mut fragment_for_merge = fragment;
230    fragment_for_merge.extends = None;
231    sourced_config.merge(fragment_for_merge);
232    sourced_config.loaded_files.push(path_str);
233
234    Ok(())
235}
236
237impl SourcedConfig<ConfigLoaded> {
238    /// Merges another SourcedConfigFragment into this SourcedConfig.
239    /// Uses source precedence to determine which values take effect.
240    pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
241        // Merge global config
242        // Enable uses replace semantics (project can enforce rules)
243        self.global.enable.merge_override(
244            fragment.global.enable.value,
245            fragment.global.enable.source,
246            fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
247            fragment.global.enable.overrides.first().and_then(|o| o.line),
248        );
249
250        // Disable uses replace semantics (child config overrides parent, matching Ruff's `ignore`)
251        self.global.disable.merge_override(
252            fragment.global.disable.value,
253            fragment.global.disable.source,
254            fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
255            fragment.global.disable.overrides.first().and_then(|o| o.line),
256        );
257
258        // Extend-enable uses union semantics (additive across config levels)
259        self.global.extend_enable.merge_union(
260            fragment.global.extend_enable.value,
261            fragment.global.extend_enable.source,
262            fragment
263                .global
264                .extend_enable
265                .overrides
266                .first()
267                .and_then(|o| o.file.clone()),
268            fragment.global.extend_enable.overrides.first().and_then(|o| o.line),
269        );
270
271        // Extend-disable uses union semantics (additive across config levels)
272        self.global.extend_disable.merge_union(
273            fragment.global.extend_disable.value,
274            fragment.global.extend_disable.source,
275            fragment
276                .global
277                .extend_disable
278                .overrides
279                .first()
280                .and_then(|o| o.file.clone()),
281            fragment.global.extend_disable.overrides.first().and_then(|o| o.line),
282        );
283
284        // Conflict resolution: Enable overrides disable
285        // Remove any rules from disable that appear in enable
286        self.global
287            .disable
288            .value
289            .retain(|rule| !self.global.enable.value.contains(rule));
290        self.global.include.merge_override(
291            fragment.global.include.value,
292            fragment.global.include.source,
293            fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
294            fragment.global.include.overrides.first().and_then(|o| o.line),
295        );
296        self.global.exclude.merge_override(
297            fragment.global.exclude.value,
298            fragment.global.exclude.source,
299            fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
300            fragment.global.exclude.overrides.first().and_then(|o| o.line),
301        );
302        self.global.respect_gitignore.merge_override(
303            fragment.global.respect_gitignore.value,
304            fragment.global.respect_gitignore.source,
305            fragment
306                .global
307                .respect_gitignore
308                .overrides
309                .first()
310                .and_then(|o| o.file.clone()),
311            fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
312        );
313        self.global.line_length.merge_override(
314            fragment.global.line_length.value,
315            fragment.global.line_length.source,
316            fragment
317                .global
318                .line_length
319                .overrides
320                .first()
321                .and_then(|o| o.file.clone()),
322            fragment.global.line_length.overrides.first().and_then(|o| o.line),
323        );
324        self.global.fixable.merge_override(
325            fragment.global.fixable.value,
326            fragment.global.fixable.source,
327            fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
328            fragment.global.fixable.overrides.first().and_then(|o| o.line),
329        );
330        self.global.unfixable.merge_override(
331            fragment.global.unfixable.value,
332            fragment.global.unfixable.source,
333            fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
334            fragment.global.unfixable.overrides.first().and_then(|o| o.line),
335        );
336
337        // Merge flavor
338        self.global.flavor.merge_override(
339            fragment.global.flavor.value,
340            fragment.global.flavor.source,
341            fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
342            fragment.global.flavor.overrides.first().and_then(|o| o.line),
343        );
344
345        // Merge force_exclude
346        self.global.force_exclude.merge_override(
347            fragment.global.force_exclude.value,
348            fragment.global.force_exclude.source,
349            fragment
350                .global
351                .force_exclude
352                .overrides
353                .first()
354                .and_then(|o| o.file.clone()),
355            fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
356        );
357
358        // Merge output_format if present
359        if let Some(output_format_fragment) = fragment.global.output_format {
360            if let Some(ref mut output_format) = self.global.output_format {
361                output_format.merge_override(
362                    output_format_fragment.value,
363                    output_format_fragment.source,
364                    output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
365                    output_format_fragment.overrides.first().and_then(|o| o.line),
366                );
367            } else {
368                self.global.output_format = Some(output_format_fragment);
369            }
370        }
371
372        // Merge cache_dir if present
373        if let Some(cache_dir_fragment) = fragment.global.cache_dir {
374            if let Some(ref mut cache_dir) = self.global.cache_dir {
375                cache_dir.merge_override(
376                    cache_dir_fragment.value,
377                    cache_dir_fragment.source,
378                    cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
379                    cache_dir_fragment.overrides.first().and_then(|o| o.line),
380                );
381            } else {
382                self.global.cache_dir = Some(cache_dir_fragment);
383            }
384        }
385
386        // Merge cache if not default (only override when explicitly set)
387        if fragment.global.cache.source != ConfigSource::Default {
388            self.global.cache.merge_override(
389                fragment.global.cache.value,
390                fragment.global.cache.source,
391                fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
392                fragment.global.cache.overrides.first().and_then(|o| o.line),
393            );
394        }
395
396        // Merge per_file_ignores
397        self.per_file_ignores.merge_override(
398            fragment.per_file_ignores.value,
399            fragment.per_file_ignores.source,
400            fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
401            fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
402        );
403
404        // Merge per_file_flavor
405        self.per_file_flavor.merge_override(
406            fragment.per_file_flavor.value,
407            fragment.per_file_flavor.source,
408            fragment.per_file_flavor.overrides.first().and_then(|o| o.file.clone()),
409            fragment.per_file_flavor.overrides.first().and_then(|o| o.line),
410        );
411
412        // Merge code_block_tools
413        self.code_block_tools.merge_override(
414            fragment.code_block_tools.value,
415            fragment.code_block_tools.source,
416            fragment.code_block_tools.overrides.first().and_then(|o| o.file.clone()),
417            fragment.code_block_tools.overrides.first().and_then(|o| o.line),
418        );
419
420        // Merge rule configs
421        for (rule_name, rule_fragment) in fragment.rules {
422            let norm_rule_name = rule_name.to_ascii_uppercase(); // Normalize to uppercase for case-insensitivity
423            let rule_entry = self.rules.entry(norm_rule_name).or_default();
424
425            // Merge severity if present in fragment
426            if let Some(severity_fragment) = rule_fragment.severity {
427                if let Some(ref mut existing_severity) = rule_entry.severity {
428                    existing_severity.merge_override(
429                        severity_fragment.value,
430                        severity_fragment.source,
431                        severity_fragment.overrides.first().and_then(|o| o.file.clone()),
432                        severity_fragment.overrides.first().and_then(|o| o.line),
433                    );
434                } else {
435                    rule_entry.severity = Some(severity_fragment);
436                }
437            }
438
439            // Merge values
440            for (key, sourced_value_fragment) in rule_fragment.values {
441                let sv_entry = rule_entry
442                    .values
443                    .entry(key.clone())
444                    .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
445                let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
446                let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
447                sv_entry.merge_override(
448                    sourced_value_fragment.value,  // Use the value from the fragment
449                    sourced_value_fragment.source, // Use the source from the fragment
450                    file_from_fragment,            // Pass the file path from the fragment override
451                    line_from_fragment,            // Pass the line number from the fragment override
452                );
453            }
454        }
455
456        // Merge unknown_keys from fragment
457        for (section, key, file_path) in fragment.unknown_keys {
458            // Deduplicate: only add if not already present
459            if !self.unknown_keys.iter().any(|(s, k, _)| s == &section && k == &key) {
460                self.unknown_keys.push((section, key, file_path));
461            }
462        }
463    }
464
465    /// Load and merge configurations from files and CLI overrides.
466    pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
467        Self::load_with_discovery(config_path, cli_overrides, false)
468    }
469
470    /// Finds project root by walking up from start_dir looking for .git directory.
471    /// Falls back to start_dir if no .git found.
472    fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
473        // Convert relative paths to absolute to ensure correct traversal
474        let mut current = if start_dir.is_relative() {
475            std::env::current_dir().map_or_else(|_| start_dir.to_path_buf(), |cwd| cwd.join(start_dir))
476        } else {
477            start_dir.to_path_buf()
478        };
479        const MAX_DEPTH: usize = 100;
480
481        for _ in 0..MAX_DEPTH {
482            if current.join(".git").exists() {
483                log::debug!("[rumdl-config] Found .git at: {}", current.display());
484                return current;
485            }
486
487            match current.parent() {
488                Some(parent) => current = parent.to_path_buf(),
489                None => break,
490            }
491        }
492
493        // No .git found, use start_dir as project root
494        log::debug!(
495            "[rumdl-config] No .git found, using config location as project root: {}",
496            start_dir.display()
497        );
498        start_dir.to_path_buf()
499    }
500
501    /// Resolve the home-directory boundary used to stop project-config discovery.
502    ///
503    /// `home_override` wins (supplied by tests); otherwise the real home is resolved on
504    /// native builds via `etcetera`. Wasm has no home/project walk to bound, so it
505    /// returns `None` there.
506    fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
507        home_override.map(Path::to_path_buf).or_else(|| {
508            #[cfg(feature = "native")]
509            {
510                use etcetera::{BaseStrategy, choose_base_strategy};
511                choose_base_strategy().ok().map(|s| s.home_dir().to_path_buf())
512            }
513            #[cfg(not(feature = "native"))]
514            {
515                None
516            }
517        })
518    }
519
520    /// Whether `current_dir` is the home-directory boundary. Compares canonically so
521    /// differing path representations (Windows 8.3 short names, Unix symlinks) match,
522    /// falling back to a literal comparison when canonicalization fails.
523    fn at_home_boundary(current_dir: &Path, home_dir: Option<&Path>, canonical_home: Option<&Path>) -> bool {
524        match (canonical_home, std::fs::canonicalize(current_dir).ok()) {
525            (Some(home), Some(current)) => current == home,
526            _ => home_dir == Some(current_dir),
527        }
528    }
529
530    /// Discover configuration file by traversing up the directory tree.
531    /// Returns the first configuration file found.
532    /// Discovers config file and returns both the config path and project root.
533    /// Returns: (config_file_path, project_root_path)
534    /// Project root is the directory containing .git, or config parent as fallback.
535    ///
536    /// The walk stops at the home directory: a config file located in `$HOME`
537    /// itself is user-level, not a project config, and must reach the loader only
538    /// through the user-config fallback (`load_user_config`) so the platform
539    /// user-config directory keeps precedence over `~/.rumdl.toml`. `home_override`
540    /// supplies the boundary for tests; production resolves the real home directory.
541    fn discover_config_upward(
542        home_override: Option<&Path>,
543    ) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
544        use std::env;
545
546        const MAX_DEPTH: usize = 100; // Prevent infinite traversal
547
548        let start_dir = match env::current_dir() {
549            Ok(dir) => dir,
550            Err(e) => {
551                log::debug!("[rumdl-config] Failed to get current directory: {e}");
552                return None;
553            }
554        };
555
556        // Resolve the home-directory boundary so a user-level `~/.rumdl.toml` is never
557        // treated as a project config; it reaches the loader only via the user-config
558        // fallback, which keeps the platform user-config dir ahead of the home dotfile.
559        let home_dir = Self::resolve_home_boundary(home_override);
560        let canonical_home = home_dir.as_deref().and_then(|h| std::fs::canonicalize(h).ok());
561
562        let mut current_dir = start_dir.clone();
563        let mut depth = 0;
564        // (winning config path, directory it was found in, any shadowed siblings there)
565        let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> = None;
566
567        loop {
568            if depth >= MAX_DEPTH {
569                log::debug!("[rumdl-config] Maximum traversal depth reached");
570                break;
571            }
572
573            // Stop at the home directory: any config already found below home is
574            // returned; home and above are left to the user-config fallback.
575            if Self::at_home_boundary(&current_dir, home_dir.as_deref(), canonical_home.as_deref()) {
576                log::debug!("[rumdl-config] Reached home directory boundary; stopping project discovery");
577                break;
578            }
579
580            log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
581
582            // Check for config files in order of precedence (only if not already found).
583            // `rumdl_configs_in_dir` is the single source of truth for "which rumdl
584            // configs live here", shared with the LSP and the shadow detector, so the
585            // winner and the silently-shadowed siblings are computed identically.
586            if found_config.is_none()
587                && let Some(winner) = rumdl_configs_in_dir(&current_dir).into_iter().next()
588            {
589                log::debug!("[rumdl-config] Found config file: {}", winner.display());
590                let shadow = detect_shadowed_configs(&current_dir);
591                // Store config, but continue looking for .git
592                found_config = Some((winner, current_dir.clone(), shadow));
593            }
594
595            // Check for .git directory (stop boundary)
596            if current_dir.join(".git").exists() {
597                log::debug!("[rumdl-config] Stopping at .git directory");
598                break;
599            }
600
601            // Move to parent directory
602            match current_dir.parent() {
603                Some(parent) => {
604                    current_dir = parent.to_owned();
605                    depth += 1;
606                }
607                None => {
608                    log::debug!("[rumdl-config] Reached filesystem root");
609                    break;
610                }
611            }
612        }
613
614        // If config found, determine project root by walking up from config location
615        if let Some((config_path, config_dir, shadow)) = found_config {
616            let project_root = Self::find_project_root_from(&config_dir);
617            return Some((config_path, project_root, shadow));
618        }
619
620        None
621    }
622
623    /// Discover markdownlint configuration file by traversing up the directory tree.
624    /// Similar to discover_config_upward but for .markdownlint.yaml/json files, and
625    /// bounded at the home directory for the same reason: a markdownlint config in
626    /// `$HOME` is user-level, not a project config.
627    fn discover_markdownlint_config_upward(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
628        use std::env;
629
630        const MAX_DEPTH: usize = 100;
631
632        let start_dir = match env::current_dir() {
633            Ok(dir) => dir,
634            Err(e) => {
635                log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
636                return None;
637            }
638        };
639
640        let home_dir = Self::resolve_home_boundary(home_override);
641        let canonical_home = home_dir.as_deref().and_then(|h| std::fs::canonicalize(h).ok());
642
643        let mut current_dir = start_dir.clone();
644        let mut depth = 0;
645
646        loop {
647            if depth >= MAX_DEPTH {
648                log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
649                break;
650            }
651
652            // Stop at the home directory, mirroring rumdl config discovery.
653            if Self::at_home_boundary(&current_dir, home_dir.as_deref(), canonical_home.as_deref()) {
654                log::debug!("[rumdl-config] Reached home directory boundary; stopping markdownlint discovery");
655                break;
656            }
657
658            log::debug!(
659                "[rumdl-config] Searching for markdownlint config in: {}",
660                current_dir.display()
661            );
662
663            // Check for markdownlint config files in order of precedence
664            for config_name in MARKDOWNLINT_CONFIG_FILES {
665                let config_path = current_dir.join(config_name);
666                if config_path.exists() {
667                    log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
668                    return Some(config_path);
669                }
670            }
671
672            // Check for .git directory (stop boundary)
673            if current_dir.join(".git").exists() {
674                log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
675                break;
676            }
677
678            // Move to parent directory
679            match current_dir.parent() {
680                Some(parent) => {
681                    current_dir = parent.to_owned();
682                    depth += 1;
683                }
684                None => {
685                    log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
686                    break;
687                }
688            }
689        }
690
691        None
692    }
693
694    /// Internal implementation that accepts config directory for testing
695    fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
696        let config_dir = config_dir.join("rumdl");
697
698        // Check for config files in precedence order (same as project discovery)
699        const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
700
701        log::debug!(
702            "[rumdl-config] Checking for user configuration in: {}",
703            config_dir.display()
704        );
705
706        for filename in USER_CONFIG_FILES {
707            let config_path = config_dir.join(filename);
708
709            if config_path.exists() {
710                // For pyproject.toml, verify it contains [tool.rumdl] section
711                if *filename == "pyproject.toml" {
712                    if let Ok(content) = std::fs::read_to_string(&config_path) {
713                        if pyproject_declares_rumdl_config(&content) {
714                            log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
715                            return Some(config_path);
716                        }
717                        log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
718                        continue;
719                    }
720                } else {
721                    log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
722                    return Some(config_path);
723                }
724            }
725        }
726
727        log::debug!(
728            "[rumdl-config] No user configuration found in: {}",
729            config_dir.display()
730        );
731        None
732    }
733
734    /// Discover user-level configuration file from platform-specific config directory.
735    /// Returns the first configuration file found in the user config directory.
736    #[cfg(feature = "native")]
737    fn user_configuration_path() -> Option<std::path::PathBuf> {
738        use etcetera::{BaseStrategy, choose_base_strategy};
739
740        match choose_base_strategy() {
741            Ok(strategy) => {
742                let config_dir = strategy.config_dir();
743                Self::user_configuration_path_impl(&config_dir)
744            }
745            Err(e) => {
746                log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
747                None
748            }
749        }
750    }
751
752    /// Stub for WASM builds - user config not supported
753    #[cfg(not(feature = "native"))]
754    fn user_configuration_path() -> Option<std::path::PathBuf> {
755        None
756    }
757
758    /// Internal implementation that accepts the home directory for testing.
759    ///
760    /// Probes `<home>/.rumdl.toml` then `<home>/rumdl.toml`, returning the first match.
761    ///
762    /// `pyproject.toml` is intentionally **not** searched in `$HOME`, even though
763    /// `user_configuration_path_impl` does check it inside the platform config dir.
764    /// The asymmetry is deliberate: a `pyproject.toml` directly in `$HOME` almost
765    /// always belongs to unrelated python tooling (poetry/uv/pip's user-level config),
766    /// and silently picking it up as a rumdl config would surprise users. The
767    /// platform config dir (`~/.config/rumdl/`) is rumdl-scoped, so the same
768    /// concern doesn't apply there.
769    fn home_configuration_path_impl(home_dir: &Path) -> Option<std::path::PathBuf> {
770        const HOME_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml"];
771
772        log::debug!(
773            "[rumdl-config] Checking for home-directory configuration in: {}",
774            home_dir.display()
775        );
776
777        for filename in HOME_CONFIG_FILES {
778            let config_path = home_dir.join(filename);
779            if config_path.exists() {
780                log::debug!(
781                    "[rumdl-config] Found home-directory configuration at: {}",
782                    config_path.display()
783                );
784                return Some(config_path);
785            }
786        }
787
788        log::debug!(
789            "[rumdl-config] No home-directory configuration found in: {}",
790            home_dir.display()
791        );
792        None
793    }
794
795    /// Discover a home-directory configuration file (`~/.rumdl.toml` or `~/rumdl.toml`).
796    ///
797    /// This is a final fallback after the platform user-config directory
798    /// (`user_configuration_path`). It honors the classic Unix dotfile convention so
799    /// users who keep tool config in `$HOME` rather than `$XDG_CONFIG_HOME` are picked up.
800    #[cfg(feature = "native")]
801    fn home_configuration_path() -> Option<std::path::PathBuf> {
802        use etcetera::{BaseStrategy, choose_base_strategy};
803
804        match choose_base_strategy() {
805            Ok(strategy) => Self::home_configuration_path_impl(strategy.home_dir()),
806            Err(e) => {
807                log::debug!("[rumdl-config] Failed to determine home directory: {e}");
808                None
809            }
810        }
811    }
812
813    /// Stub for WASM builds - home config not supported
814    #[cfg(not(feature = "native"))]
815    fn home_configuration_path() -> Option<std::path::PathBuf> {
816        None
817    }
818
819    /// Load an explicit config file (standalone, no user config merging)
820    fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
821        let path_obj = Path::new(path);
822        let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
823        let path_str = path.to_string();
824
825        log::debug!("[rumdl-config] Loading explicit config file: {filename}");
826
827        // Find project root by walking up from config location looking for .git
828        if let Some(config_parent) = path_obj.parent() {
829            let project_root = Self::find_project_root_from(config_parent);
830            log::debug!(
831                "[rumdl-config] Project root (from explicit config): {}",
832                project_root.display()
833            );
834            sourced_config.project_root = Some(project_root);
835        }
836
837        // Known markdownlint config files
838        const MARKDOWNLINT_FILENAMES: &[&str] = &[
839            ".markdownlint-cli2.jsonc",
840            ".markdownlint-cli2.yaml",
841            ".markdownlint-cli2.yml",
842            ".markdownlint.json",
843            ".markdownlint.yaml",
844            ".markdownlint.yml",
845        ];
846
847        if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
848            // Use extends-aware loading for rumdl TOML configs
849            let mut visited = IndexSet::new();
850            let chain_source = source_from_filename(filename);
851            load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
852        } else if MARKDOWNLINT_FILENAMES.contains(&filename)
853            || path_str.ends_with(".json")
854            || path_str.ends_with(".jsonc")
855            || path_str.ends_with(".yaml")
856            || path_str.ends_with(".yml")
857        {
858            // Parse as markdownlint config (JSON/YAML) - no extends support
859            let fragment = parsers::load_from_markdownlint(&path_str)?;
860            sourced_config.merge(fragment);
861            sourced_config.loaded_files.push(path_str);
862        } else {
863            // Try TOML with extends support
864            let mut visited = IndexSet::new();
865            let chain_source = source_from_filename(filename);
866            load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
867        }
868
869        Ok(())
870    }
871
872    /// Load and merge user-level configuration into this `SourcedConfig`.
873    ///
874    /// Discovers the user config file in this order, taking the first match:
875    /// 1. Platform user-config directory (XDG on Linux, `~/Library/Application Support`
876    ///    on macOS, `%APPDATA%` on Windows). Override with `user_config_dir` for tests.
877    /// 2. Home-directory dotfile (`~/.rumdl.toml`, then `~/rumdl.toml`). Override with
878    ///    `home_dir` for tests. Honors the classic Unix dotfile convention.
879    ///
880    /// Resolves any `extends` chain and merges each fragment with
881    /// `ConfigSource::UserConfig` precedence.
882    ///
883    /// Called in two contexts:
884    /// - When no project config is found: provides user defaults as the sole base
885    /// - When a markdownlint project config is found: provides rumdl-specific
886    ///   defaults that the markdownlint format cannot express; the markdownlint
887    ///   fragment is merged on top and wins on any overlapping key
888    fn load_user_config(
889        sourced_config: &mut Self,
890        user_config_dir: Option<&Path>,
891        home_dir: Option<&Path>,
892    ) -> Result<(), ConfigError> {
893        let user_config_path = if let Some(dir) = user_config_dir {
894            Self::user_configuration_path_impl(dir)
895        } else {
896            Self::user_configuration_path()
897        };
898
899        let user_config_path = user_config_path.or_else(|| match home_dir {
900            Some(home) => Self::home_configuration_path_impl(home),
901            None => Self::home_configuration_path(),
902        });
903
904        if let Some(user_config_path) = user_config_path {
905            let path_str = user_config_path.display().to_string();
906
907            log::debug!("[rumdl-config] Loading user config: {path_str}");
908
909            // User config fallback also supports extends chains.
910            // Use a uniform source across the chain so child overrides are determined by chain order.
911            let mut visited = IndexSet::new();
912            load_config_with_extends(
913                sourced_config,
914                &user_config_path,
915                &mut visited,
916                ConfigSource::UserConfig,
917            )?;
918        } else {
919            log::debug!("[rumdl-config] No user configuration file found");
920        }
921
922        Ok(())
923    }
924
925    /// Internal implementation that accepts user config directory and home directory for testing
926    #[doc(hidden)]
927    pub fn load_with_discovery_impl(
928        config_path: Option<&str>,
929        cli_overrides: Option<&SourcedGlobalConfig>,
930        skip_auto_discovery: bool,
931        user_config_dir: Option<&Path>,
932        home_dir: Option<&Path>,
933    ) -> Result<Self, ConfigError> {
934        use std::env;
935        log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
936
937        let mut sourced_config = SourcedConfig::default();
938
939        // Ruff model: Project config is standalone, user config is fallback only
940        //
941        // Priority order:
942        // 1. If explicit config path provided → use ONLY that (standalone)
943        // 2. Else if project config discovered → use ONLY that (standalone)
944        // 3. Else if user config exists → use it as fallback
945        // 4. CLI overrides always apply last
946        //
947        // This ensures project configs are reproducible across machines and
948        // CI/local runs behave identically.
949
950        // Explicit config path always takes precedence
951        if let Some(path) = config_path {
952            // Explicit config path provided - use ONLY this config (standalone)
953            log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
954            Self::load_explicit_config(&mut sourced_config, path)?;
955        } else if skip_auto_discovery {
956            log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
957            // No config loading, just apply CLI overrides at the end
958        } else {
959            // No explicit path - try auto-discovery
960            log::debug!("[rumdl-config] No explicit config_path, searching default locations");
961
962            // Try to discover project config first
963            if let Some((config_file, project_root, shadow)) = Self::discover_config_upward(home_dir) {
964                // Project config found - use ONLY this (standalone, no user config).
965                // Rumdl project configs can express all settings directly, so user config
966                // is not needed and omitting it ensures CI and local runs are identical.
967                log::debug!("[rumdl-config] Found project config: {}", config_file.display());
968                log::debug!("[rumdl-config] Project root: {}", project_root.display());
969
970                // Record any same-directory sibling configs that are silently shadowed,
971                // so the CLI and LSP can warn the user. Resolution is unchanged.
972                if let Some(shadow) = shadow {
973                    sourced_config.discovery_warnings.push(format_shadow_warning(&shadow));
974                }
975
976                sourced_config.project_root = Some(project_root);
977
978                // Use extends-aware loading for discovered configs
979                let mut visited = IndexSet::new();
980                let root_filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
981                let chain_source = source_from_filename(root_filename);
982                load_config_with_extends(&mut sourced_config, &config_file, &mut visited, chain_source)?;
983            } else {
984                // No rumdl project config - try markdownlint config
985                log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
986
987                if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward(home_dir) {
988                    let path_str = markdownlint_path.display().to_string();
989                    log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
990                    // Load user config first as a base so rumdl-specific settings (e.g. flavor,
991                    // cache) take effect. Markdownlint configs cannot express these settings.
992                    // The markdownlint fragment uses ConfigSource::ProjectConfig (precedence 3)
993                    // vs UserConfig (precedence 1), so project settings always win on overlap.
994                    Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
995                    match parsers::load_from_markdownlint(&path_str) {
996                        Ok(fragment) => {
997                            sourced_config.merge(fragment);
998                            sourced_config.loaded_files.push(path_str);
999                        }
1000                        Err(_e) => {
1001                            log::debug!("[rumdl-config] Failed to load markdownlint config");
1002                        }
1003                    }
1004                } else {
1005                    // No project config at all - use user config as fallback
1006                    log::debug!("[rumdl-config] No project config found, using user config as fallback");
1007                    Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
1008                }
1009            }
1010        }
1011
1012        // Apply CLI overrides (highest precedence)
1013        if let Some(cli) = cli_overrides {
1014            sourced_config
1015                .global
1016                .enable
1017                .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
1018            sourced_config
1019                .global
1020                .disable
1021                .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
1022            sourced_config
1023                .global
1024                .exclude
1025                .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
1026            sourced_config
1027                .global
1028                .include
1029                .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
1030            sourced_config.global.respect_gitignore.merge_override(
1031                cli.respect_gitignore.value,
1032                ConfigSource::Cli,
1033                None,
1034                None,
1035            );
1036            sourced_config
1037                .global
1038                .fixable
1039                .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
1040            sourced_config
1041                .global
1042                .unfixable
1043                .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
1044            // No rule-specific CLI overrides implemented yet
1045        }
1046
1047        // Unknown keys are now collected during parsing and validated via validate_config_sourced()
1048
1049        Ok(sourced_config)
1050    }
1051
1052    /// Load and merge configurations from files and CLI overrides.
1053    /// If skip_auto_discovery is true, only explicit config paths are loaded.
1054    pub fn load_with_discovery(
1055        config_path: Option<&str>,
1056        cli_overrides: Option<&SourcedGlobalConfig>,
1057        skip_auto_discovery: bool,
1058    ) -> Result<Self, ConfigError> {
1059        Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
1060    }
1061
1062    /// Validate the configuration against a rule registry.
1063    ///
1064    /// This method transitions the config from `ConfigLoaded` to `ConfigValidated` state,
1065    /// enabling conversion to `Config`. Validation warnings are stored in the config
1066    /// and can be displayed to the user.
1067    ///
1068    /// # Example
1069    ///
1070    /// ```ignore
1071    /// let loaded = SourcedConfig::load_with_discovery(path, None, false)?;
1072    /// let validated = loaded.validate(&registry)?;
1073    /// let config: Config = validated.into();
1074    /// ```
1075    pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
1076        let warnings = validate_config_sourced_internal(&self, registry);
1077
1078        Ok(SourcedConfig {
1079            global: self.global,
1080            per_file_ignores: self.per_file_ignores,
1081            per_file_flavor: self.per_file_flavor,
1082            code_block_tools: self.code_block_tools,
1083            rules: self.rules,
1084            loaded_files: self.loaded_files,
1085            unknown_keys: self.unknown_keys,
1086            project_root: self.project_root,
1087            discovery_warnings: self.discovery_warnings,
1088            validation_warnings: warnings,
1089            _state: PhantomData,
1090        })
1091    }
1092
1093    /// Validate and convert to Config in one step (convenience method).
1094    ///
1095    /// This combines `validate()` and `into()` for callers who want the
1096    /// validation warnings separately.
1097    pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
1098        let validated = self.validate(registry)?;
1099        let warnings = validated.validation_warnings.clone();
1100        Ok((validated.into(), warnings))
1101    }
1102
1103    /// Skip validation and convert directly to ConfigValidated state.
1104    ///
1105    /// # Safety
1106    ///
1107    /// This method bypasses validation. Use only when:
1108    /// - You've already validated via `validate_config_sourced()`
1109    /// - You're in test code that doesn't need validation
1110    /// - You're migrating legacy code and will add proper validation later
1111    ///
1112    /// Prefer `validate()` for new code.
1113    pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
1114        SourcedConfig {
1115            global: self.global,
1116            per_file_ignores: self.per_file_ignores,
1117            per_file_flavor: self.per_file_flavor,
1118            code_block_tools: self.code_block_tools,
1119            rules: self.rules,
1120            loaded_files: self.loaded_files,
1121            unknown_keys: self.unknown_keys,
1122            project_root: self.project_root,
1123            discovery_warnings: self.discovery_warnings,
1124            validation_warnings: Vec::new(),
1125            _state: PhantomData,
1126        }
1127    }
1128
1129    /// Discover the nearest config file for a specific directory,
1130    /// walking upward to `project_root` (inclusive).
1131    ///
1132    /// Searches for rumdl config files (`.rumdl.toml`, `rumdl.toml`,
1133    /// `.config/rumdl.toml`, `pyproject.toml` with `[tool.rumdl]`) and
1134    /// markdownlint config files at each directory level.
1135    ///
1136    /// Returns the config file path if found. Does NOT use CWD.
1137    pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
1138        // The walk keeps `current_dir` in its original representation so the
1139        // returned config path matches what callers passed in. The project-root
1140        // stop check, however, compares canonical paths: on Windows the walked
1141        // directory is a canonical long-name `\\?\` path while `project_root` may
1142        // be an 8.3 short name, and on Unix symlinks differ. Comparing the raw
1143        // forms would never match, so the walk would overshoot past the project
1144        // root and could pick up a config file in a parent directory outside the
1145        // project.
1146        let canonical_project_root = std::fs::canonicalize(project_root).ok();
1147
1148        // Resolve the home boundary so the walk never treats `~/.rumdl.toml` as a
1149        // project config, consistent with `discover_config_upward`. This only has
1150        // an effect when `project_root` is at or above the home directory (e.g. a
1151        // multi-path run whose grouping root spans the home boundary); for the
1152        // usual project root below home the walk stops there first.
1153        let home_dir = Self::resolve_home_boundary(None);
1154        let canonical_home = home_dir.as_deref().and_then(|h| std::fs::canonicalize(h).ok());
1155
1156        let mut current_dir = dir.to_path_buf();
1157
1158        loop {
1159            // Stop at the home directory without checking its config: `~/.rumdl.toml`
1160            // reaches the loader only via the user-config fallback, never as a
1161            // project config promoted over the platform user config.
1162            if Self::at_home_boundary(&current_dir, home_dir.as_deref(), canonical_home.as_deref()) {
1163                break;
1164            }
1165
1166            // Check rumdl config files first (higher precedence)
1167            for config_name in RUMDL_CONFIG_FILES {
1168                let config_path = current_dir.join(config_name);
1169                if config_path.exists() {
1170                    if *config_name == "pyproject.toml" {
1171                        if let Ok(content) = std::fs::read_to_string(&config_path)
1172                            && pyproject_declares_rumdl_config(&content)
1173                        {
1174                            return Some(config_path);
1175                        }
1176                        continue;
1177                    }
1178                    return Some(config_path);
1179                }
1180            }
1181
1182            // Check markdownlint config files (lower precedence)
1183            for config_name in MARKDOWNLINT_CONFIG_FILES {
1184                let config_path = current_dir.join(config_name);
1185                if config_path.exists() {
1186                    return Some(config_path);
1187                }
1188            }
1189
1190            // Stop at project root (inclusive - we already checked it). Compare
1191            // canonically so differing path representations still match.
1192            let reached_root = match (&canonical_project_root, std::fs::canonicalize(&current_dir).ok()) {
1193                (Some(root), Some(current)) => &current == root,
1194                _ => current_dir == project_root,
1195            };
1196            if reached_root {
1197                break;
1198            }
1199
1200            // Move to parent directory
1201            match current_dir.parent() {
1202                Some(parent) => current_dir = parent.to_path_buf(),
1203                None => break,
1204            }
1205        }
1206
1207        None
1208    }
1209
1210    /// Load a config from a specific file path, with extends resolution, returning
1211    /// the still-`Loaded` `SourcedConfig` (before validation and conversion).
1212    ///
1213    /// Used by per-directory resolution so the caller can layer CLI-level overrides
1214    /// (e.g. inline `--config`) on top before converting to `Config`, matching the
1215    /// precedence applied to the global config.
1216    pub fn load_sourced_for_path(
1217        config_path: &Path,
1218        project_root: &Path,
1219    ) -> Result<SourcedConfig<ConfigLoaded>, ConfigError> {
1220        let mut sourced_config = SourcedConfig {
1221            project_root: Some(project_root.to_path_buf()),
1222            ..SourcedConfig::default()
1223        };
1224
1225        let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1226        let path_str = config_path.display().to_string();
1227
1228        // Determine if this is a markdownlint config or rumdl config
1229        let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
1230            || (filename != "pyproject.toml"
1231                && filename != ".rumdl.toml"
1232                && filename != "rumdl.toml"
1233                && (path_str.ends_with(".json")
1234                    || path_str.ends_with(".jsonc")
1235                    || path_str.ends_with(".yaml")
1236                    || path_str.ends_with(".yml")));
1237
1238        if is_markdownlint {
1239            let fragment = parsers::load_from_markdownlint(&path_str)?;
1240            sourced_config.merge(fragment);
1241            sourced_config.loaded_files.push(path_str);
1242        } else {
1243            let mut visited = IndexSet::new();
1244            let chain_source = source_from_filename(filename);
1245            load_config_with_extends(&mut sourced_config, config_path, &mut visited, chain_source)?;
1246        }
1247
1248        Ok(sourced_config)
1249    }
1250
1251    /// Load a config from a specific file path, with extends resolution, and convert
1252    /// to `Config`. Used for per-directory config loading where each subdirectory
1253    /// config is standalone.
1254    pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
1255        Ok(Self::load_sourced_for_path(config_path, project_root)?
1256            .into_validated_unchecked()
1257            .into())
1258    }
1259}
1260
1261/// Convert a validated configuration to the final Config type.
1262///
1263/// This implementation only exists for `SourcedConfig<ConfigValidated>`,
1264/// ensuring that validation must occur before conversion.
1265impl From<SourcedConfig<ConfigValidated>> for Config {
1266    fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
1267        let mut rules = BTreeMap::new();
1268        for (rule_name, sourced_rule_cfg) in sourced.rules {
1269            // Normalize rule name to uppercase for case-insensitive lookup
1270            let normalized_rule_name = rule_name.to_ascii_uppercase();
1271            let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
1272            let mut values = BTreeMap::new();
1273            for (key, sourced_val) in sourced_rule_cfg.values {
1274                values.insert(key, sourced_val.value);
1275            }
1276            rules.insert(normalized_rule_name, RuleConfig { severity, values });
1277        }
1278        // Enable is "explicit" if it was set by something other than the Default source
1279        let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
1280
1281        #[allow(deprecated)]
1282        let global = GlobalConfig {
1283            enable: sourced.global.enable.value,
1284            disable: sourced.global.disable.value,
1285            exclude: sourced.global.exclude.value,
1286            include: sourced.global.include.value,
1287            respect_gitignore: sourced.global.respect_gitignore.value,
1288            line_length: sourced.global.line_length.value,
1289            output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1290            fixable: sourced.global.fixable.value,
1291            unfixable: sourced.global.unfixable.value,
1292            flavor: sourced.global.flavor.value,
1293            force_exclude: sourced.global.force_exclude.value,
1294            cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
1295            cache: sourced.global.cache.value,
1296            extend_enable: sourced.global.extend_enable.value,
1297            extend_disable: sourced.global.extend_disable.value,
1298            enable_is_explicit,
1299        };
1300
1301        let mut config = Config {
1302            extends: None,
1303            global,
1304            per_file_ignores: sourced.per_file_ignores.value,
1305            per_file_flavor: sourced.per_file_flavor.value,
1306            code_block_tools: sourced.code_block_tools.value,
1307            rules,
1308            project_root: sourced.project_root,
1309            per_file_ignores_cache: Arc::new(OnceLock::new()),
1310            per_file_flavor_cache: Arc::new(OnceLock::new()),
1311            canonical_project_root_cache: Arc::new(OnceLock::new()),
1312        };
1313
1314        // Apply per-rule `enabled = true/false` to global enable/disable lists
1315        config.apply_per_rule_enabled();
1316
1317        // Enforce the runtime invariant: every rule-name list is canonicalised.
1318        // After this point, downstream consumers (`rules::filter_rules`, the LSP,
1319        // WASM, fix coordinator, per-file-ignores) can match against
1320        // `Rule::name()` with simple string equality regardless of whether the
1321        // user's config used canonical IDs (`"MD033"`) or aliases
1322        // (`"no-inline-html"`).
1323        config.canonicalize_rule_lists();
1324
1325        config
1326    }
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331    use super::pyproject_declares_rumdl_config;
1332
1333    #[test]
1334    fn detects_flat_and_dotted_rumdl_sections() {
1335        assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
1336        // Dotted sections are valid on their own, without a flat header.
1337        assert!(pyproject_declares_rumdl_config(
1338            "[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
1339        ));
1340        assert!(pyproject_declares_rumdl_config(
1341            "[tool.rumdl.rules.MD007]\nindent = 4\n"
1342        ));
1343    }
1344
1345    #[test]
1346    fn ignores_incidental_mentions() {
1347        // A bare `tool.rumdl` in a comment or string value must not be treated
1348        // as a config section.
1349        assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
1350        assert!(!pyproject_declares_rumdl_config(
1351            "[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
1352        ));
1353        assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
1354    }
1355
1356    /// Discovery must stop at the project root even when the root is supplied in
1357    /// a different path representation than the walked directory's ancestors.
1358    ///
1359    /// This reproduces the Windows 8.3-short-name / canonical mismatch using a
1360    /// Unix symlink: the project root is passed as a symlink to the real root, so
1361    /// it does not string-match the canonical ancestors of the starting
1362    /// directory. Without canonicalization the walk overshoots the project root
1363    /// and incorrectly picks up the config in the parent directory.
1364    #[cfg(unix)]
1365    #[test]
1366    fn discover_stops_at_project_root_across_path_representations() {
1367        use super::SourcedConfig;
1368        use std::os::unix::fs::symlink;
1369        use tempfile::tempdir;
1370
1371        let tmp = tempdir().unwrap();
1372        // A config ABOVE the project root that must never be discovered.
1373        std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1374
1375        let real_root = tmp.path().join("project");
1376        let subdir = real_root.join("docs");
1377        std::fs::create_dir_all(&subdir).unwrap();
1378
1379        // Supply the project root via a symlink so it does not string-match the
1380        // canonical ancestors of `subdir`.
1381        let linked_root = tmp.path().join("project-link");
1382        symlink(&real_root, &linked_root).unwrap();
1383
1384        let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
1385        assert_eq!(
1386            found, None,
1387            "discovery must stop at the project root, not overshoot to the parent config"
1388        );
1389    }
1390
1391    mod shadowed_configs {
1392        use super::super::{ShadowedConfigs, detect_shadowed_configs, format_shadow_warning, rumdl_configs_in_dir};
1393        use tempfile::tempdir;
1394
1395        fn names(paths: &[std::path::PathBuf]) -> Vec<String> {
1396            paths
1397                .iter()
1398                .map(|p| {
1399                    // Use the last two components so `.config/rumdl.toml` is distinguishable
1400                    // from a top-level `rumdl.toml` without depending on the temp dir prefix.
1401                    let file = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
1402                    let parent = p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str());
1403                    match parent {
1404                        Some(".config") => format!(".config/{file}"),
1405                        _ => file.to_string(),
1406                    }
1407                })
1408                .collect()
1409        }
1410
1411        #[test]
1412        fn empty_directory_has_no_configs_and_no_shadow() {
1413            let tmp = tempdir().unwrap();
1414            assert!(rumdl_configs_in_dir(tmp.path()).is_empty());
1415            assert!(detect_shadowed_configs(tmp.path()).is_none());
1416        }
1417
1418        #[test]
1419        fn single_config_does_not_shadow() {
1420            let tmp = tempdir().unwrap();
1421            std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1422            assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1423            assert!(detect_shadowed_configs(tmp.path()).is_none());
1424        }
1425
1426        #[test]
1427        fn dot_wins_over_non_dot_and_non_dot_is_shadowed() {
1428            let tmp = tempdir().unwrap();
1429            std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1430            std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1431
1432            let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1433            assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1434            assert_eq!(names(&shadowed), vec!["rumdl.toml"]);
1435        }
1436
1437        #[test]
1438        fn config_subdir_counts_as_same_level_shadow() {
1439            let tmp = tempdir().unwrap();
1440            std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1441            std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1442            std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1443
1444            let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1445            assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1446            assert_eq!(names(&shadowed), vec![".config/rumdl.toml"]);
1447        }
1448
1449        #[test]
1450        fn pyproject_counts_only_when_it_declares_rumdl() {
1451            // pyproject WITHOUT [tool.rumdl] is not a rumdl config source -> no shadow.
1452            let bare = tempdir().unwrap();
1453            std::fs::write(bare.path().join(".rumdl.toml"), "").unwrap();
1454            std::fs::write(bare.path().join("pyproject.toml"), "[tool.black]\nline-length = 88\n").unwrap();
1455            assert_eq!(names(&rumdl_configs_in_dir(bare.path())), vec![".rumdl.toml"]);
1456            assert!(detect_shadowed_configs(bare.path()).is_none());
1457
1458            // pyproject WITH [tool.rumdl] is a real shadowed source.
1459            let declared = tempdir().unwrap();
1460            std::fs::write(declared.path().join(".rumdl.toml"), "").unwrap();
1461            std::fs::write(
1462                declared.path().join("pyproject.toml"),
1463                "[tool.rumdl]\nline-length = 80\n",
1464            )
1465            .unwrap();
1466            let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(declared.path()).unwrap();
1467            assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1468            assert_eq!(names(&shadowed), vec!["pyproject.toml"]);
1469        }
1470
1471        #[test]
1472        fn markdownlint_configs_are_not_rumdl_native_and_never_shadow() {
1473            let tmp = tempdir().unwrap();
1474            std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1475            std::fs::write(tmp.path().join(".markdownlint.json"), "{}").unwrap();
1476            assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1477            assert!(detect_shadowed_configs(tmp.path()).is_none());
1478        }
1479
1480        #[test]
1481        fn configs_returned_in_precedence_order() {
1482            let tmp = tempdir().unwrap();
1483            std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1484            std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1485            std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1486            std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1487            std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1488
1489            assert_eq!(
1490                names(&rumdl_configs_in_dir(tmp.path())),
1491                vec![".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]
1492            );
1493        }
1494
1495        #[test]
1496        fn warning_names_dir_once_with_relative_filenames() {
1497            let tmp = tempdir().unwrap();
1498            std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1499            std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1500            std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1501
1502            let shadow = detect_shadowed_configs(tmp.path()).unwrap();
1503            let msg = format_shadow_warning(&shadow);
1504
1505            let dir = {
1506                let s = tmp.path().to_string_lossy().into_owned();
1507                if cfg!(windows) { s.replace('\\', "/") } else { s }
1508            };
1509            assert!(msg.contains("multiple rumdl config files"), "got: {msg}");
1510            // The directory is named once; files are shown relative to it (no
1511            // repeated directory prefix on every path).
1512            assert_eq!(
1513                msg.matches(dir.as_str()).count(),
1514                1,
1515                "directory should appear exactly once, got: {msg}"
1516            );
1517            assert!(
1518                msg.contains("using .rumdl.toml, ignoring rumdl.toml, pyproject.toml"),
1519                "winner and shadowed files should be relative names in precedence order, got: {msg}"
1520            );
1521            // Paths are normalized to forward slashes on all platforms.
1522            assert!(!msg.contains('\\'), "paths must be normalized to '/': {msg}");
1523        }
1524    }
1525}