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 resolve_extends_path(extends_value: &str, config_file_path: &Path) -> PathBuf {
39 if let Some(suffix) = extends_value.strip_prefix("~/") {
40 #[cfg(feature = "native")]
42 {
43 use etcetera::{BaseStrategy, choose_base_strategy};
44 let home = choose_base_strategy().map_or_else(|_| PathBuf::from("~"), |s| s.home_dir().to_path_buf());
45 home.join(suffix)
46 }
47 #[cfg(not(feature = "native"))]
48 {
49 let _ = suffix;
50 PathBuf::from(extends_value)
51 }
52 } else {
53 let path = PathBuf::from(extends_value);
54 if path.is_absolute() {
55 path
56 } else {
57 let config_dir = config_file_path.parent().unwrap_or(Path::new("."));
59 config_dir.join(extends_value)
60 }
61 }
62}
63
64fn source_from_filename(filename: &str) -> ConfigSource {
66 if filename == "pyproject.toml" {
67 ConfigSource::PyprojectToml
68 } else {
69 ConfigSource::ProjectConfig
70 }
71}
72
73pub(crate) fn rumdl_configs_in_dir(dir: &Path) -> Vec<PathBuf> {
81 RUMDL_CONFIG_FILES
82 .iter()
83 .map(|name| dir.join(name))
84 .filter(|path| {
85 if !path.exists() {
86 return false;
87 }
88 if path.file_name().and_then(|n| n.to_str()) == Some("pyproject.toml") {
89 std::fs::read_to_string(path).is_ok_and(|content| pyproject_declares_rumdl_config(&content))
90 } else {
91 true
92 }
93 })
94 .collect()
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
106pub(crate) struct ShadowedConfigs {
107 pub dir: PathBuf,
108 pub winner: PathBuf,
109 pub shadowed: Vec<PathBuf>,
110}
111
112pub(crate) fn detect_shadowed_configs(dir: &Path) -> Option<ShadowedConfigs> {
118 let mut configs = rumdl_configs_in_dir(dir);
119 if configs.len() < 2 {
120 return None;
121 }
122 let winner = configs.remove(0);
123 Some(ShadowedConfigs {
124 dir: dir.to_path_buf(),
125 winner,
126 shadowed: configs,
127 })
128}
129
130pub(crate) fn format_shadow_warning(shadow: &ShadowedConfigs) -> String {
138 let norm = |s: String| if cfg!(windows) { s.replace('\\', "/") } else { s };
139 let rel = |path: &Path| {
140 let relative = path.strip_prefix(&shadow.dir).unwrap_or(path);
141 norm(relative.to_string_lossy().into_owned())
142 };
143 let shadowed = shadow.shadowed.iter().map(|p| rel(p)).collect::<Vec<_>>().join(", ");
144 format!(
145 "multiple rumdl config files in {}: using {}, ignoring {}",
146 norm(shadow.dir.to_string_lossy().into_owned()),
147 rel(&shadow.winner),
148 shadowed,
149 )
150}
151
152fn load_config_with_extends(
159 sourced_config: &mut SourcedConfig<ConfigLoaded>,
160 config_file_path: &Path,
161 visited: &mut IndexSet<PathBuf>,
162 chain_source: ConfigSource,
163) -> Result<(), ConfigError> {
164 let canonical = config_file_path
166 .canonicalize()
167 .unwrap_or_else(|_| config_file_path.to_path_buf());
168
169 if visited.contains(&canonical) {
171 let chain: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
172 return Err(ConfigError::CircularExtends {
173 path: config_file_path.display().to_string(),
174 chain,
175 });
176 }
177
178 if visited.len() >= MAX_EXTENDS_DEPTH {
180 return Err(ConfigError::ExtendsDepthExceeded {
181 path: config_file_path.display().to_string(),
182 max_depth: MAX_EXTENDS_DEPTH,
183 });
184 }
185
186 visited.insert(canonical);
188
189 let path_str = config_file_path.display().to_string();
190 let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
191
192 let content = std::fs::read_to_string(config_file_path).map_err(|e| ConfigError::IoError {
194 source: e,
195 path: path_str.clone(),
196 })?;
197
198 let fragment = if filename == "pyproject.toml" {
199 match parsers::parse_pyproject_toml(&content, &path_str, chain_source)? {
200 Some(f) => f,
201 None => return Ok(()), }
203 } else {
204 parsers::parse_rumdl_toml(&content, &path_str, chain_source)?
205 };
206
207 if let Some(ref extends_value) = fragment.extends {
209 let base_path = resolve_extends_path(extends_value, config_file_path);
210
211 if !base_path.exists() {
212 return Err(ConfigError::ExtendsNotFound {
213 path: base_path.display().to_string(),
214 from: path_str.clone(),
215 });
216 }
217
218 log::debug!(
219 "[rumdl-config] Config {} extends {}, loading base first",
220 path_str,
221 base_path.display()
222 );
223
224 load_config_with_extends(sourced_config, &base_path, visited, chain_source)?;
226 }
227
228 let mut fragment_for_merge = fragment;
231 fragment_for_merge.extends = None;
232 sourced_config.merge(fragment_for_merge);
233 sourced_config.loaded_files.push(path_str);
234
235 Ok(())
236}
237
238impl SourcedConfig<ConfigLoaded> {
239 pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
242 self.global.enable.merge_override(
245 fragment.global.enable.value,
246 fragment.global.enable.source,
247 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
248 fragment.global.enable.overrides.first().and_then(|o| o.line),
249 );
250
251 self.global.disable.merge_override(
253 fragment.global.disable.value,
254 fragment.global.disable.source,
255 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
256 fragment.global.disable.overrides.first().and_then(|o| o.line),
257 );
258
259 self.global.extend_enable.merge_union(
261 fragment.global.extend_enable.value,
262 fragment.global.extend_enable.source,
263 fragment
264 .global
265 .extend_enable
266 .overrides
267 .first()
268 .and_then(|o| o.file.clone()),
269 fragment.global.extend_enable.overrides.first().and_then(|o| o.line),
270 );
271
272 self.global.extend_disable.merge_union(
274 fragment.global.extend_disable.value,
275 fragment.global.extend_disable.source,
276 fragment
277 .global
278 .extend_disable
279 .overrides
280 .first()
281 .and_then(|o| o.file.clone()),
282 fragment.global.extend_disable.overrides.first().and_then(|o| o.line),
283 );
284
285 self.global
288 .disable
289 .value
290 .retain(|rule| !self.global.enable.value.contains(rule));
291 self.global.include.merge_override(
292 fragment.global.include.value,
293 fragment.global.include.source,
294 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
295 fragment.global.include.overrides.first().and_then(|o| o.line),
296 );
297 self.global.exclude.merge_override(
298 fragment.global.exclude.value,
299 fragment.global.exclude.source,
300 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
301 fragment.global.exclude.overrides.first().and_then(|o| o.line),
302 );
303 self.global.respect_gitignore.merge_override(
304 fragment.global.respect_gitignore.value,
305 fragment.global.respect_gitignore.source,
306 fragment
307 .global
308 .respect_gitignore
309 .overrides
310 .first()
311 .and_then(|o| o.file.clone()),
312 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
313 );
314 self.global.line_length.merge_override(
315 fragment.global.line_length.value,
316 fragment.global.line_length.source,
317 fragment
318 .global
319 .line_length
320 .overrides
321 .first()
322 .and_then(|o| o.file.clone()),
323 fragment.global.line_length.overrides.first().and_then(|o| o.line),
324 );
325 self.global.fixable.merge_override(
326 fragment.global.fixable.value,
327 fragment.global.fixable.source,
328 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
329 fragment.global.fixable.overrides.first().and_then(|o| o.line),
330 );
331 self.global.unfixable.merge_override(
332 fragment.global.unfixable.value,
333 fragment.global.unfixable.source,
334 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
335 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
336 );
337
338 self.global.flavor.merge_override(
340 fragment.global.flavor.value,
341 fragment.global.flavor.source,
342 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
343 fragment.global.flavor.overrides.first().and_then(|o| o.line),
344 );
345
346 self.global.force_exclude.merge_override(
348 fragment.global.force_exclude.value,
349 fragment.global.force_exclude.source,
350 fragment
351 .global
352 .force_exclude
353 .overrides
354 .first()
355 .and_then(|o| o.file.clone()),
356 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
357 );
358
359 if let Some(output_format_fragment) = fragment.global.output_format {
361 if let Some(ref mut output_format) = self.global.output_format {
362 output_format.merge_override(
363 output_format_fragment.value,
364 output_format_fragment.source,
365 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
366 output_format_fragment.overrides.first().and_then(|o| o.line),
367 );
368 } else {
369 self.global.output_format = Some(output_format_fragment);
370 }
371 }
372
373 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
375 if let Some(ref mut cache_dir) = self.global.cache_dir {
376 cache_dir.merge_override(
377 cache_dir_fragment.value,
378 cache_dir_fragment.source,
379 cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
380 cache_dir_fragment.overrides.first().and_then(|o| o.line),
381 );
382 } else {
383 self.global.cache_dir = Some(cache_dir_fragment);
384 }
385 }
386
387 if fragment.global.cache.source != ConfigSource::Default {
389 self.global.cache.merge_override(
390 fragment.global.cache.value,
391 fragment.global.cache.source,
392 fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
393 fragment.global.cache.overrides.first().and_then(|o| o.line),
394 );
395 }
396
397 self.per_file_ignores.merge_override(
399 fragment.per_file_ignores.value,
400 fragment.per_file_ignores.source,
401 fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
402 fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
403 );
404
405 self.per_file_flavor.merge_override(
407 fragment.per_file_flavor.value,
408 fragment.per_file_flavor.source,
409 fragment.per_file_flavor.overrides.first().and_then(|o| o.file.clone()),
410 fragment.per_file_flavor.overrides.first().and_then(|o| o.line),
411 );
412
413 self.code_block_tools.merge_override(
415 fragment.code_block_tools.value,
416 fragment.code_block_tools.source,
417 fragment.code_block_tools.overrides.first().and_then(|o| o.file.clone()),
418 fragment.code_block_tools.overrides.first().and_then(|o| o.line),
419 );
420
421 for (rule_name, rule_fragment) in fragment.rules {
423 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
425
426 if let Some(severity_fragment) = rule_fragment.severity {
428 if let Some(ref mut existing_severity) = rule_entry.severity {
429 existing_severity.merge_override(
430 severity_fragment.value,
431 severity_fragment.source,
432 severity_fragment.overrides.first().and_then(|o| o.file.clone()),
433 severity_fragment.overrides.first().and_then(|o| o.line),
434 );
435 } else {
436 rule_entry.severity = Some(severity_fragment);
437 }
438 }
439
440 for (key, sourced_value_fragment) in rule_fragment.values {
442 let sv_entry = rule_entry
443 .values
444 .entry(key.clone())
445 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
446 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
447 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
448 sv_entry.merge_override(
449 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
454 }
455 }
456
457 for (section, key, file_path) in fragment.unknown_keys {
459 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
461 self.unknown_keys.push((section, key, file_path));
462 }
463 }
464 }
465
466 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
468 Self::load_with_discovery(config_path, cli_overrides, false)
469 }
470
471 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
474 UpwardWalk::new(start_dir)
475 .find(|dir| dir.join(".git").exists())
476 .unwrap_or_else(|| {
477 log::debug!(
478 "[rumdl-config] No .git found, using config location as project root: {}",
479 start_dir.display()
480 );
481 start_dir.to_path_buf()
482 })
483 }
484
485 fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
491 home_override.map(Path::to_path_buf).or_else(|| {
492 #[cfg(feature = "native")]
493 {
494 use etcetera::{BaseStrategy, choose_base_strategy};
495 choose_base_strategy().ok().map(|s| s.home_dir().to_path_buf())
496 }
497 #[cfg(not(feature = "native"))]
498 {
499 None
500 }
501 })
502 }
503
504 fn discover_config_upward(
516 home_override: Option<&Path>,
517 ) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
518 let start_dir = match std::env::current_dir() {
519 Ok(dir) => dir,
520 Err(e) => {
521 log::debug!("[rumdl-config] Failed to get current directory: {e}");
522 return None;
523 }
524 };
525
526 let (config_path, config_dir, shadow) = UpwardWalk::new(&start_dir)
530 .stop_below(Self::resolve_home_boundary(home_override))
531 .stop_at_git_root()
532 .find_map(|dir| {
533 rumdl_configs_in_dir(&dir).into_iter().next().map(|winner| {
534 log::debug!("[rumdl-config] Found config file: {}", winner.display());
535 let shadow = detect_shadowed_configs(&dir);
536 (winner, dir, shadow)
537 })
538 })?;
539
540 let project_root = Self::find_project_root_from(&config_dir);
542 Some((config_path, project_root, shadow))
543 }
544
545 fn discover_markdownlint_config_upward(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
550 let start_dir = match std::env::current_dir() {
551 Ok(dir) => dir,
552 Err(e) => {
553 log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
554 return None;
555 }
556 };
557
558 UpwardWalk::new(&start_dir)
559 .stop_below(Self::resolve_home_boundary(home_override))
560 .stop_at_git_root()
561 .find_map(|dir| {
562 MARKDOWNLINT_CONFIG_FILES
563 .iter()
564 .map(|name| dir.join(name))
565 .find(|path| path.exists())
566 })
567 }
568
569 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
571 let config_dir = config_dir.join("rumdl");
572
573 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
575
576 log::debug!(
577 "[rumdl-config] Checking for user configuration in: {}",
578 config_dir.display()
579 );
580
581 for filename in USER_CONFIG_FILES {
582 let config_path = config_dir.join(filename);
583
584 if config_path.exists() {
585 if *filename == "pyproject.toml" {
587 if let Ok(content) = std::fs::read_to_string(&config_path) {
588 if pyproject_declares_rumdl_config(&content) {
589 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
590 return Some(config_path);
591 }
592 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
593 continue;
594 }
595 } else {
596 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
597 return Some(config_path);
598 }
599 }
600 }
601
602 log::debug!(
603 "[rumdl-config] No user configuration found in: {}",
604 config_dir.display()
605 );
606 None
607 }
608
609 #[cfg(feature = "native")]
612 fn user_configuration_path() -> Option<std::path::PathBuf> {
613 use etcetera::{BaseStrategy, choose_base_strategy};
614
615 match choose_base_strategy() {
616 Ok(strategy) => {
617 let config_dir = strategy.config_dir();
618 Self::user_configuration_path_impl(&config_dir)
619 }
620 Err(e) => {
621 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
622 None
623 }
624 }
625 }
626
627 #[cfg(not(feature = "native"))]
629 fn user_configuration_path() -> Option<std::path::PathBuf> {
630 None
631 }
632
633 fn home_configuration_path_impl(home_dir: &Path) -> Option<std::path::PathBuf> {
645 const HOME_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml"];
646
647 log::debug!(
648 "[rumdl-config] Checking for home-directory configuration in: {}",
649 home_dir.display()
650 );
651
652 for filename in HOME_CONFIG_FILES {
653 let config_path = home_dir.join(filename);
654 if config_path.exists() {
655 log::debug!(
656 "[rumdl-config] Found home-directory configuration at: {}",
657 config_path.display()
658 );
659 return Some(config_path);
660 }
661 }
662
663 log::debug!(
664 "[rumdl-config] No home-directory configuration found in: {}",
665 home_dir.display()
666 );
667 None
668 }
669
670 #[cfg(feature = "native")]
676 fn home_configuration_path() -> Option<std::path::PathBuf> {
677 use etcetera::{BaseStrategy, choose_base_strategy};
678
679 match choose_base_strategy() {
680 Ok(strategy) => Self::home_configuration_path_impl(strategy.home_dir()),
681 Err(e) => {
682 log::debug!("[rumdl-config] Failed to determine home directory: {e}");
683 None
684 }
685 }
686 }
687
688 #[cfg(not(feature = "native"))]
690 fn home_configuration_path() -> Option<std::path::PathBuf> {
691 None
692 }
693
694 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
696 let path_obj = Path::new(path);
697 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
698 let path_str = path.to_string();
699
700 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
701
702 if let Some(config_parent) = path_obj.parent() {
704 let project_root = Self::find_project_root_from(config_parent);
705 log::debug!(
706 "[rumdl-config] Project root (from explicit config): {}",
707 project_root.display()
708 );
709 sourced_config.project_root = Some(project_root);
710 }
711
712 const MARKDOWNLINT_FILENAMES: &[&str] = &[
714 ".markdownlint-cli2.jsonc",
715 ".markdownlint-cli2.yaml",
716 ".markdownlint-cli2.yml",
717 ".markdownlint.json",
718 ".markdownlint.yaml",
719 ".markdownlint.yml",
720 ];
721
722 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
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 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
728 || path_str.ends_with(".json")
729 || path_str.ends_with(".jsonc")
730 || path_str.ends_with(".yaml")
731 || path_str.ends_with(".yml")
732 {
733 let fragment = parsers::load_from_markdownlint(&path_str)?;
735 sourced_config.merge(fragment);
736 sourced_config.loaded_files.push(path_str);
737 } else {
738 let mut visited = IndexSet::new();
740 let chain_source = source_from_filename(filename);
741 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
742 }
743
744 Ok(())
745 }
746
747 fn load_user_config(
766 sourced_config: &mut Self,
767 user_config_dir: Option<&Path>,
768 home_dir: Option<&Path>,
769 ) -> Result<(), ConfigError> {
770 let user_config_path = if let Some(dir) = user_config_dir {
771 Self::user_configuration_path_impl(dir)
772 } else {
773 Self::user_configuration_path()
774 };
775
776 let user_config_path = user_config_path.or_else(|| match home_dir {
777 Some(home) => Self::home_configuration_path_impl(home),
778 None => Self::home_configuration_path(),
779 });
780
781 if let Some(user_config_path) = user_config_path {
782 let path_str = user_config_path.display().to_string();
783
784 log::debug!("[rumdl-config] Loading user config: {path_str}");
785
786 let mut visited = IndexSet::new();
789 load_config_with_extends(
790 sourced_config,
791 &user_config_path,
792 &mut visited,
793 ConfigSource::UserConfig,
794 )?;
795 } else {
796 log::debug!("[rumdl-config] No user configuration file found");
797 }
798
799 Ok(())
800 }
801
802 #[doc(hidden)]
804 pub fn load_with_discovery_impl(
805 config_path: Option<&str>,
806 cli_overrides: Option<&SourcedGlobalConfig>,
807 skip_auto_discovery: bool,
808 user_config_dir: Option<&Path>,
809 home_dir: Option<&Path>,
810 ) -> Result<Self, ConfigError> {
811 use std::env;
812 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
813
814 let mut sourced_config = SourcedConfig::default();
815
816 if let Some(path) = config_path {
829 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
831 Self::load_explicit_config(&mut sourced_config, path)?;
832 } else if skip_auto_discovery {
833 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
834 } else {
836 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
838
839 if let Some((config_file, project_root, shadow)) = Self::discover_config_upward(home_dir) {
841 log::debug!("[rumdl-config] Found project config: {}", config_file.display());
845 log::debug!("[rumdl-config] Project root: {}", project_root.display());
846
847 if let Some(shadow) = shadow {
850 sourced_config.discovery_warnings.push(format_shadow_warning(&shadow));
851 }
852
853 sourced_config.project_root = Some(project_root);
854
855 let mut visited = IndexSet::new();
857 let root_filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
858 let chain_source = source_from_filename(root_filename);
859 load_config_with_extends(&mut sourced_config, &config_file, &mut visited, chain_source)?;
860 } else {
861 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
863
864 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward(home_dir) {
865 let path_str = markdownlint_path.display().to_string();
866 log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
867 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
872 match parsers::load_from_markdownlint(&path_str) {
873 Ok(fragment) => {
874 sourced_config.merge(fragment);
875 sourced_config.loaded_files.push(path_str);
876 }
877 Err(_e) => {
878 log::debug!("[rumdl-config] Failed to load markdownlint config");
879 }
880 }
881 } else {
882 log::debug!("[rumdl-config] No project config found, using user config as fallback");
884 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
885 }
886 }
887 }
888
889 if let Some(cli) = cli_overrides {
891 sourced_config
892 .global
893 .enable
894 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
895 sourced_config
896 .global
897 .disable
898 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
899 sourced_config
900 .global
901 .exclude
902 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
903 sourced_config
904 .global
905 .include
906 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
907 sourced_config.global.respect_gitignore.merge_override(
908 cli.respect_gitignore.value,
909 ConfigSource::Cli,
910 None,
911 None,
912 );
913 sourced_config
914 .global
915 .fixable
916 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
917 sourced_config
918 .global
919 .unfixable
920 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
921 }
923
924 Ok(sourced_config)
927 }
928
929 pub fn load_with_discovery(
932 config_path: Option<&str>,
933 cli_overrides: Option<&SourcedGlobalConfig>,
934 skip_auto_discovery: bool,
935 ) -> Result<Self, ConfigError> {
936 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
937 }
938
939 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
953 let warnings = validate_config_sourced_internal(&self, registry);
954
955 Ok(SourcedConfig {
956 global: self.global,
957 per_file_ignores: self.per_file_ignores,
958 per_file_flavor: self.per_file_flavor,
959 code_block_tools: self.code_block_tools,
960 rules: self.rules,
961 loaded_files: self.loaded_files,
962 unknown_keys: self.unknown_keys,
963 project_root: self.project_root,
964 discovery_warnings: self.discovery_warnings,
965 validation_warnings: warnings,
966 _state: PhantomData,
967 })
968 }
969
970 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
975 let validated = self.validate(registry)?;
976 let warnings = validated.validation_warnings.clone();
977 Ok((validated.into(), warnings))
978 }
979
980 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
991 SourcedConfig {
992 global: self.global,
993 per_file_ignores: self.per_file_ignores,
994 per_file_flavor: self.per_file_flavor,
995 code_block_tools: self.code_block_tools,
996 rules: self.rules,
997 loaded_files: self.loaded_files,
998 unknown_keys: self.unknown_keys,
999 project_root: self.project_root,
1000 discovery_warnings: self.discovery_warnings,
1001 validation_warnings: Vec::new(),
1002 _state: PhantomData,
1003 }
1004 }
1005
1006 pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
1015 UpwardWalk::new(dir)
1027 .stop_below(Self::resolve_home_boundary(None))
1028 .stop_at(project_root)
1029 .find_map(|current| {
1030 for config_name in RUMDL_CONFIG_FILES {
1032 let config_path = current.join(config_name);
1033 if config_path.exists() {
1034 if *config_name == "pyproject.toml" {
1035 if let Ok(content) = std::fs::read_to_string(&config_path)
1036 && pyproject_declares_rumdl_config(&content)
1037 {
1038 return Some(config_path);
1039 }
1040 continue;
1041 }
1042 return Some(config_path);
1043 }
1044 }
1045
1046 MARKDOWNLINT_CONFIG_FILES
1048 .iter()
1049 .map(|name| current.join(name))
1050 .find(|path| path.exists())
1051 })
1052 }
1053
1054 pub fn load_sourced_for_path(
1061 config_path: &Path,
1062 project_root: &Path,
1063 ) -> Result<SourcedConfig<ConfigLoaded>, ConfigError> {
1064 let mut sourced_config = SourcedConfig {
1065 project_root: Some(project_root.to_path_buf()),
1066 ..SourcedConfig::default()
1067 };
1068
1069 let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1070 let path_str = config_path.display().to_string();
1071
1072 let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
1074 || (filename != "pyproject.toml"
1075 && filename != ".rumdl.toml"
1076 && filename != "rumdl.toml"
1077 && (path_str.ends_with(".json")
1078 || path_str.ends_with(".jsonc")
1079 || path_str.ends_with(".yaml")
1080 || path_str.ends_with(".yml")));
1081
1082 if is_markdownlint {
1083 let fragment = parsers::load_from_markdownlint(&path_str)?;
1084 sourced_config.merge(fragment);
1085 sourced_config.loaded_files.push(path_str);
1086 } else {
1087 let mut visited = IndexSet::new();
1088 let chain_source = source_from_filename(filename);
1089 load_config_with_extends(&mut sourced_config, config_path, &mut visited, chain_source)?;
1090 }
1091
1092 Ok(sourced_config)
1093 }
1094
1095 pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
1099 Ok(Self::load_sourced_for_path(config_path, project_root)?
1100 .into_validated_unchecked()
1101 .into())
1102 }
1103}
1104
1105impl From<SourcedConfig<ConfigValidated>> for Config {
1110 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
1111 let mut rules = BTreeMap::new();
1112 for (rule_name, sourced_rule_cfg) in sourced.rules {
1113 let normalized_rule_name = rule_name.to_ascii_uppercase();
1115 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
1116 let mut values = BTreeMap::new();
1117 for (key, sourced_val) in sourced_rule_cfg.values {
1118 values.insert(key, sourced_val.value);
1119 }
1120 rules.insert(normalized_rule_name, RuleConfig { severity, values });
1121 }
1122 let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
1124
1125 #[allow(deprecated)]
1126 let global = GlobalConfig {
1127 enable: sourced.global.enable.value,
1128 disable: sourced.global.disable.value,
1129 exclude: sourced.global.exclude.value,
1130 include: sourced.global.include.value,
1131 respect_gitignore: sourced.global.respect_gitignore.value,
1132 line_length: sourced.global.line_length.value,
1133 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1134 fixable: sourced.global.fixable.value,
1135 unfixable: sourced.global.unfixable.value,
1136 flavor: sourced.global.flavor.value,
1137 force_exclude: sourced.global.force_exclude.value,
1138 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
1139 cache: sourced.global.cache.value,
1140 extend_enable: sourced.global.extend_enable.value,
1141 extend_disable: sourced.global.extend_disable.value,
1142 enable_is_explicit,
1143 };
1144
1145 let mut config = Config {
1146 extends: None,
1147 global,
1148 per_file_ignores: sourced.per_file_ignores.value,
1149 per_file_flavor: sourced.per_file_flavor.value,
1150 code_block_tools: sourced.code_block_tools.value,
1151 rules,
1152 project_root: sourced.project_root,
1153 per_file_ignores_cache: Arc::new(OnceLock::new()),
1154 per_file_flavor_cache: Arc::new(OnceLock::new()),
1155 canonical_project_root_cache: Arc::new(OnceLock::new()),
1156 };
1157
1158 config.apply_per_rule_enabled();
1160
1161 config.canonicalize_rule_lists();
1168
1169 config
1170 }
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 use super::pyproject_declares_rumdl_config;
1176
1177 #[test]
1178 fn detects_flat_and_dotted_rumdl_sections() {
1179 assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
1180 assert!(pyproject_declares_rumdl_config(
1182 "[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
1183 ));
1184 assert!(pyproject_declares_rumdl_config(
1185 "[tool.rumdl.rules.MD007]\nindent = 4\n"
1186 ));
1187 }
1188
1189 #[test]
1190 fn ignores_incidental_mentions() {
1191 assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
1194 assert!(!pyproject_declares_rumdl_config(
1195 "[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
1196 ));
1197 assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
1198 }
1199
1200 #[cfg(unix)]
1209 #[test]
1210 fn discover_stops_at_project_root_across_path_representations() {
1211 use super::SourcedConfig;
1212 use std::os::unix::fs::symlink;
1213 use tempfile::tempdir;
1214
1215 let tmp = tempdir().unwrap();
1216 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1218
1219 let real_root = tmp.path().join("project");
1220 let subdir = real_root.join("docs");
1221 std::fs::create_dir_all(&subdir).unwrap();
1222
1223 let linked_root = tmp.path().join("project-link");
1226 symlink(&real_root, &linked_root).unwrap();
1227
1228 let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
1229 assert_eq!(
1230 found, None,
1231 "discovery must stop at the project root, not overshoot to the parent config"
1232 );
1233 }
1234
1235 mod shadowed_configs {
1236 use super::super::{ShadowedConfigs, detect_shadowed_configs, format_shadow_warning, rumdl_configs_in_dir};
1237 use tempfile::tempdir;
1238
1239 fn names(paths: &[std::path::PathBuf]) -> Vec<String> {
1240 paths
1241 .iter()
1242 .map(|p| {
1243 let file = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
1246 let parent = p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str());
1247 match parent {
1248 Some(".config") => format!(".config/{file}"),
1249 _ => file.to_string(),
1250 }
1251 })
1252 .collect()
1253 }
1254
1255 #[test]
1256 fn empty_directory_has_no_configs_and_no_shadow() {
1257 let tmp = tempdir().unwrap();
1258 assert!(rumdl_configs_in_dir(tmp.path()).is_empty());
1259 assert!(detect_shadowed_configs(tmp.path()).is_none());
1260 }
1261
1262 #[test]
1263 fn single_config_does_not_shadow() {
1264 let tmp = tempdir().unwrap();
1265 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1266 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1267 assert!(detect_shadowed_configs(tmp.path()).is_none());
1268 }
1269
1270 #[test]
1271 fn dot_wins_over_non_dot_and_non_dot_is_shadowed() {
1272 let tmp = tempdir().unwrap();
1273 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1274 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1275
1276 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1277 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1278 assert_eq!(names(&shadowed), vec!["rumdl.toml"]);
1279 }
1280
1281 #[test]
1282 fn config_subdir_counts_as_same_level_shadow() {
1283 let tmp = tempdir().unwrap();
1284 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1285 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1286 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1287
1288 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
1289 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1290 assert_eq!(names(&shadowed), vec![".config/rumdl.toml"]);
1291 }
1292
1293 #[test]
1294 fn pyproject_counts_only_when_it_declares_rumdl() {
1295 let bare = tempdir().unwrap();
1297 std::fs::write(bare.path().join(".rumdl.toml"), "").unwrap();
1298 std::fs::write(bare.path().join("pyproject.toml"), "[tool.black]\nline-length = 88\n").unwrap();
1299 assert_eq!(names(&rumdl_configs_in_dir(bare.path())), vec![".rumdl.toml"]);
1300 assert!(detect_shadowed_configs(bare.path()).is_none());
1301
1302 let declared = tempdir().unwrap();
1304 std::fs::write(declared.path().join(".rumdl.toml"), "").unwrap();
1305 std::fs::write(
1306 declared.path().join("pyproject.toml"),
1307 "[tool.rumdl]\nline-length = 80\n",
1308 )
1309 .unwrap();
1310 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(declared.path()).unwrap();
1311 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
1312 assert_eq!(names(&shadowed), vec!["pyproject.toml"]);
1313 }
1314
1315 #[test]
1316 fn markdownlint_configs_are_not_rumdl_native_and_never_shadow() {
1317 let tmp = tempdir().unwrap();
1318 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1319 std::fs::write(tmp.path().join(".markdownlint.json"), "{}").unwrap();
1320 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
1321 assert!(detect_shadowed_configs(tmp.path()).is_none());
1322 }
1323
1324 #[test]
1325 fn configs_returned_in_precedence_order() {
1326 let tmp = tempdir().unwrap();
1327 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1328 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1329 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
1330 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
1331 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1332
1333 assert_eq!(
1334 names(&rumdl_configs_in_dir(tmp.path())),
1335 vec![".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]
1336 );
1337 }
1338
1339 #[test]
1340 fn warning_names_dir_once_with_relative_filenames() {
1341 let tmp = tempdir().unwrap();
1342 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1343 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
1344 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
1345
1346 let shadow = detect_shadowed_configs(tmp.path()).unwrap();
1347 let msg = format_shadow_warning(&shadow);
1348
1349 let dir = {
1350 let s = tmp.path().to_string_lossy().into_owned();
1351 if cfg!(windows) { s.replace('\\', "/") } else { s }
1352 };
1353 assert!(msg.contains("multiple rumdl config files"), "got: {msg}");
1354 assert_eq!(
1357 msg.matches(dir.as_str()).count(),
1358 1,
1359 "directory should appear exactly once, got: {msg}"
1360 );
1361 assert!(
1362 msg.contains("using .rumdl.toml, ignoring rumdl.toml, pyproject.toml"),
1363 "winner and shadowed files should be relative names in precedence order, got: {msg}"
1364 );
1365 assert!(!msg.contains('\\'), "paths must be normalized to '/': {msg}");
1367 }
1368 }
1369}