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 resolve_extends_path(extends_value: &str, config_file_path: &Path) -> PathBuf {
26 if let Some(suffix) = extends_value.strip_prefix("~/") {
27 #[cfg(feature = "native")]
29 {
30 use etcetera::{BaseStrategy, choose_base_strategy};
31 let home = choose_base_strategy().map_or_else(|_| PathBuf::from("~"), |s| s.home_dir().to_path_buf());
32 home.join(suffix)
33 }
34 #[cfg(not(feature = "native"))]
35 {
36 let _ = suffix;
37 PathBuf::from(extends_value)
38 }
39 } else {
40 let path = PathBuf::from(extends_value);
41 if path.is_absolute() {
42 path
43 } else {
44 let config_dir = config_file_path.parent().unwrap_or(Path::new("."));
46 config_dir.join(extends_value)
47 }
48 }
49}
50
51fn source_from_filename(filename: &str) -> ConfigSource {
53 if filename == "pyproject.toml" {
54 ConfigSource::PyprojectToml
55 } else {
56 ConfigSource::ProjectConfig
57 }
58}
59
60fn load_config_with_extends(
67 sourced_config: &mut SourcedConfig<ConfigLoaded>,
68 config_file_path: &Path,
69 visited: &mut IndexSet<PathBuf>,
70 chain_source: ConfigSource,
71) -> Result<(), ConfigError> {
72 let canonical = config_file_path
74 .canonicalize()
75 .unwrap_or_else(|_| config_file_path.to_path_buf());
76
77 if visited.contains(&canonical) {
79 let chain: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
80 return Err(ConfigError::CircularExtends {
81 path: config_file_path.display().to_string(),
82 chain,
83 });
84 }
85
86 if visited.len() >= MAX_EXTENDS_DEPTH {
88 return Err(ConfigError::ExtendsDepthExceeded {
89 path: config_file_path.display().to_string(),
90 max_depth: MAX_EXTENDS_DEPTH,
91 });
92 }
93
94 visited.insert(canonical);
96
97 let path_str = config_file_path.display().to_string();
98 let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
99
100 let content = std::fs::read_to_string(config_file_path).map_err(|e| ConfigError::IoError {
102 source: e,
103 path: path_str.clone(),
104 })?;
105
106 let fragment = if filename == "pyproject.toml" {
107 match parsers::parse_pyproject_toml(&content, &path_str, chain_source)? {
108 Some(f) => f,
109 None => return Ok(()), }
111 } else {
112 parsers::parse_rumdl_toml(&content, &path_str, chain_source)?
113 };
114
115 if let Some(ref extends_value) = fragment.extends {
117 let base_path = resolve_extends_path(extends_value, config_file_path);
118
119 if !base_path.exists() {
120 return Err(ConfigError::ExtendsNotFound {
121 path: base_path.display().to_string(),
122 from: path_str.clone(),
123 });
124 }
125
126 log::debug!(
127 "[rumdl-config] Config {} extends {}, loading base first",
128 path_str,
129 base_path.display()
130 );
131
132 load_config_with_extends(sourced_config, &base_path, visited, chain_source)?;
134 }
135
136 let mut fragment_for_merge = fragment;
139 fragment_for_merge.extends = None;
140 sourced_config.merge(fragment_for_merge);
141 sourced_config.loaded_files.push(path_str);
142
143 Ok(())
144}
145
146impl SourcedConfig<ConfigLoaded> {
147 pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
150 self.global.enable.merge_override(
153 fragment.global.enable.value,
154 fragment.global.enable.source,
155 fragment.global.enable.overrides.first().and_then(|o| o.file.clone()),
156 fragment.global.enable.overrides.first().and_then(|o| o.line),
157 );
158
159 self.global.disable.merge_override(
161 fragment.global.disable.value,
162 fragment.global.disable.source,
163 fragment.global.disable.overrides.first().and_then(|o| o.file.clone()),
164 fragment.global.disable.overrides.first().and_then(|o| o.line),
165 );
166
167 self.global.extend_enable.merge_union(
169 fragment.global.extend_enable.value,
170 fragment.global.extend_enable.source,
171 fragment
172 .global
173 .extend_enable
174 .overrides
175 .first()
176 .and_then(|o| o.file.clone()),
177 fragment.global.extend_enable.overrides.first().and_then(|o| o.line),
178 );
179
180 self.global.extend_disable.merge_union(
182 fragment.global.extend_disable.value,
183 fragment.global.extend_disable.source,
184 fragment
185 .global
186 .extend_disable
187 .overrides
188 .first()
189 .and_then(|o| o.file.clone()),
190 fragment.global.extend_disable.overrides.first().and_then(|o| o.line),
191 );
192
193 self.global
196 .disable
197 .value
198 .retain(|rule| !self.global.enable.value.contains(rule));
199 self.global.include.merge_override(
200 fragment.global.include.value,
201 fragment.global.include.source,
202 fragment.global.include.overrides.first().and_then(|o| o.file.clone()),
203 fragment.global.include.overrides.first().and_then(|o| o.line),
204 );
205 self.global.exclude.merge_override(
206 fragment.global.exclude.value,
207 fragment.global.exclude.source,
208 fragment.global.exclude.overrides.first().and_then(|o| o.file.clone()),
209 fragment.global.exclude.overrides.first().and_then(|o| o.line),
210 );
211 self.global.respect_gitignore.merge_override(
212 fragment.global.respect_gitignore.value,
213 fragment.global.respect_gitignore.source,
214 fragment
215 .global
216 .respect_gitignore
217 .overrides
218 .first()
219 .and_then(|o| o.file.clone()),
220 fragment.global.respect_gitignore.overrides.first().and_then(|o| o.line),
221 );
222 self.global.line_length.merge_override(
223 fragment.global.line_length.value,
224 fragment.global.line_length.source,
225 fragment
226 .global
227 .line_length
228 .overrides
229 .first()
230 .and_then(|o| o.file.clone()),
231 fragment.global.line_length.overrides.first().and_then(|o| o.line),
232 );
233 self.global.fixable.merge_override(
234 fragment.global.fixable.value,
235 fragment.global.fixable.source,
236 fragment.global.fixable.overrides.first().and_then(|o| o.file.clone()),
237 fragment.global.fixable.overrides.first().and_then(|o| o.line),
238 );
239 self.global.unfixable.merge_override(
240 fragment.global.unfixable.value,
241 fragment.global.unfixable.source,
242 fragment.global.unfixable.overrides.first().and_then(|o| o.file.clone()),
243 fragment.global.unfixable.overrides.first().and_then(|o| o.line),
244 );
245
246 self.global.flavor.merge_override(
248 fragment.global.flavor.value,
249 fragment.global.flavor.source,
250 fragment.global.flavor.overrides.first().and_then(|o| o.file.clone()),
251 fragment.global.flavor.overrides.first().and_then(|o| o.line),
252 );
253
254 self.global.force_exclude.merge_override(
256 fragment.global.force_exclude.value,
257 fragment.global.force_exclude.source,
258 fragment
259 .global
260 .force_exclude
261 .overrides
262 .first()
263 .and_then(|o| o.file.clone()),
264 fragment.global.force_exclude.overrides.first().and_then(|o| o.line),
265 );
266
267 if let Some(output_format_fragment) = fragment.global.output_format {
269 if let Some(ref mut output_format) = self.global.output_format {
270 output_format.merge_override(
271 output_format_fragment.value,
272 output_format_fragment.source,
273 output_format_fragment.overrides.first().and_then(|o| o.file.clone()),
274 output_format_fragment.overrides.first().and_then(|o| o.line),
275 );
276 } else {
277 self.global.output_format = Some(output_format_fragment);
278 }
279 }
280
281 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
283 if let Some(ref mut cache_dir) = self.global.cache_dir {
284 cache_dir.merge_override(
285 cache_dir_fragment.value,
286 cache_dir_fragment.source,
287 cache_dir_fragment.overrides.first().and_then(|o| o.file.clone()),
288 cache_dir_fragment.overrides.first().and_then(|o| o.line),
289 );
290 } else {
291 self.global.cache_dir = Some(cache_dir_fragment);
292 }
293 }
294
295 if fragment.global.cache.source != ConfigSource::Default {
297 self.global.cache.merge_override(
298 fragment.global.cache.value,
299 fragment.global.cache.source,
300 fragment.global.cache.overrides.first().and_then(|o| o.file.clone()),
301 fragment.global.cache.overrides.first().and_then(|o| o.line),
302 );
303 }
304
305 self.per_file_ignores.merge_override(
307 fragment.per_file_ignores.value,
308 fragment.per_file_ignores.source,
309 fragment.per_file_ignores.overrides.first().and_then(|o| o.file.clone()),
310 fragment.per_file_ignores.overrides.first().and_then(|o| o.line),
311 );
312
313 self.per_file_flavor.merge_override(
315 fragment.per_file_flavor.value,
316 fragment.per_file_flavor.source,
317 fragment.per_file_flavor.overrides.first().and_then(|o| o.file.clone()),
318 fragment.per_file_flavor.overrides.first().and_then(|o| o.line),
319 );
320
321 self.code_block_tools.merge_override(
323 fragment.code_block_tools.value,
324 fragment.code_block_tools.source,
325 fragment.code_block_tools.overrides.first().and_then(|o| o.file.clone()),
326 fragment.code_block_tools.overrides.first().and_then(|o| o.line),
327 );
328
329 for (rule_name, rule_fragment) in fragment.rules {
331 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
333
334 if let Some(severity_fragment) = rule_fragment.severity {
336 if let Some(ref mut existing_severity) = rule_entry.severity {
337 existing_severity.merge_override(
338 severity_fragment.value,
339 severity_fragment.source,
340 severity_fragment.overrides.first().and_then(|o| o.file.clone()),
341 severity_fragment.overrides.first().and_then(|o| o.line),
342 );
343 } else {
344 rule_entry.severity = Some(severity_fragment);
345 }
346 }
347
348 for (key, sourced_value_fragment) in rule_fragment.values {
350 let sv_entry = rule_entry
351 .values
352 .entry(key.clone())
353 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
354 let file_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.file.clone());
355 let line_from_fragment = sourced_value_fragment.overrides.first().and_then(|o| o.line);
356 sv_entry.merge_override(
357 sourced_value_fragment.value, sourced_value_fragment.source, file_from_fragment, line_from_fragment, );
362 }
363 }
364
365 for (section, key, file_path) in fragment.unknown_keys {
367 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
369 self.unknown_keys.push((section, key, file_path));
370 }
371 }
372 }
373
374 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
376 Self::load_with_discovery(config_path, cli_overrides, false)
377 }
378
379 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
382 let mut current = if start_dir.is_relative() {
384 std::env::current_dir().map_or_else(|_| start_dir.to_path_buf(), |cwd| cwd.join(start_dir))
385 } else {
386 start_dir.to_path_buf()
387 };
388 const MAX_DEPTH: usize = 100;
389
390 for _ in 0..MAX_DEPTH {
391 if current.join(".git").exists() {
392 log::debug!("[rumdl-config] Found .git at: {}", current.display());
393 return current;
394 }
395
396 match current.parent() {
397 Some(parent) => current = parent.to_path_buf(),
398 None => break,
399 }
400 }
401
402 log::debug!(
404 "[rumdl-config] No .git found, using config location as project root: {}",
405 start_dir.display()
406 );
407 start_dir.to_path_buf()
408 }
409
410 fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
416 use std::env;
417
418 const MAX_DEPTH: usize = 100; let start_dir = match env::current_dir() {
421 Ok(dir) => dir,
422 Err(e) => {
423 log::debug!("[rumdl-config] Failed to get current directory: {e}");
424 return None;
425 }
426 };
427
428 let mut current_dir = start_dir.clone();
429 let mut depth = 0;
430 let mut found_config: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
431
432 loop {
433 if depth >= MAX_DEPTH {
434 log::debug!("[rumdl-config] Maximum traversal depth reached");
435 break;
436 }
437
438 log::debug!("[rumdl-config] Searching for config in: {}", current_dir.display());
439
440 if found_config.is_none() {
442 for config_name in RUMDL_CONFIG_FILES {
443 let config_path = current_dir.join(config_name);
444
445 if config_path.exists() {
446 if *config_name == "pyproject.toml" {
448 if let Ok(content) = std::fs::read_to_string(&config_path) {
449 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
450 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
451 found_config = Some((config_path.clone(), current_dir.clone()));
453 break;
454 }
455 log::debug!("[rumdl-config] Found pyproject.toml but no [tool.rumdl] section");
456 continue;
457 }
458 } else {
459 log::debug!("[rumdl-config] Found config file: {}", config_path.display());
460 found_config = Some((config_path.clone(), current_dir.clone()));
462 break;
463 }
464 }
465 }
466 }
467
468 if current_dir.join(".git").exists() {
470 log::debug!("[rumdl-config] Stopping at .git directory");
471 break;
472 }
473
474 match current_dir.parent() {
476 Some(parent) => {
477 current_dir = parent.to_owned();
478 depth += 1;
479 }
480 None => {
481 log::debug!("[rumdl-config] Reached filesystem root");
482 break;
483 }
484 }
485 }
486
487 if let Some((config_path, config_dir)) = found_config {
489 let project_root = Self::find_project_root_from(&config_dir);
490 return Some((config_path, project_root));
491 }
492
493 None
494 }
495
496 fn discover_markdownlint_config_upward() -> Option<std::path::PathBuf> {
500 use std::env;
501
502 const MAX_DEPTH: usize = 100;
503
504 let start_dir = match env::current_dir() {
505 Ok(dir) => dir,
506 Err(e) => {
507 log::debug!("[rumdl-config] Failed to get current directory for markdownlint discovery: {e}");
508 return None;
509 }
510 };
511
512 let mut current_dir = start_dir.clone();
513 let mut depth = 0;
514
515 loop {
516 if depth >= MAX_DEPTH {
517 log::debug!("[rumdl-config] Maximum traversal depth reached for markdownlint discovery");
518 break;
519 }
520
521 log::debug!(
522 "[rumdl-config] Searching for markdownlint config in: {}",
523 current_dir.display()
524 );
525
526 for config_name in MARKDOWNLINT_CONFIG_FILES {
528 let config_path = current_dir.join(config_name);
529 if config_path.exists() {
530 log::debug!("[rumdl-config] Found markdownlint config: {}", config_path.display());
531 return Some(config_path);
532 }
533 }
534
535 if current_dir.join(".git").exists() {
537 log::debug!("[rumdl-config] Stopping markdownlint search at .git directory");
538 break;
539 }
540
541 match current_dir.parent() {
543 Some(parent) => {
544 current_dir = parent.to_owned();
545 depth += 1;
546 }
547 None => {
548 log::debug!("[rumdl-config] Reached filesystem root during markdownlint search");
549 break;
550 }
551 }
552 }
553
554 None
555 }
556
557 fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
559 let config_dir = config_dir.join("rumdl");
560
561 const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
563
564 log::debug!(
565 "[rumdl-config] Checking for user configuration in: {}",
566 config_dir.display()
567 );
568
569 for filename in USER_CONFIG_FILES {
570 let config_path = config_dir.join(filename);
571
572 if config_path.exists() {
573 if *filename == "pyproject.toml" {
575 if let Ok(content) = std::fs::read_to_string(&config_path) {
576 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
577 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
578 return Some(config_path);
579 }
580 log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
581 continue;
582 }
583 } else {
584 log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
585 return Some(config_path);
586 }
587 }
588 }
589
590 log::debug!(
591 "[rumdl-config] No user configuration found in: {}",
592 config_dir.display()
593 );
594 None
595 }
596
597 #[cfg(feature = "native")]
600 fn user_configuration_path() -> Option<std::path::PathBuf> {
601 use etcetera::{BaseStrategy, choose_base_strategy};
602
603 match choose_base_strategy() {
604 Ok(strategy) => {
605 let config_dir = strategy.config_dir();
606 Self::user_configuration_path_impl(&config_dir)
607 }
608 Err(e) => {
609 log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
610 None
611 }
612 }
613 }
614
615 #[cfg(not(feature = "native"))]
617 fn user_configuration_path() -> Option<std::path::PathBuf> {
618 None
619 }
620
621 fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
623 let path_obj = Path::new(path);
624 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
625 let path_str = path.to_string();
626
627 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
628
629 if let Some(config_parent) = path_obj.parent() {
631 let project_root = Self::find_project_root_from(config_parent);
632 log::debug!(
633 "[rumdl-config] Project root (from explicit config): {}",
634 project_root.display()
635 );
636 sourced_config.project_root = Some(project_root);
637 }
638
639 const MARKDOWNLINT_FILENAMES: &[&str] = &[
641 ".markdownlint-cli2.jsonc",
642 ".markdownlint-cli2.yaml",
643 ".markdownlint-cli2.yml",
644 ".markdownlint.json",
645 ".markdownlint.yaml",
646 ".markdownlint.yml",
647 ];
648
649 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
650 let mut visited = IndexSet::new();
652 let chain_source = source_from_filename(filename);
653 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
654 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
655 || path_str.ends_with(".json")
656 || path_str.ends_with(".jsonc")
657 || path_str.ends_with(".yaml")
658 || path_str.ends_with(".yml")
659 {
660 let fragment = parsers::load_from_markdownlint(&path_str)?;
662 sourced_config.merge(fragment);
663 sourced_config.loaded_files.push(path_str);
664 } else {
665 let mut visited = IndexSet::new();
667 let chain_source = source_from_filename(filename);
668 load_config_with_extends(sourced_config, path_obj, &mut visited, chain_source)?;
669 }
670
671 Ok(())
672 }
673
674 fn load_user_config(sourced_config: &mut Self, user_config_dir: Option<&Path>) -> Result<(), ConfigError> {
686 let user_config_path = if let Some(dir) = user_config_dir {
687 Self::user_configuration_path_impl(dir)
688 } else {
689 Self::user_configuration_path()
690 };
691
692 if let Some(user_config_path) = user_config_path {
693 let path_str = user_config_path.display().to_string();
694
695 log::debug!("[rumdl-config] Loading user config: {path_str}");
696
697 let mut visited = IndexSet::new();
700 load_config_with_extends(
701 sourced_config,
702 &user_config_path,
703 &mut visited,
704 ConfigSource::UserConfig,
705 )?;
706 } else {
707 log::debug!("[rumdl-config] No user configuration file found");
708 }
709
710 Ok(())
711 }
712
713 #[doc(hidden)]
715 pub fn load_with_discovery_impl(
716 config_path: Option<&str>,
717 cli_overrides: Option<&SourcedGlobalConfig>,
718 skip_auto_discovery: bool,
719 user_config_dir: Option<&Path>,
720 ) -> Result<Self, ConfigError> {
721 use std::env;
722 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
723
724 let mut sourced_config = SourcedConfig::default();
725
726 if let Some(path) = config_path {
739 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
741 Self::load_explicit_config(&mut sourced_config, path)?;
742 } else if skip_auto_discovery {
743 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
744 } else {
746 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
748
749 if let Some((config_file, project_root)) = Self::discover_config_upward() {
751 log::debug!("[rumdl-config] Found project config: {}", config_file.display());
755 log::debug!("[rumdl-config] Project root: {}", project_root.display());
756
757 sourced_config.project_root = Some(project_root);
758
759 let mut visited = IndexSet::new();
761 let root_filename = config_file.file_name().and_then(|n| n.to_str()).unwrap_or("");
762 let chain_source = source_from_filename(root_filename);
763 load_config_with_extends(&mut sourced_config, &config_file, &mut visited, chain_source)?;
764 } else {
765 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
767
768 if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward() {
769 let path_str = markdownlint_path.display().to_string();
770 log::debug!("[rumdl-config] Found markdownlint config: {path_str}");
771 Self::load_user_config(&mut sourced_config, user_config_dir)?;
776 match parsers::load_from_markdownlint(&path_str) {
777 Ok(fragment) => {
778 sourced_config.merge(fragment);
779 sourced_config.loaded_files.push(path_str);
780 }
781 Err(_e) => {
782 log::debug!("[rumdl-config] Failed to load markdownlint config");
783 }
784 }
785 } else {
786 log::debug!("[rumdl-config] No project config found, using user config as fallback");
788 Self::load_user_config(&mut sourced_config, user_config_dir)?;
789 }
790 }
791 }
792
793 if let Some(cli) = cli_overrides {
795 sourced_config
796 .global
797 .enable
798 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None, None);
799 sourced_config
800 .global
801 .disable
802 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None, None);
803 sourced_config
804 .global
805 .exclude
806 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None, None);
807 sourced_config
808 .global
809 .include
810 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None, None);
811 sourced_config.global.respect_gitignore.merge_override(
812 cli.respect_gitignore.value,
813 ConfigSource::Cli,
814 None,
815 None,
816 );
817 sourced_config
818 .global
819 .fixable
820 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None, None);
821 sourced_config
822 .global
823 .unfixable
824 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None, None);
825 }
827
828 Ok(sourced_config)
831 }
832
833 pub fn load_with_discovery(
836 config_path: Option<&str>,
837 cli_overrides: Option<&SourcedGlobalConfig>,
838 skip_auto_discovery: bool,
839 ) -> Result<Self, ConfigError> {
840 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None)
841 }
842
843 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
857 let warnings = validate_config_sourced_internal(&self, registry);
858
859 Ok(SourcedConfig {
860 global: self.global,
861 per_file_ignores: self.per_file_ignores,
862 per_file_flavor: self.per_file_flavor,
863 code_block_tools: self.code_block_tools,
864 rules: self.rules,
865 loaded_files: self.loaded_files,
866 unknown_keys: self.unknown_keys,
867 project_root: self.project_root,
868 validation_warnings: warnings,
869 _state: PhantomData,
870 })
871 }
872
873 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
878 let validated = self.validate(registry)?;
879 let warnings = validated.validation_warnings.clone();
880 Ok((validated.into(), warnings))
881 }
882
883 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
894 SourcedConfig {
895 global: self.global,
896 per_file_ignores: self.per_file_ignores,
897 per_file_flavor: self.per_file_flavor,
898 code_block_tools: self.code_block_tools,
899 rules: self.rules,
900 loaded_files: self.loaded_files,
901 unknown_keys: self.unknown_keys,
902 project_root: self.project_root,
903 validation_warnings: Vec::new(),
904 _state: PhantomData,
905 }
906 }
907
908 pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
917 let mut current_dir = dir.to_path_buf();
918
919 loop {
920 for config_name in RUMDL_CONFIG_FILES {
922 let config_path = current_dir.join(config_name);
923 if config_path.exists() {
924 if *config_name == "pyproject.toml" {
925 if let Ok(content) = std::fs::read_to_string(&config_path)
926 && (content.contains("[tool.rumdl]") || content.contains("tool.rumdl"))
927 {
928 return Some(config_path);
929 }
930 continue;
931 }
932 return Some(config_path);
933 }
934 }
935
936 for config_name in MARKDOWNLINT_CONFIG_FILES {
938 let config_path = current_dir.join(config_name);
939 if config_path.exists() {
940 return Some(config_path);
941 }
942 }
943
944 if current_dir == project_root {
946 break;
947 }
948
949 match current_dir.parent() {
951 Some(parent) => current_dir = parent.to_path_buf(),
952 None => break,
953 }
954 }
955
956 None
957 }
958
959 pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
965 let mut sourced_config = SourcedConfig {
966 project_root: Some(project_root.to_path_buf()),
967 ..SourcedConfig::default()
968 };
969
970 let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
971 let path_str = config_path.display().to_string();
972
973 let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
975 || (filename != "pyproject.toml"
976 && filename != ".rumdl.toml"
977 && filename != "rumdl.toml"
978 && (path_str.ends_with(".json")
979 || path_str.ends_with(".jsonc")
980 || path_str.ends_with(".yaml")
981 || path_str.ends_with(".yml")));
982
983 if is_markdownlint {
984 let fragment = parsers::load_from_markdownlint(&path_str)?;
985 sourced_config.merge(fragment);
986 sourced_config.loaded_files.push(path_str);
987 } else {
988 let mut visited = IndexSet::new();
989 let chain_source = source_from_filename(filename);
990 load_config_with_extends(&mut sourced_config, config_path, &mut visited, chain_source)?;
991 }
992
993 Ok(sourced_config.into_validated_unchecked().into())
994 }
995}
996
997impl From<SourcedConfig<ConfigValidated>> for Config {
1002 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
1003 let mut rules = BTreeMap::new();
1004 for (rule_name, sourced_rule_cfg) in sourced.rules {
1005 let normalized_rule_name = rule_name.to_ascii_uppercase();
1007 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
1008 let mut values = BTreeMap::new();
1009 for (key, sourced_val) in sourced_rule_cfg.values {
1010 values.insert(key, sourced_val.value);
1011 }
1012 rules.insert(normalized_rule_name, RuleConfig { severity, values });
1013 }
1014 let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
1016
1017 #[allow(deprecated)]
1018 let global = GlobalConfig {
1019 enable: sourced.global.enable.value,
1020 disable: sourced.global.disable.value,
1021 exclude: sourced.global.exclude.value,
1022 include: sourced.global.include.value,
1023 respect_gitignore: sourced.global.respect_gitignore.value,
1024 line_length: sourced.global.line_length.value,
1025 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1026 fixable: sourced.global.fixable.value,
1027 unfixable: sourced.global.unfixable.value,
1028 flavor: sourced.global.flavor.value,
1029 force_exclude: sourced.global.force_exclude.value,
1030 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
1031 cache: sourced.global.cache.value,
1032 extend_enable: sourced.global.extend_enable.value,
1033 extend_disable: sourced.global.extend_disable.value,
1034 enable_is_explicit,
1035 };
1036
1037 let mut config = Config {
1038 extends: None,
1039 global,
1040 per_file_ignores: sourced.per_file_ignores.value,
1041 per_file_flavor: sourced.per_file_flavor.value,
1042 code_block_tools: sourced.code_block_tools.value,
1043 rules,
1044 project_root: sourced.project_root,
1045 per_file_ignores_cache: Arc::new(OnceLock::new()),
1046 per_file_flavor_cache: Arc::new(OnceLock::new()),
1047 };
1048
1049 config.apply_per_rule_enabled();
1051
1052 config.canonicalize_rule_lists();
1059
1060 config
1061 }
1062}