1use indexmap::IndexSet;
2use std::collections::BTreeMap;
3use std::marker::PhantomData;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, OnceLock};
6
7use super::flavor::ConfigLoaded;
8use super::flavor::ConfigValidated;
9use super::parsers;
10use super::registry::RuleRegistry;
11use super::source_tracking::{
12 ConfigSource, ConfigValidationWarning, SourcedConfig, SourcedConfigFragment, SourcedGlobalConfig, SourcedValue,
13};
14use super::types::{Config, ConfigError, GlobalConfig, MARKDOWNLINT_CONFIG_FILES, RUMDL_CONFIG_FILES, RuleConfig};
15use super::validation::validate_config_sourced_internal;
16use crate::utils::upward_walk::UpwardWalk;
17
18const MAX_EXTENDS_DEPTH: usize = 10;
20
21fn pyproject_declares_rumdl_config(content: &str) -> bool {
30 content.contains("[tool.rumdl]") || content.contains("[tool.rumdl.")
31}
32
33fn is_var_name_start(b: u8) -> bool {
35 b == b'_' || b.is_ascii_alphabetic()
36}
37
38fn is_var_name_continue(b: u8) -> bool {
40 b == b'_' || b.is_ascii_alphanumeric()
41}
42
43fn is_valid_var_name(name: &str) -> bool {
45 let bytes = name.as_bytes();
46 !bytes.is_empty() && is_var_name_start(bytes[0]) && bytes[1..].iter().all(|&b| is_var_name_continue(b))
47}
48
49fn expand_env_vars(input: &str, lookup: impl Fn(&str) -> Option<String>) -> Result<String, String> {
67 let bytes = input.as_bytes();
68 let mut out = String::with_capacity(input.len());
69 let mut i = 0;
70
71 while i < bytes.len() {
72 if bytes[i] != b'$' {
73 let start = i;
75 while i < bytes.len() && bytes[i] != b'$' {
76 i += 1;
77 }
78 out.push_str(&input[start..i]);
79 continue;
80 }
81
82 match bytes.get(i + 1).copied() {
83 Some(b'$') => {
85 out.push('$');
86 i += 2;
87 }
88 Some(b'{') => {
90 if let Some(rel) = input[i + 2..].find('}') {
91 let close = i + 2 + rel;
92 let name = &input[i + 2..close];
93 if is_valid_var_name(name) {
94 match lookup(name) {
95 Some(value) => out.push_str(&value),
96 None => return Err(name.to_string()),
97 }
98 } else {
99 out.push_str(&input[i..=close]);
101 }
102 i = close + 1;
103 } else {
104 out.push('$');
106 i += 1;
107 }
108 }
109 Some(b) if is_var_name_start(b) => {
111 let start = i + 1;
112 let mut j = start;
113 while j < bytes.len() && is_var_name_continue(bytes[j]) {
114 j += 1;
115 }
116 let name = &input[start..j];
117 match lookup(name) {
118 Some(value) => out.push_str(&value),
119 None => return Err(name.to_string()),
120 }
121 i = j;
122 }
123 _ => {
125 out.push('$');
126 i += 1;
127 }
128 }
129 }
130
131 Ok(out)
132}
133
134fn resolve_extends_path(extends_value: &str, config_file_path: &Path) -> Result<PathBuf, ConfigError> {
141 let expanded = expand_env_vars(extends_value, |key| std::env::var(key).ok()).map_err(|var| {
142 ConfigError::ExtendsUndefinedVar {
143 var,
144 from: config_file_path.display().to_string(),
145 }
146 })?;
147
148 if let Some(suffix) = expanded.strip_prefix("~/") {
149 #[cfg(feature = "native")]
151 {
152 use etcetera::{BaseStrategy, choose_base_strategy};
153 let home = choose_base_strategy().map_or_else(|_| PathBuf::from("~"), |s| s.home_dir().to_path_buf());
154 Ok(home.join(suffix))
155 }
156 #[cfg(not(feature = "native"))]
157 {
158 let _ = suffix;
159 Ok(PathBuf::from(expanded))
160 }
161 } else {
162 let path = PathBuf::from(&expanded);
163 if path.is_absolute() {
164 Ok(path)
165 } else {
166 let config_dir = config_file_path.parent().unwrap_or(Path::new("."));
168 Ok(config_dir.join(&expanded))
169 }
170 }
171}
172
173fn source_from_filename(filename: &str) -> ConfigSource {
175 if filename == "pyproject.toml" {
176 ConfigSource::PyprojectToml
177 } else {
178 ConfigSource::ProjectConfig
179 }
180}
181
182pub(crate) fn rumdl_configs_in_dir(dir: &Path) -> Vec<PathBuf> {
190 RUMDL_CONFIG_FILES
191 .iter()
192 .map(|name| dir.join(name))
193 .filter(|path| {
194 if !path.exists() {
195 return false;
196 }
197 if path.file_name().and_then(|n| n.to_str()) == Some("pyproject.toml") {
198 std::fs::read_to_string(path).is_ok_and(|content| pyproject_declares_rumdl_config(&content))
199 } else {
200 true
201 }
202 })
203 .collect()
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
215pub(crate) struct ShadowedConfigs {
216 pub dir: PathBuf,
217 pub winner: PathBuf,
218 pub shadowed: Vec<PathBuf>,
219}
220
221pub(crate) fn detect_shadowed_configs(dir: &Path) -> Option<ShadowedConfigs> {
227 let mut configs = rumdl_configs_in_dir(dir);
228 if configs.len() < 2 {
229 return None;
230 }
231 let winner = configs.remove(0);
232 Some(ShadowedConfigs {
233 dir: dir.to_path_buf(),
234 winner,
235 shadowed: configs,
236 })
237}
238
239pub(crate) fn format_shadow_warning(shadow: &ShadowedConfigs) -> String {
247 let norm = |s: String| if cfg!(windows) { s.replace('\\', "/") } else { s };
248 let rel = |path: &Path| {
249 let relative = path.strip_prefix(&shadow.dir).unwrap_or(path);
250 norm(relative.to_string_lossy().into_owned())
251 };
252 let shadowed = shadow.shadowed.iter().map(|p| rel(p)).collect::<Vec<_>>().join(", ");
253 format!(
254 "multiple rumdl config files in {}: using {}, ignoring {}",
255 norm(shadow.dir.to_string_lossy().into_owned()),
256 rel(&shadow.winner),
257 shadowed,
258 )
259}
260
261fn load_config_with_extends(
268 sourced_config: &mut SourcedConfig<ConfigLoaded>,
269 config_file_path: &Path,
270 visited: &mut IndexSet<PathBuf>,
271 chain_source: ConfigSource,
272) -> Result<(), ConfigError> {
273 let canonical = config_file_path
275 .canonicalize()
276 .unwrap_or_else(|_| config_file_path.to_path_buf());
277
278 if visited.contains(&canonical) {
280 let chain: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
281 return Err(ConfigError::CircularExtends {
282 path: config_file_path.display().to_string(),
283 chain,
284 });
285 }
286
287 if visited.len() >= MAX_EXTENDS_DEPTH {
289 return Err(ConfigError::ExtendsDepthExceeded {
290 path: config_file_path.display().to_string(),
291 max_depth: MAX_EXTENDS_DEPTH,
292 });
293 }
294
295 visited.insert(canonical);
297
298 let path_str = config_file_path.display().to_string();
299 let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
300
301 let content = std::fs::read_to_string(config_file_path).map_err(|e| ConfigError::IoError {
303 source: e,
304 path: path_str.clone(),
305 })?;
306
307 let fragment = if filename == "pyproject.toml" {
308 match parsers::parse_pyproject_toml(&content, &path_str, chain_source)? {
309 Some(f) => f,
310 None => return Ok(()), }
312 } else {
313 parsers::parse_rumdl_toml(&content, &path_str, chain_source)?
314 };
315
316 if let Some(ref extends_value) = fragment.extends {
318 let base_path = resolve_extends_path(extends_value, config_file_path)?;
319
320 if !base_path.exists() {
321 return Err(ConfigError::ExtendsNotFound {
322 path: base_path.display().to_string(),
323 from: path_str.clone(),
324 });
325 }
326
327 log::debug!(
328 "[rumdl-config] Config {} extends {}, loading base first",
329 path_str,
330 base_path.display()
331 );
332
333 load_config_with_extends(sourced_config, &base_path, visited, chain_source)?;
335 }
336
337 let mut fragment_for_merge = fragment;
340 fragment_for_merge.extends = None;
341 sourced_config.merge(fragment_for_merge);
342 sourced_config.loaded_files.push(path_str);
343
344 Ok(())
345}
346
347impl SourcedConfig<ConfigLoaded> {
348 pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
351 self.global.enable.merge_from(fragment.global.enable);
356 self.global.disable.merge_from(fragment.global.disable);
357 self.global
358 .extend_enable
359 .merge_union_from(fragment.global.extend_enable);
360 self.global
361 .extend_disable
362 .merge_union_from(fragment.global.extend_disable);
363
364 self.global
367 .disable
368 .value
369 .retain(|rule| !self.global.enable.value.contains(rule));
370
371 self.global.include.merge_from(fragment.global.include);
372 self.global.exclude.merge_from(fragment.global.exclude);
373 self.global
374 .respect_gitignore
375 .merge_from(fragment.global.respect_gitignore);
376 self.global.line_length.merge_from(fragment.global.line_length);
377 self.global.fixable.merge_from(fragment.global.fixable);
378 self.global.unfixable.merge_from(fragment.global.unfixable);
379 self.global.flavor.merge_from(fragment.global.flavor);
380 self.global.force_exclude.merge_from(fragment.global.force_exclude);
381
382 if let Some(output_format_fragment) = fragment.global.output_format {
384 if let Some(ref mut output_format) = self.global.output_format {
385 output_format.merge_from(output_format_fragment);
386 } else {
387 self.global.output_format = Some(output_format_fragment);
388 }
389 }
390
391 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
393 if let Some(ref mut cache_dir) = self.global.cache_dir {
394 cache_dir.merge_from(cache_dir_fragment);
395 } else {
396 self.global.cache_dir = Some(cache_dir_fragment);
397 }
398 }
399
400 if fragment.global.cache.source != ConfigSource::Default {
402 self.global.cache.merge_from(fragment.global.cache);
403 }
404
405 self.per_file_ignores.merge_from(fragment.per_file_ignores);
406 self.per_file_flavor.merge_from(fragment.per_file_flavor);
407 self.code_block_tools.merge_from(fragment.code_block_tools);
408
409 for (rule_name, rule_fragment) in fragment.rules {
411 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
413
414 if let Some(severity_fragment) = rule_fragment.severity {
416 if let Some(ref mut existing_severity) = rule_entry.severity {
417 existing_severity.merge_from(severity_fragment);
418 } else {
419 rule_entry.severity = Some(severity_fragment);
420 }
421 }
422
423 for (key, sourced_value_fragment) in rule_fragment.values {
425 let sv_entry = rule_entry
426 .values
427 .entry(key.clone())
428 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
429 sv_entry.merge_from(sourced_value_fragment);
430 }
431 }
432
433 for (section, key, file_path) in fragment.unknown_keys {
435 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
437 self.unknown_keys.push((section, key, file_path));
438 }
439 }
440 }
441
442 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
444 Self::load_with_discovery(config_path, cli_overrides, false)
445 }
446
447 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
450 UpwardWalk::new(start_dir)
451 .find(|dir| dir.join(".git").exists())
452 .unwrap_or_else(|| {
453 log::debug!(
454 "[rumdl-config] No .git found, using config location as project root: {}",
455 start_dir.display()
456 );
457 start_dir.to_path_buf()
458 })
459 }
460
461 fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
467 home_override.map(Path::to_path_buf).or_else(|| {
468 #[cfg(feature = "native")]
469 {
470 use etcetera::{BaseStrategy, choose_base_strategy};
471 choose_base_strategy().ok().map(|s| s.home_dir().to_path_buf())
472 }
473 #[cfg(not(feature = "native"))]
474 {
475 None
476 }
477 })
478 }
479
480 fn discover_config_upward(
496 home_override: Option<&Path>,
497 ) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
498 let start_dir = match std::env::current_dir() {
499 Ok(dir) => dir,
500 Err(e) => {
501 log::debug!("[rumdl-config] Failed to get current directory: {e}");
502 return None;
503 }
504 };
505
506 let (config_path, config_dir, shadow) = UpwardWalk::new(&start_dir)
510 .stop_below(Self::resolve_home_boundary(home_override))
511 .always_yield_start()
512 .stop_at_git_root()
513 .find_map(|dir| {
514 rumdl_configs_in_dir(&dir).into_iter().next().map(|winner| {
515 log::debug!("[rumdl-config] Found config file: {}", winner.display());
516 let shadow = detect_shadowed_configs(&dir);
517 (winner, dir, shadow)
518 })
519 })?;
520
521 let project_root = Self::find_project_root_from(&config_dir);
523 Some((config_path, project_root, shadow))
524 }
525
526 fn discover_markdownlint_config_upward(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
534 let start_dir = match std::env::current_dir() {
535 Ok(dir) => dir,
536 Err(e) => {
537 log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
538 return None;
539 }
540 };
541
542 UpwardWalk::new(&start_dir)
543 .stop_below(Self::resolve_home_boundary(home_override))
544 .always_yield_start()
545 .stop_at_git_root()
546 .find_map(|dir| {
547 MARKDOWNLINT_CONFIG_FILES
548 .iter()
549 .map(|name| dir.join(name))
550 .find(|path| path.exists())
551 })
552 }
553
554 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
556 let config_dir = config_dir.join("rumdl");
557
558 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
560
561 log::debug!(
562 "[rumdl-config] Checking for user configuration in: {}",
563 config_dir.display()
564 );
565
566 for filename in USER_CONFIG_FILES {
567 let config_path = config_dir.join(filename);
568
569 if config_path.exists() {
570 if *filename == "pyproject.toml" {
572 if let Ok(content) = std::fs::read_to_string(&config_path) {
573 if pyproject_declares_rumdl_config(&content) {
574 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
575 return Some(config_path);
576 }
577 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
578 continue;
579 }
580 } else {
581 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
582 return Some(config_path);
583 }
584 }
585 }
586
587 log::debug!(
588 "[rumdl-config] No user configuration found in: {}",
589 config_dir.display()
590 );
591 None
592 }
593
594 #[cfg(feature = "native")]
597 fn user_configuration_path() -> Option<std::path::PathBuf> {
598 use etcetera::{BaseStrategy, choose_base_strategy};
599
600 match choose_base_strategy() {
601 Ok(strategy) => {
602 let config_dir = strategy.config_dir();
603 Self::user_configuration_path_impl(&config_dir)
604 }
605 Err(e) => {
606 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
607 None
608 }
609 }
610 }
611
612 #[cfg(not(feature = "native"))]
614 fn user_configuration_path() -> Option<std::path::PathBuf> {
615 None
616 }
617
618 fn home_configuration_path_impl(home_dir: &Path) -> Option<std::path::PathBuf> {
630 const HOME_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml"];
631
632 log::debug!(
633 "[rumdl-config] Checking for home-directory configuration in: {}",
634 home_dir.display()
635 );
636
637 for filename in HOME_CONFIG_FILES {
638 let config_path = home_dir.join(filename);
639 if config_path.exists() {
640 log::debug!(
641 "[rumdl-config] Found home-directory configuration at: {}",
642 config_path.display()
643 );
644 return Some(config_path);
645 }
646 }
647
648 log::debug!(
649 "[rumdl-config] No home-directory configuration found in: {}",
650 home_dir.display()
651 );
652 None
653 }
654
655 #[cfg(feature = "native")]
661 fn home_configuration_path() -> Option<std::path::PathBuf> {
662 use etcetera::{BaseStrategy, choose_base_strategy};
663
664 match choose_base_strategy() {
665 Ok(strategy) => Self::home_configuration_path_impl(strategy.home_dir()),
666 Err(e) => {
667 log::debug!("[rumdl-config] Failed to determine home directory: {e}");
668 None
669 }
670 }
671 }
672
673 #[cfg(not(feature = "native"))]
675 fn home_configuration_path() -> Option<std::path::PathBuf> {
676 None
677 }
678
679 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
681 let path_obj = Path::new(path);
682 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
683 let path_str = path.to_string();
684
685 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
686
687 if let Some(config_parent) = path_obj.parent() {
689 let project_root = Self::find_project_root_from(config_parent);
690 log::debug!(
691 "[rumdl-config] Project root (from explicit config): {}",
692 project_root.display()
693 );
694 sourced_config.project_root = Some(project_root);
695 }
696
697 const MARKDOWNLINT_FILENAMES: &[&str] = &[
699 ".markdownlint-cli2.jsonc",
700 ".markdownlint-cli2.yaml",
701 ".markdownlint-cli2.yml",
702 ".markdownlint.json",
703 ".markdownlint.yaml",
704 ".markdownlint.yml",
705 ];
706
707 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
708 let mut visited = IndexSet::new();
710 let chain_source = source_from_filename(filename);
711 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
712 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
713 || path_str.ends_with(".json")
714 || path_str.ends_with(".jsonc")
715 || path_str.ends_with(".yaml")
716 || path_str.ends_with(".yml")
717 {
718 let fragment = parsers::load_from_markdownlint(&path_str)?;
720 sourced_config.merge(fragment);
721 sourced_config.loaded_files.push(path_str);
722 } else {
723 let mut visited = IndexSet::new();
725 let chain_source = source_from_filename(filename);
726 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
727 }
728
729 Ok(())
730 }
731
732 fn load_user_config(
751 sourced_config: &mut Self,
752 user_config_dir: Option<&Path>,
753 home_dir: Option<&Path>,
754 ) -> Result<(), ConfigError> {
755 let user_config_path = if let Some(dir) = user_config_dir {
756 Self::user_configuration_path_impl(dir)
757 } else {
758 Self::user_configuration_path()
759 };
760
761 let user_config_path = user_config_path.or_else(|| match home_dir {
762 Some(home) => Self::home_configuration_path_impl(home),
763 None => Self::home_configuration_path(),
764 });
765
766 if let Some(user_config_path) = user_config_path {
767 let path_str = user_config_path.display().to_string();
768
769 log::debug!("[rumdl-config] Loading user config: {path_str}");
770
771 let mut visited = IndexSet::new();
774 load_config_with_extends(
775 sourced_config,
776 &user_config_path,
777 &mut visited,
778 ConfigSource::UserConfig,
779 )?;
780 } else {
781 log::debug!("[rumdl-config] No user configuration file found");
782 }
783
784 Ok(())
785 }
786
787 #[doc(hidden)]
789 pub fn load_with_discovery_impl(
790 config_path: Option<&str>,
791 cli_overrides: Option<&SourcedGlobalConfig>,
792 skip_auto_discovery: bool,
793 user_config_dir: Option<&Path>,
794 home_dir: Option<&Path>,
795 ) -> Result<Self, ConfigError> {
796 use std::env;
797 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
798
799 let mut sourced_config = SourcedConfig::default();
800
801 if let Some(path) = config_path {
814 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
816 Self::load_explicit_config(&mut sourced_config, path)?;
817 } else if skip_auto_discovery {
818 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
819 } else {
821 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
823
824 if let Some((config_file, project_root, shadow)) = Self::discover_config_upward(home_dir) {
826 log::debug!("[rumdl-config] Found project config: {}", config_file.display());
830 log::debug!("[rumdl-config] Project root: {}", project_root.display());
831
832 if let Some(shadow) = shadow {
835 sourced_config.discovery_warnings.push(format_shadow_warning(&shadow));
836 }
837
838 sourced_config.project_root = Some(project_root);
839
840 let mut visited = IndexSet::new();
842 let root_filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
843 let chain_source = source_from_filename(root_filename);
844 load_config_with_extends(&mut sourced_config, &config_file, &mut visited, chain_source)?;
845 } else {
846 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
848
849 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward(home_dir) {
850 let path_str = markdownlint_path.display().to_string();
851 log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
852 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
857 match parsers::load_from_markdownlint(&path_str) {
858 Ok(fragment) => {
859 sourced_config.merge(fragment);
860 sourced_config.loaded_files.push(path_str);
861 }
862 Err(_e) => {
863 log::debug!("[rumdl-config] Failed to load markdownlint config");
864 }
865 }
866 } else {
867 log::debug!("[rumdl-config] No project config found, using user config as fallback");
869 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
870 }
871 }
872 }
873
874 if let Some(cli) = cli_overrides {
876 sourced_config
877 .global
878 .enable
879 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None);
880 sourced_config
881 .global
882 .disable
883 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None);
884 sourced_config
885 .global
886 .exclude
887 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None);
888 sourced_config
889 .global
890 .include
891 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None);
892 sourced_config.global.respect_gitignore.merge_override(
893 cli.respect_gitignore.value,
894 ConfigSource::Cli,
895 None,
896 );
897 sourced_config
898 .global
899 .fixable
900 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None);
901 sourced_config
902 .global
903 .unfixable
904 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None);
905 }
907
908 Ok(sourced_config)
911 }
912
913 pub fn load_with_discovery(
916 config_path: Option<&str>,
917 cli_overrides: Option<&SourcedGlobalConfig>,
918 skip_auto_discovery: bool,
919 ) -> Result<Self, ConfigError> {
920 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
921 }
922
923 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
937 let warnings = validate_config_sourced_internal(&self, registry);
938
939 Ok(SourcedConfig {
940 global: self.global,
941 per_file_ignores: self.per_file_ignores,
942 per_file_flavor: self.per_file_flavor,
943 code_block_tools: self.code_block_tools,
944 rules: self.rules,
945 loaded_files: self.loaded_files,
946 unknown_keys: self.unknown_keys,
947 project_root: self.project_root,
948 discovery_warnings: self.discovery_warnings,
949 validation_warnings: warnings,
950 _state: PhantomData,
951 })
952 }
953
954 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
959 let validated = self.validate(registry)?;
960 let warnings = validated.validation_warnings.clone();
961 Ok((validated.into(), warnings))
962 }
963
964 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
975 SourcedConfig {
976 global: self.global,
977 per_file_ignores: self.per_file_ignores,
978 per_file_flavor: self.per_file_flavor,
979 code_block_tools: self.code_block_tools,
980 rules: self.rules,
981 loaded_files: self.loaded_files,
982 unknown_keys: self.unknown_keys,
983 project_root: self.project_root,
984 discovery_warnings: self.discovery_warnings,
985 validation_warnings: Vec::new(),
986 _state: PhantomData,
987 }
988 }
989
990 pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
999 UpwardWalk::new(dir)
1011 .stop_below(Self::resolve_home_boundary(None))
1012 .stop_at(project_root)
1013 .find_map(|current| {
1014 for config_name in RUMDL_CONFIG_FILES {
1016 let config_path = current.join(config_name);
1017 if config_path.exists() {
1018 if *config_name == "pyproject.toml" {
1019 if let Ok(content) = std::fs::read_to_string(&config_path)
1020 && pyproject_declares_rumdl_config(&content)
1021 {
1022 return Some(config_path);
1023 }
1024 continue;
1025 }
1026 return Some(config_path);
1027 }
1028 }
1029
1030 MARKDOWNLINT_CONFIG_FILES
1032 .iter()
1033 .map(|name| current.join(name))
1034 .find(|path| path.exists())
1035 })
1036 }
1037
1038 pub fn load_sourced_for_path(
1045 config_path: &Path,
1046 project_root: &Path,
1047 ) -> Result<SourcedConfig<ConfigLoaded>, ConfigError> {
1048 let mut sourced_config = SourcedConfig {
1049 project_root: Some(project_root.to_path_buf()),
1050 ..SourcedConfig::default()
1051 };
1052
1053 let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1054 let path_str = config_path.display().to_string();
1055
1056 let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
1058 || (filename != "pyproject.toml"
1059 && filename != ".rumdl.toml"
1060 && filename != "rumdl.toml"
1061 && (path_str.ends_with(".json")
1062 || path_str.ends_with(".jsonc")
1063 || path_str.ends_with(".yaml")
1064 || path_str.ends_with(".yml")));
1065
1066 if is_markdownlint {
1067 let fragment = parsers::load_from_markdownlint(&path_str)?;
1068 sourced_config.merge(fragment);
1069 sourced_config.loaded_files.push(path_str);
1070 } else {
1071 let mut visited = IndexSet::new();
1072 let chain_source = source_from_filename(filename);
1073 load_config_with_extends(&mut sourced_config, config_path, &mut visited, chain_source)?;
1074 }
1075
1076 Ok(sourced_config)
1077 }
1078
1079 pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
1083 Ok(Self::load_sourced_for_path(config_path, project_root)?
1084 .into_validated_unchecked()
1085 .into())
1086 }
1087}
1088
1089impl From<SourcedConfig<ConfigValidated>> for Config {
1094 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
1095 let mut rules = BTreeMap::new();
1096 for (rule_name, sourced_rule_cfg) in sourced.rules {
1097 let normalized_rule_name = rule_name.to_ascii_uppercase();
1099 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
1100 let mut values = BTreeMap::new();
1101 for (key, sourced_val) in sourced_rule_cfg.values {
1102 values.insert(key, sourced_val.value);
1103 }
1104 rules.insert(normalized_rule_name, RuleConfig { severity, values });
1105 }
1106 let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
1108
1109 #[allow(deprecated)]
1110 let global = GlobalConfig {
1111 enable: sourced.global.enable.value,
1112 disable: sourced.global.disable.value,
1113 exclude: sourced.global.exclude.value,
1114 include: sourced.global.include.value,
1115 respect_gitignore: sourced.global.respect_gitignore.value,
1116 line_length: sourced.global.line_length.value,
1117 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1118 fixable: sourced.global.fixable.value,
1119 unfixable: sourced.global.unfixable.value,
1120 flavor: sourced.global.flavor.value,
1121 force_exclude: sourced.global.force_exclude.value,
1122 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
1123 cache: sourced.global.cache.value,
1124 extend_enable: sourced.global.extend_enable.value,
1125 extend_disable: sourced.global.extend_disable.value,
1126 enable_is_explicit,
1127 };
1128
1129 let mut config = Config {
1130 extends: None,
1131 global,
1132 per_file_ignores: sourced.per_file_ignores.value,
1133 per_file_flavor: sourced.per_file_flavor.value,
1134 code_block_tools: sourced.code_block_tools.value,
1135 rules,
1136 project_root: sourced.project_root,
1137 per_file_ignores_cache: Arc::new(OnceLock::new()),
1138 per_file_flavor_cache: Arc::new(OnceLock::new()),
1139 canonical_project_root_cache: Arc::new(OnceLock::new()),
1140 };
1141
1142 config.apply_per_rule_enabled();
1144
1145 config.canonicalize_rule_lists();
1152
1153 config
1154 }
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159 use super::pyproject_declares_rumdl_config;
1160
1161 #[test]
1162 fn detects_flat_and_dotted_rumdl_sections() {
1163 assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
1164 assert!(pyproject_declares_rumdl_config(
1166 "[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
1167 ));
1168 assert!(pyproject_declares_rumdl_config(
1169 "[tool.rumdl.rules.MD007]\nindent = 4\n"
1170 ));
1171 }
1172
1173 #[test]
1174 fn ignores_incidental_mentions() {
1175 assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
1178 assert!(!pyproject_declares_rumdl_config(
1179 "[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
1180 ));
1181 assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
1182 }
1183
1184 mod expand_env_vars {
1187 use super::super::expand_env_vars;
1188 use std::collections::HashMap;
1189
1190 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
1192 let map: HashMap<String, String> = pairs
1193 .iter()
1194 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1195 .collect();
1196 move |k: &str| map.get(k).cloned()
1197 }
1198
1199 #[test]
1200 fn expands_bare_and_braced_forms() {
1201 let e = env(&[("VAR", "val"), ("FOO_BAR", "fb")]);
1202 assert_eq!(expand_env_vars("$VAR", &e).unwrap(), "val");
1203 assert_eq!(expand_env_vars("${VAR}", &e).unwrap(), "val");
1204 assert_eq!(expand_env_vars("$FOO_BAR", &e).unwrap(), "fb");
1206 }
1207
1208 #[test]
1209 fn expands_within_paths() {
1210 let e = env(&[("BASE", "/opt/cfg"), ("A", "x"), ("B", "y")]);
1211 assert_eq!(expand_env_vars("$BASE/x/y.toml", &e).unwrap(), "/opt/cfg/x/y.toml");
1212 assert_eq!(expand_env_vars("$A/$B", &e).unwrap(), "x/y");
1213 assert_eq!(expand_env_vars("${A}suffix", &e).unwrap(), "xsuffix");
1214 }
1215
1216 #[test]
1217 fn dollar_dollar_is_a_literal_dollar() {
1218 let e = env(&[("VAR", "val")]);
1219 assert_eq!(expand_env_vars("$$", &e).unwrap(), "$");
1220 assert_eq!(expand_env_vars("$$VAR", &e).unwrap(), "$VAR");
1222 assert_eq!(expand_env_vars("$${VAR}", &e).unwrap(), "${VAR}");
1223 assert_eq!(expand_env_vars("file-$$name.toml", &e).unwrap(), "file-$name.toml");
1225 }
1226
1227 #[test]
1228 fn bare_dollar_name_in_path_is_a_variable_reference() {
1229 let e = env(&[("name", "core")]);
1232 assert_eq!(expand_env_vars("file-$name.toml", &e).unwrap(), "file-core.toml");
1233 }
1234
1235 #[test]
1236 fn incidental_dollar_stays_literal() {
1237 let e = env(&[]);
1238 assert_eq!(expand_env_vars("$5", &e).unwrap(), "$5");
1240 assert_eq!(expand_env_vars("cost$", &e).unwrap(), "cost$");
1241 assert_eq!(expand_env_vars("a$/b", &e).unwrap(), "a$/b");
1242 }
1243
1244 #[test]
1245 fn malformed_braces_stay_literal() {
1246 let e = env(&[("B", "x")]);
1247 assert_eq!(expand_env_vars("${}", &e).unwrap(), "${}");
1248 assert_eq!(expand_env_vars("${VAR", &e).unwrap(), "${VAR");
1249 assert_eq!(expand_env_vars("${A${B}}", &e).unwrap(), "${A${B}}");
1251 }
1252
1253 #[test]
1254 fn undefined_variable_is_an_error() {
1255 let e = env(&[]);
1256 assert_eq!(expand_env_vars("$NOPE", &e).unwrap_err(), "NOPE");
1257 assert_eq!(expand_env_vars("${NOPE}", &e).unwrap_err(), "NOPE");
1258 assert_eq!(expand_env_vars("prefix/$NOPE/x", &e).unwrap_err(), "NOPE");
1259 }
1260
1261 #[test]
1262 fn replacement_is_not_rescanned() {
1263 let e = env(&[("A", "$B"), ("B", "should-not-appear")]);
1265 assert_eq!(expand_env_vars("$A", &e).unwrap(), "$B");
1266 assert_eq!(expand_env_vars("${A}", &e).unwrap(), "$B");
1267 }
1268
1269 #[test]
1270 fn identifiers_are_ascii_only_unicode_stays_literal() {
1271 let e = env(&[("VAR", "v")]);
1272 assert_eq!(expand_env_vars("${föö}", &e).unwrap(), "${föö}");
1274 assert_eq!(expand_env_vars("$VARö", &e).unwrap(), "vö");
1276 assert_eq!(expand_env_vars("café/$VAR", &e).unwrap(), "café/v");
1278 }
1279
1280 #[test]
1281 fn passthrough_for_plain_input() {
1282 let e = env(&[]);
1283 assert_eq!(expand_env_vars("", &e).unwrap(), "");
1284 assert_eq!(expand_env_vars("/plain/path.toml", &e).unwrap(), "/plain/path.toml");
1285 }
1286 }
1287
1288 #[cfg(unix)]
1297 #[test]
1298 fn discover_stops_at_project_root_across_path_representations() {
1299 use super::SourcedConfig;
1300 use std::os::unix::fs::symlink;
1301 use tempfile::tempdir;
1302
1303 let tmp = tempdir().unwrap();
1304 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1306
1307 let real_root = tmp.path().join("project");
1308 let subdir = real_root.join("docs");
1309 std::fs::create_dir_all(&subdir).unwrap();
1310
1311 let linked_root = tmp.path().join("project-link");
1314 symlink(&real_root, &linked_root).unwrap();
1315
1316 let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
1317 assert_eq!(
1318 found, None,
1319 "discovery must stop at the project root, not overshoot to the parent config"
1320 );
1321 }
1322
1323 mod shadowed_configs {
1324 use super::super::{ShadowedConfigs, detect_shadowed_configs, format_shadow_warning, rumdl_configs_in_dir};
1325 use tempfile::tempdir;
1326
1327 fn names(paths: &[std::path::PathBuf]) -> Vec<String> {
1328 paths
1329 .iter()
1330 .map(|p| {
1331 let file = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
1334 let parent = p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str());
1335 match parent {
1336 Some(".config") => format!(".config/{file}"),
1337 _ => file.to_string(),
1338 }
1339 })
1340 .collect()
1341 }
1342
1343 #[test]
1344 fn empty_directory_has_no_configs_and_no_shadow() {
1345 let tmp = tempdir().unwrap();
1346 assert!(rumdl_configs_in_dir(tmp.path()).is_empty());
1347 assert!(detect_shadowed_configs(tmp.path()).is_none());
1348 }
1349
1350 #[test]
1351 fn single_config_does_not_shadow() {
1352 let tmp = tempdir().unwrap();
1353 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1354 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1355 assert!(detect_shadowed_configs(tmp.path()).is_none());
1356 }
1357
1358 #[test]
1359 fn dot_wins_over_non_dot_and_non_dot_is_shadowed() {
1360 let tmp = tempdir().unwrap();
1361 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1362 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1363
1364 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1365 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1366 assert_eq!(names(&shadowed), vec!["rumdl.toml"]);
1367 }
1368
1369 #[test]
1370 fn config_subdir_counts_as_same_level_shadow() {
1371 let tmp = tempdir().unwrap();
1372 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1373 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1374 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1375
1376 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1377 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1378 assert_eq!(names(&shadowed), vec![".config/rumdl.toml"]);
1379 }
1380
1381 #[test]
1382 fn pyproject_counts_only_when_it_declares_rumdl() {
1383 let bare = tempdir().unwrap();
1385 std::fs::write(bare.path().join(".rumdl.toml"), "").unwrap();
1386 std::fs::write(bare.path().join("pyproject.toml"), "[tool.black]\nline-length = 88\n").unwrap();
1387 assert_eq!(names(&rumdl_configs_in_dir(bare.path())), vec![".rumdl.toml"]);
1388 assert!(detect_shadowed_configs(bare.path()).is_none());
1389
1390 let declared = tempdir().unwrap();
1392 std::fs::write(declared.path().join(".rumdl.toml"), "").unwrap();
1393 std::fs::write(
1394 declared.path().join("pyproject.toml"),
1395 "[tool.rumdl]\nline-length = 80\n",
1396 )
1397 .unwrap();
1398 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(declared.path()).unwrap();
1399 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1400 assert_eq!(names(&shadowed), vec!["pyproject.toml"]);
1401 }
1402
1403 #[test]
1404 fn markdownlint_configs_are_not_rumdl_native_and_never_shadow() {
1405 let tmp = tempdir().unwrap();
1406 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1407 std::fs::write(tmp.path().join(".markdownlint.json"), "{}").unwrap();
1408 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1409 assert!(detect_shadowed_configs(tmp.path()).is_none());
1410 }
1411
1412 #[test]
1413 fn configs_returned_in_precedence_order() {
1414 let tmp = tempdir().unwrap();
1415 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1416 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1417 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1418 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1419 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1420
1421 assert_eq!(
1422 names(&rumdl_configs_in_dir(tmp.path())),
1423 vec![".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]
1424 );
1425 }
1426
1427 #[test]
1428 fn warning_names_dir_once_with_relative_filenames() {
1429 let tmp = tempdir().unwrap();
1430 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1431 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1432 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1433
1434 let shadow = detect_shadowed_configs(tmp.path()).unwrap();
1435 let msg = format_shadow_warning(&shadow);
1436
1437 let dir = {
1438 let s = tmp.path().to_string_lossy().into_owned();
1439 if cfg!(windows) { s.replace('\\', "/") } else { s }
1440 };
1441 assert!(msg.contains("multiple rumdl config files"), "got: {msg}");
1442 assert_eq!(
1445 msg.matches(dir.as_str()).count(),
1446 1,
1447 "directory should appear exactly once, got: {msg}"
1448 );
1449 assert!(
1450 msg.contains("using .rumdl.toml, ignoring rumdl.toml, pyproject.toml"),
1451 "winner and shadowed files should be relative names in precedence order, got: {msg}"
1452 );
1453 assert!(!msg.contains('\\'), "paths must be normalized to '/': {msg}");
1455 }
1456 }
1457}