Skip to main content

rumdl_lib/config/
loading.rs

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