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