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