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::{
15 Config, ConfigError, ConfigOrigin, DiscoveredConfigError, GlobalConfig, MARKDOWNLINT_CONFIG_FILES,
16 RUMDL_CONFIG_FILES, RuleConfig, WITHHELD,
17};
18use super::validation::validate_config_sourced_internal;
19use crate::utils::upward_walk::UpwardWalk;
20
21const MAX_EXTENDS_DEPTH: usize = 10;
23
24fn pyproject_declares_rumdl_config(content: &str) -> bool {
33 content.contains("[tool.rumdl]") || content.contains("[tool.rumdl.")
34}
35
36fn is_var_name_start(b: u8) -> bool {
38 b == b'_' || b.is_ascii_alphabetic()
39}
40
41fn is_var_name_continue(b: u8) -> bool {
43 b == b'_' || b.is_ascii_alphanumeric()
44}
45
46fn is_valid_var_name(name: &str) -> bool {
48 let bytes = name.as_bytes();
49 !bytes.is_empty() && is_var_name_start(bytes[0]) && bytes[1..].iter().all(|&b| is_var_name_continue(b))
50}
51
52fn expand_env_vars(input: &str, lookup: impl Fn(&str) -> Option<String>) -> Result<String, String> {
70 let bytes = input.as_bytes();
71 let mut out = String::with_capacity(input.len());
72 let mut i = 0;
73
74 while i < bytes.len() {
75 if bytes[i] != b'$' {
76 let start = i;
78 while i < bytes.len() && bytes[i] != b'$' {
79 i += 1;
80 }
81 out.push_str(&input[start..i]);
82 continue;
83 }
84
85 match bytes.get(i + 1).copied() {
86 Some(b'$') => {
88 out.push('$');
89 i += 2;
90 }
91 Some(b'{') => {
93 if let Some(rel) = input[i + 2..].find('}') {
94 let close = i + 2 + rel;
95 let name = &input[i + 2..close];
96 if is_valid_var_name(name) {
97 match lookup(name) {
98 Some(value) => out.push_str(&value),
99 None => return Err(name.to_string()),
100 }
101 } else {
102 out.push_str(&input[i..=close]);
104 }
105 i = close + 1;
106 } else {
107 out.push('$');
109 i += 1;
110 }
111 }
112 Some(b) if is_var_name_start(b) => {
114 let start = i + 1;
115 let mut j = start;
116 while j < bytes.len() && is_var_name_continue(bytes[j]) {
117 j += 1;
118 }
119 let name = &input[start..j];
120 match lookup(name) {
121 Some(value) => out.push_str(&value),
122 None => return Err(name.to_string()),
123 }
124 i = j;
125 }
126 _ => {
128 out.push('$');
129 i += 1;
130 }
131 }
132 }
133
134 Ok(out)
135}
136
137struct ExtendsRef {
145 written: Option<String>,
150 from: String,
153}
154
155impl ExtendsRef {
156 fn describe(&self) -> String {
158 format!("{} (referenced from {})", self.short(), self.from)
159 }
160
161 fn short(&self) -> String {
168 match &self.written {
169 Some(written) => format!("'{written}'"),
170 None => WITHHELD.to_string(),
171 }
172 }
173}
174
175#[derive(Default)]
181struct ExtendsChain {
182 visited: IndexSet<PathBuf>,
183 names: Vec<String>,
184}
185
186impl ExtendsChain {
187 fn contains(&self, canonical: &Path) -> bool {
188 self.visited.contains(canonical)
189 }
190
191 fn len(&self) -> usize {
192 self.visited.len()
193 }
194
195 fn push(&mut self, canonical: PathBuf, name: String) {
196 self.visited.insert(canonical);
197 self.names.push(name);
198 }
199
200 fn names(&self) -> Vec<String> {
201 self.names.clone()
202 }
203}
204
205fn resolve_extends(
216 extends_value: &str,
217 config_file_path: &Path,
218 from: &str,
219 declared_by: ConfigOrigin<'_>,
220) -> Result<(PathBuf, ExtendsRef), ConfigError> {
221 let expanded = expand_env_vars(extends_value, |key| std::env::var(key).ok()).map_err(|var| {
222 ConfigError::ExtendsUndefinedVar {
226 var: if declared_by.may_quote_contents() {
227 format!("${var}")
228 } else {
229 WITHHELD.to_string()
230 },
231 from: from.to_string(),
232 }
233 })?;
234
235 let reference = ExtendsRef {
236 written: declared_by.may_quote_contents().then(|| extends_value.to_string()),
237 from: from.to_string(),
238 };
239
240 Ok((resolve_expanded_extends_path(&expanded, config_file_path), reference))
241}
242
243fn resolve_expanded_extends_path(expanded: &str, config_file_path: &Path) -> PathBuf {
245 if let Some(suffix) = expanded.strip_prefix("~/") {
246 #[cfg(feature = "native")]
248 {
249 use etcetera::{BaseStrategy, choose_base_strategy};
250 let home = choose_base_strategy().map_or_else(|_| PathBuf::from("~"), |s| s.home_dir().to_path_buf());
251 home.join(suffix)
252 }
253 #[cfg(not(feature = "native"))]
254 {
255 let _ = suffix;
256 PathBuf::from(expanded)
257 }
258 } else {
259 let path = PathBuf::from(expanded);
260 if path.is_absolute() {
261 path
262 } else {
263 let config_dir = config_file_path.parent().unwrap_or(Path::new("."));
265 config_dir.join(expanded)
266 }
267 }
268}
269
270fn source_from_filename(filename: &str) -> ConfigSource {
272 if filename == "pyproject.toml" {
273 ConfigSource::PyprojectToml
274 } else {
275 ConfigSource::ProjectConfig
276 }
277}
278
279pub(crate) fn rumdl_configs_in_dir(dir: &Path) -> Vec<PathBuf> {
287 RUMDL_CONFIG_FILES
288 .iter()
289 .map(|name| dir.join(name))
290 .filter(|path| {
291 if !path.exists() {
292 return false;
293 }
294 if path.file_name().and_then(|n| n.to_str()) == Some("pyproject.toml") {
295 std::fs::read_to_string(path).is_ok_and(|content| pyproject_declares_rumdl_config(&content))
296 } else {
297 true
298 }
299 })
300 .collect()
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
312pub(crate) struct ShadowedConfigs {
313 pub dir: PathBuf,
314 pub winner: PathBuf,
315 pub shadowed: Vec<PathBuf>,
316}
317
318pub(crate) fn detect_shadowed_configs(dir: &Path) -> Option<ShadowedConfigs> {
324 let mut configs = rumdl_configs_in_dir(dir);
325 if configs.len() < 2 {
326 return None;
327 }
328 let winner = configs.remove(0);
329 Some(ShadowedConfigs {
330 dir: dir.to_path_buf(),
331 winner,
332 shadowed: configs,
333 })
334}
335
336pub(crate) fn format_shadow_warning(shadow: &ShadowedConfigs) -> String {
344 let norm = |s: String| if cfg!(windows) { s.replace('\\', "/") } else { s };
345 let rel = |path: &Path| {
346 let relative = path.strip_prefix(&shadow.dir).unwrap_or(path);
347 norm(relative.to_string_lossy().into_owned())
348 };
349 let shadowed = shadow.shadowed.iter().map(|p| rel(p)).collect::<Vec<_>>().join(", ");
350 format!(
351 "multiple rumdl config files in {}: using {}, ignoring {}",
352 norm(shadow.dir.to_string_lossy().into_owned()),
353 rel(&shadow.winner),
354 shadowed,
355 )
356}
357
358fn load_config_with_extends(
372 sourced_config: &mut SourcedConfig<ConfigLoaded>,
373 config_file_path: &Path,
374 chain: &mut ExtendsChain,
375 chain_source: ConfigSource,
376 origin: ConfigOrigin<'_>,
377) -> Result<(), ConfigError> {
378 let canonical = config_file_path
380 .canonicalize()
381 .unwrap_or_else(|_| config_file_path.to_path_buf());
382
383 let path_str = config_file_path.display().to_string();
384 let described = origin.display_name(&path_str);
385 let short = origin.short_name(&path_str);
386
387 if chain.contains(&canonical) {
389 return Err(ConfigError::CircularExtends {
390 path: described,
391 chain: chain.names(),
392 });
393 }
394
395 if chain.len() >= MAX_EXTENDS_DEPTH {
397 return Err(ConfigError::ExtendsDepthExceeded {
398 path: described,
399 max_depth: MAX_EXTENDS_DEPTH,
400 });
401 }
402
403 chain.push(canonical, short.clone());
405
406 let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
407
408 let content = std::fs::read_to_string(config_file_path).map_err(|e| ConfigError::IoError {
410 source: e,
411 path: described.clone(),
412 })?;
413
414 let fragment = if filename == "pyproject.toml" {
415 match parsers::parse_pyproject_toml(&content, &path_str, chain_source, origin)? {
416 Some(f) => f,
417 None => return Ok(()), }
419 } else {
420 parsers::parse_rumdl_toml(&content, &path_str, chain_source, origin)?
421 };
422
423 if let Some(ref extends_value) = fragment.extends {
425 let (base_path, reference) = resolve_extends(extends_value, config_file_path, &short, origin)?;
426 let base_described = reference.describe();
427 let base_short = reference.short();
428
429 if !base_path.exists() {
430 return Err(ConfigError::ExtendsNotFound {
431 path: base_short,
432 from: short,
433 });
434 }
435
436 log::debug!(
437 "[rumdl-config] Config {} extends {}, loading base first",
438 path_str,
439 base_path.display()
440 );
441
442 load_config_with_extends(
444 sourced_config,
445 &base_path,
446 chain,
447 chain_source,
448 ConfigOrigin::Extends {
449 described_as: &base_described,
450 short_name: &base_short,
451 },
452 )?;
453 }
454
455 let mut fragment_for_merge = fragment;
458 fragment_for_merge.extends = None;
459 sourced_config.merge(fragment_for_merge);
460 sourced_config.loaded_files.push(path_str);
461
462 Ok(())
463}
464
465impl SourcedConfig<ConfigLoaded> {
466 pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
469 self.global.enable.merge_from(fragment.global.enable);
474 self.global.disable.merge_from(fragment.global.disable);
475 self.global
476 .extend_enable
477 .merge_union_from(fragment.global.extend_enable);
478 self.global
479 .extend_disable
480 .merge_union_from(fragment.global.extend_disable);
481
482 self.global
485 .disable
486 .value
487 .retain(|rule| !self.global.enable.value.contains(rule));
488
489 if self.global.include.merge_from(fragment.global.include) {
493 self.global.include_withheld = fragment.global.include_withheld;
494 }
495 self.global.exclude.merge_from(fragment.global.exclude);
496 self.global
497 .respect_gitignore
498 .merge_from(fragment.global.respect_gitignore);
499 self.global.line_length.merge_from(fragment.global.line_length);
500 self.global.fixable.merge_from(fragment.global.fixable);
501 self.global.unfixable.merge_from(fragment.global.unfixable);
502 self.global.flavor.merge_from(fragment.global.flavor);
503 self.global.force_exclude.merge_from(fragment.global.force_exclude);
504 self.global.editorconfig.merge_from(fragment.global.editorconfig);
505
506 if let Some(output_format_fragment) = fragment.global.output_format {
508 if let Some(ref mut output_format) = self.global.output_format {
509 output_format.merge_from(output_format_fragment);
510 } else {
511 self.global.output_format = Some(output_format_fragment);
512 }
513 }
514
515 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
517 if let Some(ref mut cache_dir) = self.global.cache_dir {
518 cache_dir.merge_from(cache_dir_fragment);
519 } else {
520 self.global.cache_dir = Some(cache_dir_fragment);
521 }
522 }
523
524 if fragment.global.cache.source != ConfigSource::Default {
526 self.global.cache.merge_from(fragment.global.cache);
527 }
528
529 self.per_file_ignores.merge_from(fragment.per_file_ignores);
530 self.per_file_flavor.merge_from(fragment.per_file_flavor);
531 self.code_block_tools.merge_from(fragment.code_block_tools);
532
533 for (rule_name, rule_fragment) in fragment.rules {
535 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
537
538 if let Some(severity_fragment) = rule_fragment.severity {
540 if let Some(ref mut existing_severity) = rule_entry.severity {
541 existing_severity.merge_from(severity_fragment);
542 } else {
543 rule_entry.severity = Some(severity_fragment);
544 }
545 }
546
547 for (key, sourced_value_fragment) in rule_fragment.values {
551 let sv_entry = rule_entry
552 .values
553 .entry(key.clone())
554 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
555 if sv_entry.merge_from(sourced_value_fragment) {
556 if rule_fragment.withheld_keys.contains(&key) {
557 rule_entry.withheld_keys.insert(key);
558 } else {
559 rule_entry.withheld_keys.remove(&key);
560 }
561 }
562 }
563 }
564
565 for warning in fragment.load_warnings {
569 if !self.discovery_warnings.contains(&warning) {
570 self.discovery_warnings.push(warning);
571 }
572 }
573
574 for (section, key, file_path) in fragment.unknown_keys {
575 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
577 self.unknown_keys.push((section, key, file_path));
578 }
579 }
580 }
581
582 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
584 Self::load_with_discovery(config_path, cli_overrides, false)
585 }
586
587 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
590 UpwardWalk::new(start_dir)
591 .find(|dir| dir.join(".git").exists())
592 .unwrap_or_else(|| {
593 log::debug!(
594 "[rumdl-config] No .git found, using config location as project root: {}",
595 start_dir.display()
596 );
597 start_dir.to_path_buf()
598 })
599 }
600
601 fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
607 home_override.map(Path::to_path_buf).or_else(|| {
608 #[cfg(feature = "native")]
609 {
610 use etcetera::{BaseStrategy, choose_base_strategy};
611 choose_base_strategy().ok().map(|s| s.home_dir().to_path_buf())
612 }
613 #[cfg(not(feature = "native"))]
614 {
615 None
616 }
617 })
618 }
619
620 fn resolve_discovery_start(start_override: Option<&Path>) -> Option<std::path::PathBuf> {
626 if let Some(dir) = start_override {
627 return Some(dir.to_path_buf());
628 }
629 match std::env::current_dir() {
630 Ok(dir) => Some(dir),
631 Err(e) => {
632 log::debug!("[rumdl-config] Failed to get current directory: {e}");
633 None
634 }
635 }
636 }
637
638 fn discover_config_upward(
654 start_override: Option<&Path>,
655 home_override: Option<&Path>,
656 ) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
657 let start_dir = Self::resolve_discovery_start(start_override)?;
658
659 let (config_path, config_dir, shadow) = UpwardWalk::new(&start_dir)
663 .stop_below(Self::resolve_home_boundary(home_override))
664 .always_yield_start()
665 .stop_at_git_root()
666 .find_map(|dir| {
667 rumdl_configs_in_dir(&dir).into_iter().next().map(|winner| {
668 log::debug!("[rumdl-config] Found config file: {}", winner.display());
669 let shadow = detect_shadowed_configs(&dir);
670 (winner, dir, shadow)
671 })
672 })?;
673
674 let project_root = Self::find_project_root_from(&config_dir);
676 Some((config_path, project_root, shadow))
677 }
678
679 fn discover_markdownlint_config_upward(
687 start_override: Option<&Path>,
688 home_override: Option<&Path>,
689 ) -> Option<std::path::PathBuf> {
690 let start_dir = Self::resolve_discovery_start(start_override)?;
691
692 UpwardWalk::new(&start_dir)
693 .stop_below(Self::resolve_home_boundary(home_override))
694 .always_yield_start()
695 .stop_at_git_root()
696 .find_map(|dir| {
697 MARKDOWNLINT_CONFIG_FILES
698 .iter()
699 .map(|name| dir.join(name))
700 .find(|path| path.exists())
701 })
702 }
703
704 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
706 let config_dir = config_dir.join("rumdl");
707
708 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
710
711 log::debug!(
712 "[rumdl-config] Checking for user configuration in: {}",
713 config_dir.display()
714 );
715
716 for filename in USER_CONFIG_FILES {
717 let config_path = config_dir.join(filename);
718
719 if config_path.exists() {
720 if *filename == "pyproject.toml" {
722 if let Ok(content) = std::fs::read_to_string(&config_path) {
723 if pyproject_declares_rumdl_config(&content) {
724 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
725 return Some(config_path);
726 }
727 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
728 continue;
729 }
730 } else {
731 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
732 return Some(config_path);
733 }
734 }
735 }
736
737 log::debug!(
738 "[rumdl-config] No user configuration found in: {}",
739 config_dir.display()
740 );
741 None
742 }
743
744 #[cfg(feature = "native")]
747 fn user_configuration_path() -> Option<std::path::PathBuf> {
748 use etcetera::{BaseStrategy, choose_base_strategy};
749
750 match choose_base_strategy() {
751 Ok(strategy) => {
752 let config_dir = strategy.config_dir();
753 Self::user_configuration_path_impl(&config_dir)
754 }
755 Err(e) => {
756 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
757 None
758 }
759 }
760 }
761
762 #[cfg(not(feature = "native"))]
764 fn user_configuration_path() -> Option<std::path::PathBuf> {
765 None
766 }
767
768 fn home_configuration_path_impl(home_dir: &Path) -> Option<std::path::PathBuf> {
780 const HOME_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml"];
781
782 log::debug!(
783 "[rumdl-config] Checking for home-directory configuration in: {}",
784 home_dir.display()
785 );
786
787 for filename in HOME_CONFIG_FILES {
788 let config_path = home_dir.join(filename);
789 if config_path.exists() {
790 log::debug!(
791 "[rumdl-config] Found home-directory configuration at: {}",
792 config_path.display()
793 );
794 return Some(config_path);
795 }
796 }
797
798 log::debug!(
799 "[rumdl-config] No home-directory configuration found in: {}",
800 home_dir.display()
801 );
802 None
803 }
804
805 #[cfg(feature = "native")]
811 fn home_configuration_path() -> Option<std::path::PathBuf> {
812 use etcetera::{BaseStrategy, choose_base_strategy};
813
814 match choose_base_strategy() {
815 Ok(strategy) => Self::home_configuration_path_impl(strategy.home_dir()),
816 Err(e) => {
817 log::debug!("[rumdl-config] Failed to determine home directory: {e}");
818 None
819 }
820 }
821 }
822
823 #[cfg(not(feature = "native"))]
825 fn home_configuration_path() -> Option<std::path::PathBuf> {
826 None
827 }
828
829 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
831 let path_obj = Path::new(path);
832 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
833 let path_str = path.to_string();
834
835 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
836
837 if let Some(config_parent) = path_obj.parent() {
839 let project_root = Self::find_project_root_from(config_parent);
840 log::debug!(
841 "[rumdl-config] Project root (from explicit config): {}",
842 project_root.display()
843 );
844 sourced_config.project_root = Some(project_root);
845 }
846
847 const MARKDOWNLINT_FILENAMES: &[&str] = &[
849 ".markdownlint-cli2.jsonc",
850 ".markdownlint-cli2.yaml",
851 ".markdownlint-cli2.yml",
852 ".markdownlint.json",
853 ".markdownlint.yaml",
854 ".markdownlint.yml",
855 ];
856
857 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
858 let mut chain = ExtendsChain::default();
860 let chain_source = source_from_filename(filename);
861 load_config_with_extends(sourced_config, path_obj, &mut chain, chain_source, ConfigOrigin::Direct)?;
862 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
863 || path_str.ends_with(".json")
864 || path_str.ends_with(".jsonc")
865 || path_str.ends_with(".yaml")
866 || path_str.ends_with(".yml")
867 {
868 let fragment = parsers::load_from_markdownlint(&path_str)?;
870 sourced_config.merge(fragment);
871 sourced_config.loaded_files.push(path_str);
872 } else {
873 let mut chain = ExtendsChain::default();
875 let chain_source = source_from_filename(filename);
876 load_config_with_extends(sourced_config, path_obj, &mut chain, chain_source, ConfigOrigin::Direct)?;
877 }
878
879 Ok(())
880 }
881
882 fn load_user_config(
901 sourced_config: &mut Self,
902 user_config_dir: Option<&Path>,
903 home_dir: Option<&Path>,
904 ) -> Result<(), ConfigError> {
905 let user_config_path = if let Some(dir) = user_config_dir {
906 Self::user_configuration_path_impl(dir)
907 } else {
908 Self::user_configuration_path()
909 };
910
911 let user_config_path = user_config_path.or_else(|| match home_dir {
912 Some(home) => Self::home_configuration_path_impl(home),
913 None => Self::home_configuration_path(),
914 });
915
916 if let Some(user_config_path) = user_config_path {
917 let path_str = user_config_path.display().to_string();
918
919 log::debug!("[rumdl-config] Loading user config: {path_str}");
920
921 let mut chain = ExtendsChain::default();
924 load_config_with_extends(
925 sourced_config,
926 &user_config_path,
927 &mut chain,
928 ConfigSource::UserConfig,
929 ConfigOrigin::Direct,
930 )?;
931 } else {
932 log::debug!("[rumdl-config] No user configuration file found");
933 }
934
935 Ok(())
936 }
937
938 fn load_discovered_config(
956 sourced_config: &mut Self,
957 config_file: &Path,
958 user_config_dir: Option<&Path>,
959 home_dir: Option<&Path>,
960 ) -> Result<(), DiscoveredConfigError> {
961 let filename = config_file.file_name().and_then(|name| name.to_str()).unwrap_or("");
962
963 if MARKDOWNLINT_CONFIG_FILES.contains(&filename) {
964 Self::load_user_config(sourced_config, user_config_dir, home_dir)
965 .map_err(DiscoveredConfigError::UserConfig)?;
966
967 let path_str = config_file.display().to_string();
968 let fragment = parsers::load_from_markdownlint(&path_str).map_err(DiscoveredConfigError::ProjectConfig)?;
969 sourced_config.merge(fragment);
970 sourced_config.loaded_files.push(path_str);
971 } else {
972 let mut chain = ExtendsChain::default();
973 let chain_source = source_from_filename(filename);
974 load_config_with_extends(
975 sourced_config,
976 config_file,
977 &mut chain,
978 chain_source,
979 ConfigOrigin::Direct,
980 )
981 .map_err(DiscoveredConfigError::ProjectConfig)?;
982 }
983
984 Ok(())
985 }
986
987 pub fn load_discovered(
1005 config_file: &Path,
1006 user_config_dir: Option<&Path>,
1007 home_dir: Option<&Path>,
1008 ) -> Result<Self, DiscoveredConfigError> {
1009 let mut sourced_config = SourcedConfig::default();
1010
1011 if let Some(config_parent) = config_file.parent() {
1012 sourced_config.project_root = Some(Self::find_project_root_from(config_parent));
1013 }
1014
1015 Self::load_discovered_config(&mut sourced_config, config_file, user_config_dir, home_dir)?;
1016
1017 Ok(sourced_config)
1018 }
1019
1020 pub fn load_for_workspace(
1033 start_dir: &Path,
1034 config_path: Option<&str>,
1035 user_config_dir: Option<&Path>,
1036 home_dir: Option<&Path>,
1037 ) -> Result<Self, ConfigError> {
1038 Self::load_with_discovery_from(Some(start_dir), config_path, None, false, user_config_dir, home_dir)
1039 }
1040
1041 #[doc(hidden)]
1043 pub fn load_with_discovery_impl(
1044 config_path: Option<&str>,
1045 cli_overrides: Option<&SourcedGlobalConfig>,
1046 skip_auto_discovery: bool,
1047 user_config_dir: Option<&Path>,
1048 home_dir: Option<&Path>,
1049 ) -> Result<Self, ConfigError> {
1050 Self::load_with_discovery_from(
1051 None,
1052 config_path,
1053 cli_overrides,
1054 skip_auto_discovery,
1055 user_config_dir,
1056 home_dir,
1057 )
1058 }
1059
1060 fn load_with_discovery_from(
1065 start_dir: Option<&Path>,
1066 config_path: Option<&str>,
1067 cli_overrides: Option<&SourcedGlobalConfig>,
1068 skip_auto_discovery: bool,
1069 user_config_dir: Option<&Path>,
1070 home_dir: Option<&Path>,
1071 ) -> Result<Self, ConfigError> {
1072 use std::env;
1073 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
1074
1075 let mut sourced_config = SourcedConfig::default();
1076
1077 if let Some(path) = config_path {
1090 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
1092 Self::load_explicit_config(&mut sourced_config, path)?;
1093 } else if skip_auto_discovery {
1094 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
1095 } else {
1097 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
1099
1100 if let Some((config_file, project_root, shadow)) = Self::discover_config_upward(start_dir, home_dir) {
1102 log::debug!("[rumdl-config] Found project config: {}", config_file.display());
1106 log::debug!("[rumdl-config] Project root: {}", project_root.display());
1107
1108 if let Some(shadow) = shadow {
1111 sourced_config.discovery_warnings.push(format_shadow_warning(&shadow));
1112 }
1113
1114 sourced_config.project_root = Some(project_root);
1115
1116 Self::load_discovered_config(&mut sourced_config, &config_file, user_config_dir, home_dir)?;
1117 } else {
1118 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
1120
1121 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward(start_dir, home_dir) {
1122 log::debug!(
1123 "[rumdl-config] Found markdownlint config: {}",
1124 markdownlint_path.display()
1125 );
1126
1127 if let Err(e) =
1128 Self::load_discovered_config(&mut sourced_config, &markdownlint_path, user_config_dir, home_dir)
1129 {
1130 match e {
1131 DiscoveredConfigError::ProjectConfig(e) => {
1137 log::debug!("[rumdl-config] Failed to load markdownlint config: {e}");
1138 }
1139 DiscoveredConfigError::UserConfig(e) => return Err(e),
1141 }
1142 }
1143 } else {
1144 log::debug!("[rumdl-config] No project config found, using user config as fallback");
1146 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
1147 }
1148 }
1149 }
1150
1151 if let Some(cli) = cli_overrides {
1153 sourced_config
1154 .global
1155 .enable
1156 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None);
1157 sourced_config
1158 .global
1159 .disable
1160 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None);
1161 sourced_config
1162 .global
1163 .exclude
1164 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None);
1165 sourced_config
1166 .global
1167 .include
1168 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None);
1169 sourced_config.global.respect_gitignore.merge_override(
1170 cli.respect_gitignore.value,
1171 ConfigSource::Cli,
1172 None,
1173 );
1174 sourced_config
1175 .global
1176 .fixable
1177 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None);
1178 sourced_config
1179 .global
1180 .unfixable
1181 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None);
1182 }
1184
1185 Ok(sourced_config)
1188 }
1189
1190 pub fn load_with_discovery(
1193 config_path: Option<&str>,
1194 cli_overrides: Option<&SourcedGlobalConfig>,
1195 skip_auto_discovery: bool,
1196 ) -> Result<Self, ConfigError> {
1197 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
1198 }
1199
1200 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
1214 let warnings = validate_config_sourced_internal(&self, registry);
1215
1216 Ok(SourcedConfig {
1217 global: self.global,
1218 per_file_ignores: self.per_file_ignores,
1219 per_file_flavor: self.per_file_flavor,
1220 code_block_tools: self.code_block_tools,
1221 rules: self.rules,
1222 loaded_files: self.loaded_files,
1223 unknown_keys: self.unknown_keys,
1224 project_root: self.project_root,
1225 discovery_warnings: self.discovery_warnings,
1226 validation_warnings: warnings,
1227 _state: PhantomData,
1228 })
1229 }
1230
1231 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
1236 let validated = self.validate(registry)?;
1237 let warnings = validated.validation_warnings.clone();
1238 Ok((validated.into(), warnings))
1239 }
1240
1241 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
1252 SourcedConfig {
1253 global: self.global,
1254 per_file_ignores: self.per_file_ignores,
1255 per_file_flavor: self.per_file_flavor,
1256 code_block_tools: self.code_block_tools,
1257 rules: self.rules,
1258 loaded_files: self.loaded_files,
1259 unknown_keys: self.unknown_keys,
1260 project_root: self.project_root,
1261 discovery_warnings: self.discovery_warnings,
1262 validation_warnings: Vec::new(),
1263 _state: PhantomData,
1264 }
1265 }
1266
1267 pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
1276 UpwardWalk::new(dir)
1288 .stop_below(Self::resolve_home_boundary(None))
1289 .stop_at(project_root)
1290 .find_map(|current| {
1291 for config_name in RUMDL_CONFIG_FILES {
1293 let config_path = current.join(config_name);
1294 if config_path.exists() {
1295 if *config_name == "pyproject.toml" {
1296 if let Ok(content) = std::fs::read_to_string(&config_path)
1297 && pyproject_declares_rumdl_config(&content)
1298 {
1299 return Some(config_path);
1300 }
1301 continue;
1302 }
1303 return Some(config_path);
1304 }
1305 }
1306
1307 MARKDOWNLINT_CONFIG_FILES
1309 .iter()
1310 .map(|name| current.join(name))
1311 .find(|path| path.exists())
1312 })
1313 }
1314
1315 pub fn load_sourced_for_path(
1322 config_path: &Path,
1323 project_root: &Path,
1324 ) -> Result<SourcedConfig<ConfigLoaded>, ConfigError> {
1325 let mut sourced_config = SourcedConfig {
1326 project_root: Some(project_root.to_path_buf()),
1327 ..SourcedConfig::default()
1328 };
1329
1330 let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1331 let path_str = config_path.display().to_string();
1332
1333 let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
1335 || (filename != "pyproject.toml"
1336 && filename != ".rumdl.toml"
1337 && filename != "rumdl.toml"
1338 && (path_str.ends_with(".json")
1339 || path_str.ends_with(".jsonc")
1340 || path_str.ends_with(".yaml")
1341 || path_str.ends_with(".yml")));
1342
1343 if is_markdownlint {
1344 let fragment = parsers::load_from_markdownlint(&path_str)?;
1345 sourced_config.merge(fragment);
1346 sourced_config.loaded_files.push(path_str);
1347 } else {
1348 let mut chain = ExtendsChain::default();
1349 let chain_source = source_from_filename(filename);
1350 load_config_with_extends(
1351 &mut sourced_config,
1352 config_path,
1353 &mut chain,
1354 chain_source,
1355 ConfigOrigin::Direct,
1356 )?;
1357 }
1358
1359 Ok(sourced_config)
1360 }
1361
1362 pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
1366 Ok(Self::load_sourced_for_path(config_path, project_root)?
1367 .into_validated_unchecked()
1368 .into())
1369 }
1370}
1371
1372impl From<SourcedConfig<ConfigValidated>> for Config {
1377 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
1378 let mut rules = BTreeMap::new();
1379 let mut withheld_rule_values = std::collections::BTreeSet::new();
1380 for (rule_name, sourced_rule_cfg) in sourced.rules {
1381 let normalized_rule_name = rule_name.to_ascii_uppercase();
1383 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
1384 let mut values = BTreeMap::new();
1385 for (key, sourced_val) in sourced_rule_cfg.values {
1386 values.insert(key, sourced_val.value);
1387 }
1388 if values.keys().any(|key| sourced_rule_cfg.withheld_keys.contains(key)) {
1389 withheld_rule_values.insert(normalized_rule_name.clone());
1390 }
1391 rules.insert(normalized_rule_name, RuleConfig { severity, values });
1392 }
1393 let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
1395
1396 #[allow(deprecated)]
1397 let global = GlobalConfig {
1398 enable: sourced.global.enable.value,
1399 disable: sourced.global.disable.value,
1400 exclude: sourced.global.exclude.value,
1401 include: sourced.global.include.value,
1402 respect_gitignore: sourced.global.respect_gitignore.value,
1403 line_length: sourced.global.line_length.value,
1404 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1405 fixable: sourced.global.fixable.value,
1406 unfixable: sourced.global.unfixable.value,
1407 flavor: sourced.global.flavor.value,
1408 force_exclude: sourced.global.force_exclude.value,
1409 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
1410 cache: sourced.global.cache.value,
1411 extend_enable: sourced.global.extend_enable.value,
1412 extend_disable: sourced.global.extend_disable.value,
1413 editorconfig: sourced.global.editorconfig.value,
1414 enable_is_explicit,
1415 include_withheld: sourced.global.include_withheld,
1416 };
1417
1418 let mut config = Config {
1419 extends: None,
1420 global,
1421 per_file_ignores: sourced.per_file_ignores.value,
1422 per_file_flavor: sourced.per_file_flavor.value,
1423 code_block_tools: sourced.code_block_tools.value,
1424 rules,
1425 withheld_rule_values,
1426 project_root: sourced.project_root,
1427 per_file_ignores_cache: Arc::new(OnceLock::new()),
1428 per_file_flavor_cache: Arc::new(OnceLock::new()),
1429 canonical_project_root_cache: Arc::new(OnceLock::new()),
1430 };
1431
1432 config.apply_per_rule_enabled();
1434
1435 config.canonicalize_rule_lists();
1442
1443 config
1444 }
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 use super::pyproject_declares_rumdl_config;
1450
1451 #[test]
1452 fn detects_flat_and_dotted_rumdl_sections() {
1453 assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
1454 assert!(pyproject_declares_rumdl_config(
1456 "[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
1457 ));
1458 assert!(pyproject_declares_rumdl_config(
1459 "[tool.rumdl.rules.MD007]\nindent = 4\n"
1460 ));
1461 }
1462
1463 #[test]
1464 fn ignores_incidental_mentions() {
1465 assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
1468 assert!(!pyproject_declares_rumdl_config(
1469 "[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
1470 ));
1471 assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
1472 }
1473
1474 mod expand_env_vars {
1477 use super::super::expand_env_vars;
1478 use std::collections::HashMap;
1479
1480 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
1482 let map: HashMap<String, String> = pairs
1483 .iter()
1484 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1485 .collect();
1486 move |k: &str| map.get(k).cloned()
1487 }
1488
1489 #[test]
1490 fn expands_bare_and_braced_forms() {
1491 let e = env(&[("VAR", "val"), ("FOO_BAR", "fb")]);
1492 assert_eq!(expand_env_vars("$VAR", &e).unwrap(), "val");
1493 assert_eq!(expand_env_vars("${VAR}", &e).unwrap(), "val");
1494 assert_eq!(expand_env_vars("$FOO_BAR", &e).unwrap(), "fb");
1496 }
1497
1498 #[test]
1499 fn expands_within_paths() {
1500 let e = env(&[("BASE", "/opt/cfg"), ("A", "x"), ("B", "y")]);
1501 assert_eq!(expand_env_vars("$BASE/x/y.toml", &e).unwrap(), "/opt/cfg/x/y.toml");
1502 assert_eq!(expand_env_vars("$A/$B", &e).unwrap(), "x/y");
1503 assert_eq!(expand_env_vars("${A}suffix", &e).unwrap(), "xsuffix");
1504 }
1505
1506 #[test]
1507 fn dollar_dollar_is_a_literal_dollar() {
1508 let e = env(&[("VAR", "val")]);
1509 assert_eq!(expand_env_vars("$$", &e).unwrap(), "$");
1510 assert_eq!(expand_env_vars("$$VAR", &e).unwrap(), "$VAR");
1512 assert_eq!(expand_env_vars("$${VAR}", &e).unwrap(), "${VAR}");
1513 assert_eq!(expand_env_vars("file-$$name.toml", &e).unwrap(), "file-$name.toml");
1515 }
1516
1517 #[test]
1518 fn bare_dollar_name_in_path_is_a_variable_reference() {
1519 let e = env(&[("name", "core")]);
1522 assert_eq!(expand_env_vars("file-$name.toml", &e).unwrap(), "file-core.toml");
1523 }
1524
1525 #[test]
1526 fn incidental_dollar_stays_literal() {
1527 let e = env(&[]);
1528 assert_eq!(expand_env_vars("$5", &e).unwrap(), "$5");
1530 assert_eq!(expand_env_vars("cost$", &e).unwrap(), "cost$");
1531 assert_eq!(expand_env_vars("a$/b", &e).unwrap(), "a$/b");
1532 }
1533
1534 #[test]
1535 fn malformed_braces_stay_literal() {
1536 let e = env(&[("B", "x")]);
1537 assert_eq!(expand_env_vars("${}", &e).unwrap(), "${}");
1538 assert_eq!(expand_env_vars("${VAR", &e).unwrap(), "${VAR");
1539 assert_eq!(expand_env_vars("${A${B}}", &e).unwrap(), "${A${B}}");
1541 }
1542
1543 #[test]
1544 fn undefined_variable_is_an_error() {
1545 let e = env(&[]);
1546 assert_eq!(expand_env_vars("$NOPE", &e).unwrap_err(), "NOPE");
1547 assert_eq!(expand_env_vars("${NOPE}", &e).unwrap_err(), "NOPE");
1548 assert_eq!(expand_env_vars("prefix/$NOPE/x", &e).unwrap_err(), "NOPE");
1549 }
1550
1551 #[test]
1552 fn replacement_is_not_rescanned() {
1553 let e = env(&[("A", "$B"), ("B", "should-not-appear")]);
1555 assert_eq!(expand_env_vars("$A", &e).unwrap(), "$B");
1556 assert_eq!(expand_env_vars("${A}", &e).unwrap(), "$B");
1557 }
1558
1559 #[test]
1560 fn identifiers_are_ascii_only_unicode_stays_literal() {
1561 let e = env(&[("VAR", "v")]);
1562 assert_eq!(expand_env_vars("${föö}", &e).unwrap(), "${föö}");
1564 assert_eq!(expand_env_vars("$VARö", &e).unwrap(), "vö");
1566 assert_eq!(expand_env_vars("café/$VAR", &e).unwrap(), "café/v");
1568 }
1569
1570 #[test]
1571 fn passthrough_for_plain_input() {
1572 let e = env(&[]);
1573 assert_eq!(expand_env_vars("", &e).unwrap(), "");
1574 assert_eq!(expand_env_vars("/plain/path.toml", &e).unwrap(), "/plain/path.toml");
1575 }
1576 }
1577
1578 #[cfg(unix)]
1587 #[test]
1588 fn discover_stops_at_project_root_across_path_representations() {
1589 use super::SourcedConfig;
1590 use std::os::unix::fs::symlink;
1591 use tempfile::tempdir;
1592
1593 let tmp = tempdir().unwrap();
1594 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1596
1597 let real_root = tmp.path().join("project");
1598 let subdir = real_root.join("docs");
1599 std::fs::create_dir_all(&subdir).unwrap();
1600
1601 let linked_root = tmp.path().join("project-link");
1604 symlink(&real_root, &linked_root).unwrap();
1605
1606 let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
1607 assert_eq!(
1608 found, None,
1609 "discovery must stop at the project root, not overshoot to the parent config"
1610 );
1611 }
1612
1613 mod shadowed_configs {
1614 use super::super::{ShadowedConfigs, detect_shadowed_configs, format_shadow_warning, rumdl_configs_in_dir};
1615 use tempfile::tempdir;
1616
1617 fn names(paths: &[std::path::PathBuf]) -> Vec<String> {
1618 paths
1619 .iter()
1620 .map(|p| {
1621 let file = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
1624 let parent = p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str());
1625 match parent {
1626 Some(".config") => format!(".config/{file}"),
1627 _ => file.to_string(),
1628 }
1629 })
1630 .collect()
1631 }
1632
1633 #[test]
1634 fn empty_directory_has_no_configs_and_no_shadow() {
1635 let tmp = tempdir().unwrap();
1636 assert!(rumdl_configs_in_dir(tmp.path()).is_empty());
1637 assert!(detect_shadowed_configs(tmp.path()).is_none());
1638 }
1639
1640 #[test]
1641 fn single_config_does_not_shadow() {
1642 let tmp = tempdir().unwrap();
1643 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1644 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1645 assert!(detect_shadowed_configs(tmp.path()).is_none());
1646 }
1647
1648 #[test]
1649 fn dot_wins_over_non_dot_and_non_dot_is_shadowed() {
1650 let tmp = tempdir().unwrap();
1651 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1652 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1653
1654 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1655 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1656 assert_eq!(names(&shadowed), vec!["rumdl.toml"]);
1657 }
1658
1659 #[test]
1660 fn config_subdir_counts_as_same_level_shadow() {
1661 let tmp = tempdir().unwrap();
1662 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1663 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1664 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1665
1666 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1667 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1668 assert_eq!(names(&shadowed), vec![".config/rumdl.toml"]);
1669 }
1670
1671 #[test]
1672 fn pyproject_counts_only_when_it_declares_rumdl() {
1673 let bare = tempdir().unwrap();
1675 std::fs::write(bare.path().join(".rumdl.toml"), "").unwrap();
1676 std::fs::write(bare.path().join("pyproject.toml"), "[tool.black]\nline-length = 88\n").unwrap();
1677 assert_eq!(names(&rumdl_configs_in_dir(bare.path())), vec![".rumdl.toml"]);
1678 assert!(detect_shadowed_configs(bare.path()).is_none());
1679
1680 let declared = tempdir().unwrap();
1682 std::fs::write(declared.path().join(".rumdl.toml"), "").unwrap();
1683 std::fs::write(
1684 declared.path().join("pyproject.toml"),
1685 "[tool.rumdl]\nline-length = 80\n",
1686 )
1687 .unwrap();
1688 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(declared.path()).unwrap();
1689 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1690 assert_eq!(names(&shadowed), vec!["pyproject.toml"]);
1691 }
1692
1693 #[test]
1694 fn markdownlint_configs_are_not_rumdl_native_and_never_shadow() {
1695 let tmp = tempdir().unwrap();
1696 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1697 std::fs::write(tmp.path().join(".markdownlint.json"), "{}").unwrap();
1698 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1699 assert!(detect_shadowed_configs(tmp.path()).is_none());
1700 }
1701
1702 #[test]
1703 fn configs_returned_in_precedence_order() {
1704 let tmp = tempdir().unwrap();
1705 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1706 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1707 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1708 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1709 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1710
1711 assert_eq!(
1712 names(&rumdl_configs_in_dir(tmp.path())),
1713 vec![".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]
1714 );
1715 }
1716
1717 #[test]
1718 fn warning_names_dir_once_with_relative_filenames() {
1719 let tmp = tempdir().unwrap();
1720 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1721 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1722 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1723
1724 let shadow = detect_shadowed_configs(tmp.path()).unwrap();
1725 let msg = format_shadow_warning(&shadow);
1726
1727 let dir = {
1728 let s = tmp.path().to_string_lossy().into_owned();
1729 if cfg!(windows) { s.replace('\\', "/") } else { s }
1730 };
1731 assert!(msg.contains("multiple rumdl config files"), "got: {msg}");
1732 assert_eq!(
1735 msg.matches(dir.as_str()).count(),
1736 1,
1737 "directory should appear exactly once, got: {msg}"
1738 );
1739 assert!(
1740 msg.contains("using .rumdl.toml, ignoring rumdl.toml, pyproject.toml"),
1741 "winner and shadowed files should be relative names in precedence order, got: {msg}"
1742 );
1743 assert!(!msg.contains('\\'), "paths must be normalized to '/': {msg}");
1745 }
1746 }
1747}