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