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