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