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
17const MAX_EXTENDS_DEPTH: usize = 10;
19
20fn pyproject_declares_rumdl_config(content: &str) -> bool {
29 content.contains("[tool.rumdl]") || content.contains("[tool.rumdl.")
30}
31
32fn resolve_extends_path(extends_value: &str, config_file_path: &Path) -> PathBuf {
38 if let Some(suffix) = extends_value.strip_prefix("~/") {
39 #[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 let config_dir = config_file_path.parent().unwrap_or(Path::new("."));
58 config_dir.join(extends_value)
59 }
60 }
61}
62
63fn source_from_filename(filename: &str) -> ConfigSource {
65 if filename == "pyproject.toml" {
66 ConfigSource::PyprojectToml
67 } else {
68 ConfigSource::ProjectConfig
69 }
70}
71
72pub(crate) fn rumdl_configs_in_dir(dir: &Path) -> Vec<PathBuf> {
80 RUMDL_CONFIG_FILES
81 .iter()
82 .map(|name| dir.join(name))
83 .filter(|path| {
84 if !path.exists() {
85 return false;
86 }
87 if path.file_name().and_then(|n| n.to_str()) == Some("pyproject.toml") {
88 std::fs::read_to_string(path).is_ok_and(|content| pyproject_declares_rumdl_config(&content))
89 } else {
90 true
91 }
92 })
93 .collect()
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
105pub(crate) struct ShadowedConfigs {
106 pub dir: PathBuf,
107 pub winner: PathBuf,
108 pub shadowed: Vec<PathBuf>,
109}
110
111pub(crate) fn detect_shadowed_configs(dir: &Path) -> Option<ShadowedConfigs> {
117 let mut configs = rumdl_configs_in_dir(dir);
118 if configs.len() < 2 {
119 return None;
120 }
121 let winner = configs.remove(0);
122 Some(ShadowedConfigs {
123 dir: dir.to_path_buf(),
124 winner,
125 shadowed: configs,
126 })
127}
128
129pub(crate) fn format_shadow_warning(shadow: &ShadowedConfigs) -> String {
137 let norm = |s: String| if cfg!(windows) { s.replace('\\', "/") } else { s };
138 let rel = |path: &Path| {
139 let relative = path.strip_prefix(&shadow.dir).unwrap_or(path);
140 norm(relative.to_string_lossy().into_owned())
141 };
142 let shadowed = shadow.shadowed.iter().map(|p| rel(p)).collect::<Vec<_>>().join(", ");
143 format!(
144 "multiple rumdl config files in {}: using {}, ignoring {}",
145 norm(shadow.dir.to_string_lossy().into_owned()),
146 rel(&shadow.winner),
147 shadowed,
148 )
149}
150
151fn load_config_with_extends(
158 sourced_config: &mut SourcedConfig<ConfigLoaded>,
159 config_file_path: &Path,
160 visited: &mut IndexSet<PathBuf>,
161 chain_source: ConfigSource,
162) -> Result<(), ConfigError> {
163 let canonical = config_file_path
165 .canonicalize()
166 .unwrap_or_else(|_| config_file_path.to_path_buf());
167
168 if visited.contains(&canonical) {
170 let chain: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
171 return Err(ConfigError::CircularExtends {
172 path: config_file_path.display().to_string(),
173 chain,
174 });
175 }
176
177 if visited.len() >= MAX_EXTENDS_DEPTH {
179 return Err(ConfigError::ExtendsDepthExceeded {
180 path: config_file_path.display().to_string(),
181 max_depth: MAX_EXTENDS_DEPTH,
182 });
183 }
184
185 visited.insert(canonical);
187
188 let path_str = config_file_path.display().to_string();
189 let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
190
191 let content = std::fs::read_to_string(config_file_path).map_err(|e| ConfigError::IoError {
193 source: e,
194 path: path_str.clone(),
195 })?;
196
197 let fragment = if filename == "pyproject.toml" {
198 match parsers::parse_pyproject_toml(&content, &path_str, chain_source)? {
199 Some(f) => f,
200 None => return Ok(()), }
202 } else {
203 parsers::parse_rumdl_toml(&content, &path_str, chain_source)?
204 };
205
206 if let Some(ref extends_value) = fragment.extends {
208 let base_path = resolve_extends_path(extends_value, config_file_path);
209
210 if !base_path.exists() {
211 return Err(ConfigError::ExtendsNotFound {
212 path: base_path.display().to_string(),
213 from: path_str.clone(),
214 });
215 }
216
217 log::debug!(
218 "[rumdl-config] Config {} extends {}, loading base first",
219 path_str,
220 base_path.display()
221 );
222
223 load_config_with_extends(sourced_config, &base_path, visited, chain_source)?;
225 }
226
227 let mut fragment_for_merge = fragment;
230 fragment_for_merge.extends = None;
231 sourced_config.merge(fragment_for_merge);
232 sourced_config.loaded_files.push(path_str);
233
234 Ok(())
235}
236
237impl SourcedConfig<ConfigLoaded> {
238 pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
241 self.global.enable.merge_override(
244 fragment.global.enable.value,
245 fragment.global.enable.source,
246 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
247 fragment.global.enable.overrides.first().and_then(|o| o.line),
248 );
249
250 self.global.disable.merge_override(
252 fragment.global.disable.value,
253 fragment.global.disable.source,
254 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
255 fragment.global.disable.overrides.first().and_then(|o| o.line),
256 );
257
258 self.global.extend_enable.merge_union(
260 fragment.global.extend_enable.value,
261 fragment.global.extend_enable.source,
262 fragment
263 .global
264 .extend_enable
265 .overrides
266 .first()
267 .and_then(|o| o.file.clone()),
268 fragment.global.extend_enable.overrides.first().and_then(|o| o.line),
269 );
270
271 self.global.extend_disable.merge_union(
273 fragment.global.extend_disable.value,
274 fragment.global.extend_disable.source,
275 fragment
276 .global
277 .extend_disable
278 .overrides
279 .first()
280 .and_then(|o| o.file.clone()),
281 fragment.global.extend_disable.overrides.first().and_then(|o| o.line),
282 );
283
284 self.global
287 .disable
288 .value
289 .retain(|rule| !self.global.enable.value.contains(rule));
290 self.global.include.merge_override(
291 fragment.global.include.value,
292 fragment.global.include.source,
293 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
294 fragment.global.include.overrides.first().and_then(|o| o.line),
295 );
296 self.global.exclude.merge_override(
297 fragment.global.exclude.value,
298 fragment.global.exclude.source,
299 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
300 fragment.global.exclude.overrides.first().and_then(|o| o.line),
301 );
302 self.global.respect_gitignore.merge_override(
303 fragment.global.respect_gitignore.value,
304 fragment.global.respect_gitignore.source,
305 fragment
306 .global
307 .respect_gitignore
308 .overrides
309 .first()
310 .and_then(|o| o.file.clone()),
311 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
312 );
313 self.global.line_length.merge_override(
314 fragment.global.line_length.value,
315 fragment.global.line_length.source,
316 fragment
317 .global
318 .line_length
319 .overrides
320 .first()
321 .and_then(|o| o.file.clone()),
322 fragment.global.line_length.overrides.first().and_then(|o| o.line),
323 );
324 self.global.fixable.merge_override(
325 fragment.global.fixable.value,
326 fragment.global.fixable.source,
327 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
328 fragment.global.fixable.overrides.first().and_then(|o| o.line),
329 );
330 self.global.unfixable.merge_override(
331 fragment.global.unfixable.value,
332 fragment.global.unfixable.source,
333 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
334 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
335 );
336
337 self.global.flavor.merge_override(
339 fragment.global.flavor.value,
340 fragment.global.flavor.source,
341 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
342 fragment.global.flavor.overrides.first().and_then(|o| o.line),
343 );
344
345 self.global.force_exclude.merge_override(
347 fragment.global.force_exclude.value,
348 fragment.global.force_exclude.source,
349 fragment
350 .global
351 .force_exclude
352 .overrides
353 .first()
354 .and_then(|o| o.file.clone()),
355 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
356 );
357
358 if let Some(output_format_fragment) = fragment.global.output_format {
360 if let Some(ref mut output_format) = self.global.output_format {
361 output_format.merge_override(
362 output_format_fragment.value,
363 output_format_fragment.source,
364 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
365 output_format_fragment.overrides.first().and_then(|o| o.line),
366 );
367 } else {
368 self.global.output_format = Some(output_format_fragment);
369 }
370 }
371
372 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
374 if let Some(ref mut cache_dir) = self.global.cache_dir {
375 cache_dir.merge_override(
376 cache_dir_fragment.value,
377 cache_dir_fragment.source,
378 cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
379 cache_dir_fragment.overrides.first().and_then(|o| o.line),
380 );
381 } else {
382 self.global.cache_dir = Some(cache_dir_fragment);
383 }
384 }
385
386 if fragment.global.cache.source != ConfigSource::Default {
388 self.global.cache.merge_override(
389 fragment.global.cache.value,
390 fragment.global.cache.source,
391 fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
392 fragment.global.cache.overrides.first().and_then(|o| o.line),
393 );
394 }
395
396 self.per_file_ignores.merge_override(
398 fragment.per_file_ignores.value,
399 fragment.per_file_ignores.source,
400 fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
401 fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
402 );
403
404 self.per_file_flavor.merge_override(
406 fragment.per_file_flavor.value,
407 fragment.per_file_flavor.source,
408 fragment.per_file_flavor.overrides.first().and_then(|o| o.file.clone()),
409 fragment.per_file_flavor.overrides.first().and_then(|o| o.line),
410 );
411
412 self.code_block_tools.merge_override(
414 fragment.code_block_tools.value,
415 fragment.code_block_tools.source,
416 fragment.code_block_tools.overrides.first().and_then(|o| o.file.clone()),
417 fragment.code_block_tools.overrides.first().and_then(|o| o.line),
418 );
419
420 for (rule_name, rule_fragment) in fragment.rules {
422 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
424
425 if let Some(severity_fragment) = rule_fragment.severity {
427 if let Some(ref mut existing_severity) = rule_entry.severity {
428 existing_severity.merge_override(
429 severity_fragment.value,
430 severity_fragment.source,
431 severity_fragment.overrides.first().and_then(|o| o.file.clone()),
432 severity_fragment.overrides.first().and_then(|o| o.line),
433 );
434 } else {
435 rule_entry.severity = Some(severity_fragment);
436 }
437 }
438
439 for (key, sourced_value_fragment) in rule_fragment.values {
441 let sv_entry = rule_entry
442 .values
443 .entry(key.clone())
444 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
445 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
446 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
447 sv_entry.merge_override(
448 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
453 }
454 }
455
456 for (section, key, file_path) in fragment.unknown_keys {
458 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
460 self.unknown_keys.push((section, key, file_path));
461 }
462 }
463 }
464
465 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
467 Self::load_with_discovery(config_path, cli_overrides, false)
468 }
469
470 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
473 let mut current = if start_dir.is_relative() {
475 std::env::current_dir().map_or_else(|_| start_dir.to_path_buf(), |cwd| cwd.join(start_dir))
476 } else {
477 start_dir.to_path_buf()
478 };
479 const MAX_DEPTH: usize = 100;
480
481 for _ in 0..MAX_DEPTH {
482 if current.join(".git").exists() {
483 log::debug!("[rumdl-config] Found .git at: {}", current.display());
484 return current;
485 }
486
487 match current.parent() {
488 Some(parent) => current = parent.to_path_buf(),
489 None => break,
490 }
491 }
492
493 log::debug!(
495 "[rumdl-config] No .git found, using config location as project root: {}",
496 start_dir.display()
497 );
498 start_dir.to_path_buf()
499 }
500
501 fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
507 home_override.map(Path::to_path_buf).or_else(|| {
508 #[cfg(feature = "native")]
509 {
510 use etcetera::{BaseStrategy, choose_base_strategy};
511 choose_base_strategy().ok().map(|s| s.home_dir().to_path_buf())
512 }
513 #[cfg(not(feature = "native"))]
514 {
515 None
516 }
517 })
518 }
519
520 fn at_home_boundary(current_dir: &Path, home_dir: Option<&Path>, canonical_home: Option<&Path>) -> bool {
524 match (canonical_home, std::fs::canonicalize(current_dir).ok()) {
525 (Some(home), Some(current)) => current == home,
526 _ => home_dir == Some(current_dir),
527 }
528 }
529
530 fn discover_config_upward(
542 home_override: Option<&Path>,
543 ) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
544 use std::env;
545
546 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
549 Ok(dir) => dir,
550 Err(e) => {
551 log::debug!("[rumdl-config] Failed to get current directory: {e}");
552 return None;
553 }
554 };
555
556 let home_dir = Self::resolve_home_boundary(home_override);
560 let canonical_home = home_dir.as_deref().and_then(|h| std::fs::canonicalize(h).ok());
561
562 let mut current_dir = start_dir.clone();
563 let mut depth = 0;
564 let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> = None;
566
567 loop {
568 if depth >= MAX_DEPTH {
569 log::debug!("[rumdl-config] Maximum traversal depth reached");
570 break;
571 }
572
573 if Self::at_home_boundary(¤t_dir, home_dir.as_deref(), canonical_home.as_deref()) {
576 log::debug!("[rumdl-config] Reached home directory boundary; stopping project discovery");
577 break;
578 }
579
580 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
581
582 if found_config.is_none()
587 && let Some(winner) = rumdl_configs_in_dir(¤t_dir).into_iter().next()
588 {
589 log::debug!("[rumdl-config] Found config file: {}", winner.display());
590 let shadow = detect_shadowed_configs(¤t_dir);
591 found_config = Some((winner, current_dir.clone(), shadow));
593 }
594
595 if current_dir.join(".git").exists() {
597 log::debug!("[rumdl-config] Stopping at .git directory");
598 break;
599 }
600
601 match current_dir.parent() {
603 Some(parent) => {
604 current_dir = parent.to_owned();
605 depth += 1;
606 }
607 None => {
608 log::debug!("[rumdl-config] Reached filesystem root");
609 break;
610 }
611 }
612 }
613
614 if let Some((config_path, config_dir, shadow)) = found_config {
616 let project_root = Self::find_project_root_from(&config_dir);
617 return Some((config_path, project_root, shadow));
618 }
619
620 None
621 }
622
623 fn discover_markdownlint_config_upward(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
628 use std::env;
629
630 const MAX_DEPTH: usize = 100;
631
632 let start_dir = match env::current_dir() {
633 Ok(dir) => dir,
634 Err(e) => {
635 log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
636 return None;
637 }
638 };
639
640 let home_dir = Self::resolve_home_boundary(home_override);
641 let canonical_home = home_dir.as_deref().and_then(|h| std::fs::canonicalize(h).ok());
642
643 let mut current_dir = start_dir.clone();
644 let mut depth = 0;
645
646 loop {
647 if depth >= MAX_DEPTH {
648 log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
649 break;
650 }
651
652 if Self::at_home_boundary(¤t_dir, home_dir.as_deref(), canonical_home.as_deref()) {
654 log::debug!("[rumdl-config] Reached home directory boundary; stopping markdownlint discovery");
655 break;
656 }
657
658 log::debug!(
659 "[rumdl-config] Searching for markdownlint config in: {}",
660 current_dir.display()
661 );
662
663 for config_name in MARKDOWNLINT_CONFIG_FILES {
665 let config_path = current_dir.join(config_name);
666 if config_path.exists() {
667 log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
668 return Some(config_path);
669 }
670 }
671
672 if current_dir.join(".git").exists() {
674 log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
675 break;
676 }
677
678 match current_dir.parent() {
680 Some(parent) => {
681 current_dir = parent.to_owned();
682 depth += 1;
683 }
684 None => {
685 log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
686 break;
687 }
688 }
689 }
690
691 None
692 }
693
694 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
696 let config_dir = config_dir.join("rumdl");
697
698 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
700
701 log::debug!(
702 "[rumdl-config] Checking for user configuration in: {}",
703 config_dir.display()
704 );
705
706 for filename in USER_CONFIG_FILES {
707 let config_path = config_dir.join(filename);
708
709 if config_path.exists() {
710 if *filename == "pyproject.toml" {
712 if let Ok(content) = std::fs::read_to_string(&config_path) {
713 if pyproject_declares_rumdl_config(&content) {
714 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
715 return Some(config_path);
716 }
717 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
718 continue;
719 }
720 } else {
721 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
722 return Some(config_path);
723 }
724 }
725 }
726
727 log::debug!(
728 "[rumdl-config] No user configuration found in: {}",
729 config_dir.display()
730 );
731 None
732 }
733
734 #[cfg(feature = "native")]
737 fn user_configuration_path() -> Option<std::path::PathBuf> {
738 use etcetera::{BaseStrategy, choose_base_strategy};
739
740 match choose_base_strategy() {
741 Ok(strategy) => {
742 let config_dir = strategy.config_dir();
743 Self::user_configuration_path_impl(&config_dir)
744 }
745 Err(e) => {
746 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
747 None
748 }
749 }
750 }
751
752 #[cfg(not(feature = "native"))]
754 fn user_configuration_path() -> Option<std::path::PathBuf> {
755 None
756 }
757
758 fn home_configuration_path_impl(home_dir: &Path) -> Option<std::path::PathBuf> {
770 const HOME_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml"];
771
772 log::debug!(
773 "[rumdl-config] Checking for home-directory configuration in: {}",
774 home_dir.display()
775 );
776
777 for filename in HOME_CONFIG_FILES {
778 let config_path = home_dir.join(filename);
779 if config_path.exists() {
780 log::debug!(
781 "[rumdl-config] Found home-directory configuration at: {}",
782 config_path.display()
783 );
784 return Some(config_path);
785 }
786 }
787
788 log::debug!(
789 "[rumdl-config] No home-directory configuration found in: {}",
790 home_dir.display()
791 );
792 None
793 }
794
795 #[cfg(feature = "native")]
801 fn home_configuration_path() -> Option<std::path::PathBuf> {
802 use etcetera::{BaseStrategy, choose_base_strategy};
803
804 match choose_base_strategy() {
805 Ok(strategy) => Self::home_configuration_path_impl(strategy.home_dir()),
806 Err(e) => {
807 log::debug!("[rumdl-config] Failed to determine home directory: {e}");
808 None
809 }
810 }
811 }
812
813 #[cfg(not(feature = "native"))]
815 fn home_configuration_path() -> Option<std::path::PathBuf> {
816 None
817 }
818
819 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
821 let path_obj = Path::new(path);
822 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
823 let path_str = path.to_string();
824
825 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
826
827 if let Some(config_parent) = path_obj.parent() {
829 let project_root = Self::find_project_root_from(config_parent);
830 log::debug!(
831 "[rumdl-config] Project root (from explicit config): {}",
832 project_root.display()
833 );
834 sourced_config.project_root = Some(project_root);
835 }
836
837 const MARKDOWNLINT_FILENAMES: &[&str] = &[
839 ".markdownlint-cli2.jsonc",
840 ".markdownlint-cli2.yaml",
841 ".markdownlint-cli2.yml",
842 ".markdownlint.json",
843 ".markdownlint.yaml",
844 ".markdownlint.yml",
845 ];
846
847 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
848 let mut visited = IndexSet::new();
850 let chain_source = source_from_filename(filename);
851 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
852 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
853 || path_str.ends_with(".json")
854 || path_str.ends_with(".jsonc")
855 || path_str.ends_with(".yaml")
856 || path_str.ends_with(".yml")
857 {
858 let fragment = parsers::load_from_markdownlint(&path_str)?;
860 sourced_config.merge(fragment);
861 sourced_config.loaded_files.push(path_str);
862 } else {
863 let mut visited = IndexSet::new();
865 let chain_source = source_from_filename(filename);
866 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
867 }
868
869 Ok(())
870 }
871
872 fn load_user_config(
889 sourced_config: &mut Self,
890 user_config_dir: Option<&Path>,
891 home_dir: Option<&Path>,
892 ) -> Result<(), ConfigError> {
893 let user_config_path = if let Some(dir) = user_config_dir {
894 Self::user_configuration_path_impl(dir)
895 } else {
896 Self::user_configuration_path()
897 };
898
899 let user_config_path = user_config_path.or_else(|| match home_dir {
900 Some(home) => Self::home_configuration_path_impl(home),
901 None => Self::home_configuration_path(),
902 });
903
904 if let Some(user_config_path) = user_config_path {
905 let path_str = user_config_path.display().to_string();
906
907 log::debug!("[rumdl-config] Loading user config: {path_str}");
908
909 let mut visited = IndexSet::new();
912 load_config_with_extends(
913 sourced_config,
914 &user_config_path,
915 &mut visited,
916 ConfigSource::UserConfig,
917 )?;
918 } else {
919 log::debug!("[rumdl-config] No user configuration file found");
920 }
921
922 Ok(())
923 }
924
925 #[doc(hidden)]
927 pub fn load_with_discovery_impl(
928 config_path: Option<&str>,
929 cli_overrides: Option<&SourcedGlobalConfig>,
930 skip_auto_discovery: bool,
931 user_config_dir: Option<&Path>,
932 home_dir: Option<&Path>,
933 ) -> Result<Self, ConfigError> {
934 use std::env;
935 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
936
937 let mut sourced_config = SourcedConfig::default();
938
939 if let Some(path) = config_path {
952 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
954 Self::load_explicit_config(&mut sourced_config, path)?;
955 } else if skip_auto_discovery {
956 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
957 } else {
959 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
961
962 if let Some((config_file, project_root, shadow)) = Self::discover_config_upward(home_dir) {
964 log::debug!("[rumdl-config] Found project config: {}", config_file.display());
968 log::debug!("[rumdl-config] Project root: {}", project_root.display());
969
970 if let Some(shadow) = shadow {
973 sourced_config.discovery_warnings.push(format_shadow_warning(&shadow));
974 }
975
976 sourced_config.project_root = Some(project_root);
977
978 let mut visited = IndexSet::new();
980 let root_filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
981 let chain_source = source_from_filename(root_filename);
982 load_config_with_extends(&mut sourced_config, &config_file, &mut visited, chain_source)?;
983 } else {
984 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
986
987 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward(home_dir) {
988 let path_str = markdownlint_path.display().to_string();
989 log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
990 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
995 match parsers::load_from_markdownlint(&path_str) {
996 Ok(fragment) => {
997 sourced_config.merge(fragment);
998 sourced_config.loaded_files.push(path_str);
999 }
1000 Err(_e) => {
1001 log::debug!("[rumdl-config] Failed to load markdownlint config");
1002 }
1003 }
1004 } else {
1005 log::debug!("[rumdl-config] No project config found, using user config as fallback");
1007 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
1008 }
1009 }
1010 }
1011
1012 if let Some(cli) = cli_overrides {
1014 sourced_config
1015 .global
1016 .enable
1017 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
1018 sourced_config
1019 .global
1020 .disable
1021 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
1022 sourced_config
1023 .global
1024 .exclude
1025 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
1026 sourced_config
1027 .global
1028 .include
1029 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
1030 sourced_config.global.respect_gitignore.merge_override(
1031 cli.respect_gitignore.value,
1032 ConfigSource::Cli,
1033 None,
1034 None,
1035 );
1036 sourced_config
1037 .global
1038 .fixable
1039 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
1040 sourced_config
1041 .global
1042 .unfixable
1043 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
1044 }
1046
1047 Ok(sourced_config)
1050 }
1051
1052 pub fn load_with_discovery(
1055 config_path: Option<&str>,
1056 cli_overrides: Option<&SourcedGlobalConfig>,
1057 skip_auto_discovery: bool,
1058 ) -> Result<Self, ConfigError> {
1059 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
1060 }
1061
1062 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
1076 let warnings = validate_config_sourced_internal(&self, registry);
1077
1078 Ok(SourcedConfig {
1079 global: self.global,
1080 per_file_ignores: self.per_file_ignores,
1081 per_file_flavor: self.per_file_flavor,
1082 code_block_tools: self.code_block_tools,
1083 rules: self.rules,
1084 loaded_files: self.loaded_files,
1085 unknown_keys: self.unknown_keys,
1086 project_root: self.project_root,
1087 discovery_warnings: self.discovery_warnings,
1088 validation_warnings: warnings,
1089 _state: PhantomData,
1090 })
1091 }
1092
1093 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
1098 let validated = self.validate(registry)?;
1099 let warnings = validated.validation_warnings.clone();
1100 Ok((validated.into(), warnings))
1101 }
1102
1103 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
1114 SourcedConfig {
1115 global: self.global,
1116 per_file_ignores: self.per_file_ignores,
1117 per_file_flavor: self.per_file_flavor,
1118 code_block_tools: self.code_block_tools,
1119 rules: self.rules,
1120 loaded_files: self.loaded_files,
1121 unknown_keys: self.unknown_keys,
1122 project_root: self.project_root,
1123 discovery_warnings: self.discovery_warnings,
1124 validation_warnings: Vec::new(),
1125 _state: PhantomData,
1126 }
1127 }
1128
1129 pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
1138 let canonical_project_root = std::fs::canonicalize(project_root).ok();
1147
1148 let home_dir = Self::resolve_home_boundary(None);
1154 let canonical_home = home_dir.as_deref().and_then(|h| std::fs::canonicalize(h).ok());
1155
1156 let mut current_dir = dir.to_path_buf();
1157
1158 loop {
1159 if Self::at_home_boundary(¤t_dir, home_dir.as_deref(), canonical_home.as_deref()) {
1163 break;
1164 }
1165
1166 for config_name in RUMDL_CONFIG_FILES {
1168 let config_path = current_dir.join(config_name);
1169 if config_path.exists() {
1170 if *config_name == "pyproject.toml" {
1171 if let Ok(content) = std::fs::read_to_string(&config_path)
1172 && pyproject_declares_rumdl_config(&content)
1173 {
1174 return Some(config_path);
1175 }
1176 continue;
1177 }
1178 return Some(config_path);
1179 }
1180 }
1181
1182 for config_name in MARKDOWNLINT_CONFIG_FILES {
1184 let config_path = current_dir.join(config_name);
1185 if config_path.exists() {
1186 return Some(config_path);
1187 }
1188 }
1189
1190 let reached_root = match (&canonical_project_root, std::fs::canonicalize(¤t_dir).ok()) {
1193 (Some(root), Some(current)) => ¤t == root,
1194 _ => current_dir == project_root,
1195 };
1196 if reached_root {
1197 break;
1198 }
1199
1200 match current_dir.parent() {
1202 Some(parent) => current_dir = parent.to_path_buf(),
1203 None => break,
1204 }
1205 }
1206
1207 None
1208 }
1209
1210 pub fn load_sourced_for_path(
1217 config_path: &Path,
1218 project_root: &Path,
1219 ) -> Result<SourcedConfig<ConfigLoaded>, ConfigError> {
1220 let mut sourced_config = SourcedConfig {
1221 project_root: Some(project_root.to_path_buf()),
1222 ..SourcedConfig::default()
1223 };
1224
1225 let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1226 let path_str = config_path.display().to_string();
1227
1228 let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
1230 || (filename != "pyproject.toml"
1231 && filename != ".rumdl.toml"
1232 && filename != "rumdl.toml"
1233 && (path_str.ends_with(".json")
1234 || path_str.ends_with(".jsonc")
1235 || path_str.ends_with(".yaml")
1236 || path_str.ends_with(".yml")));
1237
1238 if is_markdownlint {
1239 let fragment = parsers::load_from_markdownlint(&path_str)?;
1240 sourced_config.merge(fragment);
1241 sourced_config.loaded_files.push(path_str);
1242 } else {
1243 let mut visited = IndexSet::new();
1244 let chain_source = source_from_filename(filename);
1245 load_config_with_extends(&mut sourced_config, config_path, &mut visited, chain_source)?;
1246 }
1247
1248 Ok(sourced_config)
1249 }
1250
1251 pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
1255 Ok(Self::load_sourced_for_path(config_path, project_root)?
1256 .into_validated_unchecked()
1257 .into())
1258 }
1259}
1260
1261impl From<SourcedConfig<ConfigValidated>> for Config {
1266 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
1267 let mut rules = BTreeMap::new();
1268 for (rule_name, sourced_rule_cfg) in sourced.rules {
1269 let normalized_rule_name = rule_name.to_ascii_uppercase();
1271 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
1272 let mut values = BTreeMap::new();
1273 for (key, sourced_val) in sourced_rule_cfg.values {
1274 values.insert(key, sourced_val.value);
1275 }
1276 rules.insert(normalized_rule_name, RuleConfig { severity, values });
1277 }
1278 let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
1280
1281 #[allow(deprecated)]
1282 let global = GlobalConfig {
1283 enable: sourced.global.enable.value,
1284 disable: sourced.global.disable.value,
1285 exclude: sourced.global.exclude.value,
1286 include: sourced.global.include.value,
1287 respect_gitignore: sourced.global.respect_gitignore.value,
1288 line_length: sourced.global.line_length.value,
1289 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1290 fixable: sourced.global.fixable.value,
1291 unfixable: sourced.global.unfixable.value,
1292 flavor: sourced.global.flavor.value,
1293 force_exclude: sourced.global.force_exclude.value,
1294 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
1295 cache: sourced.global.cache.value,
1296 extend_enable: sourced.global.extend_enable.value,
1297 extend_disable: sourced.global.extend_disable.value,
1298 enable_is_explicit,
1299 };
1300
1301 let mut config = Config {
1302 extends: None,
1303 global,
1304 per_file_ignores: sourced.per_file_ignores.value,
1305 per_file_flavor: sourced.per_file_flavor.value,
1306 code_block_tools: sourced.code_block_tools.value,
1307 rules,
1308 project_root: sourced.project_root,
1309 per_file_ignores_cache: Arc::new(OnceLock::new()),
1310 per_file_flavor_cache: Arc::new(OnceLock::new()),
1311 canonical_project_root_cache: Arc::new(OnceLock::new()),
1312 };
1313
1314 config.apply_per_rule_enabled();
1316
1317 config.canonicalize_rule_lists();
1324
1325 config
1326 }
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331 use super::pyproject_declares_rumdl_config;
1332
1333 #[test]
1334 fn detects_flat_and_dotted_rumdl_sections() {
1335 assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
1336 assert!(pyproject_declares_rumdl_config(
1338 "[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
1339 ));
1340 assert!(pyproject_declares_rumdl_config(
1341 "[tool.rumdl.rules.MD007]\nindent = 4\n"
1342 ));
1343 }
1344
1345 #[test]
1346 fn ignores_incidental_mentions() {
1347 assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
1350 assert!(!pyproject_declares_rumdl_config(
1351 "[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
1352 ));
1353 assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
1354 }
1355
1356 #[cfg(unix)]
1365 #[test]
1366 fn discover_stops_at_project_root_across_path_representations() {
1367 use super::SourcedConfig;
1368 use std::os::unix::fs::symlink;
1369 use tempfile::tempdir;
1370
1371 let tmp = tempdir().unwrap();
1372 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1374
1375 let real_root = tmp.path().join("project");
1376 let subdir = real_root.join("docs");
1377 std::fs::create_dir_all(&subdir).unwrap();
1378
1379 let linked_root = tmp.path().join("project-link");
1382 symlink(&real_root, &linked_root).unwrap();
1383
1384 let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
1385 assert_eq!(
1386 found, None,
1387 "discovery must stop at the project root, not overshoot to the parent config"
1388 );
1389 }
1390
1391 mod shadowed_configs {
1392 use super::super::{ShadowedConfigs, detect_shadowed_configs, format_shadow_warning, rumdl_configs_in_dir};
1393 use tempfile::tempdir;
1394
1395 fn names(paths: &[std::path::PathBuf]) -> Vec<String> {
1396 paths
1397 .iter()
1398 .map(|p| {
1399 let file = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
1402 let parent = p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str());
1403 match parent {
1404 Some(".config") => format!(".config/{file}"),
1405 _ => file.to_string(),
1406 }
1407 })
1408 .collect()
1409 }
1410
1411 #[test]
1412 fn empty_directory_has_no_configs_and_no_shadow() {
1413 let tmp = tempdir().unwrap();
1414 assert!(rumdl_configs_in_dir(tmp.path()).is_empty());
1415 assert!(detect_shadowed_configs(tmp.path()).is_none());
1416 }
1417
1418 #[test]
1419 fn single_config_does_not_shadow() {
1420 let tmp = tempdir().unwrap();
1421 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1422 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1423 assert!(detect_shadowed_configs(tmp.path()).is_none());
1424 }
1425
1426 #[test]
1427 fn dot_wins_over_non_dot_and_non_dot_is_shadowed() {
1428 let tmp = tempdir().unwrap();
1429 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1430 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1431
1432 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1433 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1434 assert_eq!(names(&shadowed), vec!["rumdl.toml"]);
1435 }
1436
1437 #[test]
1438 fn config_subdir_counts_as_same_level_shadow() {
1439 let tmp = tempdir().unwrap();
1440 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1441 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1442 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1443
1444 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1445 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1446 assert_eq!(names(&shadowed), vec![".config/rumdl.toml"]);
1447 }
1448
1449 #[test]
1450 fn pyproject_counts_only_when_it_declares_rumdl() {
1451 let bare = tempdir().unwrap();
1453 std::fs::write(bare.path().join(".rumdl.toml"), "").unwrap();
1454 std::fs::write(bare.path().join("pyproject.toml"), "[tool.black]\nline-length = 88\n").unwrap();
1455 assert_eq!(names(&rumdl_configs_in_dir(bare.path())), vec![".rumdl.toml"]);
1456 assert!(detect_shadowed_configs(bare.path()).is_none());
1457
1458 let declared = tempdir().unwrap();
1460 std::fs::write(declared.path().join(".rumdl.toml"), "").unwrap();
1461 std::fs::write(
1462 declared.path().join("pyproject.toml"),
1463 "[tool.rumdl]\nline-length = 80\n",
1464 )
1465 .unwrap();
1466 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(declared.path()).unwrap();
1467 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1468 assert_eq!(names(&shadowed), vec!["pyproject.toml"]);
1469 }
1470
1471 #[test]
1472 fn markdownlint_configs_are_not_rumdl_native_and_never_shadow() {
1473 let tmp = tempdir().unwrap();
1474 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1475 std::fs::write(tmp.path().join(".markdownlint.json"), "{}").unwrap();
1476 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1477 assert!(detect_shadowed_configs(tmp.path()).is_none());
1478 }
1479
1480 #[test]
1481 fn configs_returned_in_precedence_order() {
1482 let tmp = tempdir().unwrap();
1483 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1484 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1485 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1486 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1487 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1488
1489 assert_eq!(
1490 names(&rumdl_configs_in_dir(tmp.path())),
1491 vec![".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]
1492 );
1493 }
1494
1495 #[test]
1496 fn warning_names_dir_once_with_relative_filenames() {
1497 let tmp = tempdir().unwrap();
1498 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1499 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1500 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1501
1502 let shadow = detect_shadowed_configs(tmp.path()).unwrap();
1503 let msg = format_shadow_warning(&shadow);
1504
1505 let dir = {
1506 let s = tmp.path().to_string_lossy().into_owned();
1507 if cfg!(windows) { s.replace('\\', "/") } else { s }
1508 };
1509 assert!(msg.contains("multiple rumdl config files"), "got: {msg}");
1510 assert_eq!(
1513 msg.matches(dir.as_str()).count(),
1514 1,
1515 "directory should appear exactly once, got: {msg}"
1516 );
1517 assert!(
1518 msg.contains("using .rumdl.toml, ignoring rumdl.toml, pyproject.toml"),
1519 "winner and shadowed files should be relative names in precedence order, got: {msg}"
1520 );
1521 assert!(!msg.contains('\\'), "paths must be normalized to '/': {msg}");
1523 }
1524 }
1525}