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
303pub(crate) fn collect_project_config_candidates(
311 search_dir: &Path,
312 workspace_root: Option<&Path>,
313 home_dir: Option<&Path>,
314) -> Vec<PathBuf> {
315 let mut candidates = Vec::new();
316 let walk = UpwardWalk::new(search_dir).stop_below(home_dir.map(Path::to_path_buf));
317 let walk = match workspace_root {
318 Some(root) => walk.stop_at(root),
319 None => walk,
320 };
321
322 for current_dir in walk {
323 candidates.extend(rumdl_configs_in_dir(¤t_dir));
324 candidates.extend(
325 MARKDOWNLINT_CONFIG_FILES
326 .iter()
327 .map(|name| current_dir.join(name))
328 .filter(|path| path.exists()),
329 );
330 }
331
332 candidates
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
344pub(crate) struct ShadowedConfigs {
345 pub dir: PathBuf,
346 pub winner: PathBuf,
347 pub shadowed: Vec<PathBuf>,
348}
349
350pub(crate) fn detect_shadowed_configs(dir: &Path) -> Option<ShadowedConfigs> {
356 let mut configs = rumdl_configs_in_dir(dir);
357 if configs.len() < 2 {
358 return None;
359 }
360 let winner = configs.remove(0);
361 Some(ShadowedConfigs {
362 dir: dir.to_path_buf(),
363 winner,
364 shadowed: configs,
365 })
366}
367
368pub(crate) fn format_shadow_warning(shadow: &ShadowedConfigs) -> String {
376 let norm = |s: String| if cfg!(windows) { s.replace('\\', "/") } else { s };
377 let rel = |path: &Path| {
378 let relative = path.strip_prefix(&shadow.dir).unwrap_or(path);
379 norm(relative.to_string_lossy().into_owned())
380 };
381 let shadowed = shadow.shadowed.iter().map(|p| rel(p)).collect::<Vec<_>>().join(", ");
382 format!(
383 "multiple rumdl config files in {}: using {}, ignoring {}",
384 norm(shadow.dir.to_string_lossy().into_owned()),
385 rel(&shadow.winner),
386 shadowed,
387 )
388}
389
390fn load_config_with_extends(
404 sourced_config: &mut SourcedConfig<ConfigLoaded>,
405 config_file_path: &Path,
406 chain: &mut ExtendsChain,
407 chain_source: ConfigSource,
408 origin: ConfigOrigin<'_>,
409) -> Result<(), ConfigError> {
410 let canonical = config_file_path
412 .canonicalize()
413 .unwrap_or_else(|_| config_file_path.to_path_buf());
414
415 let path_str = config_file_path.display().to_string();
416 let described = origin.display_name(&path_str);
417 let short = origin.short_name(&path_str);
418
419 if chain.contains(&canonical) {
421 return Err(ConfigError::CircularExtends {
422 path: described,
423 chain: chain.names(),
424 });
425 }
426
427 if chain.len() >= MAX_EXTENDS_DEPTH {
429 return Err(ConfigError::ExtendsDepthExceeded {
430 path: described,
431 max_depth: MAX_EXTENDS_DEPTH,
432 });
433 }
434
435 chain.push(canonical, short.clone());
437
438 let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
439
440 let content = std::fs::read_to_string(config_file_path).map_err(|e| ConfigError::IoError {
442 source: e,
443 path: described.clone(),
444 })?;
445
446 let fragment = if filename == "pyproject.toml" {
447 match parsers::parse_pyproject_toml(&content, &path_str, chain_source, origin)? {
448 Some(f) => f,
449 None => return Ok(()), }
451 } else {
452 parsers::parse_rumdl_toml(&content, &path_str, chain_source, origin)?
453 };
454
455 if let Some(ref extends_value) = fragment.extends {
457 let (base_path, reference) = resolve_extends(extends_value, config_file_path, &short, origin)?;
458 let base_described = reference.describe();
459 let base_short = reference.short();
460
461 if !base_path.exists() {
462 return Err(ConfigError::ExtendsNotFound {
463 path: base_short,
464 from: short,
465 });
466 }
467
468 log::debug!(
469 "[rumdl-config] Config {} extends {}, loading base first",
470 path_str,
471 base_path.display()
472 );
473
474 load_config_with_extends(
476 sourced_config,
477 &base_path,
478 chain,
479 chain_source,
480 ConfigOrigin::Extends {
481 described_as: &base_described,
482 short_name: &base_short,
483 },
484 )?;
485 }
486
487 let mut fragment_for_merge = fragment;
490 fragment_for_merge.extends = None;
491 sourced_config.merge(fragment_for_merge);
492 sourced_config.loaded_files.push(path_str);
493
494 Ok(())
495}
496
497impl SourcedConfig<ConfigLoaded> {
498 pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
501 self.global.enable.merge_from(fragment.global.enable);
506 self.global.disable.merge_from(fragment.global.disable);
507 self.global
508 .extend_enable
509 .merge_union_from(fragment.global.extend_enable);
510 self.global
511 .extend_disable
512 .merge_union_from(fragment.global.extend_disable);
513
514 self.global
517 .disable
518 .value
519 .retain(|rule| !self.global.enable.value.contains(rule));
520
521 if self.global.include.merge_from(fragment.global.include) {
525 self.global.include_withheld = fragment.global.include_withheld;
526 }
527 self.global.exclude.merge_from(fragment.global.exclude);
528 self.global
529 .respect_gitignore
530 .merge_from(fragment.global.respect_gitignore);
531 self.global.line_length.merge_from(fragment.global.line_length);
532 self.global.fixable.merge_from(fragment.global.fixable);
533 self.global.unfixable.merge_from(fragment.global.unfixable);
534 self.global.flavor.merge_from(fragment.global.flavor);
535 self.global.force_exclude.merge_from(fragment.global.force_exclude);
536 self.global.editorconfig.merge_from(fragment.global.editorconfig);
537
538 if let Some(output_format_fragment) = fragment.global.output_format {
540 if let Some(ref mut output_format) = self.global.output_format {
541 output_format.merge_from(output_format_fragment);
542 } else {
543 self.global.output_format = Some(output_format_fragment);
544 }
545 }
546
547 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
549 if let Some(ref mut cache_dir) = self.global.cache_dir {
550 cache_dir.merge_from(cache_dir_fragment);
551 } else {
552 self.global.cache_dir = Some(cache_dir_fragment);
553 }
554 }
555
556 if fragment.global.cache.source != ConfigSource::Default {
558 self.global.cache.merge_from(fragment.global.cache);
559 }
560
561 self.per_file_ignores.merge_from(fragment.per_file_ignores);
562 self.per_file_flavor.merge_from(fragment.per_file_flavor);
563 self.code_block_tools.merge_from(fragment.code_block_tools);
564
565 for (rule_name, rule_fragment) in fragment.rules {
567 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
569
570 if let Some(severity_fragment) = rule_fragment.severity {
572 if let Some(ref mut existing_severity) = rule_entry.severity {
573 existing_severity.merge_from(severity_fragment);
574 } else {
575 rule_entry.severity = Some(severity_fragment);
576 }
577 }
578
579 for (key, sourced_value_fragment) in rule_fragment.values {
583 let sv_entry = rule_entry
584 .values
585 .entry(key.clone())
586 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
587 if sv_entry.merge_from(sourced_value_fragment) {
588 if rule_fragment.withheld_keys.contains(&key) {
589 rule_entry.withheld_keys.insert(key);
590 } else {
591 rule_entry.withheld_keys.remove(&key);
592 }
593 }
594 }
595 }
596
597 for warning in fragment.load_warnings {
601 if !self.discovery_warnings.contains(&warning) {
602 self.discovery_warnings.push(warning);
603 }
604 }
605
606 for (section, key, file_path) in fragment.unknown_keys {
607 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
609 self.unknown_keys.push((section, key, file_path));
610 }
611 }
612 }
613
614 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
616 Self::load_with_discovery(config_path, cli_overrides, false)
617 }
618
619 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
622 UpwardWalk::new(start_dir)
623 .find(|dir| dir.join(".git").exists())
624 .unwrap_or_else(|| {
625 log::debug!(
626 "[rumdl-config] No .git found, using config location as project root: {}",
627 start_dir.display()
628 );
629 start_dir.to_path_buf()
630 })
631 }
632
633 fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
639 home_override.map(Path::to_path_buf).or_else(|| {
640 #[cfg(feature = "native")]
641 {
642 use etcetera::{BaseStrategy, choose_base_strategy};
643 choose_base_strategy().ok().map(|s| s.home_dir().to_path_buf())
644 }
645 #[cfg(not(feature = "native"))]
646 {
647 None
648 }
649 })
650 }
651
652 fn resolve_discovery_start(start_override: Option<&Path>) -> Option<std::path::PathBuf> {
658 if let Some(dir) = start_override {
659 return Some(dir.to_path_buf());
660 }
661 match std::env::current_dir() {
662 Ok(dir) => Some(dir),
663 Err(e) => {
664 log::debug!("[rumdl-config] Failed to get current directory: {e}");
665 None
666 }
667 }
668 }
669
670 fn discover_config_upward(
686 start_override: Option<&Path>,
687 home_override: Option<&Path>,
688 ) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
689 let start_dir = Self::resolve_discovery_start(start_override)?;
690
691 let (config_path, config_dir, shadow) = UpwardWalk::new(&start_dir)
695 .stop_below(Self::resolve_home_boundary(home_override))
696 .always_yield_start()
697 .stop_at_git_root()
698 .find_map(|dir| {
699 rumdl_configs_in_dir(&dir).into_iter().next().map(|winner| {
700 log::debug!("[rumdl-config] Found config file: {}", winner.display());
701 let shadow = detect_shadowed_configs(&dir);
702 (winner, dir, shadow)
703 })
704 })?;
705
706 let project_root = Self::find_project_root_from(&config_dir);
708 Some((config_path, project_root, shadow))
709 }
710
711 fn discover_markdownlint_config_upward(
719 start_override: Option<&Path>,
720 home_override: Option<&Path>,
721 ) -> Option<std::path::PathBuf> {
722 let start_dir = Self::resolve_discovery_start(start_override)?;
723
724 UpwardWalk::new(&start_dir)
725 .stop_below(Self::resolve_home_boundary(home_override))
726 .always_yield_start()
727 .stop_at_git_root()
728 .find_map(|dir| {
729 MARKDOWNLINT_CONFIG_FILES
730 .iter()
731 .map(|name| dir.join(name))
732 .find(|path| path.exists())
733 })
734 }
735
736 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
738 let config_dir = config_dir.join("rumdl");
739
740 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
742
743 log::debug!(
744 "[rumdl-config] Checking for user configuration in: {}",
745 config_dir.display()
746 );
747
748 for filename in USER_CONFIG_FILES {
749 let config_path = config_dir.join(filename);
750
751 if config_path.exists() {
752 if *filename == "pyproject.toml" {
754 if let Ok(content) = std::fs::read_to_string(&config_path) {
755 if pyproject_declares_rumdl_config(&content) {
756 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
757 return Some(config_path);
758 }
759 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
760 continue;
761 }
762 } else {
763 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
764 return Some(config_path);
765 }
766 }
767 }
768
769 log::debug!(
770 "[rumdl-config] No user configuration found in: {}",
771 config_dir.display()
772 );
773 None
774 }
775
776 #[cfg(feature = "native")]
779 fn user_configuration_path() -> Option<std::path::PathBuf> {
780 use etcetera::{BaseStrategy, choose_base_strategy};
781
782 match choose_base_strategy() {
783 Ok(strategy) => {
784 let config_dir = strategy.config_dir();
785 Self::user_configuration_path_impl(&config_dir)
786 }
787 Err(e) => {
788 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
789 None
790 }
791 }
792 }
793
794 #[cfg(not(feature = "native"))]
796 fn user_configuration_path() -> Option<std::path::PathBuf> {
797 None
798 }
799
800 fn home_configuration_path_impl(home_dir: &Path) -> Option<std::path::PathBuf> {
812 const HOME_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml"];
813
814 log::debug!(
815 "[rumdl-config] Checking for home-directory configuration in: {}",
816 home_dir.display()
817 );
818
819 for filename in HOME_CONFIG_FILES {
820 let config_path = home_dir.join(filename);
821 if config_path.exists() {
822 log::debug!(
823 "[rumdl-config] Found home-directory configuration at: {}",
824 config_path.display()
825 );
826 return Some(config_path);
827 }
828 }
829
830 log::debug!(
831 "[rumdl-config] No home-directory configuration found in: {}",
832 home_dir.display()
833 );
834 None
835 }
836
837 #[cfg(feature = "native")]
843 fn home_configuration_path() -> Option<std::path::PathBuf> {
844 use etcetera::{BaseStrategy, choose_base_strategy};
845
846 match choose_base_strategy() {
847 Ok(strategy) => Self::home_configuration_path_impl(strategy.home_dir()),
848 Err(e) => {
849 log::debug!("[rumdl-config] Failed to determine home directory: {e}");
850 None
851 }
852 }
853 }
854
855 #[cfg(not(feature = "native"))]
857 fn home_configuration_path() -> Option<std::path::PathBuf> {
858 None
859 }
860
861 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
863 let path_obj = Path::new(path);
864 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
865 let path_str = path.to_string();
866
867 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
868
869 if let Some(config_parent) = path_obj.parent() {
871 let project_root = Self::find_project_root_from(config_parent);
872 log::debug!(
873 "[rumdl-config] Project root (from explicit config): {}",
874 project_root.display()
875 );
876 sourced_config.project_root = Some(project_root);
877 }
878
879 const MARKDOWNLINT_FILENAMES: &[&str] = &[
881 ".markdownlint-cli2.jsonc",
882 ".markdownlint-cli2.yaml",
883 ".markdownlint-cli2.yml",
884 ".markdownlint.json",
885 ".markdownlint.yaml",
886 ".markdownlint.yml",
887 ];
888
889 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
890 let mut chain = ExtendsChain::default();
892 let chain_source = source_from_filename(filename);
893 load_config_with_extends(sourced_config, path_obj, &mut chain, chain_source, ConfigOrigin::Direct)?;
894 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
895 || path_str.ends_with(".json")
896 || path_str.ends_with(".jsonc")
897 || path_str.ends_with(".yaml")
898 || path_str.ends_with(".yml")
899 {
900 let fragment = parsers::load_from_markdownlint(&path_str)?;
902 sourced_config.merge(fragment);
903 sourced_config.loaded_files.push(path_str);
904 } else {
905 let mut chain = ExtendsChain::default();
907 let chain_source = source_from_filename(filename);
908 load_config_with_extends(sourced_config, path_obj, &mut chain, chain_source, ConfigOrigin::Direct)?;
909 }
910
911 Ok(())
912 }
913
914 fn load_user_config(
933 sourced_config: &mut Self,
934 user_config_dir: Option<&Path>,
935 home_dir: Option<&Path>,
936 ) -> Result<(), ConfigError> {
937 let user_config_path = if let Some(dir) = user_config_dir {
938 Self::user_configuration_path_impl(dir)
939 } else {
940 Self::user_configuration_path()
941 };
942
943 let user_config_path = user_config_path.or_else(|| match home_dir {
944 Some(home) => Self::home_configuration_path_impl(home),
945 None => Self::home_configuration_path(),
946 });
947
948 if let Some(user_config_path) = user_config_path {
949 let path_str = user_config_path.display().to_string();
950
951 log::debug!("[rumdl-config] Loading user config: {path_str}");
952
953 let mut chain = ExtendsChain::default();
956 load_config_with_extends(
957 sourced_config,
958 &user_config_path,
959 &mut chain,
960 ConfigSource::UserConfig,
961 ConfigOrigin::Direct,
962 )?;
963 } else {
964 log::debug!("[rumdl-config] No user configuration file found");
965 }
966
967 Ok(())
968 }
969
970 fn load_discovered_config(
988 sourced_config: &mut Self,
989 config_file: &Path,
990 user_config_dir: Option<&Path>,
991 home_dir: Option<&Path>,
992 ) -> Result<(), DiscoveredConfigError> {
993 let filename = config_file.file_name().and_then(|name| name.to_str()).unwrap_or("");
994
995 if MARKDOWNLINT_CONFIG_FILES.contains(&filename) {
996 Self::load_user_config(sourced_config, user_config_dir, home_dir)
997 .map_err(DiscoveredConfigError::UserConfig)?;
998
999 let path_str = config_file.display().to_string();
1000 let fragment = parsers::load_from_markdownlint(&path_str).map_err(DiscoveredConfigError::ProjectConfig)?;
1001 sourced_config.merge(fragment);
1002 sourced_config.loaded_files.push(path_str);
1003 } else {
1004 let mut chain = ExtendsChain::default();
1005 let chain_source = source_from_filename(filename);
1006 load_config_with_extends(
1007 sourced_config,
1008 config_file,
1009 &mut chain,
1010 chain_source,
1011 ConfigOrigin::Direct,
1012 )
1013 .map_err(DiscoveredConfigError::ProjectConfig)?;
1014 }
1015
1016 Ok(())
1017 }
1018
1019 pub fn load_discovered(
1037 config_file: &Path,
1038 user_config_dir: Option<&Path>,
1039 home_dir: Option<&Path>,
1040 ) -> Result<Self, DiscoveredConfigError> {
1041 let mut sourced_config = SourcedConfig::default();
1042
1043 if let Some(config_parent) = config_file.parent() {
1044 sourced_config.project_root = Some(Self::find_project_root_from(config_parent));
1045 }
1046
1047 Self::load_discovered_config(&mut sourced_config, config_file, user_config_dir, home_dir)?;
1048
1049 Ok(sourced_config)
1050 }
1051
1052 pub fn load_for_workspace(
1065 start_dir: &Path,
1066 config_path: Option<&str>,
1067 user_config_dir: Option<&Path>,
1068 home_dir: Option<&Path>,
1069 ) -> Result<Self, ConfigError> {
1070 Self::load_with_discovery_from(Some(start_dir), config_path, None, false, user_config_dir, home_dir)
1071 }
1072
1073 #[doc(hidden)]
1075 pub fn load_with_discovery_impl(
1076 config_path: Option<&str>,
1077 cli_overrides: Option<&SourcedGlobalConfig>,
1078 skip_auto_discovery: bool,
1079 user_config_dir: Option<&Path>,
1080 home_dir: Option<&Path>,
1081 ) -> Result<Self, ConfigError> {
1082 Self::load_with_discovery_from(
1083 None,
1084 config_path,
1085 cli_overrides,
1086 skip_auto_discovery,
1087 user_config_dir,
1088 home_dir,
1089 )
1090 }
1091
1092 fn load_with_discovery_from(
1097 start_dir: Option<&Path>,
1098 config_path: Option<&str>,
1099 cli_overrides: Option<&SourcedGlobalConfig>,
1100 skip_auto_discovery: bool,
1101 user_config_dir: Option<&Path>,
1102 home_dir: Option<&Path>,
1103 ) -> Result<Self, ConfigError> {
1104 use std::env;
1105 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
1106
1107 let mut sourced_config = SourcedConfig::default();
1108
1109 if let Some(path) = config_path {
1122 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
1124 Self::load_explicit_config(&mut sourced_config, path)?;
1125 } else if skip_auto_discovery {
1126 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
1127 } else {
1129 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
1131
1132 if let Some((config_file, project_root, shadow)) = Self::discover_config_upward(start_dir, home_dir) {
1134 log::debug!("[rumdl-config] Found project config: {}", config_file.display());
1138 log::debug!("[rumdl-config] Project root: {}", project_root.display());
1139
1140 if let Some(shadow) = shadow {
1143 sourced_config.discovery_warnings.push(format_shadow_warning(&shadow));
1144 }
1145
1146 sourced_config.project_root = Some(project_root);
1147
1148 Self::load_discovered_config(&mut sourced_config, &config_file, user_config_dir, home_dir)?;
1149 } else {
1150 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
1152
1153 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward(start_dir, home_dir) {
1154 log::debug!(
1155 "[rumdl-config] Found markdownlint config: {}",
1156 markdownlint_path.display()
1157 );
1158
1159 if let Err(e) =
1160 Self::load_discovered_config(&mut sourced_config, &markdownlint_path, user_config_dir, home_dir)
1161 {
1162 match e {
1163 DiscoveredConfigError::ProjectConfig(e) => {
1169 log::debug!("[rumdl-config] Failed to load markdownlint config: {e}");
1170 }
1171 DiscoveredConfigError::UserConfig(e) => return Err(e),
1173 }
1174 }
1175 } else {
1176 log::debug!("[rumdl-config] No project config found, using user config as fallback");
1178 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
1179 }
1180 }
1181 }
1182
1183 if let Some(cli) = cli_overrides {
1185 sourced_config
1186 .global
1187 .enable
1188 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None);
1189 sourced_config
1190 .global
1191 .disable
1192 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None);
1193 sourced_config
1194 .global
1195 .exclude
1196 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None);
1197 sourced_config
1198 .global
1199 .include
1200 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None);
1201 sourced_config.global.respect_gitignore.merge_override(
1202 cli.respect_gitignore.value,
1203 ConfigSource::Cli,
1204 None,
1205 );
1206 sourced_config
1207 .global
1208 .fixable
1209 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None);
1210 sourced_config
1211 .global
1212 .unfixable
1213 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None);
1214 }
1216
1217 Ok(sourced_config)
1220 }
1221
1222 pub fn load_with_discovery(
1225 config_path: Option<&str>,
1226 cli_overrides: Option<&SourcedGlobalConfig>,
1227 skip_auto_discovery: bool,
1228 ) -> Result<Self, ConfigError> {
1229 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
1230 }
1231
1232 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
1246 let warnings = validate_config_sourced_internal(&self, registry);
1247
1248 Ok(SourcedConfig {
1249 global: self.global,
1250 per_file_ignores: self.per_file_ignores,
1251 per_file_flavor: self.per_file_flavor,
1252 code_block_tools: self.code_block_tools,
1253 rules: self.rules,
1254 loaded_files: self.loaded_files,
1255 unknown_keys: self.unknown_keys,
1256 project_root: self.project_root,
1257 discovery_warnings: self.discovery_warnings,
1258 validation_warnings: warnings,
1259 _state: PhantomData,
1260 })
1261 }
1262
1263 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
1268 let validated = self.validate(registry)?;
1269 let warnings = validated.validation_warnings.clone();
1270 Ok((validated.into(), warnings))
1271 }
1272
1273 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
1284 SourcedConfig {
1285 global: self.global,
1286 per_file_ignores: self.per_file_ignores,
1287 per_file_flavor: self.per_file_flavor,
1288 code_block_tools: self.code_block_tools,
1289 rules: self.rules,
1290 loaded_files: self.loaded_files,
1291 unknown_keys: self.unknown_keys,
1292 project_root: self.project_root,
1293 discovery_warnings: self.discovery_warnings,
1294 validation_warnings: Vec::new(),
1295 _state: PhantomData,
1296 }
1297 }
1298
1299 pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
1308 collect_project_config_candidates(dir, Some(project_root), Self::resolve_home_boundary(None).as_deref())
1320 .into_iter()
1321 .next()
1322 }
1323
1324 pub fn load_sourced_for_path(
1331 config_path: &Path,
1332 project_root: &Path,
1333 ) -> Result<SourcedConfig<ConfigLoaded>, ConfigError> {
1334 let mut sourced_config = SourcedConfig {
1335 project_root: Some(project_root.to_path_buf()),
1336 ..SourcedConfig::default()
1337 };
1338
1339 let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1340 let path_str = config_path.display().to_string();
1341
1342 let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
1344 || (filename != "pyproject.toml"
1345 && filename != ".rumdl.toml"
1346 && filename != "rumdl.toml"
1347 && (path_str.ends_with(".json")
1348 || path_str.ends_with(".jsonc")
1349 || path_str.ends_with(".yaml")
1350 || path_str.ends_with(".yml")));
1351
1352 if is_markdownlint {
1353 let fragment = parsers::load_from_markdownlint(&path_str)?;
1354 sourced_config.merge(fragment);
1355 sourced_config.loaded_files.push(path_str);
1356 } else {
1357 let mut chain = ExtendsChain::default();
1358 let chain_source = source_from_filename(filename);
1359 load_config_with_extends(
1360 &mut sourced_config,
1361 config_path,
1362 &mut chain,
1363 chain_source,
1364 ConfigOrigin::Direct,
1365 )?;
1366 }
1367
1368 Ok(sourced_config)
1369 }
1370
1371 pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
1375 Ok(Self::load_sourced_for_path(config_path, project_root)?
1376 .into_validated_unchecked()
1377 .into())
1378 }
1379}
1380
1381impl From<SourcedConfig<ConfigValidated>> for Config {
1386 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
1387 let mut rules = BTreeMap::new();
1388 let mut withheld_rule_values = std::collections::BTreeSet::new();
1389 for (rule_name, sourced_rule_cfg) in sourced.rules {
1390 let normalized_rule_name = rule_name.to_ascii_uppercase();
1392 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
1393 let mut values = BTreeMap::new();
1394 for (key, sourced_val) in sourced_rule_cfg.values {
1395 values.insert(key, sourced_val.value);
1396 }
1397 if values.keys().any(|key| sourced_rule_cfg.withheld_keys.contains(key)) {
1398 withheld_rule_values.insert(normalized_rule_name.clone());
1399 }
1400 rules.insert(normalized_rule_name, RuleConfig { severity, values });
1401 }
1402 let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
1404
1405 #[allow(deprecated)]
1406 let global = GlobalConfig {
1407 enable: sourced.global.enable.value,
1408 disable: sourced.global.disable.value,
1409 exclude: sourced.global.exclude.value,
1410 include: sourced.global.include.value,
1411 respect_gitignore: sourced.global.respect_gitignore.value,
1412 line_length: sourced.global.line_length.value,
1413 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1414 fixable: sourced.global.fixable.value,
1415 unfixable: sourced.global.unfixable.value,
1416 flavor: sourced.global.flavor.value,
1417 force_exclude: sourced.global.force_exclude.value,
1418 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
1419 cache: sourced.global.cache.value,
1420 extend_enable: sourced.global.extend_enable.value,
1421 extend_disable: sourced.global.extend_disable.value,
1422 editorconfig: sourced.global.editorconfig.value,
1423 enable_is_explicit,
1424 include_withheld: sourced.global.include_withheld,
1425 };
1426
1427 let mut config = Config {
1428 extends: None,
1429 global,
1430 per_file_ignores: sourced.per_file_ignores.value,
1431 per_file_flavor: sourced.per_file_flavor.value,
1432 code_block_tools: sourced.code_block_tools.value,
1433 rules,
1434 withheld_rule_values,
1435 project_root: sourced.project_root,
1436 per_file_ignores_cache: Arc::new(OnceLock::new()),
1437 per_file_flavor_cache: Arc::new(OnceLock::new()),
1438 canonical_project_root_cache: Arc::new(OnceLock::new()),
1439 };
1440
1441 config.apply_per_rule_enabled();
1443
1444 config.canonicalize_rule_lists();
1451
1452 config
1453 }
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458 use super::pyproject_declares_rumdl_config;
1459
1460 #[test]
1461 fn detects_flat_and_dotted_rumdl_sections() {
1462 assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
1463 assert!(pyproject_declares_rumdl_config(
1465 "[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
1466 ));
1467 assert!(pyproject_declares_rumdl_config(
1468 "[tool.rumdl.rules.MD007]\nindent = 4\n"
1469 ));
1470 }
1471
1472 #[test]
1473 fn ignores_incidental_mentions() {
1474 assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
1477 assert!(!pyproject_declares_rumdl_config(
1478 "[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
1479 ));
1480 assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
1481 }
1482
1483 mod expand_env_vars {
1486 use super::super::expand_env_vars;
1487 use std::collections::HashMap;
1488
1489 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
1491 let map: HashMap<String, String> = pairs
1492 .iter()
1493 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1494 .collect();
1495 move |k: &str| map.get(k).cloned()
1496 }
1497
1498 #[test]
1499 fn expands_bare_and_braced_forms() {
1500 let e = env(&[("VAR", "val"), ("FOO_BAR", "fb")]);
1501 assert_eq!(expand_env_vars("$VAR", &e).unwrap(), "val");
1502 assert_eq!(expand_env_vars("${VAR}", &e).unwrap(), "val");
1503 assert_eq!(expand_env_vars("$FOO_BAR", &e).unwrap(), "fb");
1505 }
1506
1507 #[test]
1508 fn expands_within_paths() {
1509 let e = env(&[("BASE", "/opt/cfg"), ("A", "x"), ("B", "y")]);
1510 assert_eq!(expand_env_vars("$BASE/x/y.toml", &e).unwrap(), "/opt/cfg/x/y.toml");
1511 assert_eq!(expand_env_vars("$A/$B", &e).unwrap(), "x/y");
1512 assert_eq!(expand_env_vars("${A}suffix", &e).unwrap(), "xsuffix");
1513 }
1514
1515 #[test]
1516 fn dollar_dollar_is_a_literal_dollar() {
1517 let e = env(&[("VAR", "val")]);
1518 assert_eq!(expand_env_vars("$$", &e).unwrap(), "$");
1519 assert_eq!(expand_env_vars("$$VAR", &e).unwrap(), "$VAR");
1521 assert_eq!(expand_env_vars("$${VAR}", &e).unwrap(), "${VAR}");
1522 assert_eq!(expand_env_vars("file-$$name.toml", &e).unwrap(), "file-$name.toml");
1524 }
1525
1526 #[test]
1527 fn bare_dollar_name_in_path_is_a_variable_reference() {
1528 let e = env(&[("name", "core")]);
1531 assert_eq!(expand_env_vars("file-$name.toml", &e).unwrap(), "file-core.toml");
1532 }
1533
1534 #[test]
1535 fn incidental_dollar_stays_literal() {
1536 let e = env(&[]);
1537 assert_eq!(expand_env_vars("$5", &e).unwrap(), "$5");
1539 assert_eq!(expand_env_vars("cost$", &e).unwrap(), "cost$");
1540 assert_eq!(expand_env_vars("a$/b", &e).unwrap(), "a$/b");
1541 }
1542
1543 #[test]
1544 fn malformed_braces_stay_literal() {
1545 let e = env(&[("B", "x")]);
1546 assert_eq!(expand_env_vars("${}", &e).unwrap(), "${}");
1547 assert_eq!(expand_env_vars("${VAR", &e).unwrap(), "${VAR");
1548 assert_eq!(expand_env_vars("${A${B}}", &e).unwrap(), "${A${B}}");
1550 }
1551
1552 #[test]
1553 fn undefined_variable_is_an_error() {
1554 let e = env(&[]);
1555 assert_eq!(expand_env_vars("$NOPE", &e).unwrap_err(), "NOPE");
1556 assert_eq!(expand_env_vars("${NOPE}", &e).unwrap_err(), "NOPE");
1557 assert_eq!(expand_env_vars("prefix/$NOPE/x", &e).unwrap_err(), "NOPE");
1558 }
1559
1560 #[test]
1561 fn replacement_is_not_rescanned() {
1562 let e = env(&[("A", "$B"), ("B", "should-not-appear")]);
1564 assert_eq!(expand_env_vars("$A", &e).unwrap(), "$B");
1565 assert_eq!(expand_env_vars("${A}", &e).unwrap(), "$B");
1566 }
1567
1568 #[test]
1569 fn identifiers_are_ascii_only_unicode_stays_literal() {
1570 let e = env(&[("VAR", "v")]);
1571 assert_eq!(expand_env_vars("${föö}", &e).unwrap(), "${föö}");
1573 assert_eq!(expand_env_vars("$VARö", &e).unwrap(), "vö");
1575 assert_eq!(expand_env_vars("café/$VAR", &e).unwrap(), "café/v");
1577 }
1578
1579 #[test]
1580 fn passthrough_for_plain_input() {
1581 let e = env(&[]);
1582 assert_eq!(expand_env_vars("", &e).unwrap(), "");
1583 assert_eq!(expand_env_vars("/plain/path.toml", &e).unwrap(), "/plain/path.toml");
1584 }
1585 }
1586
1587 #[cfg(unix)]
1596 #[test]
1597 fn discover_stops_at_project_root_across_path_representations() {
1598 use super::SourcedConfig;
1599 use std::os::unix::fs::symlink;
1600 use tempfile::tempdir;
1601
1602 let tmp = tempdir().unwrap();
1603 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1605
1606 let real_root = tmp.path().join("project");
1607 let subdir = real_root.join("docs");
1608 std::fs::create_dir_all(&subdir).unwrap();
1609
1610 let linked_root = tmp.path().join("project-link");
1613 symlink(&real_root, &linked_root).unwrap();
1614
1615 let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
1616 assert_eq!(
1617 found, None,
1618 "discovery must stop at the project root, not overshoot to the parent config"
1619 );
1620 }
1621
1622 #[test]
1623 #[serial_test::serial]
1624 fn project_candidates_absolutize_a_relative_start() {
1625 let cwd = std::env::current_dir().unwrap();
1626 let temp = tempfile::Builder::new()
1627 .prefix("rumdl-relative-config-")
1628 .tempdir_in(&cwd)
1629 .unwrap();
1630 let nested = temp.path().join("docs");
1631 std::fs::create_dir(&nested).unwrap();
1632 let config = temp.path().join(".rumdl.toml");
1633 std::fs::write(&config, "").unwrap();
1634
1635 let relative_root = temp.path().strip_prefix(&cwd).unwrap();
1636 let relative_nested = nested.strip_prefix(&cwd).unwrap();
1637 let candidates = super::collect_project_config_candidates(relative_nested, Some(relative_root), None);
1638
1639 assert_eq!(candidates.first(), Some(&config));
1640 assert!(candidates.iter().all(|path| path.is_absolute()));
1641 }
1642
1643 mod shadowed_configs {
1644 use super::super::{ShadowedConfigs, detect_shadowed_configs, format_shadow_warning, rumdl_configs_in_dir};
1645 use tempfile::tempdir;
1646
1647 fn names(paths: &[std::path::PathBuf]) -> Vec<String> {
1648 paths
1649 .iter()
1650 .map(|p| {
1651 let file = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
1654 let parent = p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str());
1655 match parent {
1656 Some(".config") => format!(".config/{file}"),
1657 _ => file.to_string(),
1658 }
1659 })
1660 .collect()
1661 }
1662
1663 #[test]
1664 fn empty_directory_has_no_configs_and_no_shadow() {
1665 let tmp = tempdir().unwrap();
1666 assert!(rumdl_configs_in_dir(tmp.path()).is_empty());
1667 assert!(detect_shadowed_configs(tmp.path()).is_none());
1668 }
1669
1670 #[test]
1671 fn single_config_does_not_shadow() {
1672 let tmp = tempdir().unwrap();
1673 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1674 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1675 assert!(detect_shadowed_configs(tmp.path()).is_none());
1676 }
1677
1678 #[test]
1679 fn dot_wins_over_non_dot_and_non_dot_is_shadowed() {
1680 let tmp = tempdir().unwrap();
1681 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1682 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1683
1684 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1685 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1686 assert_eq!(names(&shadowed), vec!["rumdl.toml"]);
1687 }
1688
1689 #[test]
1690 fn config_subdir_counts_as_same_level_shadow() {
1691 let tmp = tempdir().unwrap();
1692 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1693 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1694 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1695
1696 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1697 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1698 assert_eq!(names(&shadowed), vec![".config/rumdl.toml"]);
1699 }
1700
1701 #[test]
1702 fn pyproject_counts_only_when_it_declares_rumdl() {
1703 let bare = tempdir().unwrap();
1705 std::fs::write(bare.path().join(".rumdl.toml"), "").unwrap();
1706 std::fs::write(bare.path().join("pyproject.toml"), "[tool.black]\nline-length = 88\n").unwrap();
1707 assert_eq!(names(&rumdl_configs_in_dir(bare.path())), vec![".rumdl.toml"]);
1708 assert!(detect_shadowed_configs(bare.path()).is_none());
1709
1710 let declared = tempdir().unwrap();
1712 std::fs::write(declared.path().join(".rumdl.toml"), "").unwrap();
1713 std::fs::write(
1714 declared.path().join("pyproject.toml"),
1715 "[tool.rumdl]\nline-length = 80\n",
1716 )
1717 .unwrap();
1718 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(declared.path()).unwrap();
1719 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1720 assert_eq!(names(&shadowed), vec!["pyproject.toml"]);
1721 }
1722
1723 #[test]
1724 fn markdownlint_configs_are_not_rumdl_native_and_never_shadow() {
1725 let tmp = tempdir().unwrap();
1726 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1727 std::fs::write(tmp.path().join(".markdownlint.json"), "{}").unwrap();
1728 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1729 assert!(detect_shadowed_configs(tmp.path()).is_none());
1730 }
1731
1732 #[test]
1733 fn configs_returned_in_precedence_order() {
1734 let tmp = tempdir().unwrap();
1735 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1736 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1737 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1738 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1739 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1740
1741 assert_eq!(
1742 names(&rumdl_configs_in_dir(tmp.path())),
1743 vec![".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]
1744 );
1745 }
1746
1747 #[test]
1748 fn warning_names_dir_once_with_relative_filenames() {
1749 let tmp = tempdir().unwrap();
1750 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1751 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1752 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1753
1754 let shadow = detect_shadowed_configs(tmp.path()).unwrap();
1755 let msg = format_shadow_warning(&shadow);
1756
1757 let dir = {
1758 let s = tmp.path().to_string_lossy().into_owned();
1759 if cfg!(windows) { s.replace('\\', "/") } else { s }
1760 };
1761 assert!(msg.contains("multiple rumdl config files"), "got: {msg}");
1762 assert_eq!(
1765 msg.matches(dir.as_str()).count(),
1766 1,
1767 "directory should appear exactly once, got: {msg}"
1768 );
1769 assert!(
1770 msg.contains("using .rumdl.toml, ignoring rumdl.toml, pyproject.toml"),
1771 "winner and shadowed files should be relative names in precedence order, got: {msg}"
1772 );
1773 assert!(!msg.contains('\\'), "paths must be normalized to '/': {msg}");
1775 }
1776 }
1777}