1use indexmap::IndexSet;
2use std::collections::BTreeMap;
3use std::marker::PhantomData;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, OnceLock};
6
7use super::file_source::{ConfigFileSource, FsConfigFiles};
8use super::flavor::ConfigLoaded;
9use super::flavor::ConfigValidated;
10use super::parsers;
11use super::registry::RuleRegistry;
12use super::source_tracking::{
13 ConfigSource, ConfigValidationWarning, SourcedConfig, SourcedConfigFragment, SourcedGlobalConfig, SourcedValue,
14};
15use super::types::{
16 Config, ConfigError, ConfigOrigin, DiscoveredConfigError, GlobalConfig, MARKDOWNLINT_CONFIG_FILES,
17 RUMDL_CONFIG_FILES, RuleConfig, WITHHELD,
18};
19use super::validation::validate_config_sourced_internal;
20use crate::utils::upward_walk::UpwardWalk;
21
22const MAX_EXTENDS_DEPTH: usize = 10;
24
25fn pyproject_declares_rumdl_config(content: &str) -> bool {
34 content.contains("[tool.rumdl]") || content.contains("[tool.rumdl.")
35}
36
37fn is_var_name_start(b: u8) -> bool {
39 b == b'_' || b.is_ascii_alphabetic()
40}
41
42fn is_var_name_continue(b: u8) -> bool {
44 b == b'_' || b.is_ascii_alphanumeric()
45}
46
47fn is_valid_var_name(name: &str) -> bool {
49 let bytes = name.as_bytes();
50 !bytes.is_empty() && is_var_name_start(bytes[0]) && bytes[1..].iter().all(|&b| is_var_name_continue(b))
51}
52
53fn expand_env_vars(input: &str, lookup: impl Fn(&str) -> Option<String>) -> Result<String, String> {
71 let bytes = input.as_bytes();
72 let mut out = String::with_capacity(input.len());
73 let mut i = 0;
74
75 while i < bytes.len() {
76 if bytes[i] != b'$' {
77 let start = i;
79 while i < bytes.len() && bytes[i] != b'$' {
80 i += 1;
81 }
82 out.push_str(&input[start..i]);
83 continue;
84 }
85
86 match bytes.get(i + 1).copied() {
87 Some(b'$') => {
89 out.push('$');
90 i += 2;
91 }
92 Some(b'{') => {
94 if let Some(rel) = input[i + 2..].find('}') {
95 let close = i + 2 + rel;
96 let name = &input[i + 2..close];
97 if is_valid_var_name(name) {
98 match lookup(name) {
99 Some(value) => out.push_str(&value),
100 None => return Err(name.to_string()),
101 }
102 } else {
103 out.push_str(&input[i..=close]);
105 }
106 i = close + 1;
107 } else {
108 out.push('$');
110 i += 1;
111 }
112 }
113 Some(b) if is_var_name_start(b) => {
115 let start = i + 1;
116 let mut j = start;
117 while j < bytes.len() && is_var_name_continue(bytes[j]) {
118 j += 1;
119 }
120 let name = &input[start..j];
121 match lookup(name) {
122 Some(value) => out.push_str(&value),
123 None => return Err(name.to_string()),
124 }
125 i = j;
126 }
127 _ => {
129 out.push('$');
130 i += 1;
131 }
132 }
133 }
134
135 Ok(out)
136}
137
138struct ExtendsRef {
146 written: Option<String>,
151 from: String,
154}
155
156impl ExtendsRef {
157 fn describe(&self) -> String {
159 format!("{} (referenced from {})", self.short(), self.from)
160 }
161
162 fn short(&self) -> String {
169 match &self.written {
170 Some(written) => format!("'{written}'"),
171 None => WITHHELD.to_string(),
172 }
173 }
174}
175
176#[derive(Default)]
182struct ExtendsChain {
183 visited: IndexSet<PathBuf>,
184 names: Vec<String>,
185}
186
187impl ExtendsChain {
188 fn contains(&self, canonical: &Path) -> bool {
189 self.visited.contains(canonical)
190 }
191
192 fn len(&self) -> usize {
193 self.visited.len()
194 }
195
196 fn push(&mut self, canonical: PathBuf, name: String) {
197 self.visited.insert(canonical);
198 self.names.push(name);
199 }
200
201 fn names(&self) -> Vec<String> {
202 self.names.clone()
203 }
204}
205
206fn resolve_extends(
219 extends_value: &str,
220 config_file_path: &Path,
221 from: &str,
222 declared_by: ConfigOrigin<'_>,
223 source: &dyn ConfigFileSource,
224) -> Result<(PathBuf, ExtendsRef), ConfigError> {
225 let expanded = expand_env_vars(extends_value, |key| source.env_var(key)).map_err(|var| {
226 ConfigError::ExtendsUndefinedVar {
230 var: if declared_by.may_quote_contents() {
231 format!("${var}")
232 } else {
233 WITHHELD.to_string()
234 },
235 from: from.to_string(),
236 }
237 })?;
238
239 let reference = ExtendsRef {
240 written: declared_by.may_quote_contents().then(|| extends_value.to_string()),
241 from: from.to_string(),
242 };
243
244 Ok((
245 resolve_expanded_extends_path(&expanded, config_file_path, source.home_dir().as_deref()),
246 reference,
247 ))
248}
249
250fn resolve_expanded_extends_path(expanded: &str, config_file_path: &Path, home: Option<&Path>) -> PathBuf {
253 if let Some(suffix) = expanded.strip_prefix("~/") {
254 match home {
255 Some(home) => home.join(suffix),
256 None => 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 source: &dyn ConfigFileSource,
410) -> Result<(), ConfigError> {
411 let canonical = source.canonicalize(config_file_path);
413
414 let path_str = config_file_path.display().to_string();
415 let described = origin.display_name(&path_str);
416 let short = origin.short_name(&path_str);
417
418 if chain.contains(&canonical) {
420 return Err(ConfigError::CircularExtends {
421 path: described,
422 chain: chain.names(),
423 });
424 }
425
426 if chain.len() >= MAX_EXTENDS_DEPTH {
428 return Err(ConfigError::ExtendsDepthExceeded {
429 path: described,
430 max_depth: MAX_EXTENDS_DEPTH,
431 });
432 }
433
434 chain.push(canonical, short.clone());
436
437 let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
438
439 let content = source
441 .read_to_string(config_file_path)
442 .map_err(|e| ConfigError::IoError {
443 source: e,
444 path: described.clone(),
445 })?;
446
447 let fragment = if filename == "pyproject.toml" {
448 match parsers::parse_pyproject_toml(&content, &path_str, chain_source, origin)? {
449 Some(f) => f,
450 None => return Ok(()), }
452 } else {
453 parsers::parse_rumdl_toml(&content, &path_str, chain_source, origin)?
454 };
455
456 if let Some(ref extends_value) = fragment.extends {
458 let (base_path, reference) = resolve_extends(extends_value, config_file_path, &short, origin, source)?;
459 let base_described = reference.describe();
460 let base_short = reference.short();
461
462 if !source.exists(&base_path) {
463 return Err(ConfigError::ExtendsNotFound {
464 path: base_short,
465 from: short,
466 });
467 }
468
469 log::debug!(
470 "[rumdl-config] Config {} extends {}, loading base first",
471 path_str,
472 base_path.display()
473 );
474
475 load_config_with_extends(
477 sourced_config,
478 &base_path,
479 chain,
480 chain_source,
481 ConfigOrigin::Extends {
482 described_as: &base_described,
483 short_name: &base_short,
484 },
485 source,
486 )?;
487 }
488
489 let mut fragment_for_merge = fragment;
492 fragment_for_merge.extends = None;
493 sourced_config.merge(fragment_for_merge);
494 sourced_config.loaded_files.push(path_str);
495
496 Ok(())
497}
498
499impl SourcedConfig<ConfigLoaded> {
500 pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
503 self.global.enable.merge_from(fragment.global.enable);
508 self.global.disable.merge_from(fragment.global.disable);
509 self.global
510 .extend_enable
511 .merge_union_from(fragment.global.extend_enable);
512 self.global
513 .extend_disable
514 .merge_union_from(fragment.global.extend_disable);
515
516 self.global
519 .disable
520 .value
521 .retain(|rule| !self.global.enable.value.contains(rule));
522
523 if self.global.include.merge_from(fragment.global.include) {
527 self.global.include_withheld = fragment.global.include_withheld;
528 }
529 self.global.exclude.merge_from(fragment.global.exclude);
530 self.global
531 .respect_gitignore
532 .merge_from(fragment.global.respect_gitignore);
533 self.global.line_length.merge_from(fragment.global.line_length);
534 self.global.fixable.merge_from(fragment.global.fixable);
535 self.global.unfixable.merge_from(fragment.global.unfixable);
536 self.global.flavor.merge_from(fragment.global.flavor);
537 self.global.force_exclude.merge_from(fragment.global.force_exclude);
538 self.global.editorconfig.merge_from(fragment.global.editorconfig);
539
540 if let Some(output_format_fragment) = fragment.global.output_format {
542 if let Some(ref mut output_format) = self.global.output_format {
543 output_format.merge_from(output_format_fragment);
544 } else {
545 self.global.output_format = Some(output_format_fragment);
546 }
547 }
548
549 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
551 if let Some(ref mut cache_dir) = self.global.cache_dir {
552 cache_dir.merge_from(cache_dir_fragment);
553 } else {
554 self.global.cache_dir = Some(cache_dir_fragment);
555 }
556 }
557
558 if fragment.global.cache.source != ConfigSource::Default {
560 self.global.cache.merge_from(fragment.global.cache);
561 }
562
563 self.per_file_ignores.merge_from(fragment.per_file_ignores);
564 self.per_file_flavor.merge_from(fragment.per_file_flavor);
565 self.code_block_tools.merge_from(fragment.code_block_tools);
566
567 for (rule_name, rule_fragment) in fragment.rules {
569 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
571
572 if let Some(severity_fragment) = rule_fragment.severity {
574 if let Some(ref mut existing_severity) = rule_entry.severity {
575 existing_severity.merge_from(severity_fragment);
576 } else {
577 rule_entry.severity = Some(severity_fragment);
578 }
579 }
580
581 for (key, sourced_value_fragment) in rule_fragment.values {
585 let sv_entry = rule_entry
586 .values
587 .entry(key.clone())
588 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
589 if sv_entry.merge_from(sourced_value_fragment) {
590 if rule_fragment.withheld_keys.contains(&key) {
591 rule_entry.withheld_keys.insert(key);
592 } else {
593 rule_entry.withheld_keys.remove(&key);
594 }
595 }
596 }
597 }
598
599 for warning in fragment.load_warnings {
603 if !self.discovery_warnings.contains(&warning) {
604 self.discovery_warnings.push(warning);
605 }
606 }
607
608 for (section, key, file_path) in fragment.unknown_keys {
609 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
611 self.unknown_keys.push((section, key, file_path));
612 }
613 }
614 }
615
616 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
618 Self::load_with_discovery(config_path, cli_overrides, false)
619 }
620
621 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
624 UpwardWalk::new(start_dir)
625 .find(|dir| dir.join(".git").exists())
626 .unwrap_or_else(|| {
627 log::debug!(
628 "[rumdl-config] No .git found, using config location as project root: {}",
629 start_dir.display()
630 );
631 start_dir.to_path_buf()
632 })
633 }
634
635 fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
641 home_override.map(Path::to_path_buf).or_else(|| {
642 #[cfg(feature = "native")]
643 {
644 use etcetera::{BaseStrategy, choose_base_strategy};
645 choose_base_strategy().ok().map(|s| s.home_dir().to_path_buf())
646 }
647 #[cfg(not(feature = "native"))]
648 {
649 None
650 }
651 })
652 }
653
654 fn resolve_discovery_start(start_override: Option<&Path>) -> Option<std::path::PathBuf> {
660 if let Some(dir) = start_override {
661 return Some(dir.to_path_buf());
662 }
663 match std::env::current_dir() {
664 Ok(dir) => Some(dir),
665 Err(e) => {
666 log::debug!("[rumdl-config] Failed to get current directory: {e}");
667 None
668 }
669 }
670 }
671
672 fn discover_config_upward(
688 start_override: Option<&Path>,
689 home_override: Option<&Path>,
690 ) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
691 let start_dir = Self::resolve_discovery_start(start_override)?;
692
693 let (config_path, config_dir, shadow) = UpwardWalk::new(&start_dir)
697 .stop_below(Self::resolve_home_boundary(home_override))
698 .always_yield_start()
699 .stop_at_git_root()
700 .find_map(|dir| {
701 rumdl_configs_in_dir(&dir).into_iter().next().map(|winner| {
702 log::debug!("[rumdl-config] Found config file: {}", winner.display());
703 let shadow = detect_shadowed_configs(&dir);
704 (winner, dir, shadow)
705 })
706 })?;
707
708 let project_root = Self::find_project_root_from(&config_dir);
710 Some((config_path, project_root, shadow))
711 }
712
713 fn discover_markdownlint_config_upward(
721 start_override: Option<&Path>,
722 home_override: Option<&Path>,
723 ) -> Option<std::path::PathBuf> {
724 let start_dir = Self::resolve_discovery_start(start_override)?;
725
726 UpwardWalk::new(&start_dir)
727 .stop_below(Self::resolve_home_boundary(home_override))
728 .always_yield_start()
729 .stop_at_git_root()
730 .find_map(|dir| {
731 MARKDOWNLINT_CONFIG_FILES
732 .iter()
733 .map(|name| dir.join(name))
734 .find(|path| path.exists())
735 })
736 }
737
738 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
740 let config_dir = config_dir.join("rumdl");
741
742 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
744
745 log::debug!(
746 "[rumdl-config] Checking for user configuration in: {}",
747 config_dir.display()
748 );
749
750 for filename in USER_CONFIG_FILES {
751 let config_path = config_dir.join(filename);
752
753 if config_path.exists() {
754 if *filename == "pyproject.toml" {
756 if let Ok(content) = std::fs::read_to_string(&config_path) {
757 if pyproject_declares_rumdl_config(&content) {
758 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
759 return Some(config_path);
760 }
761 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
762 continue;
763 }
764 } else {
765 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
766 return Some(config_path);
767 }
768 }
769 }
770
771 log::debug!(
772 "[rumdl-config] No user configuration found in: {}",
773 config_dir.display()
774 );
775 None
776 }
777
778 #[cfg(feature = "native")]
781 fn user_configuration_path() -> Option<std::path::PathBuf> {
782 use etcetera::{BaseStrategy, choose_base_strategy};
783
784 match choose_base_strategy() {
785 Ok(strategy) => {
786 let config_dir = strategy.config_dir();
787 Self::user_configuration_path_impl(&config_dir)
788 }
789 Err(e) => {
790 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
791 None
792 }
793 }
794 }
795
796 #[cfg(not(feature = "native"))]
798 fn user_configuration_path() -> Option<std::path::PathBuf> {
799 None
800 }
801
802 fn home_configuration_path_impl(home_dir: &Path) -> Option<std::path::PathBuf> {
814 const HOME_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml"];
815
816 log::debug!(
817 "[rumdl-config] Checking for home-directory configuration in: {}",
818 home_dir.display()
819 );
820
821 for filename in HOME_CONFIG_FILES {
822 let config_path = home_dir.join(filename);
823 if config_path.exists() {
824 log::debug!(
825 "[rumdl-config] Found home-directory configuration at: {}",
826 config_path.display()
827 );
828 return Some(config_path);
829 }
830 }
831
832 log::debug!(
833 "[rumdl-config] No home-directory configuration found in: {}",
834 home_dir.display()
835 );
836 None
837 }
838
839 #[cfg(feature = "native")]
845 fn home_configuration_path() -> Option<std::path::PathBuf> {
846 use etcetera::{BaseStrategy, choose_base_strategy};
847
848 match choose_base_strategy() {
849 Ok(strategy) => Self::home_configuration_path_impl(strategy.home_dir()),
850 Err(e) => {
851 log::debug!("[rumdl-config] Failed to determine home directory: {e}");
852 None
853 }
854 }
855 }
856
857 #[cfg(not(feature = "native"))]
859 fn home_configuration_path() -> Option<std::path::PathBuf> {
860 None
861 }
862
863 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
865 let path_obj = Path::new(path);
866 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
867 let path_str = path.to_string();
868
869 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
870
871 if let Some(config_parent) = path_obj.parent() {
873 let project_root = Self::find_project_root_from(config_parent);
874 log::debug!(
875 "[rumdl-config] Project root (from explicit config): {}",
876 project_root.display()
877 );
878 sourced_config.project_root = Some(project_root);
879 }
880
881 const MARKDOWNLINT_FILENAMES: &[&str] = &[
883 ".markdownlint-cli2.jsonc",
884 ".markdownlint-cli2.yaml",
885 ".markdownlint-cli2.yml",
886 ".markdownlint.json",
887 ".markdownlint.yaml",
888 ".markdownlint.yml",
889 ];
890
891 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
892 let mut chain = ExtendsChain::default();
894 let chain_source = source_from_filename(filename);
895 load_config_with_extends(
896 sourced_config,
897 path_obj,
898 &mut chain,
899 chain_source,
900 ConfigOrigin::Direct,
901 &FsConfigFiles,
902 )?;
903 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
904 || path_str.ends_with(".json")
905 || path_str.ends_with(".jsonc")
906 || path_str.ends_with(".yaml")
907 || path_str.ends_with(".yml")
908 {
909 let fragment = parsers::load_from_markdownlint(&path_str)?;
911 sourced_config.merge(fragment);
912 sourced_config.loaded_files.push(path_str);
913 } else {
914 let mut chain = ExtendsChain::default();
916 let chain_source = source_from_filename(filename);
917 load_config_with_extends(
918 sourced_config,
919 path_obj,
920 &mut chain,
921 chain_source,
922 ConfigOrigin::Direct,
923 &FsConfigFiles,
924 )?;
925 }
926
927 Ok(())
928 }
929
930 fn load_user_config(
949 sourced_config: &mut Self,
950 user_config_dir: Option<&Path>,
951 home_dir: Option<&Path>,
952 ) -> Result<(), ConfigError> {
953 let user_config_path = if let Some(dir) = user_config_dir {
954 Self::user_configuration_path_impl(dir)
955 } else {
956 Self::user_configuration_path()
957 };
958
959 let user_config_path = user_config_path.or_else(|| match home_dir {
960 Some(home) => Self::home_configuration_path_impl(home),
961 None => Self::home_configuration_path(),
962 });
963
964 if let Some(user_config_path) = user_config_path {
965 let path_str = user_config_path.display().to_string();
966
967 log::debug!("[rumdl-config] Loading user config: {path_str}");
968
969 let mut chain = ExtendsChain::default();
972 load_config_with_extends(
973 sourced_config,
974 &user_config_path,
975 &mut chain,
976 ConfigSource::UserConfig,
977 ConfigOrigin::Direct,
978 &FsConfigFiles,
979 )?;
980 } else {
981 log::debug!("[rumdl-config] No user configuration file found");
982 }
983
984 Ok(())
985 }
986
987 fn load_discovered_config(
1005 sourced_config: &mut Self,
1006 config_file: &Path,
1007 user_config_dir: Option<&Path>,
1008 home_dir: Option<&Path>,
1009 ) -> Result<(), DiscoveredConfigError> {
1010 let filename = config_file.file_name().and_then(|name| name.to_str()).unwrap_or("");
1011
1012 if MARKDOWNLINT_CONFIG_FILES.contains(&filename) {
1013 Self::load_user_config(sourced_config, user_config_dir, home_dir)
1014 .map_err(DiscoveredConfigError::UserConfig)?;
1015
1016 let path_str = config_file.display().to_string();
1017 let fragment = parsers::load_from_markdownlint(&path_str).map_err(DiscoveredConfigError::ProjectConfig)?;
1018 sourced_config.merge(fragment);
1019 sourced_config.loaded_files.push(path_str);
1020 } else {
1021 let mut chain = ExtendsChain::default();
1022 let chain_source = source_from_filename(filename);
1023 load_config_with_extends(
1024 sourced_config,
1025 config_file,
1026 &mut chain,
1027 chain_source,
1028 ConfigOrigin::Direct,
1029 &FsConfigFiles,
1030 )
1031 .map_err(DiscoveredConfigError::ProjectConfig)?;
1032 }
1033
1034 Ok(())
1035 }
1036
1037 pub fn load_discovered(
1055 config_file: &Path,
1056 user_config_dir: Option<&Path>,
1057 home_dir: Option<&Path>,
1058 ) -> Result<Self, DiscoveredConfigError> {
1059 let mut sourced_config = SourcedConfig::default();
1060
1061 if let Some(config_parent) = config_file.parent() {
1062 sourced_config.project_root = Some(Self::find_project_root_from(config_parent));
1063 }
1064
1065 Self::load_discovered_config(&mut sourced_config, config_file, user_config_dir, home_dir)?;
1066
1067 Ok(sourced_config)
1068 }
1069
1070 pub fn load_for_workspace(
1083 start_dir: &Path,
1084 config_path: Option<&str>,
1085 user_config_dir: Option<&Path>,
1086 home_dir: Option<&Path>,
1087 ) -> Result<Self, ConfigError> {
1088 Self::load_with_discovery_from(Some(start_dir), config_path, None, false, user_config_dir, home_dir)
1089 }
1090
1091 #[doc(hidden)]
1093 pub fn load_with_discovery_impl(
1094 config_path: Option<&str>,
1095 cli_overrides: Option<&SourcedGlobalConfig>,
1096 skip_auto_discovery: bool,
1097 user_config_dir: Option<&Path>,
1098 home_dir: Option<&Path>,
1099 ) -> Result<Self, ConfigError> {
1100 Self::load_with_discovery_from(
1101 None,
1102 config_path,
1103 cli_overrides,
1104 skip_auto_discovery,
1105 user_config_dir,
1106 home_dir,
1107 )
1108 }
1109
1110 fn load_with_discovery_from(
1115 start_dir: Option<&Path>,
1116 config_path: Option<&str>,
1117 cli_overrides: Option<&SourcedGlobalConfig>,
1118 skip_auto_discovery: bool,
1119 user_config_dir: Option<&Path>,
1120 home_dir: Option<&Path>,
1121 ) -> Result<Self, ConfigError> {
1122 use std::env;
1123 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
1124
1125 let mut sourced_config = SourcedConfig::default();
1126
1127 if let Some(path) = config_path {
1140 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
1142 Self::load_explicit_config(&mut sourced_config, path)?;
1143 } else if skip_auto_discovery {
1144 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
1145 } else {
1147 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
1149
1150 if let Some((config_file, project_root, shadow)) = Self::discover_config_upward(start_dir, home_dir) {
1152 log::debug!("[rumdl-config] Found project config: {}", config_file.display());
1156 log::debug!("[rumdl-config] Project root: {}", project_root.display());
1157
1158 if let Some(shadow) = shadow {
1161 sourced_config.discovery_warnings.push(format_shadow_warning(&shadow));
1162 }
1163
1164 sourced_config.project_root = Some(project_root);
1165
1166 Self::load_discovered_config(&mut sourced_config, &config_file, user_config_dir, home_dir)?;
1167 } else {
1168 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
1170
1171 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward(start_dir, home_dir) {
1172 log::debug!(
1173 "[rumdl-config] Found markdownlint config: {}",
1174 markdownlint_path.display()
1175 );
1176
1177 if let Err(e) =
1178 Self::load_discovered_config(&mut sourced_config, &markdownlint_path, user_config_dir, home_dir)
1179 {
1180 match e {
1181 DiscoveredConfigError::ProjectConfig(e) => {
1187 log::debug!("[rumdl-config] Failed to load markdownlint config: {e}");
1188 }
1189 DiscoveredConfigError::UserConfig(e) => return Err(e),
1191 }
1192 }
1193 } else {
1194 log::debug!("[rumdl-config] No project config found, using user config as fallback");
1196 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
1197 }
1198 }
1199 }
1200
1201 if let Some(cli) = cli_overrides {
1203 sourced_config
1204 .global
1205 .enable
1206 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None);
1207 sourced_config
1208 .global
1209 .disable
1210 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None);
1211 sourced_config
1212 .global
1213 .exclude
1214 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None);
1215 sourced_config
1216 .global
1217 .include
1218 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None);
1219 sourced_config.global.respect_gitignore.merge_override(
1220 cli.respect_gitignore.value,
1221 ConfigSource::Cli,
1222 None,
1223 );
1224 sourced_config
1225 .global
1226 .fixable
1227 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None);
1228 sourced_config
1229 .global
1230 .unfixable
1231 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None);
1232 }
1234
1235 Ok(sourced_config)
1238 }
1239
1240 pub fn load_with_discovery(
1243 config_path: Option<&str>,
1244 cli_overrides: Option<&SourcedGlobalConfig>,
1245 skip_auto_discovery: bool,
1246 ) -> Result<Self, ConfigError> {
1247 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
1248 }
1249
1250 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
1264 let warnings = validate_config_sourced_internal(&self, registry);
1265
1266 Ok(SourcedConfig {
1267 global: self.global,
1268 per_file_ignores: self.per_file_ignores,
1269 per_file_flavor: self.per_file_flavor,
1270 code_block_tools: self.code_block_tools,
1271 rules: self.rules,
1272 loaded_files: self.loaded_files,
1273 unknown_keys: self.unknown_keys,
1274 project_root: self.project_root,
1275 discovery_warnings: self.discovery_warnings,
1276 validation_warnings: warnings,
1277 _state: PhantomData,
1278 })
1279 }
1280
1281 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
1286 let validated = self.validate(registry)?;
1287 let warnings = validated.validation_warnings.clone();
1288 Ok((validated.into(), warnings))
1289 }
1290
1291 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
1302 SourcedConfig {
1303 global: self.global,
1304 per_file_ignores: self.per_file_ignores,
1305 per_file_flavor: self.per_file_flavor,
1306 code_block_tools: self.code_block_tools,
1307 rules: self.rules,
1308 loaded_files: self.loaded_files,
1309 unknown_keys: self.unknown_keys,
1310 project_root: self.project_root,
1311 discovery_warnings: self.discovery_warnings,
1312 validation_warnings: Vec::new(),
1313 _state: PhantomData,
1314 }
1315 }
1316
1317 pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
1326 collect_project_config_candidates(dir, Some(project_root), Self::resolve_home_boundary(None).as_deref())
1338 .into_iter()
1339 .next()
1340 }
1341
1342 pub fn load_sourced_for_path(
1349 config_path: &Path,
1350 project_root: &Path,
1351 ) -> Result<SourcedConfig<ConfigLoaded>, ConfigError> {
1352 let mut sourced_config = SourcedConfig {
1353 project_root: Some(project_root.to_path_buf()),
1354 ..SourcedConfig::default()
1355 };
1356
1357 let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1358 let path_str = config_path.display().to_string();
1359
1360 let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
1362 || (filename != "pyproject.toml"
1363 && filename != ".rumdl.toml"
1364 && filename != "rumdl.toml"
1365 && (path_str.ends_with(".json")
1366 || path_str.ends_with(".jsonc")
1367 || path_str.ends_with(".yaml")
1368 || path_str.ends_with(".yml")));
1369
1370 if is_markdownlint {
1371 let fragment = parsers::load_from_markdownlint(&path_str)?;
1372 sourced_config.merge(fragment);
1373 sourced_config.loaded_files.push(path_str);
1374 } else {
1375 let mut chain = ExtendsChain::default();
1376 let chain_source = source_from_filename(filename);
1377 load_config_with_extends(
1378 &mut sourced_config,
1379 config_path,
1380 &mut chain,
1381 chain_source,
1382 ConfigOrigin::Direct,
1383 &FsConfigFiles,
1384 )?;
1385 }
1386
1387 Ok(sourced_config)
1388 }
1389
1390 pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
1394 Ok(Self::load_sourced_for_path(config_path, project_root)?
1395 .into_validated_unchecked()
1396 .into())
1397 }
1398
1399 #[cfg(any(feature = "wasm", test))]
1409 pub(crate) fn load_chain_from(root: &Path, source: &dyn ConfigFileSource) -> Result<Self, ConfigError> {
1410 let mut sourced_config = SourcedConfig::default();
1411 let filename = root.file_name().and_then(|n| n.to_str()).unwrap_or("");
1412 let mut chain = ExtendsChain::default();
1413 load_config_with_extends(
1414 &mut sourced_config,
1415 root,
1416 &mut chain,
1417 source_from_filename(filename),
1418 ConfigOrigin::Direct,
1419 source,
1420 )?;
1421 Ok(sourced_config)
1422 }
1423}
1424
1425impl From<SourcedConfig<ConfigValidated>> for Config {
1430 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
1431 let mut rules = BTreeMap::new();
1432 let mut withheld_rule_values = std::collections::BTreeSet::new();
1433 for (rule_name, sourced_rule_cfg) in sourced.rules {
1434 let normalized_rule_name = rule_name.to_ascii_uppercase();
1436 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
1437 let mut values = BTreeMap::new();
1438 for (key, sourced_val) in sourced_rule_cfg.values {
1439 values.insert(key, sourced_val.value);
1440 }
1441 if values.keys().any(|key| sourced_rule_cfg.withheld_keys.contains(key)) {
1442 withheld_rule_values.insert(normalized_rule_name.clone());
1443 }
1444 rules.insert(normalized_rule_name, RuleConfig { severity, values });
1445 }
1446 let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
1448
1449 #[allow(deprecated)]
1450 let global = GlobalConfig {
1451 enable: sourced.global.enable.value,
1452 disable: sourced.global.disable.value,
1453 exclude: sourced.global.exclude.value,
1454 include: sourced.global.include.value,
1455 respect_gitignore: sourced.global.respect_gitignore.value,
1456 line_length: sourced.global.line_length.value,
1457 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1458 fixable: sourced.global.fixable.value,
1459 unfixable: sourced.global.unfixable.value,
1460 flavor: sourced.global.flavor.value,
1461 force_exclude: sourced.global.force_exclude.value,
1462 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
1463 cache: sourced.global.cache.value,
1464 extend_enable: sourced.global.extend_enable.value,
1465 extend_disable: sourced.global.extend_disable.value,
1466 editorconfig: sourced.global.editorconfig.value,
1467 enable_is_explicit,
1468 include_withheld: sourced.global.include_withheld,
1469 };
1470
1471 let mut config = Config {
1472 extends: None,
1473 global,
1474 per_file_ignores: sourced.per_file_ignores.value,
1475 per_file_flavor: sourced.per_file_flavor.value,
1476 code_block_tools: sourced.code_block_tools.value,
1477 rules,
1478 withheld_rule_values,
1479 project_root: sourced.project_root,
1480 per_file_ignores_cache: Arc::new(OnceLock::new()),
1481 per_file_flavor_cache: Arc::new(OnceLock::new()),
1482 canonical_project_root_cache: Arc::new(OnceLock::new()),
1483 };
1484
1485 config.apply_per_rule_enabled();
1487
1488 config.canonicalize_rule_lists();
1495
1496 config
1497 }
1498}
1499
1500#[cfg(test)]
1501mod tests {
1502 use super::pyproject_declares_rumdl_config;
1503
1504 #[test]
1505 fn detects_flat_and_dotted_rumdl_sections() {
1506 assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
1507 assert!(pyproject_declares_rumdl_config(
1509 "[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
1510 ));
1511 assert!(pyproject_declares_rumdl_config(
1512 "[tool.rumdl.rules.MD007]\nindent = 4\n"
1513 ));
1514 }
1515
1516 #[test]
1517 fn ignores_incidental_mentions() {
1518 assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
1521 assert!(!pyproject_declares_rumdl_config(
1522 "[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
1523 ));
1524 assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
1525 }
1526
1527 mod expand_env_vars {
1530 use super::super::expand_env_vars;
1531 use std::collections::HashMap;
1532
1533 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
1535 let map: HashMap<String, String> = pairs
1536 .iter()
1537 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1538 .collect();
1539 move |k: &str| map.get(k).cloned()
1540 }
1541
1542 #[test]
1543 fn expands_bare_and_braced_forms() {
1544 let e = env(&[("VAR", "val"), ("FOO_BAR", "fb")]);
1545 assert_eq!(expand_env_vars("$VAR", &e).unwrap(), "val");
1546 assert_eq!(expand_env_vars("${VAR}", &e).unwrap(), "val");
1547 assert_eq!(expand_env_vars("$FOO_BAR", &e).unwrap(), "fb");
1549 }
1550
1551 #[test]
1552 fn expands_within_paths() {
1553 let e = env(&[("BASE", "/opt/cfg"), ("A", "x"), ("B", "y")]);
1554 assert_eq!(expand_env_vars("$BASE/x/y.toml", &e).unwrap(), "/opt/cfg/x/y.toml");
1555 assert_eq!(expand_env_vars("$A/$B", &e).unwrap(), "x/y");
1556 assert_eq!(expand_env_vars("${A}suffix", &e).unwrap(), "xsuffix");
1557 }
1558
1559 #[test]
1560 fn dollar_dollar_is_a_literal_dollar() {
1561 let e = env(&[("VAR", "val")]);
1562 assert_eq!(expand_env_vars("$$", &e).unwrap(), "$");
1563 assert_eq!(expand_env_vars("$$VAR", &e).unwrap(), "$VAR");
1565 assert_eq!(expand_env_vars("$${VAR}", &e).unwrap(), "${VAR}");
1566 assert_eq!(expand_env_vars("file-$$name.toml", &e).unwrap(), "file-$name.toml");
1568 }
1569
1570 #[test]
1571 fn bare_dollar_name_in_path_is_a_variable_reference() {
1572 let e = env(&[("name", "core")]);
1575 assert_eq!(expand_env_vars("file-$name.toml", &e).unwrap(), "file-core.toml");
1576 }
1577
1578 #[test]
1579 fn incidental_dollar_stays_literal() {
1580 let e = env(&[]);
1581 assert_eq!(expand_env_vars("$5", &e).unwrap(), "$5");
1583 assert_eq!(expand_env_vars("cost$", &e).unwrap(), "cost$");
1584 assert_eq!(expand_env_vars("a$/b", &e).unwrap(), "a$/b");
1585 }
1586
1587 #[test]
1588 fn malformed_braces_stay_literal() {
1589 let e = env(&[("B", "x")]);
1590 assert_eq!(expand_env_vars("${}", &e).unwrap(), "${}");
1591 assert_eq!(expand_env_vars("${VAR", &e).unwrap(), "${VAR");
1592 assert_eq!(expand_env_vars("${A${B}}", &e).unwrap(), "${A${B}}");
1594 }
1595
1596 #[test]
1597 fn undefined_variable_is_an_error() {
1598 let e = env(&[]);
1599 assert_eq!(expand_env_vars("$NOPE", &e).unwrap_err(), "NOPE");
1600 assert_eq!(expand_env_vars("${NOPE}", &e).unwrap_err(), "NOPE");
1601 assert_eq!(expand_env_vars("prefix/$NOPE/x", &e).unwrap_err(), "NOPE");
1602 }
1603
1604 #[test]
1605 fn replacement_is_not_rescanned() {
1606 let e = env(&[("A", "$B"), ("B", "should-not-appear")]);
1608 assert_eq!(expand_env_vars("$A", &e).unwrap(), "$B");
1609 assert_eq!(expand_env_vars("${A}", &e).unwrap(), "$B");
1610 }
1611
1612 #[test]
1613 fn identifiers_are_ascii_only_unicode_stays_literal() {
1614 let e = env(&[("VAR", "v")]);
1615 assert_eq!(expand_env_vars("${föö}", &e).unwrap(), "${föö}");
1617 assert_eq!(expand_env_vars("$VARö", &e).unwrap(), "vö");
1619 assert_eq!(expand_env_vars("café/$VAR", &e).unwrap(), "café/v");
1621 }
1622
1623 #[test]
1624 fn passthrough_for_plain_input() {
1625 let e = env(&[]);
1626 assert_eq!(expand_env_vars("", &e).unwrap(), "");
1627 assert_eq!(expand_env_vars("/plain/path.toml", &e).unwrap(), "/plain/path.toml");
1628 }
1629 }
1630
1631 #[cfg(unix)]
1640 #[test]
1641 fn discover_stops_at_project_root_across_path_representations() {
1642 use super::SourcedConfig;
1643 use std::os::unix::fs::symlink;
1644 use tempfile::tempdir;
1645
1646 let tmp = tempdir().unwrap();
1647 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1649
1650 let real_root = tmp.path().join("project");
1651 let subdir = real_root.join("docs");
1652 std::fs::create_dir_all(&subdir).unwrap();
1653
1654 let linked_root = tmp.path().join("project-link");
1657 symlink(&real_root, &linked_root).unwrap();
1658
1659 let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
1660 assert_eq!(
1661 found, None,
1662 "discovery must stop at the project root, not overshoot to the parent config"
1663 );
1664 }
1665
1666 #[test]
1667 #[serial_test::serial]
1668 fn project_candidates_absolutize_a_relative_start() {
1669 let cwd = std::env::current_dir().unwrap();
1670 let temp = tempfile::Builder::new()
1671 .prefix("rumdl-relative-config-")
1672 .tempdir_in(&cwd)
1673 .unwrap();
1674 let nested = temp.path().join("docs");
1675 std::fs::create_dir(&nested).unwrap();
1676 let config = temp.path().join(".rumdl.toml");
1677 std::fs::write(&config, "").unwrap();
1678
1679 let relative_root = temp.path().strip_prefix(&cwd).unwrap();
1680 let relative_nested = nested.strip_prefix(&cwd).unwrap();
1681 let candidates = super::collect_project_config_candidates(relative_nested, Some(relative_root), None);
1682
1683 assert_eq!(candidates.first(), Some(&config));
1684 assert!(candidates.iter().all(|path| path.is_absolute()));
1685 }
1686
1687 mod shadowed_configs {
1688 use super::super::{ShadowedConfigs, detect_shadowed_configs, format_shadow_warning, rumdl_configs_in_dir};
1689 use tempfile::tempdir;
1690
1691 fn names(paths: &[std::path::PathBuf]) -> Vec<String> {
1692 paths
1693 .iter()
1694 .map(|p| {
1695 let file = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
1698 let parent = p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str());
1699 match parent {
1700 Some(".config") => format!(".config/{file}"),
1701 _ => file.to_string(),
1702 }
1703 })
1704 .collect()
1705 }
1706
1707 #[test]
1708 fn empty_directory_has_no_configs_and_no_shadow() {
1709 let tmp = tempdir().unwrap();
1710 assert!(rumdl_configs_in_dir(tmp.path()).is_empty());
1711 assert!(detect_shadowed_configs(tmp.path()).is_none());
1712 }
1713
1714 #[test]
1715 fn single_config_does_not_shadow() {
1716 let tmp = tempdir().unwrap();
1717 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1718 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1719 assert!(detect_shadowed_configs(tmp.path()).is_none());
1720 }
1721
1722 #[test]
1723 fn dot_wins_over_non_dot_and_non_dot_is_shadowed() {
1724 let tmp = tempdir().unwrap();
1725 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1726 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1727
1728 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1729 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1730 assert_eq!(names(&shadowed), vec!["rumdl.toml"]);
1731 }
1732
1733 #[test]
1734 fn config_subdir_counts_as_same_level_shadow() {
1735 let tmp = tempdir().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
1740 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1741 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1742 assert_eq!(names(&shadowed), vec![".config/rumdl.toml"]);
1743 }
1744
1745 #[test]
1746 fn pyproject_counts_only_when_it_declares_rumdl() {
1747 let bare = tempdir().unwrap();
1749 std::fs::write(bare.path().join(".rumdl.toml"), "").unwrap();
1750 std::fs::write(bare.path().join("pyproject.toml"), "[tool.black]\nline-length = 88\n").unwrap();
1751 assert_eq!(names(&rumdl_configs_in_dir(bare.path())), vec![".rumdl.toml"]);
1752 assert!(detect_shadowed_configs(bare.path()).is_none());
1753
1754 let declared = tempdir().unwrap();
1756 std::fs::write(declared.path().join(".rumdl.toml"), "").unwrap();
1757 std::fs::write(
1758 declared.path().join("pyproject.toml"),
1759 "[tool.rumdl]\nline-length = 80\n",
1760 )
1761 .unwrap();
1762 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(declared.path()).unwrap();
1763 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1764 assert_eq!(names(&shadowed), vec!["pyproject.toml"]);
1765 }
1766
1767 #[test]
1768 fn markdownlint_configs_are_not_rumdl_native_and_never_shadow() {
1769 let tmp = tempdir().unwrap();
1770 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1771 std::fs::write(tmp.path().join(".markdownlint.json"), "{}").unwrap();
1772 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1773 assert!(detect_shadowed_configs(tmp.path()).is_none());
1774 }
1775
1776 #[test]
1777 fn configs_returned_in_precedence_order() {
1778 let tmp = tempdir().unwrap();
1779 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1780 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1781 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1782 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1783 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1784
1785 assert_eq!(
1786 names(&rumdl_configs_in_dir(tmp.path())),
1787 vec![".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]
1788 );
1789 }
1790
1791 #[test]
1792 fn warning_names_dir_once_with_relative_filenames() {
1793 let tmp = tempdir().unwrap();
1794 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1795 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1796 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1797
1798 let shadow = detect_shadowed_configs(tmp.path()).unwrap();
1799 let msg = format_shadow_warning(&shadow);
1800
1801 let dir = {
1802 let s = tmp.path().to_string_lossy().into_owned();
1803 if cfg!(windows) { s.replace('\\', "/") } else { s }
1804 };
1805 assert!(msg.contains("multiple rumdl config files"), "got: {msg}");
1806 assert_eq!(
1809 msg.matches(dir.as_str()).count(),
1810 1,
1811 "directory should appear exactly once, got: {msg}"
1812 );
1813 assert!(
1814 msg.contains("using .rumdl.toml, ignoring rumdl.toml, pyproject.toml"),
1815 "winner and shadowed files should be relative names in precedence order, got: {msg}"
1816 );
1817 assert!(!msg.contains('\\'), "paths must be normalized to '/': {msg}");
1819 }
1820 }
1821}