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, DiscoveredConfigError, GlobalConfig, MARKDOWNLINT_CONFIG_FILES, RUMDL_CONFIG_FILES, RuleConfig,
16};
17use super::validation::validate_config_sourced_internal;
18use crate::utils::upward_walk::UpwardWalk;
19
20const MAX_EXTENDS_DEPTH: usize = 10;
22
23fn pyproject_declares_rumdl_config(content: &str) -> bool {
32 content.contains("[tool.rumdl]") || content.contains("[tool.rumdl.")
33}
34
35fn is_var_name_start(b: u8) -> bool {
37 b == b'_' || b.is_ascii_alphabetic()
38}
39
40fn is_var_name_continue(b: u8) -> bool {
42 b == b'_' || b.is_ascii_alphanumeric()
43}
44
45fn is_valid_var_name(name: &str) -> bool {
47 let bytes = name.as_bytes();
48 !bytes.is_empty() && is_var_name_start(bytes[0]) && bytes[1..].iter().all(|&b| is_var_name_continue(b))
49}
50
51fn expand_env_vars(input: &str, lookup: impl Fn(&str) -> Option<String>) -> Result<String, String> {
69 let bytes = input.as_bytes();
70 let mut out = String::with_capacity(input.len());
71 let mut i = 0;
72
73 while i < bytes.len() {
74 if bytes[i] != b'$' {
75 let start = i;
77 while i < bytes.len() && bytes[i] != b'$' {
78 i += 1;
79 }
80 out.push_str(&input[start..i]);
81 continue;
82 }
83
84 match bytes.get(i + 1).copied() {
85 Some(b'$') => {
87 out.push('$');
88 i += 2;
89 }
90 Some(b'{') => {
92 if let Some(rel) = input[i + 2..].find('}') {
93 let close = i + 2 + rel;
94 let name = &input[i + 2..close];
95 if is_valid_var_name(name) {
96 match lookup(name) {
97 Some(value) => out.push_str(&value),
98 None => return Err(name.to_string()),
99 }
100 } else {
101 out.push_str(&input[i..=close]);
103 }
104 i = close + 1;
105 } else {
106 out.push('$');
108 i += 1;
109 }
110 }
111 Some(b) if is_var_name_start(b) => {
113 let start = i + 1;
114 let mut j = start;
115 while j < bytes.len() && is_var_name_continue(bytes[j]) {
116 j += 1;
117 }
118 let name = &input[start..j];
119 match lookup(name) {
120 Some(value) => out.push_str(&value),
121 None => return Err(name.to_string()),
122 }
123 i = j;
124 }
125 _ => {
127 out.push('$');
128 i += 1;
129 }
130 }
131 }
132
133 Ok(out)
134}
135
136fn resolve_extends_path(extends_value: &str, config_file_path: &Path) -> Result<PathBuf, ConfigError> {
143 let expanded = expand_env_vars(extends_value, |key| std::env::var(key).ok()).map_err(|var| {
144 ConfigError::ExtendsUndefinedVar {
145 var,
146 from: config_file_path.display().to_string(),
147 }
148 })?;
149
150 if let Some(suffix) = expanded.strip_prefix("~/") {
151 #[cfg(feature = "native")]
153 {
154 use etcetera::{BaseStrategy, choose_base_strategy};
155 let home = choose_base_strategy().map_or_else(|_| PathBuf::from("~"), |s| s.home_dir().to_path_buf());
156 Ok(home.join(suffix))
157 }
158 #[cfg(not(feature = "native"))]
159 {
160 let _ = suffix;
161 Ok(PathBuf::from(expanded))
162 }
163 } else {
164 let path = PathBuf::from(&expanded);
165 if path.is_absolute() {
166 Ok(path)
167 } else {
168 let config_dir = config_file_path.parent().unwrap_or(Path::new("."));
170 Ok(config_dir.join(&expanded))
171 }
172 }
173}
174
175fn source_from_filename(filename: &str) -> ConfigSource {
177 if filename == "pyproject.toml" {
178 ConfigSource::PyprojectToml
179 } else {
180 ConfigSource::ProjectConfig
181 }
182}
183
184pub(crate) fn rumdl_configs_in_dir(dir: &Path) -> Vec<PathBuf> {
192 RUMDL_CONFIG_FILES
193 .iter()
194 .map(|name| dir.join(name))
195 .filter(|path| {
196 if !path.exists() {
197 return false;
198 }
199 if path.file_name().and_then(|n| n.to_str()) == Some("pyproject.toml") {
200 std::fs::read_to_string(path).is_ok_and(|content| pyproject_declares_rumdl_config(&content))
201 } else {
202 true
203 }
204 })
205 .collect()
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
217pub(crate) struct ShadowedConfigs {
218 pub dir: PathBuf,
219 pub winner: PathBuf,
220 pub shadowed: Vec<PathBuf>,
221}
222
223pub(crate) fn detect_shadowed_configs(dir: &Path) -> Option<ShadowedConfigs> {
229 let mut configs = rumdl_configs_in_dir(dir);
230 if configs.len() < 2 {
231 return None;
232 }
233 let winner = configs.remove(0);
234 Some(ShadowedConfigs {
235 dir: dir.to_path_buf(),
236 winner,
237 shadowed: configs,
238 })
239}
240
241pub(crate) fn format_shadow_warning(shadow: &ShadowedConfigs) -> String {
249 let norm = |s: String| if cfg!(windows) { s.replace('\\', "/") } else { s };
250 let rel = |path: &Path| {
251 let relative = path.strip_prefix(&shadow.dir).unwrap_or(path);
252 norm(relative.to_string_lossy().into_owned())
253 };
254 let shadowed = shadow.shadowed.iter().map(|p| rel(p)).collect::<Vec<_>>().join(", ");
255 format!(
256 "multiple rumdl config files in {}: using {}, ignoring {}",
257 norm(shadow.dir.to_string_lossy().into_owned()),
258 rel(&shadow.winner),
259 shadowed,
260 )
261}
262
263fn load_config_with_extends(
270 sourced_config: &mut SourcedConfig<ConfigLoaded>,
271 config_file_path: &Path,
272 visited: &mut IndexSet<PathBuf>,
273 chain_source: ConfigSource,
274) -> Result<(), ConfigError> {
275 let canonical = config_file_path
277 .canonicalize()
278 .unwrap_or_else(|_| config_file_path.to_path_buf());
279
280 if visited.contains(&canonical) {
282 let chain: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
283 return Err(ConfigError::CircularExtends {
284 path: config_file_path.display().to_string(),
285 chain,
286 });
287 }
288
289 if visited.len() >= MAX_EXTENDS_DEPTH {
291 return Err(ConfigError::ExtendsDepthExceeded {
292 path: config_file_path.display().to_string(),
293 max_depth: MAX_EXTENDS_DEPTH,
294 });
295 }
296
297 visited.insert(canonical);
299
300 let path_str = config_file_path.display().to_string();
301 let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
302
303 let content = std::fs::read_to_string(config_file_path).map_err(|e| ConfigError::IoError {
305 source: e,
306 path: path_str.clone(),
307 })?;
308
309 let fragment = if filename == "pyproject.toml" {
310 match parsers::parse_pyproject_toml(&content, &path_str, chain_source)? {
311 Some(f) => f,
312 None => return Ok(()), }
314 } else {
315 parsers::parse_rumdl_toml(&content, &path_str, chain_source)?
316 };
317
318 if let Some(ref extends_value) = fragment.extends {
320 let base_path = resolve_extends_path(extends_value, config_file_path)?;
321
322 if !base_path.exists() {
323 return Err(ConfigError::ExtendsNotFound {
324 path: base_path.display().to_string(),
325 from: path_str.clone(),
326 });
327 }
328
329 log::debug!(
330 "[rumdl-config] Config {} extends {}, loading base first",
331 path_str,
332 base_path.display()
333 );
334
335 load_config_with_extends(sourced_config, &base_path, visited, chain_source)?;
337 }
338
339 let mut fragment_for_merge = fragment;
342 fragment_for_merge.extends = None;
343 sourced_config.merge(fragment_for_merge);
344 sourced_config.loaded_files.push(path_str);
345
346 Ok(())
347}
348
349impl SourcedConfig<ConfigLoaded> {
350 pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
353 self.global.enable.merge_from(fragment.global.enable);
358 self.global.disable.merge_from(fragment.global.disable);
359 self.global
360 .extend_enable
361 .merge_union_from(fragment.global.extend_enable);
362 self.global
363 .extend_disable
364 .merge_union_from(fragment.global.extend_disable);
365
366 self.global
369 .disable
370 .value
371 .retain(|rule| !self.global.enable.value.contains(rule));
372
373 self.global.include.merge_from(fragment.global.include);
374 self.global.exclude.merge_from(fragment.global.exclude);
375 self.global
376 .respect_gitignore
377 .merge_from(fragment.global.respect_gitignore);
378 self.global.line_length.merge_from(fragment.global.line_length);
379 self.global.fixable.merge_from(fragment.global.fixable);
380 self.global.unfixable.merge_from(fragment.global.unfixable);
381 self.global.flavor.merge_from(fragment.global.flavor);
382 self.global.force_exclude.merge_from(fragment.global.force_exclude);
383 self.global.editorconfig.merge_from(fragment.global.editorconfig);
384
385 if let Some(output_format_fragment) = fragment.global.output_format {
387 if let Some(ref mut output_format) = self.global.output_format {
388 output_format.merge_from(output_format_fragment);
389 } else {
390 self.global.output_format = Some(output_format_fragment);
391 }
392 }
393
394 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
396 if let Some(ref mut cache_dir) = self.global.cache_dir {
397 cache_dir.merge_from(cache_dir_fragment);
398 } else {
399 self.global.cache_dir = Some(cache_dir_fragment);
400 }
401 }
402
403 if fragment.global.cache.source != ConfigSource::Default {
405 self.global.cache.merge_from(fragment.global.cache);
406 }
407
408 self.per_file_ignores.merge_from(fragment.per_file_ignores);
409 self.per_file_flavor.merge_from(fragment.per_file_flavor);
410 self.code_block_tools.merge_from(fragment.code_block_tools);
411
412 for (rule_name, rule_fragment) in fragment.rules {
414 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
416
417 if let Some(severity_fragment) = rule_fragment.severity {
419 if let Some(ref mut existing_severity) = rule_entry.severity {
420 existing_severity.merge_from(severity_fragment);
421 } else {
422 rule_entry.severity = Some(severity_fragment);
423 }
424 }
425
426 for (key, sourced_value_fragment) in rule_fragment.values {
428 let sv_entry = rule_entry
429 .values
430 .entry(key.clone())
431 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
432 sv_entry.merge_from(sourced_value_fragment);
433 }
434 }
435
436 for (section, key, file_path) in fragment.unknown_keys {
438 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
440 self.unknown_keys.push((section, key, file_path));
441 }
442 }
443 }
444
445 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
447 Self::load_with_discovery(config_path, cli_overrides, false)
448 }
449
450 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
453 UpwardWalk::new(start_dir)
454 .find(|dir| dir.join(".git").exists())
455 .unwrap_or_else(|| {
456 log::debug!(
457 "[rumdl-config] No .git found, using config location as project root: {}",
458 start_dir.display()
459 );
460 start_dir.to_path_buf()
461 })
462 }
463
464 fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
470 home_override.map(Path::to_path_buf).or_else(|| {
471 #[cfg(feature = "native")]
472 {
473 use etcetera::{BaseStrategy, choose_base_strategy};
474 choose_base_strategy().ok().map(|s| s.home_dir().to_path_buf())
475 }
476 #[cfg(not(feature = "native"))]
477 {
478 None
479 }
480 })
481 }
482
483 fn resolve_discovery_start(start_override: Option<&Path>) -> Option<std::path::PathBuf> {
489 if let Some(dir) = start_override {
490 return Some(dir.to_path_buf());
491 }
492 match std::env::current_dir() {
493 Ok(dir) => Some(dir),
494 Err(e) => {
495 log::debug!("[rumdl-config] Failed to get current directory: {e}");
496 None
497 }
498 }
499 }
500
501 fn discover_config_upward(
517 start_override: Option<&Path>,
518 home_override: Option<&Path>,
519 ) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
520 let start_dir = Self::resolve_discovery_start(start_override)?;
521
522 let (config_path, config_dir, shadow) = UpwardWalk::new(&start_dir)
526 .stop_below(Self::resolve_home_boundary(home_override))
527 .always_yield_start()
528 .stop_at_git_root()
529 .find_map(|dir| {
530 rumdl_configs_in_dir(&dir).into_iter().next().map(|winner| {
531 log::debug!("[rumdl-config] Found config file: {}", winner.display());
532 let shadow = detect_shadowed_configs(&dir);
533 (winner, dir, shadow)
534 })
535 })?;
536
537 let project_root = Self::find_project_root_from(&config_dir);
539 Some((config_path, project_root, shadow))
540 }
541
542 fn discover_markdownlint_config_upward(
550 start_override: Option<&Path>,
551 home_override: Option<&Path>,
552 ) -> Option<std::path::PathBuf> {
553 let start_dir = Self::resolve_discovery_start(start_override)?;
554
555 UpwardWalk::new(&start_dir)
556 .stop_below(Self::resolve_home_boundary(home_override))
557 .always_yield_start()
558 .stop_at_git_root()
559 .find_map(|dir| {
560 MARKDOWNLINT_CONFIG_FILES
561 .iter()
562 .map(|name| dir.join(name))
563 .find(|path| path.exists())
564 })
565 }
566
567 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
569 let config_dir = config_dir.join("rumdl");
570
571 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
573
574 log::debug!(
575 "[rumdl-config] Checking for user configuration in: {}",
576 config_dir.display()
577 );
578
579 for filename in USER_CONFIG_FILES {
580 let config_path = config_dir.join(filename);
581
582 if config_path.exists() {
583 if *filename == "pyproject.toml" {
585 if let Ok(content) = std::fs::read_to_string(&config_path) {
586 if pyproject_declares_rumdl_config(&content) {
587 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
588 return Some(config_path);
589 }
590 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
591 continue;
592 }
593 } else {
594 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
595 return Some(config_path);
596 }
597 }
598 }
599
600 log::debug!(
601 "[rumdl-config] No user configuration found in: {}",
602 config_dir.display()
603 );
604 None
605 }
606
607 #[cfg(feature = "native")]
610 fn user_configuration_path() -> Option<std::path::PathBuf> {
611 use etcetera::{BaseStrategy, choose_base_strategy};
612
613 match choose_base_strategy() {
614 Ok(strategy) => {
615 let config_dir = strategy.config_dir();
616 Self::user_configuration_path_impl(&config_dir)
617 }
618 Err(e) => {
619 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
620 None
621 }
622 }
623 }
624
625 #[cfg(not(feature = "native"))]
627 fn user_configuration_path() -> Option<std::path::PathBuf> {
628 None
629 }
630
631 fn home_configuration_path_impl(home_dir: &Path) -> Option<std::path::PathBuf> {
643 const HOME_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml"];
644
645 log::debug!(
646 "[rumdl-config] Checking for home-directory configuration in: {}",
647 home_dir.display()
648 );
649
650 for filename in HOME_CONFIG_FILES {
651 let config_path = home_dir.join(filename);
652 if config_path.exists() {
653 log::debug!(
654 "[rumdl-config] Found home-directory configuration at: {}",
655 config_path.display()
656 );
657 return Some(config_path);
658 }
659 }
660
661 log::debug!(
662 "[rumdl-config] No home-directory configuration found in: {}",
663 home_dir.display()
664 );
665 None
666 }
667
668 #[cfg(feature = "native")]
674 fn home_configuration_path() -> Option<std::path::PathBuf> {
675 use etcetera::{BaseStrategy, choose_base_strategy};
676
677 match choose_base_strategy() {
678 Ok(strategy) => Self::home_configuration_path_impl(strategy.home_dir()),
679 Err(e) => {
680 log::debug!("[rumdl-config] Failed to determine home directory: {e}");
681 None
682 }
683 }
684 }
685
686 #[cfg(not(feature = "native"))]
688 fn home_configuration_path() -> Option<std::path::PathBuf> {
689 None
690 }
691
692 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
694 let path_obj = Path::new(path);
695 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
696 let path_str = path.to_string();
697
698 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
699
700 if let Some(config_parent) = path_obj.parent() {
702 let project_root = Self::find_project_root_from(config_parent);
703 log::debug!(
704 "[rumdl-config] Project root (from explicit config): {}",
705 project_root.display()
706 );
707 sourced_config.project_root = Some(project_root);
708 }
709
710 const MARKDOWNLINT_FILENAMES: &[&str] = &[
712 ".markdownlint-cli2.jsonc",
713 ".markdownlint-cli2.yaml",
714 ".markdownlint-cli2.yml",
715 ".markdownlint.json",
716 ".markdownlint.yaml",
717 ".markdownlint.yml",
718 ];
719
720 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
721 let mut visited = IndexSet::new();
723 let chain_source = source_from_filename(filename);
724 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
725 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
726 || path_str.ends_with(".json")
727 || path_str.ends_with(".jsonc")
728 || path_str.ends_with(".yaml")
729 || path_str.ends_with(".yml")
730 {
731 let fragment = parsers::load_from_markdownlint(&path_str)?;
733 sourced_config.merge(fragment);
734 sourced_config.loaded_files.push(path_str);
735 } else {
736 let mut visited = IndexSet::new();
738 let chain_source = source_from_filename(filename);
739 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
740 }
741
742 Ok(())
743 }
744
745 fn load_user_config(
764 sourced_config: &mut Self,
765 user_config_dir: Option<&Path>,
766 home_dir: Option<&Path>,
767 ) -> Result<(), ConfigError> {
768 let user_config_path = if let Some(dir) = user_config_dir {
769 Self::user_configuration_path_impl(dir)
770 } else {
771 Self::user_configuration_path()
772 };
773
774 let user_config_path = user_config_path.or_else(|| match home_dir {
775 Some(home) => Self::home_configuration_path_impl(home),
776 None => Self::home_configuration_path(),
777 });
778
779 if let Some(user_config_path) = user_config_path {
780 let path_str = user_config_path.display().to_string();
781
782 log::debug!("[rumdl-config] Loading user config: {path_str}");
783
784 let mut visited = IndexSet::new();
787 load_config_with_extends(
788 sourced_config,
789 &user_config_path,
790 &mut visited,
791 ConfigSource::UserConfig,
792 )?;
793 } else {
794 log::debug!("[rumdl-config] No user configuration file found");
795 }
796
797 Ok(())
798 }
799
800 fn load_discovered_config(
818 sourced_config: &mut Self,
819 config_file: &Path,
820 user_config_dir: Option<&Path>,
821 home_dir: Option<&Path>,
822 ) -> Result<(), DiscoveredConfigError> {
823 let filename = config_file.file_name().and_then(|name| name.to_str()).unwrap_or("");
824
825 if MARKDOWNLINT_CONFIG_FILES.contains(&filename) {
826 Self::load_user_config(sourced_config, user_config_dir, home_dir)
827 .map_err(DiscoveredConfigError::UserConfig)?;
828
829 let path_str = config_file.display().to_string();
830 let fragment = parsers::load_from_markdownlint(&path_str).map_err(DiscoveredConfigError::ProjectConfig)?;
831 sourced_config.merge(fragment);
832 sourced_config.loaded_files.push(path_str);
833 } else {
834 let mut visited = IndexSet::new();
835 let chain_source = source_from_filename(filename);
836 load_config_with_extends(sourced_config, config_file, &mut visited, chain_source)
837 .map_err(DiscoveredConfigError::ProjectConfig)?;
838 }
839
840 Ok(())
841 }
842
843 pub fn load_discovered(
861 config_file: &Path,
862 user_config_dir: Option<&Path>,
863 home_dir: Option<&Path>,
864 ) -> Result<Self, DiscoveredConfigError> {
865 let mut sourced_config = SourcedConfig::default();
866
867 if let Some(config_parent) = config_file.parent() {
868 sourced_config.project_root = Some(Self::find_project_root_from(config_parent));
869 }
870
871 Self::load_discovered_config(&mut sourced_config, config_file, user_config_dir, home_dir)?;
872
873 Ok(sourced_config)
874 }
875
876 pub fn load_for_workspace(
889 start_dir: &Path,
890 config_path: Option<&str>,
891 user_config_dir: Option<&Path>,
892 home_dir: Option<&Path>,
893 ) -> Result<Self, ConfigError> {
894 Self::load_with_discovery_from(Some(start_dir), config_path, None, false, user_config_dir, home_dir)
895 }
896
897 #[doc(hidden)]
899 pub fn load_with_discovery_impl(
900 config_path: Option<&str>,
901 cli_overrides: Option<&SourcedGlobalConfig>,
902 skip_auto_discovery: bool,
903 user_config_dir: Option<&Path>,
904 home_dir: Option<&Path>,
905 ) -> Result<Self, ConfigError> {
906 Self::load_with_discovery_from(
907 None,
908 config_path,
909 cli_overrides,
910 skip_auto_discovery,
911 user_config_dir,
912 home_dir,
913 )
914 }
915
916 fn load_with_discovery_from(
921 start_dir: Option<&Path>,
922 config_path: Option<&str>,
923 cli_overrides: Option<&SourcedGlobalConfig>,
924 skip_auto_discovery: bool,
925 user_config_dir: Option<&Path>,
926 home_dir: Option<&Path>,
927 ) -> Result<Self, ConfigError> {
928 use std::env;
929 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
930
931 let mut sourced_config = SourcedConfig::default();
932
933 if let Some(path) = config_path {
946 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
948 Self::load_explicit_config(&mut sourced_config, path)?;
949 } else if skip_auto_discovery {
950 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
951 } else {
953 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
955
956 if let Some((config_file, project_root, shadow)) = Self::discover_config_upward(start_dir, home_dir) {
958 log::debug!("[rumdl-config] Found project config: {}", config_file.display());
962 log::debug!("[rumdl-config] Project root: {}", project_root.display());
963
964 if let Some(shadow) = shadow {
967 sourced_config.discovery_warnings.push(format_shadow_warning(&shadow));
968 }
969
970 sourced_config.project_root = Some(project_root);
971
972 Self::load_discovered_config(&mut sourced_config, &config_file, user_config_dir, home_dir)?;
973 } else {
974 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
976
977 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward(start_dir, home_dir) {
978 log::debug!(
979 "[rumdl-config] Found markdownlint config: {}",
980 markdownlint_path.display()
981 );
982
983 if let Err(e) =
984 Self::load_discovered_config(&mut sourced_config, &markdownlint_path, user_config_dir, home_dir)
985 {
986 match e {
987 DiscoveredConfigError::ProjectConfig(e) => {
993 log::debug!("[rumdl-config] Failed to load markdownlint config: {e}");
994 }
995 DiscoveredConfigError::UserConfig(e) => return Err(e),
997 }
998 }
999 } else {
1000 log::debug!("[rumdl-config] No project config found, using user config as fallback");
1002 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
1003 }
1004 }
1005 }
1006
1007 if let Some(cli) = cli_overrides {
1009 sourced_config
1010 .global
1011 .enable
1012 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None);
1013 sourced_config
1014 .global
1015 .disable
1016 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None);
1017 sourced_config
1018 .global
1019 .exclude
1020 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None);
1021 sourced_config
1022 .global
1023 .include
1024 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None);
1025 sourced_config.global.respect_gitignore.merge_override(
1026 cli.respect_gitignore.value,
1027 ConfigSource::Cli,
1028 None,
1029 );
1030 sourced_config
1031 .global
1032 .fixable
1033 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None);
1034 sourced_config
1035 .global
1036 .unfixable
1037 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None);
1038 }
1040
1041 Ok(sourced_config)
1044 }
1045
1046 pub fn load_with_discovery(
1049 config_path: Option<&str>,
1050 cli_overrides: Option<&SourcedGlobalConfig>,
1051 skip_auto_discovery: bool,
1052 ) -> Result<Self, ConfigError> {
1053 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
1054 }
1055
1056 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
1070 let warnings = validate_config_sourced_internal(&self, registry);
1071
1072 Ok(SourcedConfig {
1073 global: self.global,
1074 per_file_ignores: self.per_file_ignores,
1075 per_file_flavor: self.per_file_flavor,
1076 code_block_tools: self.code_block_tools,
1077 rules: self.rules,
1078 loaded_files: self.loaded_files,
1079 unknown_keys: self.unknown_keys,
1080 project_root: self.project_root,
1081 discovery_warnings: self.discovery_warnings,
1082 validation_warnings: warnings,
1083 _state: PhantomData,
1084 })
1085 }
1086
1087 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
1092 let validated = self.validate(registry)?;
1093 let warnings = validated.validation_warnings.clone();
1094 Ok((validated.into(), warnings))
1095 }
1096
1097 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
1108 SourcedConfig {
1109 global: self.global,
1110 per_file_ignores: self.per_file_ignores,
1111 per_file_flavor: self.per_file_flavor,
1112 code_block_tools: self.code_block_tools,
1113 rules: self.rules,
1114 loaded_files: self.loaded_files,
1115 unknown_keys: self.unknown_keys,
1116 project_root: self.project_root,
1117 discovery_warnings: self.discovery_warnings,
1118 validation_warnings: Vec::new(),
1119 _state: PhantomData,
1120 }
1121 }
1122
1123 pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
1132 UpwardWalk::new(dir)
1144 .stop_below(Self::resolve_home_boundary(None))
1145 .stop_at(project_root)
1146 .find_map(|current| {
1147 for config_name in RUMDL_CONFIG_FILES {
1149 let config_path = current.join(config_name);
1150 if config_path.exists() {
1151 if *config_name == "pyproject.toml" {
1152 if let Ok(content) = std::fs::read_to_string(&config_path)
1153 && pyproject_declares_rumdl_config(&content)
1154 {
1155 return Some(config_path);
1156 }
1157 continue;
1158 }
1159 return Some(config_path);
1160 }
1161 }
1162
1163 MARKDOWNLINT_CONFIG_FILES
1165 .iter()
1166 .map(|name| current.join(name))
1167 .find(|path| path.exists())
1168 })
1169 }
1170
1171 pub fn load_sourced_for_path(
1178 config_path: &Path,
1179 project_root: &Path,
1180 ) -> Result<SourcedConfig<ConfigLoaded>, ConfigError> {
1181 let mut sourced_config = SourcedConfig {
1182 project_root: Some(project_root.to_path_buf()),
1183 ..SourcedConfig::default()
1184 };
1185
1186 let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1187 let path_str = config_path.display().to_string();
1188
1189 let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
1191 || (filename != "pyproject.toml"
1192 && filename != ".rumdl.toml"
1193 && filename != "rumdl.toml"
1194 && (path_str.ends_with(".json")
1195 || path_str.ends_with(".jsonc")
1196 || path_str.ends_with(".yaml")
1197 || path_str.ends_with(".yml")));
1198
1199 if is_markdownlint {
1200 let fragment = parsers::load_from_markdownlint(&path_str)?;
1201 sourced_config.merge(fragment);
1202 sourced_config.loaded_files.push(path_str);
1203 } else {
1204 let mut visited = IndexSet::new();
1205 let chain_source = source_from_filename(filename);
1206 load_config_with_extends(&mut sourced_config, config_path, &mut visited, chain_source)?;
1207 }
1208
1209 Ok(sourced_config)
1210 }
1211
1212 pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
1216 Ok(Self::load_sourced_for_path(config_path, project_root)?
1217 .into_validated_unchecked()
1218 .into())
1219 }
1220}
1221
1222impl From<SourcedConfig<ConfigValidated>> for Config {
1227 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
1228 let mut rules = BTreeMap::new();
1229 for (rule_name, sourced_rule_cfg) in sourced.rules {
1230 let normalized_rule_name = rule_name.to_ascii_uppercase();
1232 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
1233 let mut values = BTreeMap::new();
1234 for (key, sourced_val) in sourced_rule_cfg.values {
1235 values.insert(key, sourced_val.value);
1236 }
1237 rules.insert(normalized_rule_name, RuleConfig { severity, values });
1238 }
1239 let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
1241
1242 #[allow(deprecated)]
1243 let global = GlobalConfig {
1244 enable: sourced.global.enable.value,
1245 disable: sourced.global.disable.value,
1246 exclude: sourced.global.exclude.value,
1247 include: sourced.global.include.value,
1248 respect_gitignore: sourced.global.respect_gitignore.value,
1249 line_length: sourced.global.line_length.value,
1250 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1251 fixable: sourced.global.fixable.value,
1252 unfixable: sourced.global.unfixable.value,
1253 flavor: sourced.global.flavor.value,
1254 force_exclude: sourced.global.force_exclude.value,
1255 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
1256 cache: sourced.global.cache.value,
1257 extend_enable: sourced.global.extend_enable.value,
1258 extend_disable: sourced.global.extend_disable.value,
1259 editorconfig: sourced.global.editorconfig.value,
1260 enable_is_explicit,
1261 };
1262
1263 let mut config = Config {
1264 extends: None,
1265 global,
1266 per_file_ignores: sourced.per_file_ignores.value,
1267 per_file_flavor: sourced.per_file_flavor.value,
1268 code_block_tools: sourced.code_block_tools.value,
1269 rules,
1270 project_root: sourced.project_root,
1271 per_file_ignores_cache: Arc::new(OnceLock::new()),
1272 per_file_flavor_cache: Arc::new(OnceLock::new()),
1273 canonical_project_root_cache: Arc::new(OnceLock::new()),
1274 };
1275
1276 config.apply_per_rule_enabled();
1278
1279 config.canonicalize_rule_lists();
1286
1287 config
1288 }
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::pyproject_declares_rumdl_config;
1294
1295 #[test]
1296 fn detects_flat_and_dotted_rumdl_sections() {
1297 assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
1298 assert!(pyproject_declares_rumdl_config(
1300 "[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
1301 ));
1302 assert!(pyproject_declares_rumdl_config(
1303 "[tool.rumdl.rules.MD007]\nindent = 4\n"
1304 ));
1305 }
1306
1307 #[test]
1308 fn ignores_incidental_mentions() {
1309 assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
1312 assert!(!pyproject_declares_rumdl_config(
1313 "[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
1314 ));
1315 assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
1316 }
1317
1318 mod expand_env_vars {
1321 use super::super::expand_env_vars;
1322 use std::collections::HashMap;
1323
1324 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
1326 let map: HashMap<String, String> = pairs
1327 .iter()
1328 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1329 .collect();
1330 move |k: &str| map.get(k).cloned()
1331 }
1332
1333 #[test]
1334 fn expands_bare_and_braced_forms() {
1335 let e = env(&[("VAR", "val"), ("FOO_BAR", "fb")]);
1336 assert_eq!(expand_env_vars("$VAR", &e).unwrap(), "val");
1337 assert_eq!(expand_env_vars("${VAR}", &e).unwrap(), "val");
1338 assert_eq!(expand_env_vars("$FOO_BAR", &e).unwrap(), "fb");
1340 }
1341
1342 #[test]
1343 fn expands_within_paths() {
1344 let e = env(&[("BASE", "/opt/cfg"), ("A", "x"), ("B", "y")]);
1345 assert_eq!(expand_env_vars("$BASE/x/y.toml", &e).unwrap(), "/opt/cfg/x/y.toml");
1346 assert_eq!(expand_env_vars("$A/$B", &e).unwrap(), "x/y");
1347 assert_eq!(expand_env_vars("${A}suffix", &e).unwrap(), "xsuffix");
1348 }
1349
1350 #[test]
1351 fn dollar_dollar_is_a_literal_dollar() {
1352 let e = env(&[("VAR", "val")]);
1353 assert_eq!(expand_env_vars("$$", &e).unwrap(), "$");
1354 assert_eq!(expand_env_vars("$$VAR", &e).unwrap(), "$VAR");
1356 assert_eq!(expand_env_vars("$${VAR}", &e).unwrap(), "${VAR}");
1357 assert_eq!(expand_env_vars("file-$$name.toml", &e).unwrap(), "file-$name.toml");
1359 }
1360
1361 #[test]
1362 fn bare_dollar_name_in_path_is_a_variable_reference() {
1363 let e = env(&[("name", "core")]);
1366 assert_eq!(expand_env_vars("file-$name.toml", &e).unwrap(), "file-core.toml");
1367 }
1368
1369 #[test]
1370 fn incidental_dollar_stays_literal() {
1371 let e = env(&[]);
1372 assert_eq!(expand_env_vars("$5", &e).unwrap(), "$5");
1374 assert_eq!(expand_env_vars("cost$", &e).unwrap(), "cost$");
1375 assert_eq!(expand_env_vars("a$/b", &e).unwrap(), "a$/b");
1376 }
1377
1378 #[test]
1379 fn malformed_braces_stay_literal() {
1380 let e = env(&[("B", "x")]);
1381 assert_eq!(expand_env_vars("${}", &e).unwrap(), "${}");
1382 assert_eq!(expand_env_vars("${VAR", &e).unwrap(), "${VAR");
1383 assert_eq!(expand_env_vars("${A${B}}", &e).unwrap(), "${A${B}}");
1385 }
1386
1387 #[test]
1388 fn undefined_variable_is_an_error() {
1389 let e = env(&[]);
1390 assert_eq!(expand_env_vars("$NOPE", &e).unwrap_err(), "NOPE");
1391 assert_eq!(expand_env_vars("${NOPE}", &e).unwrap_err(), "NOPE");
1392 assert_eq!(expand_env_vars("prefix/$NOPE/x", &e).unwrap_err(), "NOPE");
1393 }
1394
1395 #[test]
1396 fn replacement_is_not_rescanned() {
1397 let e = env(&[("A", "$B"), ("B", "should-not-appear")]);
1399 assert_eq!(expand_env_vars("$A", &e).unwrap(), "$B");
1400 assert_eq!(expand_env_vars("${A}", &e).unwrap(), "$B");
1401 }
1402
1403 #[test]
1404 fn identifiers_are_ascii_only_unicode_stays_literal() {
1405 let e = env(&[("VAR", "v")]);
1406 assert_eq!(expand_env_vars("${föö}", &e).unwrap(), "${föö}");
1408 assert_eq!(expand_env_vars("$VARö", &e).unwrap(), "vö");
1410 assert_eq!(expand_env_vars("café/$VAR", &e).unwrap(), "café/v");
1412 }
1413
1414 #[test]
1415 fn passthrough_for_plain_input() {
1416 let e = env(&[]);
1417 assert_eq!(expand_env_vars("", &e).unwrap(), "");
1418 assert_eq!(expand_env_vars("/plain/path.toml", &e).unwrap(), "/plain/path.toml");
1419 }
1420 }
1421
1422 #[cfg(unix)]
1431 #[test]
1432 fn discover_stops_at_project_root_across_path_representations() {
1433 use super::SourcedConfig;
1434 use std::os::unix::fs::symlink;
1435 use tempfile::tempdir;
1436
1437 let tmp = tempdir().unwrap();
1438 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1440
1441 let real_root = tmp.path().join("project");
1442 let subdir = real_root.join("docs");
1443 std::fs::create_dir_all(&subdir).unwrap();
1444
1445 let linked_root = tmp.path().join("project-link");
1448 symlink(&real_root, &linked_root).unwrap();
1449
1450 let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
1451 assert_eq!(
1452 found, None,
1453 "discovery must stop at the project root, not overshoot to the parent config"
1454 );
1455 }
1456
1457 mod shadowed_configs {
1458 use super::super::{ShadowedConfigs, detect_shadowed_configs, format_shadow_warning, rumdl_configs_in_dir};
1459 use tempfile::tempdir;
1460
1461 fn names(paths: &[std::path::PathBuf]) -> Vec<String> {
1462 paths
1463 .iter()
1464 .map(|p| {
1465 let file = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
1468 let parent = p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str());
1469 match parent {
1470 Some(".config") => format!(".config/{file}"),
1471 _ => file.to_string(),
1472 }
1473 })
1474 .collect()
1475 }
1476
1477 #[test]
1478 fn empty_directory_has_no_configs_and_no_shadow() {
1479 let tmp = tempdir().unwrap();
1480 assert!(rumdl_configs_in_dir(tmp.path()).is_empty());
1481 assert!(detect_shadowed_configs(tmp.path()).is_none());
1482 }
1483
1484 #[test]
1485 fn single_config_does_not_shadow() {
1486 let tmp = tempdir().unwrap();
1487 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1488 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1489 assert!(detect_shadowed_configs(tmp.path()).is_none());
1490 }
1491
1492 #[test]
1493 fn dot_wins_over_non_dot_and_non_dot_is_shadowed() {
1494 let tmp = tempdir().unwrap();
1495 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1496 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1497
1498 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1499 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1500 assert_eq!(names(&shadowed), vec!["rumdl.toml"]);
1501 }
1502
1503 #[test]
1504 fn config_subdir_counts_as_same_level_shadow() {
1505 let tmp = tempdir().unwrap();
1506 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1507 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1508 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1509
1510 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1511 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1512 assert_eq!(names(&shadowed), vec![".config/rumdl.toml"]);
1513 }
1514
1515 #[test]
1516 fn pyproject_counts_only_when_it_declares_rumdl() {
1517 let bare = tempdir().unwrap();
1519 std::fs::write(bare.path().join(".rumdl.toml"), "").unwrap();
1520 std::fs::write(bare.path().join("pyproject.toml"), "[tool.black]\nline-length = 88\n").unwrap();
1521 assert_eq!(names(&rumdl_configs_in_dir(bare.path())), vec![".rumdl.toml"]);
1522 assert!(detect_shadowed_configs(bare.path()).is_none());
1523
1524 let declared = tempdir().unwrap();
1526 std::fs::write(declared.path().join(".rumdl.toml"), "").unwrap();
1527 std::fs::write(
1528 declared.path().join("pyproject.toml"),
1529 "[tool.rumdl]\nline-length = 80\n",
1530 )
1531 .unwrap();
1532 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(declared.path()).unwrap();
1533 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1534 assert_eq!(names(&shadowed), vec!["pyproject.toml"]);
1535 }
1536
1537 #[test]
1538 fn markdownlint_configs_are_not_rumdl_native_and_never_shadow() {
1539 let tmp = tempdir().unwrap();
1540 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1541 std::fs::write(tmp.path().join(".markdownlint.json"), "{}").unwrap();
1542 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1543 assert!(detect_shadowed_configs(tmp.path()).is_none());
1544 }
1545
1546 #[test]
1547 fn configs_returned_in_precedence_order() {
1548 let tmp = tempdir().unwrap();
1549 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1550 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1551 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1552 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1553 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1554
1555 assert_eq!(
1556 names(&rumdl_configs_in_dir(tmp.path())),
1557 vec![".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]
1558 );
1559 }
1560
1561 #[test]
1562 fn warning_names_dir_once_with_relative_filenames() {
1563 let tmp = tempdir().unwrap();
1564 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1565 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1566 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1567
1568 let shadow = detect_shadowed_configs(tmp.path()).unwrap();
1569 let msg = format_shadow_warning(&shadow);
1570
1571 let dir = {
1572 let s = tmp.path().to_string_lossy().into_owned();
1573 if cfg!(windows) { s.replace('\\', "/") } else { s }
1574 };
1575 assert!(msg.contains("multiple rumdl config files"), "got: {msg}");
1576 assert_eq!(
1579 msg.matches(dir.as_str()).count(),
1580 1,
1581 "directory should appear exactly once, got: {msg}"
1582 );
1583 assert!(
1584 msg.contains("using .rumdl.toml, ignoring rumdl.toml, pyproject.toml"),
1585 "winner and shadowed files should be relative names in precedence order, got: {msg}"
1586 );
1587 assert!(!msg.contains('\\'), "paths must be normalized to '/': {msg}");
1589 }
1590 }
1591}