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