1use indexmap::IndexSet;
2use std::collections::BTreeMap;
3use std::marker::PhantomData;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, OnceLock};
6
7use super::flavor::ConfigLoaded;
8use super::flavor::ConfigValidated;
9use super::parsers;
10use super::registry::RuleRegistry;
11use super::source_tracking::{
12 ConfigSource, ConfigValidationWarning, SourcedConfig, SourcedConfigFragment, SourcedGlobalConfig, SourcedValue,
13};
14use super::types::{Config, ConfigError, GlobalConfig, MARKDOWNLINT_CONFIG_FILES, RUMDL_CONFIG_FILES, RuleConfig};
15use super::validation::validate_config_sourced_internal;
16use crate::utils::upward_walk::UpwardWalk;
17
18const MAX_EXTENDS_DEPTH: usize = 10;
20
21fn pyproject_declares_rumdl_config(content: &str) -> bool {
30 content.contains("[tool.rumdl]") || content.contains("[tool.rumdl.")
31}
32
33fn resolve_extends_path(extends_value: &str, config_file_path: &Path) -> PathBuf {
39 if let Some(suffix) = extends_value.strip_prefix("~/") {
40 #[cfg(feature = "native")]
42 {
43 use etcetera::{BaseStrategy, choose_base_strategy};
44 let home = choose_base_strategy().map_or_else(|_| PathBuf::from("~"), |s| s.home_dir().to_path_buf());
45 home.join(suffix)
46 }
47 #[cfg(not(feature = "native"))]
48 {
49 let _ = suffix;
50 PathBuf::from(extends_value)
51 }
52 } else {
53 let path = PathBuf::from(extends_value);
54 if path.is_absolute() {
55 path
56 } else {
57 let config_dir = config_file_path.parent().unwrap_or(Path::new("."));
59 config_dir.join(extends_value)
60 }
61 }
62}
63
64fn source_from_filename(filename: &str) -> ConfigSource {
66 if filename == "pyproject.toml" {
67 ConfigSource::PyprojectToml
68 } else {
69 ConfigSource::ProjectConfig
70 }
71}
72
73pub(crate) fn rumdl_configs_in_dir(dir: &Path) -> Vec<PathBuf> {
81 RUMDL_CONFIG_FILES
82 .iter()
83 .map(|name| dir.join(name))
84 .filter(|path| {
85 if !path.exists() {
86 return false;
87 }
88 if path.file_name().and_then(|n| n.to_str()) == Some("pyproject.toml") {
89 std::fs::read_to_string(path).is_ok_and(|content| pyproject_declares_rumdl_config(&content))
90 } else {
91 true
92 }
93 })
94 .collect()
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
106pub(crate) struct ShadowedConfigs {
107 pub dir: PathBuf,
108 pub winner: PathBuf,
109 pub shadowed: Vec<PathBuf>,
110}
111
112pub(crate) fn detect_shadowed_configs(dir: &Path) -> Option<ShadowedConfigs> {
118 let mut configs = rumdl_configs_in_dir(dir);
119 if configs.len() < 2 {
120 return None;
121 }
122 let winner = configs.remove(0);
123 Some(ShadowedConfigs {
124 dir: dir.to_path_buf(),
125 winner,
126 shadowed: configs,
127 })
128}
129
130pub(crate) fn format_shadow_warning(shadow: &ShadowedConfigs) -> String {
138 let norm = |s: String| if cfg!(windows) { s.replace('\\', "/") } else { s };
139 let rel = |path: &Path| {
140 let relative = path.strip_prefix(&shadow.dir).unwrap_or(path);
141 norm(relative.to_string_lossy().into_owned())
142 };
143 let shadowed = shadow.shadowed.iter().map(|p| rel(p)).collect::<Vec<_>>().join(", ");
144 format!(
145 "multiple rumdl config files in {}: using {}, ignoring {}",
146 norm(shadow.dir.to_string_lossy().into_owned()),
147 rel(&shadow.winner),
148 shadowed,
149 )
150}
151
152fn load_config_with_extends(
159 sourced_config: &mut SourcedConfig<ConfigLoaded>,
160 config_file_path: &Path,
161 visited: &mut IndexSet<PathBuf>,
162 chain_source: ConfigSource,
163) -> Result<(), ConfigError> {
164 let canonical = config_file_path
166 .canonicalize()
167 .unwrap_or_else(|_| config_file_path.to_path_buf());
168
169 if visited.contains(&canonical) {
171 let chain: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
172 return Err(ConfigError::CircularExtends {
173 path: config_file_path.display().to_string(),
174 chain,
175 });
176 }
177
178 if visited.len() >= MAX_EXTENDS_DEPTH {
180 return Err(ConfigError::ExtendsDepthExceeded {
181 path: config_file_path.display().to_string(),
182 max_depth: MAX_EXTENDS_DEPTH,
183 });
184 }
185
186 visited.insert(canonical);
188
189 let path_str = config_file_path.display().to_string();
190 let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
191
192 let content = std::fs::read_to_string(config_file_path).map_err(|e| ConfigError::IoError {
194 source: e,
195 path: path_str.clone(),
196 })?;
197
198 let fragment = if filename == "pyproject.toml" {
199 match parsers::parse_pyproject_toml(&content, &path_str, chain_source)? {
200 Some(f) => f,
201 None => return Ok(()), }
203 } else {
204 parsers::parse_rumdl_toml(&content, &path_str, chain_source)?
205 };
206
207 if let Some(ref extends_value) = fragment.extends {
209 let base_path = resolve_extends_path(extends_value, config_file_path);
210
211 if !base_path.exists() {
212 return Err(ConfigError::ExtendsNotFound {
213 path: base_path.display().to_string(),
214 from: path_str.clone(),
215 });
216 }
217
218 log::debug!(
219 "[rumdl-config] Config {} extends {}, loading base first",
220 path_str,
221 base_path.display()
222 );
223
224 load_config_with_extends(sourced_config, &base_path, visited, chain_source)?;
226 }
227
228 let mut fragment_for_merge = fragment;
231 fragment_for_merge.extends = None;
232 sourced_config.merge(fragment_for_merge);
233 sourced_config.loaded_files.push(path_str);
234
235 Ok(())
236}
237
238impl SourcedConfig<ConfigLoaded> {
239 pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
242 self.global.enable.merge_from(fragment.global.enable);
247 self.global.disable.merge_from(fragment.global.disable);
248 self.global
249 .extend_enable
250 .merge_union_from(fragment.global.extend_enable);
251 self.global
252 .extend_disable
253 .merge_union_from(fragment.global.extend_disable);
254
255 self.global
258 .disable
259 .value
260 .retain(|rule| !self.global.enable.value.contains(rule));
261
262 self.global.include.merge_from(fragment.global.include);
263 self.global.exclude.merge_from(fragment.global.exclude);
264 self.global
265 .respect_gitignore
266 .merge_from(fragment.global.respect_gitignore);
267 self.global.line_length.merge_from(fragment.global.line_length);
268 self.global.fixable.merge_from(fragment.global.fixable);
269 self.global.unfixable.merge_from(fragment.global.unfixable);
270 self.global.flavor.merge_from(fragment.global.flavor);
271 self.global.force_exclude.merge_from(fragment.global.force_exclude);
272
273 if let Some(output_format_fragment) = fragment.global.output_format {
275 if let Some(ref mut output_format) = self.global.output_format {
276 output_format.merge_from(output_format_fragment);
277 } else {
278 self.global.output_format = Some(output_format_fragment);
279 }
280 }
281
282 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
284 if let Some(ref mut cache_dir) = self.global.cache_dir {
285 cache_dir.merge_from(cache_dir_fragment);
286 } else {
287 self.global.cache_dir = Some(cache_dir_fragment);
288 }
289 }
290
291 if fragment.global.cache.source != ConfigSource::Default {
293 self.global.cache.merge_from(fragment.global.cache);
294 }
295
296 self.per_file_ignores.merge_from(fragment.per_file_ignores);
297 self.per_file_flavor.merge_from(fragment.per_file_flavor);
298 self.code_block_tools.merge_from(fragment.code_block_tools);
299
300 for (rule_name, rule_fragment) in fragment.rules {
302 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
304
305 if let Some(severity_fragment) = rule_fragment.severity {
307 if let Some(ref mut existing_severity) = rule_entry.severity {
308 existing_severity.merge_from(severity_fragment);
309 } else {
310 rule_entry.severity = Some(severity_fragment);
311 }
312 }
313
314 for (key, sourced_value_fragment) in rule_fragment.values {
316 let sv_entry = rule_entry
317 .values
318 .entry(key.clone())
319 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
320 sv_entry.merge_from(sourced_value_fragment);
321 }
322 }
323
324 for (section, key, file_path) in fragment.unknown_keys {
326 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
328 self.unknown_keys.push((section, key, file_path));
329 }
330 }
331 }
332
333 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
335 Self::load_with_discovery(config_path, cli_overrides, false)
336 }
337
338 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
341 UpwardWalk::new(start_dir)
342 .find(|dir| dir.join(".git").exists())
343 .unwrap_or_else(|| {
344 log::debug!(
345 "[rumdl-config] No .git found, using config location as project root: {}",
346 start_dir.display()
347 );
348 start_dir.to_path_buf()
349 })
350 }
351
352 fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
358 home_override.map(Path::to_path_buf).or_else(|| {
359 #[cfg(feature = "native")]
360 {
361 use etcetera::{BaseStrategy, choose_base_strategy};
362 choose_base_strategy().ok().map(|s| s.home_dir().to_path_buf())
363 }
364 #[cfg(not(feature = "native"))]
365 {
366 None
367 }
368 })
369 }
370
371 fn discover_config_upward(
387 home_override: Option<&Path>,
388 ) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
389 let start_dir = match std::env::current_dir() {
390 Ok(dir) => dir,
391 Err(e) => {
392 log::debug!("[rumdl-config] Failed to get current directory: {e}");
393 return None;
394 }
395 };
396
397 let (config_path, config_dir, shadow) = UpwardWalk::new(&start_dir)
401 .stop_below(Self::resolve_home_boundary(home_override))
402 .always_yield_start()
403 .stop_at_git_root()
404 .find_map(|dir| {
405 rumdl_configs_in_dir(&dir).into_iter().next().map(|winner| {
406 log::debug!("[rumdl-config] Found config file: {}", winner.display());
407 let shadow = detect_shadowed_configs(&dir);
408 (winner, dir, shadow)
409 })
410 })?;
411
412 let project_root = Self::find_project_root_from(&config_dir);
414 Some((config_path, project_root, shadow))
415 }
416
417 fn discover_markdownlint_config_upward(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
425 let start_dir = match std::env::current_dir() {
426 Ok(dir) => dir,
427 Err(e) => {
428 log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
429 return None;
430 }
431 };
432
433 UpwardWalk::new(&start_dir)
434 .stop_below(Self::resolve_home_boundary(home_override))
435 .always_yield_start()
436 .stop_at_git_root()
437 .find_map(|dir| {
438 MARKDOWNLINT_CONFIG_FILES
439 .iter()
440 .map(|name| dir.join(name))
441 .find(|path| path.exists())
442 })
443 }
444
445 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
447 let config_dir = config_dir.join("rumdl");
448
449 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
451
452 log::debug!(
453 "[rumdl-config] Checking for user configuration in: {}",
454 config_dir.display()
455 );
456
457 for filename in USER_CONFIG_FILES {
458 let config_path = config_dir.join(filename);
459
460 if config_path.exists() {
461 if *filename == "pyproject.toml" {
463 if let Ok(content) = std::fs::read_to_string(&config_path) {
464 if pyproject_declares_rumdl_config(&content) {
465 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
466 return Some(config_path);
467 }
468 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
469 continue;
470 }
471 } else {
472 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
473 return Some(config_path);
474 }
475 }
476 }
477
478 log::debug!(
479 "[rumdl-config] No user configuration found in: {}",
480 config_dir.display()
481 );
482 None
483 }
484
485 #[cfg(feature = "native")]
488 fn user_configuration_path() -> Option<std::path::PathBuf> {
489 use etcetera::{BaseStrategy, choose_base_strategy};
490
491 match choose_base_strategy() {
492 Ok(strategy) => {
493 let config_dir = strategy.config_dir();
494 Self::user_configuration_path_impl(&config_dir)
495 }
496 Err(e) => {
497 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
498 None
499 }
500 }
501 }
502
503 #[cfg(not(feature = "native"))]
505 fn user_configuration_path() -> Option<std::path::PathBuf> {
506 None
507 }
508
509 fn home_configuration_path_impl(home_dir: &Path) -> Option<std::path::PathBuf> {
521 const HOME_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml"];
522
523 log::debug!(
524 "[rumdl-config] Checking for home-directory configuration in: {}",
525 home_dir.display()
526 );
527
528 for filename in HOME_CONFIG_FILES {
529 let config_path = home_dir.join(filename);
530 if config_path.exists() {
531 log::debug!(
532 "[rumdl-config] Found home-directory configuration at: {}",
533 config_path.display()
534 );
535 return Some(config_path);
536 }
537 }
538
539 log::debug!(
540 "[rumdl-config] No home-directory configuration found in: {}",
541 home_dir.display()
542 );
543 None
544 }
545
546 #[cfg(feature = "native")]
552 fn home_configuration_path() -> Option<std::path::PathBuf> {
553 use etcetera::{BaseStrategy, choose_base_strategy};
554
555 match choose_base_strategy() {
556 Ok(strategy) => Self::home_configuration_path_impl(strategy.home_dir()),
557 Err(e) => {
558 log::debug!("[rumdl-config] Failed to determine home directory: {e}");
559 None
560 }
561 }
562 }
563
564 #[cfg(not(feature = "native"))]
566 fn home_configuration_path() -> Option<std::path::PathBuf> {
567 None
568 }
569
570 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
572 let path_obj = Path::new(path);
573 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
574 let path_str = path.to_string();
575
576 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
577
578 if let Some(config_parent) = path_obj.parent() {
580 let project_root = Self::find_project_root_from(config_parent);
581 log::debug!(
582 "[rumdl-config] Project root (from explicit config): {}",
583 project_root.display()
584 );
585 sourced_config.project_root = Some(project_root);
586 }
587
588 const MARKDOWNLINT_FILENAMES: &[&str] = &[
590 ".markdownlint-cli2.jsonc",
591 ".markdownlint-cli2.yaml",
592 ".markdownlint-cli2.yml",
593 ".markdownlint.json",
594 ".markdownlint.yaml",
595 ".markdownlint.yml",
596 ];
597
598 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
599 let mut visited = IndexSet::new();
601 let chain_source = source_from_filename(filename);
602 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
603 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
604 || path_str.ends_with(".json")
605 || path_str.ends_with(".jsonc")
606 || path_str.ends_with(".yaml")
607 || path_str.ends_with(".yml")
608 {
609 let fragment = parsers::load_from_markdownlint(&path_str)?;
611 sourced_config.merge(fragment);
612 sourced_config.loaded_files.push(path_str);
613 } else {
614 let mut visited = IndexSet::new();
616 let chain_source = source_from_filename(filename);
617 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
618 }
619
620 Ok(())
621 }
622
623 fn load_user_config(
642 sourced_config: &mut Self,
643 user_config_dir: Option<&Path>,
644 home_dir: Option<&Path>,
645 ) -> Result<(), ConfigError> {
646 let user_config_path = if let Some(dir) = user_config_dir {
647 Self::user_configuration_path_impl(dir)
648 } else {
649 Self::user_configuration_path()
650 };
651
652 let user_config_path = user_config_path.or_else(|| match home_dir {
653 Some(home) => Self::home_configuration_path_impl(home),
654 None => Self::home_configuration_path(),
655 });
656
657 if let Some(user_config_path) = user_config_path {
658 let path_str = user_config_path.display().to_string();
659
660 log::debug!("[rumdl-config] Loading user config: {path_str}");
661
662 let mut visited = IndexSet::new();
665 load_config_with_extends(
666 sourced_config,
667 &user_config_path,
668 &mut visited,
669 ConfigSource::UserConfig,
670 )?;
671 } else {
672 log::debug!("[rumdl-config] No user configuration file found");
673 }
674
675 Ok(())
676 }
677
678 #[doc(hidden)]
680 pub fn load_with_discovery_impl(
681 config_path: Option<&str>,
682 cli_overrides: Option<&SourcedGlobalConfig>,
683 skip_auto_discovery: bool,
684 user_config_dir: Option<&Path>,
685 home_dir: Option<&Path>,
686 ) -> Result<Self, ConfigError> {
687 use std::env;
688 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
689
690 let mut sourced_config = SourcedConfig::default();
691
692 if let Some(path) = config_path {
705 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
707 Self::load_explicit_config(&mut sourced_config, path)?;
708 } else if skip_auto_discovery {
709 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
710 } else {
712 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
714
715 if let Some((config_file, project_root, shadow)) = Self::discover_config_upward(home_dir) {
717 log::debug!("[rumdl-config] Found project config: {}", config_file.display());
721 log::debug!("[rumdl-config] Project root: {}", project_root.display());
722
723 if let Some(shadow) = shadow {
726 sourced_config.discovery_warnings.push(format_shadow_warning(&shadow));
727 }
728
729 sourced_config.project_root = Some(project_root);
730
731 let mut visited = IndexSet::new();
733 let root_filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
734 let chain_source = source_from_filename(root_filename);
735 load_config_with_extends(&mut sourced_config, &config_file, &mut visited, chain_source)?;
736 } else {
737 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
739
740 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward(home_dir) {
741 let path_str = markdownlint_path.display().to_string();
742 log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
743 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
748 match parsers::load_from_markdownlint(&path_str) {
749 Ok(fragment) => {
750 sourced_config.merge(fragment);
751 sourced_config.loaded_files.push(path_str);
752 }
753 Err(_e) => {
754 log::debug!("[rumdl-config] Failed to load markdownlint config");
755 }
756 }
757 } else {
758 log::debug!("[rumdl-config] No project config found, using user config as fallback");
760 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
761 }
762 }
763 }
764
765 if let Some(cli) = cli_overrides {
767 sourced_config
768 .global
769 .enable
770 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None);
771 sourced_config
772 .global
773 .disable
774 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None);
775 sourced_config
776 .global
777 .exclude
778 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None);
779 sourced_config
780 .global
781 .include
782 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None);
783 sourced_config.global.respect_gitignore.merge_override(
784 cli.respect_gitignore.value,
785 ConfigSource::Cli,
786 None,
787 );
788 sourced_config
789 .global
790 .fixable
791 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None);
792 sourced_config
793 .global
794 .unfixable
795 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None);
796 }
798
799 Ok(sourced_config)
802 }
803
804 pub fn load_with_discovery(
807 config_path: Option<&str>,
808 cli_overrides: Option<&SourcedGlobalConfig>,
809 skip_auto_discovery: bool,
810 ) -> Result<Self, ConfigError> {
811 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
812 }
813
814 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
828 let warnings = validate_config_sourced_internal(&self, registry);
829
830 Ok(SourcedConfig {
831 global: self.global,
832 per_file_ignores: self.per_file_ignores,
833 per_file_flavor: self.per_file_flavor,
834 code_block_tools: self.code_block_tools,
835 rules: self.rules,
836 loaded_files: self.loaded_files,
837 unknown_keys: self.unknown_keys,
838 project_root: self.project_root,
839 discovery_warnings: self.discovery_warnings,
840 validation_warnings: warnings,
841 _state: PhantomData,
842 })
843 }
844
845 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
850 let validated = self.validate(registry)?;
851 let warnings = validated.validation_warnings.clone();
852 Ok((validated.into(), warnings))
853 }
854
855 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
866 SourcedConfig {
867 global: self.global,
868 per_file_ignores: self.per_file_ignores,
869 per_file_flavor: self.per_file_flavor,
870 code_block_tools: self.code_block_tools,
871 rules: self.rules,
872 loaded_files: self.loaded_files,
873 unknown_keys: self.unknown_keys,
874 project_root: self.project_root,
875 discovery_warnings: self.discovery_warnings,
876 validation_warnings: Vec::new(),
877 _state: PhantomData,
878 }
879 }
880
881 pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
890 UpwardWalk::new(dir)
902 .stop_below(Self::resolve_home_boundary(None))
903 .stop_at(project_root)
904 .find_map(|current| {
905 for config_name in RUMDL_CONFIG_FILES {
907 let config_path = current.join(config_name);
908 if config_path.exists() {
909 if *config_name == "pyproject.toml" {
910 if let Ok(content) = std::fs::read_to_string(&config_path)
911 && pyproject_declares_rumdl_config(&content)
912 {
913 return Some(config_path);
914 }
915 continue;
916 }
917 return Some(config_path);
918 }
919 }
920
921 MARKDOWNLINT_CONFIG_FILES
923 .iter()
924 .map(|name| current.join(name))
925 .find(|path| path.exists())
926 })
927 }
928
929 pub fn load_sourced_for_path(
936 config_path: &Path,
937 project_root: &Path,
938 ) -> Result<SourcedConfig<ConfigLoaded>, ConfigError> {
939 let mut sourced_config = SourcedConfig {
940 project_root: Some(project_root.to_path_buf()),
941 ..SourcedConfig::default()
942 };
943
944 let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
945 let path_str = config_path.display().to_string();
946
947 let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
949 || (filename != "pyproject.toml"
950 && filename != ".rumdl.toml"
951 && filename != "rumdl.toml"
952 && (path_str.ends_with(".json")
953 || path_str.ends_with(".jsonc")
954 || path_str.ends_with(".yaml")
955 || path_str.ends_with(".yml")));
956
957 if is_markdownlint {
958 let fragment = parsers::load_from_markdownlint(&path_str)?;
959 sourced_config.merge(fragment);
960 sourced_config.loaded_files.push(path_str);
961 } else {
962 let mut visited = IndexSet::new();
963 let chain_source = source_from_filename(filename);
964 load_config_with_extends(&mut sourced_config, config_path, &mut visited, chain_source)?;
965 }
966
967 Ok(sourced_config)
968 }
969
970 pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
974 Ok(Self::load_sourced_for_path(config_path, project_root)?
975 .into_validated_unchecked()
976 .into())
977 }
978}
979
980impl From<SourcedConfig<ConfigValidated>> for Config {
985 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
986 let mut rules = BTreeMap::new();
987 for (rule_name, sourced_rule_cfg) in sourced.rules {
988 let normalized_rule_name = rule_name.to_ascii_uppercase();
990 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
991 let mut values = BTreeMap::new();
992 for (key, sourced_val) in sourced_rule_cfg.values {
993 values.insert(key, sourced_val.value);
994 }
995 rules.insert(normalized_rule_name, RuleConfig { severity, values });
996 }
997 let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
999
1000 #[allow(deprecated)]
1001 let global = GlobalConfig {
1002 enable: sourced.global.enable.value,
1003 disable: sourced.global.disable.value,
1004 exclude: sourced.global.exclude.value,
1005 include: sourced.global.include.value,
1006 respect_gitignore: sourced.global.respect_gitignore.value,
1007 line_length: sourced.global.line_length.value,
1008 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1009 fixable: sourced.global.fixable.value,
1010 unfixable: sourced.global.unfixable.value,
1011 flavor: sourced.global.flavor.value,
1012 force_exclude: sourced.global.force_exclude.value,
1013 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
1014 cache: sourced.global.cache.value,
1015 extend_enable: sourced.global.extend_enable.value,
1016 extend_disable: sourced.global.extend_disable.value,
1017 enable_is_explicit,
1018 };
1019
1020 let mut config = Config {
1021 extends: None,
1022 global,
1023 per_file_ignores: sourced.per_file_ignores.value,
1024 per_file_flavor: sourced.per_file_flavor.value,
1025 code_block_tools: sourced.code_block_tools.value,
1026 rules,
1027 project_root: sourced.project_root,
1028 per_file_ignores_cache: Arc::new(OnceLock::new()),
1029 per_file_flavor_cache: Arc::new(OnceLock::new()),
1030 canonical_project_root_cache: Arc::new(OnceLock::new()),
1031 };
1032
1033 config.apply_per_rule_enabled();
1035
1036 config.canonicalize_rule_lists();
1043
1044 config
1045 }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use super::pyproject_declares_rumdl_config;
1051
1052 #[test]
1053 fn detects_flat_and_dotted_rumdl_sections() {
1054 assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
1055 assert!(pyproject_declares_rumdl_config(
1057 "[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
1058 ));
1059 assert!(pyproject_declares_rumdl_config(
1060 "[tool.rumdl.rules.MD007]\nindent = 4\n"
1061 ));
1062 }
1063
1064 #[test]
1065 fn ignores_incidental_mentions() {
1066 assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
1069 assert!(!pyproject_declares_rumdl_config(
1070 "[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
1071 ));
1072 assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
1073 }
1074
1075 #[cfg(unix)]
1084 #[test]
1085 fn discover_stops_at_project_root_across_path_representations() {
1086 use super::SourcedConfig;
1087 use std::os::unix::fs::symlink;
1088 use tempfile::tempdir;
1089
1090 let tmp = tempdir().unwrap();
1091 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1093
1094 let real_root = tmp.path().join("project");
1095 let subdir = real_root.join("docs");
1096 std::fs::create_dir_all(&subdir).unwrap();
1097
1098 let linked_root = tmp.path().join("project-link");
1101 symlink(&real_root, &linked_root).unwrap();
1102
1103 let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
1104 assert_eq!(
1105 found, None,
1106 "discovery must stop at the project root, not overshoot to the parent config"
1107 );
1108 }
1109
1110 mod shadowed_configs {
1111 use super::super::{ShadowedConfigs, detect_shadowed_configs, format_shadow_warning, rumdl_configs_in_dir};
1112 use tempfile::tempdir;
1113
1114 fn names(paths: &[std::path::PathBuf]) -> Vec<String> {
1115 paths
1116 .iter()
1117 .map(|p| {
1118 let file = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
1121 let parent = p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str());
1122 match parent {
1123 Some(".config") => format!(".config/{file}"),
1124 _ => file.to_string(),
1125 }
1126 })
1127 .collect()
1128 }
1129
1130 #[test]
1131 fn empty_directory_has_no_configs_and_no_shadow() {
1132 let tmp = tempdir().unwrap();
1133 assert!(rumdl_configs_in_dir(tmp.path()).is_empty());
1134 assert!(detect_shadowed_configs(tmp.path()).is_none());
1135 }
1136
1137 #[test]
1138 fn single_config_does_not_shadow() {
1139 let tmp = tempdir().unwrap();
1140 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1141 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1142 assert!(detect_shadowed_configs(tmp.path()).is_none());
1143 }
1144
1145 #[test]
1146 fn dot_wins_over_non_dot_and_non_dot_is_shadowed() {
1147 let tmp = tempdir().unwrap();
1148 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1149 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1150
1151 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1152 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1153 assert_eq!(names(&shadowed), vec!["rumdl.toml"]);
1154 }
1155
1156 #[test]
1157 fn config_subdir_counts_as_same_level_shadow() {
1158 let tmp = tempdir().unwrap();
1159 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1160 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1161 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1162
1163 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1164 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1165 assert_eq!(names(&shadowed), vec![".config/rumdl.toml"]);
1166 }
1167
1168 #[test]
1169 fn pyproject_counts_only_when_it_declares_rumdl() {
1170 let bare = tempdir().unwrap();
1172 std::fs::write(bare.path().join(".rumdl.toml"), "").unwrap();
1173 std::fs::write(bare.path().join("pyproject.toml"), "[tool.black]\nline-length = 88\n").unwrap();
1174 assert_eq!(names(&rumdl_configs_in_dir(bare.path())), vec![".rumdl.toml"]);
1175 assert!(detect_shadowed_configs(bare.path()).is_none());
1176
1177 let declared = tempdir().unwrap();
1179 std::fs::write(declared.path().join(".rumdl.toml"), "").unwrap();
1180 std::fs::write(
1181 declared.path().join("pyproject.toml"),
1182 "[tool.rumdl]\nline-length = 80\n",
1183 )
1184 .unwrap();
1185 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(declared.path()).unwrap();
1186 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1187 assert_eq!(names(&shadowed), vec!["pyproject.toml"]);
1188 }
1189
1190 #[test]
1191 fn markdownlint_configs_are_not_rumdl_native_and_never_shadow() {
1192 let tmp = tempdir().unwrap();
1193 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1194 std::fs::write(tmp.path().join(".markdownlint.json"), "{}").unwrap();
1195 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1196 assert!(detect_shadowed_configs(tmp.path()).is_none());
1197 }
1198
1199 #[test]
1200 fn configs_returned_in_precedence_order() {
1201 let tmp = tempdir().unwrap();
1202 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1203 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1204 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1205 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1206 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1207
1208 assert_eq!(
1209 names(&rumdl_configs_in_dir(tmp.path())),
1210 vec![".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]
1211 );
1212 }
1213
1214 #[test]
1215 fn warning_names_dir_once_with_relative_filenames() {
1216 let tmp = tempdir().unwrap();
1217 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1218 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1219 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1220
1221 let shadow = detect_shadowed_configs(tmp.path()).unwrap();
1222 let msg = format_shadow_warning(&shadow);
1223
1224 let dir = {
1225 let s = tmp.path().to_string_lossy().into_owned();
1226 if cfg!(windows) { s.replace('\\', "/") } else { s }
1227 };
1228 assert!(msg.contains("multiple rumdl config files"), "got: {msg}");
1229 assert_eq!(
1232 msg.matches(dir.as_str()).count(),
1233 1,
1234 "directory should appear exactly once, got: {msg}"
1235 );
1236 assert!(
1237 msg.contains("using .rumdl.toml, ignoring rumdl.toml, pyproject.toml"),
1238 "winner and shadowed files should be relative names in precedence order, got: {msg}"
1239 );
1240 assert!(!msg.contains('\\'), "paths must be normalized to '/': {msg}");
1242 }
1243 }
1244}