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;
16
17const MAX_EXTENDS_DEPTH: usize = 10;
19
20fn pyproject_declares_rumdl_config(content: &str) -> bool {
29 content.contains("[tool.rumdl]") || content.contains("[tool.rumdl.")
30}
31
32fn resolve_extends_path(extends_value: &str, config_file_path: &Path) -> PathBuf {
38 if let Some(suffix) = extends_value.strip_prefix("~/") {
39 #[cfg(feature = "native")]
41 {
42 use etcetera::{BaseStrategy, choose_base_strategy};
43 let home = choose_base_strategy().map_or_else(|_| PathBuf::from("~"), |s| s.home_dir().to_path_buf());
44 home.join(suffix)
45 }
46 #[cfg(not(feature = "native"))]
47 {
48 let _ = suffix;
49 PathBuf::from(extends_value)
50 }
51 } else {
52 let path = PathBuf::from(extends_value);
53 if path.is_absolute() {
54 path
55 } else {
56 let config_dir = config_file_path.parent().unwrap_or(Path::new("."));
58 config_dir.join(extends_value)
59 }
60 }
61}
62
63fn source_from_filename(filename: &str) -> ConfigSource {
65 if filename == "pyproject.toml" {
66 ConfigSource::PyprojectToml
67 } else {
68 ConfigSource::ProjectConfig
69 }
70}
71
72fn load_config_with_extends(
79 sourced_config: &mut SourcedConfig<ConfigLoaded>,
80 config_file_path: &Path,
81 visited: &mut IndexSet<PathBuf>,
82 chain_source: ConfigSource,
83) -> Result<(), ConfigError> {
84 let canonical = config_file_path
86 .canonicalize()
87 .unwrap_or_else(|_| config_file_path.to_path_buf());
88
89 if visited.contains(&canonical) {
91 let chain: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
92 return Err(ConfigError::CircularExtends {
93 path: config_file_path.display().to_string(),
94 chain,
95 });
96 }
97
98 if visited.len() >= MAX_EXTENDS_DEPTH {
100 return Err(ConfigError::ExtendsDepthExceeded {
101 path: config_file_path.display().to_string(),
102 max_depth: MAX_EXTENDS_DEPTH,
103 });
104 }
105
106 visited.insert(canonical);
108
109 let path_str = config_file_path.display().to_string();
110 let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
111
112 let content = std::fs::read_to_string(config_file_path).map_err(|e| ConfigError::IoError {
114 source: e,
115 path: path_str.clone(),
116 })?;
117
118 let fragment = if filename == "pyproject.toml" {
119 match parsers::parse_pyproject_toml(&content, &path_str, chain_source)? {
120 Some(f) => f,
121 None => return Ok(()), }
123 } else {
124 parsers::parse_rumdl_toml(&content, &path_str, chain_source)?
125 };
126
127 if let Some(ref extends_value) = fragment.extends {
129 let base_path = resolve_extends_path(extends_value, config_file_path);
130
131 if !base_path.exists() {
132 return Err(ConfigError::ExtendsNotFound {
133 path: base_path.display().to_string(),
134 from: path_str.clone(),
135 });
136 }
137
138 log::debug!(
139 "[rumdl-config] Config {} extends {}, loading base first",
140 path_str,
141 base_path.display()
142 );
143
144 load_config_with_extends(sourced_config, &base_path, visited, chain_source)?;
146 }
147
148 let mut fragment_for_merge = fragment;
151 fragment_for_merge.extends = None;
152 sourced_config.merge(fragment_for_merge);
153 sourced_config.loaded_files.push(path_str);
154
155 Ok(())
156}
157
158impl SourcedConfig<ConfigLoaded> {
159 pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
162 self.global.enable.merge_override(
165 fragment.global.enable.value,
166 fragment.global.enable.source,
167 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
168 fragment.global.enable.overrides.first().and_then(|o| o.line),
169 );
170
171 self.global.disable.merge_override(
173 fragment.global.disable.value,
174 fragment.global.disable.source,
175 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
176 fragment.global.disable.overrides.first().and_then(|o| o.line),
177 );
178
179 self.global.extend_enable.merge_union(
181 fragment.global.extend_enable.value,
182 fragment.global.extend_enable.source,
183 fragment
184 .global
185 .extend_enable
186 .overrides
187 .first()
188 .and_then(|o| o.file.clone()),
189 fragment.global.extend_enable.overrides.first().and_then(|o| o.line),
190 );
191
192 self.global.extend_disable.merge_union(
194 fragment.global.extend_disable.value,
195 fragment.global.extend_disable.source,
196 fragment
197 .global
198 .extend_disable
199 .overrides
200 .first()
201 .and_then(|o| o.file.clone()),
202 fragment.global.extend_disable.overrides.first().and_then(|o| o.line),
203 );
204
205 self.global
208 .disable
209 .value
210 .retain(|rule| !self.global.enable.value.contains(rule));
211 self.global.include.merge_override(
212 fragment.global.include.value,
213 fragment.global.include.source,
214 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
215 fragment.global.include.overrides.first().and_then(|o| o.line),
216 );
217 self.global.exclude.merge_override(
218 fragment.global.exclude.value,
219 fragment.global.exclude.source,
220 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
221 fragment.global.exclude.overrides.first().and_then(|o| o.line),
222 );
223 self.global.respect_gitignore.merge_override(
224 fragment.global.respect_gitignore.value,
225 fragment.global.respect_gitignore.source,
226 fragment
227 .global
228 .respect_gitignore
229 .overrides
230 .first()
231 .and_then(|o| o.file.clone()),
232 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
233 );
234 self.global.line_length.merge_override(
235 fragment.global.line_length.value,
236 fragment.global.line_length.source,
237 fragment
238 .global
239 .line_length
240 .overrides
241 .first()
242 .and_then(|o| o.file.clone()),
243 fragment.global.line_length.overrides.first().and_then(|o| o.line),
244 );
245 self.global.fixable.merge_override(
246 fragment.global.fixable.value,
247 fragment.global.fixable.source,
248 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
249 fragment.global.fixable.overrides.first().and_then(|o| o.line),
250 );
251 self.global.unfixable.merge_override(
252 fragment.global.unfixable.value,
253 fragment.global.unfixable.source,
254 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
255 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
256 );
257
258 self.global.flavor.merge_override(
260 fragment.global.flavor.value,
261 fragment.global.flavor.source,
262 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
263 fragment.global.flavor.overrides.first().and_then(|o| o.line),
264 );
265
266 self.global.force_exclude.merge_override(
268 fragment.global.force_exclude.value,
269 fragment.global.force_exclude.source,
270 fragment
271 .global
272 .force_exclude
273 .overrides
274 .first()
275 .and_then(|o| o.file.clone()),
276 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
277 );
278
279 if let Some(output_format_fragment) = fragment.global.output_format {
281 if let Some(ref mut output_format) = self.global.output_format {
282 output_format.merge_override(
283 output_format_fragment.value,
284 output_format_fragment.source,
285 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
286 output_format_fragment.overrides.first().and_then(|o| o.line),
287 );
288 } else {
289 self.global.output_format = Some(output_format_fragment);
290 }
291 }
292
293 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
295 if let Some(ref mut cache_dir) = self.global.cache_dir {
296 cache_dir.merge_override(
297 cache_dir_fragment.value,
298 cache_dir_fragment.source,
299 cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
300 cache_dir_fragment.overrides.first().and_then(|o| o.line),
301 );
302 } else {
303 self.global.cache_dir = Some(cache_dir_fragment);
304 }
305 }
306
307 if fragment.global.cache.source != ConfigSource::Default {
309 self.global.cache.merge_override(
310 fragment.global.cache.value,
311 fragment.global.cache.source,
312 fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
313 fragment.global.cache.overrides.first().and_then(|o| o.line),
314 );
315 }
316
317 self.per_file_ignores.merge_override(
319 fragment.per_file_ignores.value,
320 fragment.per_file_ignores.source,
321 fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
322 fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
323 );
324
325 self.per_file_flavor.merge_override(
327 fragment.per_file_flavor.value,
328 fragment.per_file_flavor.source,
329 fragment.per_file_flavor.overrides.first().and_then(|o| o.file.clone()),
330 fragment.per_file_flavor.overrides.first().and_then(|o| o.line),
331 );
332
333 self.code_block_tools.merge_override(
335 fragment.code_block_tools.value,
336 fragment.code_block_tools.source,
337 fragment.code_block_tools.overrides.first().and_then(|o| o.file.clone()),
338 fragment.code_block_tools.overrides.first().and_then(|o| o.line),
339 );
340
341 for (rule_name, rule_fragment) in fragment.rules {
343 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
345
346 if let Some(severity_fragment) = rule_fragment.severity {
348 if let Some(ref mut existing_severity) = rule_entry.severity {
349 existing_severity.merge_override(
350 severity_fragment.value,
351 severity_fragment.source,
352 severity_fragment.overrides.first().and_then(|o| o.file.clone()),
353 severity_fragment.overrides.first().and_then(|o| o.line),
354 );
355 } else {
356 rule_entry.severity = Some(severity_fragment);
357 }
358 }
359
360 for (key, sourced_value_fragment) in rule_fragment.values {
362 let sv_entry = rule_entry
363 .values
364 .entry(key.clone())
365 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
366 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
367 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
368 sv_entry.merge_override(
369 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
374 }
375 }
376
377 for (section, key, file_path) in fragment.unknown_keys {
379 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
381 self.unknown_keys.push((section, key, file_path));
382 }
383 }
384 }
385
386 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
388 Self::load_with_discovery(config_path, cli_overrides, false)
389 }
390
391 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
394 let mut current = if start_dir.is_relative() {
396 std::env::current_dir().map_or_else(|_| start_dir.to_path_buf(), |cwd| cwd.join(start_dir))
397 } else {
398 start_dir.to_path_buf()
399 };
400 const MAX_DEPTH: usize = 100;
401
402 for _ in 0..MAX_DEPTH {
403 if current.join(".git").exists() {
404 log::debug!("[rumdl-config] Found .git at: {}", current.display());
405 return current;
406 }
407
408 match current.parent() {
409 Some(parent) => current = parent.to_path_buf(),
410 None => break,
411 }
412 }
413
414 log::debug!(
416 "[rumdl-config] No .git found, using config location as project root: {}",
417 start_dir.display()
418 );
419 start_dir.to_path_buf()
420 }
421
422 fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
428 home_override.map(Path::to_path_buf).or_else(|| {
429 #[cfg(feature = "native")]
430 {
431 use etcetera::{BaseStrategy, choose_base_strategy};
432 choose_base_strategy().ok().map(|s| s.home_dir().to_path_buf())
433 }
434 #[cfg(not(feature = "native"))]
435 {
436 None
437 }
438 })
439 }
440
441 fn at_home_boundary(current_dir: &Path, home_dir: Option<&Path>, canonical_home: Option<&Path>) -> bool {
445 match (canonical_home, std::fs::canonicalize(current_dir).ok()) {
446 (Some(home), Some(current)) => current == home,
447 _ => home_dir == Some(current_dir),
448 }
449 }
450
451 fn discover_config_upward(home_override: Option<&Path>) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
463 use std::env;
464
465 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
468 Ok(dir) => dir,
469 Err(e) => {
470 log::debug!("[rumdl-config] Failed to get current directory: {e}");
471 return None;
472 }
473 };
474
475 let home_dir = Self::resolve_home_boundary(home_override);
479 let canonical_home = home_dir.as_deref().and_then(|h| std::fs::canonicalize(h).ok());
480
481 let mut current_dir = start_dir.clone();
482 let mut depth = 0;
483 let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
484
485 loop {
486 if depth >= MAX_DEPTH {
487 log::debug!("[rumdl-config] Maximum traversal depth reached");
488 break;
489 }
490
491 if Self::at_home_boundary(¤t_dir, home_dir.as_deref(), canonical_home.as_deref()) {
494 log::debug!("[rumdl-config] Reached home directory boundary; stopping project discovery");
495 break;
496 }
497
498 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
499
500 if found_config.is_none() {
502 for config_name in RUMDL_CONFIG_FILES {
503 let config_path = current_dir.join(config_name);
504
505 if config_path.exists() {
506 if *config_name == "pyproject.toml" {
508 if let Ok(content) = std::fs::read_to_string(&config_path) {
509 if pyproject_declares_rumdl_config(&content) {
510 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
511 found_config = Some((config_path.clone(), current_dir.clone()));
513 break;
514 }
515 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
516 continue;
517 }
518 } else {
519 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
520 found_config = Some((config_path.clone(), current_dir.clone()));
522 break;
523 }
524 }
525 }
526 }
527
528 if current_dir.join(".git").exists() {
530 log::debug!("[rumdl-config] Stopping at .git directory");
531 break;
532 }
533
534 match current_dir.parent() {
536 Some(parent) => {
537 current_dir = parent.to_owned();
538 depth += 1;
539 }
540 None => {
541 log::debug!("[rumdl-config] Reached filesystem root");
542 break;
543 }
544 }
545 }
546
547 if let Some((config_path, config_dir)) = found_config {
549 let project_root = Self::find_project_root_from(&config_dir);
550 return Some((config_path, project_root));
551 }
552
553 None
554 }
555
556 fn discover_markdownlint_config_upward(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
561 use std::env;
562
563 const MAX_DEPTH: usize = 100;
564
565 let start_dir = match env::current_dir() {
566 Ok(dir) => dir,
567 Err(e) => {
568 log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
569 return None;
570 }
571 };
572
573 let home_dir = Self::resolve_home_boundary(home_override);
574 let canonical_home = home_dir.as_deref().and_then(|h| std::fs::canonicalize(h).ok());
575
576 let mut current_dir = start_dir.clone();
577 let mut depth = 0;
578
579 loop {
580 if depth >= MAX_DEPTH {
581 log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
582 break;
583 }
584
585 if Self::at_home_boundary(¤t_dir, home_dir.as_deref(), canonical_home.as_deref()) {
587 log::debug!("[rumdl-config] Reached home directory boundary; stopping markdownlint discovery");
588 break;
589 }
590
591 log::debug!(
592 "[rumdl-config] Searching for markdownlint config in: {}",
593 current_dir.display()
594 );
595
596 for config_name in MARKDOWNLINT_CONFIG_FILES {
598 let config_path = current_dir.join(config_name);
599 if config_path.exists() {
600 log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
601 return Some(config_path);
602 }
603 }
604
605 if current_dir.join(".git").exists() {
607 log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
608 break;
609 }
610
611 match current_dir.parent() {
613 Some(parent) => {
614 current_dir = parent.to_owned();
615 depth += 1;
616 }
617 None => {
618 log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
619 break;
620 }
621 }
622 }
623
624 None
625 }
626
627 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
629 let config_dir = config_dir.join("rumdl");
630
631 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
633
634 log::debug!(
635 "[rumdl-config] Checking for user configuration in: {}",
636 config_dir.display()
637 );
638
639 for filename in USER_CONFIG_FILES {
640 let config_path = config_dir.join(filename);
641
642 if config_path.exists() {
643 if *filename == "pyproject.toml" {
645 if let Ok(content) = std::fs::read_to_string(&config_path) {
646 if pyproject_declares_rumdl_config(&content) {
647 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
648 return Some(config_path);
649 }
650 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
651 continue;
652 }
653 } else {
654 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
655 return Some(config_path);
656 }
657 }
658 }
659
660 log::debug!(
661 "[rumdl-config] No user configuration found in: {}",
662 config_dir.display()
663 );
664 None
665 }
666
667 #[cfg(feature = "native")]
670 fn user_configuration_path() -> Option<std::path::PathBuf> {
671 use etcetera::{BaseStrategy, choose_base_strategy};
672
673 match choose_base_strategy() {
674 Ok(strategy) => {
675 let config_dir = strategy.config_dir();
676 Self::user_configuration_path_impl(&config_dir)
677 }
678 Err(e) => {
679 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
680 None
681 }
682 }
683 }
684
685 #[cfg(not(feature = "native"))]
687 fn user_configuration_path() -> Option<std::path::PathBuf> {
688 None
689 }
690
691 fn home_configuration_path_impl(home_dir: &Path) -> Option<std::path::PathBuf> {
703 const HOME_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml"];
704
705 log::debug!(
706 "[rumdl-config] Checking for home-directory configuration in: {}",
707 home_dir.display()
708 );
709
710 for filename in HOME_CONFIG_FILES {
711 let config_path = home_dir.join(filename);
712 if config_path.exists() {
713 log::debug!(
714 "[rumdl-config] Found home-directory configuration at: {}",
715 config_path.display()
716 );
717 return Some(config_path);
718 }
719 }
720
721 log::debug!(
722 "[rumdl-config] No home-directory configuration found in: {}",
723 home_dir.display()
724 );
725 None
726 }
727
728 #[cfg(feature = "native")]
734 fn home_configuration_path() -> Option<std::path::PathBuf> {
735 use etcetera::{BaseStrategy, choose_base_strategy};
736
737 match choose_base_strategy() {
738 Ok(strategy) => Self::home_configuration_path_impl(strategy.home_dir()),
739 Err(e) => {
740 log::debug!("[rumdl-config] Failed to determine home directory: {e}");
741 None
742 }
743 }
744 }
745
746 #[cfg(not(feature = "native"))]
748 fn home_configuration_path() -> Option<std::path::PathBuf> {
749 None
750 }
751
752 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
754 let path_obj = Path::new(path);
755 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
756 let path_str = path.to_string();
757
758 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
759
760 if let Some(config_parent) = path_obj.parent() {
762 let project_root = Self::find_project_root_from(config_parent);
763 log::debug!(
764 "[rumdl-config] Project root (from explicit config): {}",
765 project_root.display()
766 );
767 sourced_config.project_root = Some(project_root);
768 }
769
770 const MARKDOWNLINT_FILENAMES: &[&str] = &[
772 ".markdownlint-cli2.jsonc",
773 ".markdownlint-cli2.yaml",
774 ".markdownlint-cli2.yml",
775 ".markdownlint.json",
776 ".markdownlint.yaml",
777 ".markdownlint.yml",
778 ];
779
780 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
781 let mut visited = IndexSet::new();
783 let chain_source = source_from_filename(filename);
784 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
785 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
786 || path_str.ends_with(".json")
787 || path_str.ends_with(".jsonc")
788 || path_str.ends_with(".yaml")
789 || path_str.ends_with(".yml")
790 {
791 let fragment = parsers::load_from_markdownlint(&path_str)?;
793 sourced_config.merge(fragment);
794 sourced_config.loaded_files.push(path_str);
795 } else {
796 let mut visited = IndexSet::new();
798 let chain_source = source_from_filename(filename);
799 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
800 }
801
802 Ok(())
803 }
804
805 fn load_user_config(
822 sourced_config: &mut Self,
823 user_config_dir: Option<&Path>,
824 home_dir: Option<&Path>,
825 ) -> Result<(), ConfigError> {
826 let user_config_path = if let Some(dir) = user_config_dir {
827 Self::user_configuration_path_impl(dir)
828 } else {
829 Self::user_configuration_path()
830 };
831
832 let user_config_path = user_config_path.or_else(|| match home_dir {
833 Some(home) => Self::home_configuration_path_impl(home),
834 None => Self::home_configuration_path(),
835 });
836
837 if let Some(user_config_path) = user_config_path {
838 let path_str = user_config_path.display().to_string();
839
840 log::debug!("[rumdl-config] Loading user config: {path_str}");
841
842 let mut visited = IndexSet::new();
845 load_config_with_extends(
846 sourced_config,
847 &user_config_path,
848 &mut visited,
849 ConfigSource::UserConfig,
850 )?;
851 } else {
852 log::debug!("[rumdl-config] No user configuration file found");
853 }
854
855 Ok(())
856 }
857
858 #[doc(hidden)]
860 pub fn load_with_discovery_impl(
861 config_path: Option<&str>,
862 cli_overrides: Option<&SourcedGlobalConfig>,
863 skip_auto_discovery: bool,
864 user_config_dir: Option<&Path>,
865 home_dir: Option<&Path>,
866 ) -> Result<Self, ConfigError> {
867 use std::env;
868 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
869
870 let mut sourced_config = SourcedConfig::default();
871
872 if let Some(path) = config_path {
885 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
887 Self::load_explicit_config(&mut sourced_config, path)?;
888 } else if skip_auto_discovery {
889 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
890 } else {
892 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
894
895 if let Some((config_file, project_root)) = Self::discover_config_upward(home_dir) {
897 log::debug!("[rumdl-config] Found project config: {}", config_file.display());
901 log::debug!("[rumdl-config] Project root: {}", project_root.display());
902
903 sourced_config.project_root = Some(project_root);
904
905 let mut visited = IndexSet::new();
907 let root_filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
908 let chain_source = source_from_filename(root_filename);
909 load_config_with_extends(&mut sourced_config, &config_file, &mut visited, chain_source)?;
910 } else {
911 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
913
914 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward(home_dir) {
915 let path_str = markdownlint_path.display().to_string();
916 log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
917 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
922 match parsers::load_from_markdownlint(&path_str) {
923 Ok(fragment) => {
924 sourced_config.merge(fragment);
925 sourced_config.loaded_files.push(path_str);
926 }
927 Err(_e) => {
928 log::debug!("[rumdl-config] Failed to load markdownlint config");
929 }
930 }
931 } else {
932 log::debug!("[rumdl-config] No project config found, using user config as fallback");
934 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
935 }
936 }
937 }
938
939 if let Some(cli) = cli_overrides {
941 sourced_config
942 .global
943 .enable
944 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
945 sourced_config
946 .global
947 .disable
948 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
949 sourced_config
950 .global
951 .exclude
952 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
953 sourced_config
954 .global
955 .include
956 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
957 sourced_config.global.respect_gitignore.merge_override(
958 cli.respect_gitignore.value,
959 ConfigSource::Cli,
960 None,
961 None,
962 );
963 sourced_config
964 .global
965 .fixable
966 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
967 sourced_config
968 .global
969 .unfixable
970 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
971 }
973
974 Ok(sourced_config)
977 }
978
979 pub fn load_with_discovery(
982 config_path: Option<&str>,
983 cli_overrides: Option<&SourcedGlobalConfig>,
984 skip_auto_discovery: bool,
985 ) -> Result<Self, ConfigError> {
986 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
987 }
988
989 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
1003 let warnings = validate_config_sourced_internal(&self, registry);
1004
1005 Ok(SourcedConfig {
1006 global: self.global,
1007 per_file_ignores: self.per_file_ignores,
1008 per_file_flavor: self.per_file_flavor,
1009 code_block_tools: self.code_block_tools,
1010 rules: self.rules,
1011 loaded_files: self.loaded_files,
1012 unknown_keys: self.unknown_keys,
1013 project_root: self.project_root,
1014 validation_warnings: warnings,
1015 _state: PhantomData,
1016 })
1017 }
1018
1019 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
1024 let validated = self.validate(registry)?;
1025 let warnings = validated.validation_warnings.clone();
1026 Ok((validated.into(), warnings))
1027 }
1028
1029 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
1040 SourcedConfig {
1041 global: self.global,
1042 per_file_ignores: self.per_file_ignores,
1043 per_file_flavor: self.per_file_flavor,
1044 code_block_tools: self.code_block_tools,
1045 rules: self.rules,
1046 loaded_files: self.loaded_files,
1047 unknown_keys: self.unknown_keys,
1048 project_root: self.project_root,
1049 validation_warnings: Vec::new(),
1050 _state: PhantomData,
1051 }
1052 }
1053
1054 pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
1063 let canonical_project_root = std::fs::canonicalize(project_root).ok();
1072 let mut current_dir = dir.to_path_buf();
1073
1074 loop {
1075 for config_name in RUMDL_CONFIG_FILES {
1077 let config_path = current_dir.join(config_name);
1078 if config_path.exists() {
1079 if *config_name == "pyproject.toml" {
1080 if let Ok(content) = std::fs::read_to_string(&config_path)
1081 && pyproject_declares_rumdl_config(&content)
1082 {
1083 return Some(config_path);
1084 }
1085 continue;
1086 }
1087 return Some(config_path);
1088 }
1089 }
1090
1091 for config_name in MARKDOWNLINT_CONFIG_FILES {
1093 let config_path = current_dir.join(config_name);
1094 if config_path.exists() {
1095 return Some(config_path);
1096 }
1097 }
1098
1099 let reached_root = match (&canonical_project_root, std::fs::canonicalize(¤t_dir).ok()) {
1102 (Some(root), Some(current)) => ¤t == root,
1103 _ => current_dir == project_root,
1104 };
1105 if reached_root {
1106 break;
1107 }
1108
1109 match current_dir.parent() {
1111 Some(parent) => current_dir = parent.to_path_buf(),
1112 None => break,
1113 }
1114 }
1115
1116 None
1117 }
1118
1119 pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
1125 let mut sourced_config = SourcedConfig {
1126 project_root: Some(project_root.to_path_buf()),
1127 ..SourcedConfig::default()
1128 };
1129
1130 let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1131 let path_str = config_path.display().to_string();
1132
1133 let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
1135 || (filename != "pyproject.toml"
1136 && filename != ".rumdl.toml"
1137 && filename != "rumdl.toml"
1138 && (path_str.ends_with(".json")
1139 || path_str.ends_with(".jsonc")
1140 || path_str.ends_with(".yaml")
1141 || path_str.ends_with(".yml")));
1142
1143 if is_markdownlint {
1144 let fragment = parsers::load_from_markdownlint(&path_str)?;
1145 sourced_config.merge(fragment);
1146 sourced_config.loaded_files.push(path_str);
1147 } else {
1148 let mut visited = IndexSet::new();
1149 let chain_source = source_from_filename(filename);
1150 load_config_with_extends(&mut sourced_config, config_path, &mut visited, chain_source)?;
1151 }
1152
1153 Ok(sourced_config.into_validated_unchecked().into())
1154 }
1155}
1156
1157impl From<SourcedConfig<ConfigValidated>> for Config {
1162 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
1163 let mut rules = BTreeMap::new();
1164 for (rule_name, sourced_rule_cfg) in sourced.rules {
1165 let normalized_rule_name = rule_name.to_ascii_uppercase();
1167 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
1168 let mut values = BTreeMap::new();
1169 for (key, sourced_val) in sourced_rule_cfg.values {
1170 values.insert(key, sourced_val.value);
1171 }
1172 rules.insert(normalized_rule_name, RuleConfig { severity, values });
1173 }
1174 let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
1176
1177 #[allow(deprecated)]
1178 let global = GlobalConfig {
1179 enable: sourced.global.enable.value,
1180 disable: sourced.global.disable.value,
1181 exclude: sourced.global.exclude.value,
1182 include: sourced.global.include.value,
1183 respect_gitignore: sourced.global.respect_gitignore.value,
1184 line_length: sourced.global.line_length.value,
1185 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1186 fixable: sourced.global.fixable.value,
1187 unfixable: sourced.global.unfixable.value,
1188 flavor: sourced.global.flavor.value,
1189 force_exclude: sourced.global.force_exclude.value,
1190 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
1191 cache: sourced.global.cache.value,
1192 extend_enable: sourced.global.extend_enable.value,
1193 extend_disable: sourced.global.extend_disable.value,
1194 enable_is_explicit,
1195 };
1196
1197 let mut config = Config {
1198 extends: None,
1199 global,
1200 per_file_ignores: sourced.per_file_ignores.value,
1201 per_file_flavor: sourced.per_file_flavor.value,
1202 code_block_tools: sourced.code_block_tools.value,
1203 rules,
1204 project_root: sourced.project_root,
1205 per_file_ignores_cache: Arc::new(OnceLock::new()),
1206 per_file_flavor_cache: Arc::new(OnceLock::new()),
1207 canonical_project_root_cache: Arc::new(OnceLock::new()),
1208 };
1209
1210 config.apply_per_rule_enabled();
1212
1213 config.canonicalize_rule_lists();
1220
1221 config
1222 }
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::pyproject_declares_rumdl_config;
1228
1229 #[test]
1230 fn detects_flat_and_dotted_rumdl_sections() {
1231 assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
1232 assert!(pyproject_declares_rumdl_config(
1234 "[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
1235 ));
1236 assert!(pyproject_declares_rumdl_config(
1237 "[tool.rumdl.rules.MD007]\nindent = 4\n"
1238 ));
1239 }
1240
1241 #[test]
1242 fn ignores_incidental_mentions() {
1243 assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
1246 assert!(!pyproject_declares_rumdl_config(
1247 "[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
1248 ));
1249 assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
1250 }
1251
1252 #[cfg(unix)]
1261 #[test]
1262 fn discover_stops_at_project_root_across_path_representations() {
1263 use super::SourcedConfig;
1264 use std::os::unix::fs::symlink;
1265 use tempfile::tempdir;
1266
1267 let tmp = tempdir().unwrap();
1268 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
1270
1271 let real_root = tmp.path().join("project");
1272 let subdir = real_root.join("docs");
1273 std::fs::create_dir_all(&subdir).unwrap();
1274
1275 let linked_root = tmp.path().join("project-link");
1278 symlink(&real_root, &linked_root).unwrap();
1279
1280 let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
1281 assert_eq!(
1282 found, None,
1283 "discovery must stop at the project root, not overshoot to the parent config"
1284 );
1285 }
1286}