1use indexmap::IndexSet;
2use std::collections::BTreeMap;
3use std::marker::PhantomData;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, OnceLock};
6
7use super::discovery_trace::{DiscoveryTrace, HomeSource, ProbeOutcome, SearchStage, SkipReason, TraceEvent, WalkEnd};
8use super::file_source::{ConfigFileSource, FsConfigFiles};
9use super::flavor::ConfigLoaded;
10use super::flavor::ConfigValidated;
11use super::parsers;
12use super::registry::RuleRegistry;
13use super::source_tracking::{
14 ConfigSource, ConfigValidationWarning, SourcedConfig, SourcedConfigFragment, SourcedGlobalConfig, SourcedValue,
15};
16use super::types::{
17 Config, ConfigError, ConfigOrigin, DiscoveredConfigError, GlobalConfig, MARKDOWNLINT_CONFIG_FILES,
18 RUMDL_CONFIG_FILES, RuleConfig, WITHHELD,
19};
20use super::user_paths::{self, Platform, UserCandidate};
21use super::validation::validate_config_sourced_internal;
22use crate::utils::upward_walk::UpwardWalk;
23
24const MAX_EXTENDS_DEPTH: usize = 10;
26
27fn pyproject_declares_rumdl_config(content: &str) -> bool {
36 content.contains("[tool.rumdl]") || content.contains("[tool.rumdl.")
37}
38
39fn is_var_name_start(b: u8) -> bool {
41 b == b'_' || b.is_ascii_alphabetic()
42}
43
44fn is_var_name_continue(b: u8) -> bool {
46 b == b'_' || b.is_ascii_alphanumeric()
47}
48
49fn is_valid_var_name(name: &str) -> bool {
51 let bytes = name.as_bytes();
52 !bytes.is_empty() && is_var_name_start(bytes[0]) && bytes[1..].iter().all(|&b| is_var_name_continue(b))
53}
54
55fn expand_env_vars(input: &str, lookup: impl Fn(&str) -> Option<String>) -> Result<String, String> {
73 let bytes = input.as_bytes();
74 let mut out = String::with_capacity(input.len());
75 let mut i = 0;
76
77 while i < bytes.len() {
78 if bytes[i] != b'$' {
79 let start = i;
81 while i < bytes.len() && bytes[i] != b'$' {
82 i += 1;
83 }
84 out.push_str(&input[start..i]);
85 continue;
86 }
87
88 match bytes.get(i + 1).copied() {
89 Some(b'$') => {
91 out.push('$');
92 i += 2;
93 }
94 Some(b'{') => {
96 if let Some(rel) = input[i + 2..].find('}') {
97 let close = i + 2 + rel;
98 let name = &input[i + 2..close];
99 if is_valid_var_name(name) {
100 match lookup(name) {
101 Some(value) => out.push_str(&value),
102 None => return Err(name.to_string()),
103 }
104 } else {
105 out.push_str(&input[i..=close]);
107 }
108 i = close + 1;
109 } else {
110 out.push('$');
112 i += 1;
113 }
114 }
115 Some(b) if is_var_name_start(b) => {
117 let start = i + 1;
118 let mut j = start;
119 while j < bytes.len() && is_var_name_continue(bytes[j]) {
120 j += 1;
121 }
122 let name = &input[start..j];
123 match lookup(name) {
124 Some(value) => out.push_str(&value),
125 None => return Err(name.to_string()),
126 }
127 i = j;
128 }
129 _ => {
131 out.push('$');
132 i += 1;
133 }
134 }
135 }
136
137 Ok(out)
138}
139
140struct ExtendsRef {
148 written: Option<String>,
153 from: String,
156}
157
158impl ExtendsRef {
159 fn describe(&self) -> String {
161 format!("{} (referenced from {})", self.short(), self.from)
162 }
163
164 fn short(&self) -> String {
171 match &self.written {
172 Some(written) => format!("'{written}'"),
173 None => WITHHELD.to_string(),
174 }
175 }
176}
177
178#[derive(Default)]
184struct ExtendsChain {
185 visited: IndexSet<PathBuf>,
186 names: Vec<String>,
187}
188
189impl ExtendsChain {
190 fn contains(&self, canonical: &Path) -> bool {
191 self.visited.contains(canonical)
192 }
193
194 fn len(&self) -> usize {
195 self.visited.len()
196 }
197
198 fn push(&mut self, canonical: PathBuf, name: String) {
199 self.visited.insert(canonical);
200 self.names.push(name);
201 }
202
203 fn names(&self) -> Vec<String> {
204 self.names.clone()
205 }
206}
207
208fn resolve_extends(
221 extends_value: &str,
222 config_file_path: &Path,
223 from: &str,
224 declared_by: ConfigOrigin<'_>,
225 source: &dyn ConfigFileSource,
226) -> Result<(PathBuf, ExtendsRef), ConfigError> {
227 let expanded = expand_env_vars(extends_value, |key| source.env_var(key)).map_err(|var| {
228 ConfigError::ExtendsUndefinedVar {
232 var: if declared_by.may_quote_contents() {
233 format!("${var}")
234 } else {
235 WITHHELD.to_string()
236 },
237 from: from.to_string(),
238 }
239 })?;
240
241 let reference = ExtendsRef {
242 written: declared_by.may_quote_contents().then(|| extends_value.to_string()),
243 from: from.to_string(),
244 };
245
246 Ok((
247 resolve_expanded_extends_path(&expanded, config_file_path, source.home_dir().as_deref()),
248 reference,
249 ))
250}
251
252fn resolve_expanded_extends_path(expanded: &str, config_file_path: &Path, home: Option<&Path>) -> PathBuf {
255 if let Some(suffix) = expanded.strip_prefix("~/") {
256 match home {
257 Some(home) => home.join(suffix),
258 None => PathBuf::from(expanded),
259 }
260 } else {
261 let path = PathBuf::from(expanded);
262 if path.is_absolute() {
263 path
264 } else {
265 let config_dir = config_file_path.parent().unwrap_or(Path::new("."));
267 config_dir.join(expanded)
268 }
269 }
270}
271
272fn source_from_filename(filename: &str) -> ConfigSource {
274 if filename == "pyproject.toml" {
275 ConfigSource::PyprojectToml
276 } else {
277 ConfigSource::ProjectConfig
278 }
279}
280
281pub(crate) fn probe_config_file(path: &Path, needs_rumdl_section: bool) -> ProbeOutcome {
289 match std::fs::metadata(path) {
290 Ok(_) => {}
291 Err(e)
292 if matches!(
293 e.kind(),
294 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
295 ) =>
296 {
297 return ProbeOutcome::Missing;
298 }
299 Err(e) => return ProbeOutcome::AccessError { error: e.to_string() },
300 }
301 if !needs_rumdl_section {
302 return ProbeOutcome::Found;
303 }
304 match std::fs::read_to_string(path) {
305 Ok(content) if pyproject_declares_rumdl_config(&content) => ProbeOutcome::Found,
306 Ok(_) => ProbeOutcome::IneligiblePyproject,
307 Err(e) => ProbeOutcome::AccessError { error: e.to_string() },
308 }
309}
310
311fn is_pyproject(path: &Path) -> bool {
312 path.file_name().and_then(|n| n.to_str()) == Some("pyproject.toml")
313}
314
315fn probe_rumdl_configs_in_dir(dir: &Path) -> Vec<(PathBuf, ProbeOutcome)> {
324 RUMDL_CONFIG_FILES
325 .iter()
326 .map(|name| {
327 let path = dir.join(name);
328 let outcome = probe_config_file(&path, is_pyproject(&path));
329 (path, outcome)
330 })
331 .collect()
332}
333
334fn found_paths(probes: &[(PathBuf, ProbeOutcome)]) -> Vec<PathBuf> {
336 probes
337 .iter()
338 .filter(|(_, outcome)| *outcome == ProbeOutcome::Found)
339 .map(|(path, _)| path.clone())
340 .collect()
341}
342
343pub(crate) fn rumdl_configs_in_dir(dir: &Path) -> Vec<PathBuf> {
345 found_paths(&probe_rumdl_configs_in_dir(dir))
346}
347
348fn record_probes(trace: &mut DiscoveryTrace, stage: SearchStage, probes: &[(PathBuf, ProbeOutcome)]) {
349 for (path, outcome) in probes {
350 trace.record(TraceEvent::Probe {
351 stage,
352 path: path.to_string_lossy().into_owned(),
353 outcome: outcome.clone(),
354 profile_fallback: false,
355 });
356 }
357}
358
359fn record_selected(
360 trace: &mut DiscoveryTrace,
361 stage: SearchStage,
362 winner: &Path,
363 shadows: &[PathBuf],
364 profile_fallback: bool,
365) {
366 trace.record(TraceEvent::Selected {
367 stage,
368 path: winner.to_string_lossy().into_owned(),
369 shadows: shadows.iter().map(|p| p.to_string_lossy().into_owned()).collect(),
370 profile_fallback,
371 });
372}
373
374pub(crate) fn collect_project_config_candidates(
382 search_dir: &Path,
383 workspace_root: Option<&Path>,
384 home_dir: Option<&Path>,
385) -> Vec<PathBuf> {
386 let profile = user_paths::profile_boundary_for(home_dir);
387 collect_project_config_candidates_bounded(search_dir, workspace_root, home_dir, profile.as_deref())
388}
389
390pub(crate) fn collect_project_config_candidates_bounded(
392 search_dir: &Path,
393 workspace_root: Option<&Path>,
394 home_dir: Option<&Path>,
395 profile_dir: Option<&Path>,
396) -> Vec<PathBuf> {
397 let mut candidates = Vec::new();
398 let canonical = |path: &Path| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
399 let spared = |dir: &Path| workspace_root.is_none_or(|root| canonical(dir) != canonical(root));
400 let home_boundary = home_dir.filter(|home| spared(home));
401 let profile_boundary = profile_dir.filter(|profile| spared(profile));
402 let walk = UpwardWalk::new(search_dir)
403 .stop_below(home_boundary.map(Path::to_path_buf))
404 .stop_below_profile(profile_boundary.map(Path::to_path_buf));
405 let walk = match workspace_root {
406 Some(root) => walk.stop_at(root),
407 None => walk,
408 };
409
410 for current_dir in walk {
411 candidates.extend(rumdl_configs_in_dir(¤t_dir));
412 candidates.extend(
413 MARKDOWNLINT_CONFIG_FILES
414 .iter()
415 .map(|name| current_dir.join(name))
416 .filter(|path| path.exists()),
417 );
418 }
419
420 candidates
421}
422
423const MARKDOWNLINT_CLI2_CONFIG_FILES: &[&str] = &[
425 ".markdownlint-cli2.jsonc",
426 ".markdownlint-cli2.yaml",
427 ".markdownlint-cli2.yml",
428];
429
430const MARKDOWNLINT_CLI2_RULE_FILES: &[&str] = &[
432 ".markdownlint.jsonc",
433 ".markdownlint.json",
434 ".markdownlint.yaml",
435 ".markdownlint.yml",
436];
437
438fn first_existing(dir: &Path, names: &[&str]) -> Option<String> {
439 names
440 .iter()
441 .map(|name| dir.join(name))
442 .find(|path| path.is_file())
443 .map(|path| path.display().to_string())
444}
445
446fn is_within_config_base(directory: &Path, base: &Path) -> bool {
448 let absolute = |path: &Path| std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
449 let canonical = |path: &Path| std::fs::canonicalize(path).unwrap_or_else(|_| absolute(path));
450 absolute(directory).starts_with(absolute(base)) || canonical(directory).starts_with(canonical(base))
451}
452
453fn markdownlint_config_chain(
471 config_file: &Path,
472 base_dir: Option<&Path>,
473 home_boundary: Option<PathBuf>,
474) -> Vec<String> {
475 let path_str = config_file.display().to_string();
476 let filename = config_file.file_name().and_then(|name| name.to_str()).unwrap_or("");
477 let (Some(config_dir), Some(base_dir)) = (config_file.parent(), base_dir) else {
478 return vec![path_str];
479 };
480 let canonical = |path: &Path| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
481 if !MARKDOWNLINT_CLI2_CONFIG_FILES.contains(&filename) || !is_within_config_base(config_dir, base_dir) {
482 return vec![path_str];
483 }
484
485 let mut declares_config = crate::markdownlint_config::declares_cli2_config(&path_str);
486 let mut chain = vec![path_str];
487 let profile_boundary = user_paths::profile_boundary_for(home_boundary.as_deref())
490 .filter(|profile| canonical(profile) != canonical(base_dir));
491 let home_boundary = home_boundary.filter(|home| canonical(home) != canonical(base_dir));
492 let parents = UpwardWalk::new(config_dir)
493 .stop_below(home_boundary)
494 .stop_below_profile(profile_boundary)
495 .always_yield_start()
496 .stop_at(base_dir)
497 .skip(1);
498 let usable = |file: &String| match parsers::load_from_markdownlint(std::slice::from_ref(file)) {
499 Ok(_) => true,
500 Err(error) => {
501 log::debug!("[rumdl-config] Skipping unusable ancestor rules in {file}: {error}");
502 false
503 }
504 };
505 for dir in parents {
506 if let Some(cli2_file) = first_existing(&dir, MARKDOWNLINT_CLI2_CONFIG_FILES)
507 && usable(&cli2_file)
508 {
509 declares_config |= crate::markdownlint_config::declares_cli2_config(&cli2_file);
510 chain.push(cli2_file);
511 }
512 if !declares_config
513 && let Some(rule_file) = first_existing(&dir, MARKDOWNLINT_CLI2_RULE_FILES)
514 && usable(&rule_file)
515 {
516 return vec![rule_file];
517 }
518 }
519 chain.reverse();
520 chain
521}
522
523fn markdownlint_ignore_scopes(
526 config_file: &Path,
527 base_dir: Option<&Path>,
528 home_boundary: Option<PathBuf>,
529) -> (Vec<super::Cli2Ignore>, Vec<String>) {
530 let Some(config_dir) = config_file.parent() else {
531 return (Vec::new(), Vec::new());
532 };
533 let base = base_dir.unwrap_or(config_dir);
534 let canonical = |path: &Path| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
535 let mut dirs = if is_within_config_base(config_dir, base) {
536 let profile_boundary = user_paths::profile_boundary_for(home_boundary.as_deref())
537 .filter(|profile| canonical(profile) != canonical(base));
538 UpwardWalk::new(config_dir)
539 .stop_below(home_boundary.filter(|home| canonical(home) != canonical(base)))
540 .stop_below_profile(profile_boundary)
541 .always_yield_start()
542 .stop_at(base)
543 .collect::<Vec<_>>()
544 } else {
545 vec![config_dir.to_path_buf()]
546 };
547 dirs.reverse();
548 let mut scopes = Vec::new();
549 let mut loaded = Vec::new();
550 let mut nested_scope = None;
551 for dir in dirs {
552 let Some(file) = first_existing(&dir, MARKDOWNLINT_CLI2_CONFIG_FILES) else {
553 continue;
554 };
555 let is_root = canonical(&dir) == canonical(base);
556 if is_root && !rumdl_configs_in_dir(base).is_empty() {
557 continue;
558 }
559 let directory = if is_root { base } else { config_dir };
560 let scope = crate::markdownlint_config::read_cli2_ignores(&file).and_then(|patterns| {
561 patterns
562 .map(|patterns| super::Cli2Ignore::new(directory.to_path_buf(), patterns, is_root))
563 .transpose()
564 });
565 loaded.push(file.clone());
566 match scope {
567 Ok(Some(scope)) if is_root => {
568 if let Ok(nested) = super::Cli2Ignore::new(config_dir.to_path_buf(), scope.patterns.clone(), false) {
571 nested_scope = Some(nested);
572 }
573 scopes.push(scope);
574 }
575 Ok(Some(scope)) => nested_scope = Some(scope),
576 Ok(None) => {}
577 Err(error) => log::debug!("[rumdl-config] Ignoring unusable discovered CLI2 options in {file}: {error}"),
578 }
579 }
580 if let Some(scope) = nested_scope {
583 scopes.push(scope);
584 }
585 (scopes, loaded)
586}
587
588fn load_discovered_markdownlint(
589 sourced: &mut SourcedConfig,
590 config_file: &Path,
591 base_dir: Option<&Path>,
592 home_boundary: Option<PathBuf>,
593) -> Result<(), ConfigError> {
594 let chain = markdownlint_config_chain(config_file, base_dir, home_boundary.clone());
595 let fragment = parsers::load_from_markdownlint(&chain)?;
596 let (ignores, option_files) = markdownlint_ignore_scopes(config_file, base_dir, home_boundary);
597 sourced.merge(fragment);
598 sourced.cli2_ignores = ignores;
599 for file in chain.into_iter().chain(option_files) {
600 if !sourced.loaded_files.contains(&file) {
601 sourced.loaded_files.push(file);
602 }
603 }
604 Ok(())
605}
606
607fn load_root_cli2_ignores(sourced: &mut SourcedConfig, config_file: &Path, base_dir: Option<&Path>) {
610 let (Some(config_dir), Some(base)) = (config_file.parent(), base_dir) else {
611 return;
612 };
613 let canonical = |path: &Path| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
614 if canonical(config_dir) == canonical(base)
615 || !is_within_config_base(config_dir, base)
616 || !rumdl_configs_in_dir(base).is_empty()
617 {
618 return;
619 }
620 load_run_cli2_ignores(sourced, base, true)
621}
622
623fn load_run_cli2_ignores(sourced: &mut SourcedConfig, base: &Path, preserve_root_group: bool) {
624 if !rumdl_configs_in_dir(base).is_empty() {
625 return;
626 }
627 if let Some(file) = first_existing(base, MARKDOWNLINT_CLI2_CONFIG_FILES) {
628 let scope = crate::markdownlint_config::read_cli2_ignores(&file).and_then(|patterns| {
629 patterns
630 .map(|patterns| super::Cli2Ignore::new(base.to_path_buf(), patterns, true))
631 .transpose()
632 });
633 let scope = match scope {
634 Ok(scope) => scope,
635 Err(error) => {
636 log::debug!("[rumdl-config] Skipping CLI2 selection options {file}: {error}");
639 return;
640 }
641 };
642 if let Some(scope) = scope {
643 if preserve_root_group
646 && let Ok(group) = super::Cli2Ignore::new(base.to_path_buf(), scope.patterns.clone(), false)
647 && !sourced.cli2_ignores.contains(&group)
648 {
649 sourced.cli2_ignores.push(group);
650 }
651 if !sourced.cli2_ignores.contains(&scope) {
652 sourced.cli2_ignores.push(scope);
653 }
654 }
655 if !sourced.loaded_files.contains(&file) {
656 sourced.loaded_files.push(file);
657 }
658 }
659}
660
661#[derive(Debug, Clone, PartialEq, Eq)]
670pub(crate) struct ShadowedConfigs {
671 pub dir: PathBuf,
672 pub winner: PathBuf,
673 pub shadowed: Vec<PathBuf>,
674}
675
676#[cfg(test)]
682pub(crate) fn detect_shadowed_configs(dir: &Path) -> Option<ShadowedConfigs> {
683 shadow_from_found(dir, rumdl_configs_in_dir(dir))
684}
685
686fn shadow_from_found(dir: &Path, mut configs: Vec<PathBuf>) -> Option<ShadowedConfigs> {
688 if configs.len() < 2 {
689 return None;
690 }
691 let winner = configs.remove(0);
692 Some(ShadowedConfigs {
693 dir: dir.to_path_buf(),
694 winner,
695 shadowed: configs,
696 })
697}
698
699pub(crate) fn format_shadow_warning(shadow: &ShadowedConfigs) -> String {
707 let norm = |s: String| if cfg!(windows) { s.replace('\\', "/") } else { s };
708 let rel = |path: &Path| {
709 let relative = path.strip_prefix(&shadow.dir).unwrap_or(path);
710 norm(relative.to_string_lossy().into_owned())
711 };
712 let shadowed = shadow.shadowed.iter().map(|p| rel(p)).collect::<Vec<_>>().join(", ");
713 format!(
714 "multiple rumdl config files in {}: using {}, ignoring {}",
715 norm(shadow.dir.to_string_lossy().into_owned()),
716 rel(&shadow.winner),
717 shadowed,
718 )
719}
720
721fn load_config_with_extends(
735 sourced_config: &mut SourcedConfig<ConfigLoaded>,
736 config_file_path: &Path,
737 chain: &mut ExtendsChain,
738 chain_source: ConfigSource,
739 origin: ConfigOrigin<'_>,
740 source: &dyn ConfigFileSource,
741) -> Result<(), ConfigError> {
742 let canonical = source.canonicalize(config_file_path);
744
745 let path_str = config_file_path.display().to_string();
746 let described = origin.display_name(&path_str);
747 let short = origin.short_name(&path_str);
748
749 if chain.contains(&canonical) {
751 return Err(ConfigError::CircularExtends {
752 path: described,
753 chain: chain.names(),
754 });
755 }
756
757 if chain.len() >= MAX_EXTENDS_DEPTH {
759 return Err(ConfigError::ExtendsDepthExceeded {
760 path: described,
761 max_depth: MAX_EXTENDS_DEPTH,
762 });
763 }
764
765 chain.push(canonical, short.clone());
767
768 let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
769
770 let content = source
772 .read_to_string(config_file_path)
773 .map_err(|e| ConfigError::IoError {
774 source: e,
775 path: described.clone(),
776 })?;
777
778 let fragment = if filename == "pyproject.toml" {
779 match parsers::parse_pyproject_toml(&content, &path_str, chain_source, origin)? {
780 Some(f) => f,
781 None => return Ok(()), }
783 } else {
784 parsers::parse_rumdl_toml(&content, &path_str, chain_source, origin)?
785 };
786
787 if let Some(ref extends_value) = fragment.extends {
789 let (base_path, reference) = resolve_extends(extends_value, config_file_path, &short, origin, source)?;
790 let base_described = reference.describe();
791 let base_short = reference.short();
792
793 if !source.exists(&base_path) {
794 return Err(ConfigError::ExtendsNotFound {
795 path: base_short,
796 from: short,
797 });
798 }
799
800 log::debug!(
801 "[rumdl-config] Config {} extends {}, loading base first",
802 path_str,
803 base_path.display()
804 );
805
806 load_config_with_extends(
808 sourced_config,
809 &base_path,
810 chain,
811 chain_source,
812 ConfigOrigin::Extends {
813 described_as: &base_described,
814 short_name: &base_short,
815 },
816 source,
817 )?;
818 }
819
820 let mut fragment_for_merge = fragment;
823 fragment_for_merge.extends = None;
824 sourced_config.merge(fragment_for_merge);
825 sourced_config.loaded_files.push(path_str);
826
827 Ok(())
828}
829
830impl SourcedConfig<ConfigLoaded> {
831 pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
834 self.global.enable.merge_from(fragment.global.enable);
839 self.global.disable.merge_from(fragment.global.disable);
840 self.global
841 .extend_enable
842 .merge_union_from(fragment.global.extend_enable);
843 self.global
844 .extend_disable
845 .merge_union_from(fragment.global.extend_disable);
846
847 self.global
850 .disable
851 .value
852 .retain(|rule| !self.global.enable.value.contains(rule));
853
854 if self.global.include.merge_from(fragment.global.include) {
858 self.global.include_withheld = fragment.global.include_withheld;
859 }
860 self.global.exclude.merge_from(fragment.global.exclude);
861 self.global
862 .respect_gitignore
863 .merge_from(fragment.global.respect_gitignore);
864 self.global.line_length.merge_from(fragment.global.line_length);
865 self.global.fixable.merge_from(fragment.global.fixable);
866 self.global.unfixable.merge_from(fragment.global.unfixable);
867 self.global.flavor.merge_from(fragment.global.flavor);
868 self.global.force_exclude.merge_from(fragment.global.force_exclude);
869 self.global.editorconfig.merge_from(fragment.global.editorconfig);
870
871 if let Some(output_format_fragment) = fragment.global.output_format {
873 if let Some(ref mut output_format) = self.global.output_format {
874 output_format.merge_from(output_format_fragment);
875 } else {
876 self.global.output_format = Some(output_format_fragment);
877 }
878 }
879
880 if let Some(cache_dir_fragment) = fragment.global.cache_dir {
882 if let Some(ref mut cache_dir) = self.global.cache_dir {
883 cache_dir.merge_from(cache_dir_fragment);
884 } else {
885 self.global.cache_dir = Some(cache_dir_fragment);
886 }
887 }
888
889 if fragment.global.cache.source != ConfigSource::Default {
891 self.global.cache.merge_from(fragment.global.cache);
892 }
893
894 self.per_file_ignores.merge_from(fragment.per_file_ignores);
895 self.per_file_flavor.merge_from(fragment.per_file_flavor);
896 self.code_block_tools.merge_from(fragment.code_block_tools);
897
898 for (rule_name, rule_fragment) in fragment.rules {
900 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
902
903 if let Some(severity_fragment) = rule_fragment.severity {
905 if let Some(ref mut existing_severity) = rule_entry.severity {
906 existing_severity.merge_from(severity_fragment);
907 } else {
908 rule_entry.severity = Some(severity_fragment);
909 }
910 }
911
912 for (key, sourced_value_fragment) in rule_fragment.values {
916 let sv_entry = rule_entry
917 .values
918 .entry(key.clone())
919 .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
920 if sv_entry.merge_from(sourced_value_fragment) {
921 if rule_fragment.withheld_keys.contains(&key) {
922 rule_entry.withheld_keys.insert(key);
923 } else {
924 rule_entry.withheld_keys.remove(&key);
925 }
926 }
927 }
928 }
929
930 for warning in fragment.load_warnings {
934 if !self.discovery_warnings.contains(&warning) {
935 self.discovery_warnings.push(warning);
936 }
937 }
938
939 for (section, key, file_path) in fragment.unknown_keys {
940 if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
942 self.unknown_keys.push((section, key, file_path));
943 }
944 }
945 }
946
947 pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
949 Self::load_with_discovery(config_path, cli_overrides, false)
950 }
951
952 fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
955 UpwardWalk::new(start_dir)
956 .find(|dir| dir.join(".git").exists())
957 .unwrap_or_else(|| {
958 log::debug!(
959 "[rumdl-config] No .git found, using config location as project root: {}",
960 start_dir.display()
961 );
962 start_dir.to_path_buf()
963 })
964 }
965
966 fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
973 home_override
974 .map(Path::to_path_buf)
975 .or_else(user_paths::process_home_path)
976 }
977
978 fn effective_home(home_override: Option<&Path>, trace: &mut DiscoveryTrace) -> Option<std::path::PathBuf> {
984 if let Some(home) = home_override {
985 if !trace.has_home() {
986 trace.record(TraceEvent::Home {
987 path: Some(home.to_string_lossy().into_owned()),
988 source: Some(HomeSource::Caller),
989 });
990 }
991 return Some(home.to_path_buf());
992 }
993 let resolution = user_paths::process_home();
994 if !trace.has_home() {
995 if let Some(ignored) = &resolution.ignored {
996 trace.record(TraceEvent::IgnoredHome {
997 value: ignored.value.clone(),
998 reason: ignored.reason,
999 });
1000 }
1001 trace.record(TraceEvent::Home {
1002 path: resolution.home.as_ref().map(|h| h.path.to_string_lossy().into_owned()),
1003 source: resolution.home.as_ref().map(|h| h.source),
1004 });
1005 }
1006 resolution.home.map(|home| home.path)
1007 }
1008
1009 fn resolve_discovery_start(start_override: Option<&Path>) -> Option<std::path::PathBuf> {
1015 if let Some(dir) = start_override {
1016 return Some(dir.to_path_buf());
1017 }
1018 match std::env::current_dir() {
1019 Ok(dir) => Some(dir),
1020 Err(e) => {
1021 log::debug!("[rumdl-config] Failed to get current directory: {e}");
1022 None
1023 }
1024 }
1025 }
1026
1027 fn discover_config_upward(
1043 start_override: Option<&Path>,
1044 home_override: Option<&Path>,
1045 trace: &mut DiscoveryTrace,
1046 ) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
1047 let home = Self::resolve_home_boundary(home_override);
1048 let profile = user_paths::profile_boundary_for(home.as_deref());
1049 Self::discover_config_upward_bounded(start_override, home, profile, trace)
1050 }
1051
1052 pub(crate) fn discover_config_upward_bounded(
1055 start_override: Option<&Path>,
1056 home: Option<std::path::PathBuf>,
1057 profile: Option<std::path::PathBuf>,
1058 trace: &mut DiscoveryTrace,
1059 ) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
1060 let start_dir = Self::resolve_discovery_start(start_override)?;
1061
1062 let mut walk = UpwardWalk::new(&start_dir)
1066 .stop_below(home)
1067 .stop_below_profile(profile)
1068 .always_yield_start()
1069 .stop_at_git_root();
1070 let found = walk.find_map(|dir| {
1071 let probes = probe_rumdl_configs_in_dir(&dir);
1072 record_probes(trace, SearchStage::Project, &probes);
1073 let found = found_paths(&probes);
1074 let winner = found.first()?.clone();
1075 log::debug!("[rumdl-config] Found config file: {}", winner.display());
1076 let shadow = shadow_from_found(&dir, found);
1077 record_selected(
1078 trace,
1079 SearchStage::Project,
1080 &winner,
1081 shadow.as_ref().map_or(&[][..], |s| &s.shadowed),
1082 false,
1083 );
1084 Some((winner, dir, shadow))
1085 });
1086 let Some((config_path, config_dir, shadow)) = found else {
1087 if let Some(stop) = walk.stop_reason() {
1088 trace.record(TraceEvent::WalkEnded {
1089 stage: SearchStage::Project,
1090 end: WalkEnd::from(stop),
1091 });
1092 }
1093 return None;
1094 };
1095
1096 let project_root = Self::find_project_root_from(&config_dir);
1098 Some((config_path, project_root, shadow))
1099 }
1100
1101 fn discover_markdownlint_config_upward(
1109 start_override: Option<&Path>,
1110 home_override: Option<&Path>,
1111 trace: &mut DiscoveryTrace,
1112 ) -> Option<std::path::PathBuf> {
1113 let start_dir = Self::resolve_discovery_start(start_override)?;
1114
1115 let home = Self::resolve_home_boundary(home_override);
1116 let mut walk = UpwardWalk::new(&start_dir)
1117 .stop_below_profile(user_paths::profile_boundary_for(home.as_deref()))
1118 .stop_below(home)
1119 .always_yield_start()
1120 .stop_at_git_root();
1121 let found = walk.find_map(|dir| {
1122 let probes: Vec<(PathBuf, ProbeOutcome)> = MARKDOWNLINT_CONFIG_FILES
1123 .iter()
1124 .map(|name| dir.join(name))
1125 .map(|path| {
1126 let outcome = probe_config_file(&path, false);
1127 (path, outcome)
1128 })
1129 .collect();
1130 record_probes(trace, SearchStage::Markdownlint, &probes);
1131 let winner = found_paths(&probes).into_iter().next()?;
1132 record_selected(trace, SearchStage::Markdownlint, &winner, &[], false);
1133 Some(winner)
1134 });
1135 if found.is_none()
1136 && let Some(stop) = walk.stop_reason()
1137 {
1138 trace.record(TraceEvent::WalkEnded {
1139 stage: SearchStage::Markdownlint,
1140 end: WalkEnd::from(stop),
1141 });
1142 }
1143 found
1144 }
1145
1146 fn load_explicit_config(sourced_config: &mut Self, path: &str, run_dir: Option<&Path>) -> Result<(), ConfigError> {
1148 let path_obj = Path::new(path);
1149 let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
1150 let path_str = path.to_string();
1151
1152 log::debug!("[rumdl-config] Loading explicit config file: {filename}");
1153
1154 if let Some(config_parent) = path_obj.parent() {
1156 let project_root = Self::find_project_root_from(config_parent);
1157 log::debug!(
1158 "[rumdl-config] Project root (from explicit config): {}",
1159 project_root.display()
1160 );
1161 sourced_config.project_root = Some(project_root);
1162 }
1163
1164 const MARKDOWNLINT_FILENAMES: &[&str] = &[
1166 ".markdownlint-cli2.jsonc",
1167 ".markdownlint-cli2.yaml",
1168 ".markdownlint-cli2.yml",
1169 ".markdownlint.json",
1170 ".markdownlint.yaml",
1171 ".markdownlint.yml",
1172 ];
1173
1174 if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
1175 let mut chain = ExtendsChain::default();
1177 let chain_source = source_from_filename(filename);
1178 load_config_with_extends(
1179 sourced_config,
1180 path_obj,
1181 &mut chain,
1182 chain_source,
1183 ConfigOrigin::Direct,
1184 &FsConfigFiles,
1185 )?;
1186 } else if MARKDOWNLINT_FILENAMES.contains(&filename)
1187 || path_str.ends_with(".json")
1188 || path_str.ends_with(".jsonc")
1189 || path_str.ends_with(".yaml")
1190 || path_str.ends_with(".yml")
1191 {
1192 if MARKDOWNLINT_CLI2_CONFIG_FILES.contains(&filename)
1195 && let Some(patterns) =
1196 crate::markdownlint_config::read_cli2_ignores(&path_str).map_err(ConfigError::ParseError)?
1197 {
1198 let base = Self::resolve_discovery_start(run_dir).unwrap_or_else(|| PathBuf::from("."));
1199 let discovery =
1200 super::Cli2Ignore::new(base.clone(), patterns.clone(), true).map_err(ConfigError::ParseError)?;
1201 let group = super::Cli2Ignore::new(base, patterns, false).map_err(ConfigError::ParseError)?;
1202 sourced_config.cli2_ignores.extend([discovery, group]);
1203 }
1204 let chain = vec![path_str];
1205 let fragment = parsers::load_from_markdownlint(&chain)?;
1206 sourced_config.merge(fragment);
1207 sourced_config.loaded_files.extend(chain);
1208 } else {
1209 let mut chain = ExtendsChain::default();
1211 let chain_source = source_from_filename(filename);
1212 load_config_with_extends(
1213 sourced_config,
1214 path_obj,
1215 &mut chain,
1216 chain_source,
1217 ConfigOrigin::Direct,
1218 &FsConfigFiles,
1219 )?;
1220 }
1221
1222 Ok(())
1223 }
1224
1225 fn load_user_config(
1242 sourced_config: &mut Self,
1243 user_config_dir: Option<&Path>,
1244 home_dir: Option<&Path>,
1245 ) -> Result<(), ConfigError> {
1246 let home = Self::effective_home(home_dir, &mut sourced_config.discovery_trace);
1247 let platform_dir = match user_config_dir {
1248 Some(dir) => Some(dir.to_path_buf()),
1249 None => user_paths::platform_config_dir(),
1250 };
1251 let profile_fallback = user_paths::process_home().profile_fallback_for(home.as_deref());
1254 let candidates = user_paths::user_config_candidates(
1255 Platform::current(),
1256 platform_dir.as_deref(),
1257 home.as_deref(),
1258 profile_fallback.as_deref(),
1259 );
1260 Self::load_user_candidates(sourced_config, &candidates)
1261 }
1262
1263 pub(crate) fn load_user_candidates(
1267 sourced_config: &mut Self,
1268 candidates: &[UserCandidate],
1269 ) -> Result<(), ConfigError> {
1270 let probes: Vec<(PathBuf, ProbeOutcome)> = candidates
1271 .iter()
1272 .map(|c| (c.path.clone(), probe_config_file(&c.path, c.requires_rumdl_section)))
1273 .collect();
1274 for (candidate, (path, outcome)) in candidates.iter().zip(&probes) {
1275 sourced_config.discovery_trace.record(TraceEvent::Probe {
1276 stage: SearchStage::User,
1277 path: path.to_string_lossy().into_owned(),
1278 outcome: outcome.clone(),
1279 profile_fallback: candidate.profile_fallback,
1280 });
1281 }
1282
1283 let mut found = candidates
1284 .iter()
1285 .zip(&probes)
1286 .filter(|(_, (_, outcome))| *outcome == ProbeOutcome::Found)
1287 .map(|(candidate, (path, _))| (path.clone(), candidate.profile_fallback));
1288 let Some((user_config_path, from_profile_fallback)) = found.next() else {
1289 log::debug!("[rumdl-config] No user configuration file found");
1290 return Ok(());
1291 };
1292 let shadows: Vec<PathBuf> = found.map(|(path, _)| path).collect();
1293 record_selected(
1294 &mut sourced_config.discovery_trace,
1295 SearchStage::User,
1296 &user_config_path,
1297 &shadows,
1298 from_profile_fallback,
1299 );
1300
1301 log::debug!("[rumdl-config] Loading user config: {}", user_config_path.display());
1302
1303 let mut chain = ExtendsChain::default();
1306 load_config_with_extends(
1307 sourced_config,
1308 &user_config_path,
1309 &mut chain,
1310 ConfigSource::UserConfig,
1311 ConfigOrigin::Direct,
1312 &FsConfigFiles,
1313 )
1314 }
1315
1316 fn load_discovered_config(
1338 sourced_config: &mut Self,
1339 config_file: &Path,
1340 user_config_dir: Option<&Path>,
1341 home_dir: Option<&Path>,
1342 base_dir: Option<&Path>,
1343 ) -> Result<(), DiscoveredConfigError> {
1344 let filename = config_file.file_name().and_then(|name| name.to_str()).unwrap_or("");
1345
1346 if MARKDOWNLINT_CONFIG_FILES.contains(&filename) {
1347 Self::load_user_config(sourced_config, user_config_dir, home_dir)
1348 .map_err(DiscoveredConfigError::UserConfig)?;
1349
1350 load_discovered_markdownlint(
1351 sourced_config,
1352 config_file,
1353 base_dir,
1354 Self::resolve_home_boundary(home_dir),
1355 )
1356 .map_err(DiscoveredConfigError::ProjectConfig)?;
1357 } else {
1358 let mut chain = ExtendsChain::default();
1359 let chain_source = source_from_filename(filename);
1360 load_config_with_extends(
1361 sourced_config,
1362 config_file,
1363 &mut chain,
1364 chain_source,
1365 ConfigOrigin::Direct,
1366 &FsConfigFiles,
1367 )
1368 .map_err(DiscoveredConfigError::ProjectConfig)?;
1369 load_root_cli2_ignores(sourced_config, config_file, base_dir);
1370 }
1371
1372 Ok(())
1373 }
1374
1375 pub fn load_discovered(
1396 config_file: &Path,
1397 user_config_dir: Option<&Path>,
1398 home_dir: Option<&Path>,
1399 ) -> Result<Self, DiscoveredConfigError> {
1400 Self::load_discovered_within(config_file, user_config_dir, home_dir, None)
1401 }
1402
1403 pub(crate) fn load_discovered_within(
1407 config_file: &Path,
1408 user_config_dir: Option<&Path>,
1409 home_dir: Option<&Path>,
1410 base_dir: Option<&Path>,
1411 ) -> Result<Self, DiscoveredConfigError> {
1412 let mut sourced_config = SourcedConfig::default();
1413
1414 if let Some(config_parent) = config_file.parent() {
1415 sourced_config.project_root = Some(Self::find_project_root_from(config_parent));
1416 }
1417
1418 Self::load_discovered_config(&mut sourced_config, config_file, user_config_dir, home_dir, base_dir)?;
1419
1420 Ok(sourced_config)
1421 }
1422
1423 pub fn load_for_workspace(
1436 start_dir: &Path,
1437 config_path: Option<&str>,
1438 user_config_dir: Option<&Path>,
1439 home_dir: Option<&Path>,
1440 ) -> Result<Self, ConfigError> {
1441 Self::load_with_discovery_from(
1442 Some(start_dir),
1443 config_path,
1444 None,
1445 false,
1446 user_config_dir,
1447 home_dir,
1448 None,
1449 )
1450 }
1451
1452 #[doc(hidden)]
1454 pub fn load_with_discovery_impl(
1455 config_path: Option<&str>,
1456 cli_overrides: Option<&SourcedGlobalConfig>,
1457 skip_auto_discovery: bool,
1458 user_config_dir: Option<&Path>,
1459 home_dir: Option<&Path>,
1460 ) -> Result<Self, ConfigError> {
1461 Self::load_with_discovery_from(
1462 None,
1463 config_path,
1464 cli_overrides,
1465 skip_auto_discovery,
1466 user_config_dir,
1467 home_dir,
1468 None,
1469 )
1470 }
1471
1472 pub fn load_for_directory(
1475 start_dir: &Path,
1476 config_path: Option<&str>,
1477 skip_auto_discovery: bool,
1478 run_dir: &Path,
1479 ) -> Result<Self, ConfigError> {
1480 let mut sourced = Self::load_with_discovery_from(
1481 Some(start_dir),
1482 config_path,
1483 None,
1484 skip_auto_discovery,
1485 None,
1486 None,
1487 Some(run_dir),
1488 )?;
1489 if !skip_auto_discovery && config_path.is_none() {
1491 load_run_cli2_ignores(&mut sourced, run_dir, false);
1492 }
1493 Ok(sourced)
1494 }
1495
1496 fn load_with_discovery_from(
1501 start_dir: Option<&Path>,
1502 config_path: Option<&str>,
1503 cli_overrides: Option<&SourcedGlobalConfig>,
1504 skip_auto_discovery: bool,
1505 user_config_dir: Option<&Path>,
1506 home_dir: Option<&Path>,
1507 inheritance_base: Option<&Path>,
1508 ) -> Result<Self, ConfigError> {
1509 use std::env;
1510 log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
1511
1512 let mut sourced_config = SourcedConfig::default();
1513
1514 if let Some(path) = config_path {
1527 log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
1529 for stage in [SearchStage::Project, SearchStage::User] {
1530 sourced_config.discovery_trace.record(TraceEvent::Skipped {
1531 stage,
1532 reason: SkipReason::ExplicitConfig,
1533 });
1534 }
1535 sourced_config.discovery_trace.record(TraceEvent::Selected {
1536 stage: SearchStage::Explicit,
1537 path: path.to_string(),
1538 shadows: Vec::new(),
1539 profile_fallback: false,
1540 });
1541 Self::load_explicit_config(&mut sourced_config, path, inheritance_base.or(start_dir))?;
1542 } else if skip_auto_discovery {
1543 log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
1544 for stage in [SearchStage::Project, SearchStage::User] {
1545 sourced_config.discovery_trace.record(TraceEvent::Skipped {
1546 stage,
1547 reason: SkipReason::Isolated,
1548 });
1549 }
1550 } else {
1552 log::debug!("[rumdl-config] No explicit config_path, searching default locations");
1554 let discovery_base = Self::resolve_discovery_start(inheritance_base.or(start_dir));
1555 if let Some(start) = Self::resolve_discovery_start(start_dir) {
1556 sourced_config.discovery_trace.record(TraceEvent::StartDirectory {
1557 path: start.to_string_lossy().into_owned(),
1558 });
1559 }
1560 let home = Self::effective_home(home_dir, &mut sourced_config.discovery_trace);
1563 let home_dir = home.as_deref();
1564
1565 if let Some((config_file, project_root, shadow)) =
1567 Self::discover_config_upward(start_dir, home_dir, &mut sourced_config.discovery_trace)
1568 {
1569 for stage in [SearchStage::Markdownlint, SearchStage::User] {
1570 sourced_config.discovery_trace.record(TraceEvent::Skipped {
1571 stage,
1572 reason: SkipReason::ProjectConfigFound,
1573 });
1574 }
1575 log::debug!("[rumdl-config] Found project config: {}", config_file.display());
1579 log::debug!("[rumdl-config] Project root: {}", project_root.display());
1580
1581 if let Some(shadow) = shadow {
1584 sourced_config.discovery_warnings.push(format_shadow_warning(&shadow));
1585 }
1586
1587 sourced_config.project_root = Some(project_root);
1588
1589 Self::load_discovered_config(
1590 &mut sourced_config,
1591 &config_file,
1592 user_config_dir,
1593 home_dir,
1594 discovery_base.as_deref(),
1595 )?;
1596 } else {
1597 log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
1599
1600 if let Some(markdownlint_path) =
1601 Self::discover_markdownlint_config_upward(start_dir, home_dir, &mut sourced_config.discovery_trace)
1602 {
1603 log::debug!(
1604 "[rumdl-config] Found markdownlint config: {}",
1605 markdownlint_path.display()
1606 );
1607
1608 if let Err(e) = Self::load_discovered_config(
1609 &mut sourced_config,
1610 &markdownlint_path,
1611 user_config_dir,
1612 home_dir,
1613 discovery_base.as_deref(),
1614 ) {
1615 match e {
1616 DiscoveredConfigError::ProjectConfig(e) => {
1622 log::debug!("[rumdl-config] Failed to load markdownlint config: {e}");
1623 sourced_config.discovery_trace.reject_selected(
1624 SearchStage::Markdownlint,
1625 &markdownlint_path.to_string_lossy(),
1626 "could not be loaded as a markdownlint config",
1627 );
1628 }
1629 DiscoveredConfigError::UserConfig(e) => return Err(e),
1631 }
1632 }
1633 } else {
1634 log::debug!("[rumdl-config] No project config found, using user config as fallback");
1636 Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
1637 }
1638 }
1639 }
1640
1641 if let Some(cli) = cli_overrides {
1643 sourced_config
1644 .global
1645 .enable
1646 .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None);
1647 sourced_config
1648 .global
1649 .disable
1650 .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None);
1651 sourced_config
1652 .global
1653 .exclude
1654 .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None);
1655 sourced_config
1656 .global
1657 .include
1658 .merge_override(cli.include.value.clone(), ConfigSource::Cli, None);
1659 sourced_config.global.respect_gitignore.merge_override(
1660 cli.respect_gitignore.value,
1661 ConfigSource::Cli,
1662 None,
1663 );
1664 sourced_config
1665 .global
1666 .fixable
1667 .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None);
1668 sourced_config
1669 .global
1670 .unfixable
1671 .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None);
1672 }
1674
1675 Ok(sourced_config)
1678 }
1679
1680 pub fn load_with_discovery(
1683 config_path: Option<&str>,
1684 cli_overrides: Option<&SourcedGlobalConfig>,
1685 skip_auto_discovery: bool,
1686 ) -> Result<Self, ConfigError> {
1687 Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
1688 }
1689
1690 pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
1704 let warnings = validate_config_sourced_internal(&self, registry);
1705
1706 Ok(SourcedConfig {
1707 global: self.global,
1708 cli2_ignores: self.cli2_ignores,
1709 per_file_ignores: self.per_file_ignores,
1710 per_file_flavor: self.per_file_flavor,
1711 code_block_tools: self.code_block_tools,
1712 rules: self.rules,
1713 loaded_files: self.loaded_files,
1714 unknown_keys: self.unknown_keys,
1715 project_root: self.project_root,
1716 discovery_warnings: self.discovery_warnings,
1717 discovery_trace: self.discovery_trace,
1718 validation_warnings: warnings,
1719 _state: PhantomData,
1720 })
1721 }
1722
1723 pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
1728 let validated = self.validate(registry)?;
1729 let warnings = validated.validation_warnings.clone();
1730 Ok((validated.into(), warnings))
1731 }
1732
1733 pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
1744 SourcedConfig {
1745 global: self.global,
1746 cli2_ignores: self.cli2_ignores,
1747 per_file_ignores: self.per_file_ignores,
1748 per_file_flavor: self.per_file_flavor,
1749 code_block_tools: self.code_block_tools,
1750 rules: self.rules,
1751 loaded_files: self.loaded_files,
1752 unknown_keys: self.unknown_keys,
1753 project_root: self.project_root,
1754 discovery_warnings: self.discovery_warnings,
1755 discovery_trace: self.discovery_trace,
1756 validation_warnings: Vec::new(),
1757 _state: PhantomData,
1758 }
1759 }
1760
1761 pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
1770 collect_project_config_candidates(dir, Some(project_root), Self::resolve_home_boundary(None).as_deref())
1782 .into_iter()
1783 .next()
1784 }
1785
1786 pub fn load_sourced_for_path(
1793 config_path: &Path,
1794 project_root: &Path,
1795 ) -> Result<SourcedConfig<ConfigLoaded>, ConfigError> {
1796 let mut sourced_config = SourcedConfig {
1797 project_root: Some(project_root.to_path_buf()),
1798 ..SourcedConfig::default()
1799 };
1800
1801 let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1802 let path_str = config_path.display().to_string();
1803
1804 let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
1806 || (filename != "pyproject.toml"
1807 && filename != ".rumdl.toml"
1808 && filename != "rumdl.toml"
1809 && (path_str.ends_with(".json")
1810 || path_str.ends_with(".jsonc")
1811 || path_str.ends_with(".yaml")
1812 || path_str.ends_with(".yml")));
1813
1814 if is_markdownlint {
1815 Self::load_user_config(&mut sourced_config, None, None)?;
1818 load_discovered_markdownlint(
1819 &mut sourced_config,
1820 config_path,
1821 Self::resolve_discovery_start(None).as_deref(),
1822 Self::resolve_home_boundary(None),
1823 )?;
1824 } else {
1825 let mut chain = ExtendsChain::default();
1826 let chain_source = source_from_filename(filename);
1827 load_config_with_extends(
1828 &mut sourced_config,
1829 config_path,
1830 &mut chain,
1831 chain_source,
1832 ConfigOrigin::Direct,
1833 &FsConfigFiles,
1834 )?;
1835 load_root_cli2_ignores(
1836 &mut sourced_config,
1837 config_path,
1838 Self::resolve_discovery_start(None).as_deref(),
1839 );
1840 }
1841
1842 Ok(sourced_config)
1843 }
1844
1845 pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
1849 Ok(Self::load_sourced_for_path(config_path, project_root)?
1850 .into_validated_unchecked()
1851 .into())
1852 }
1853
1854 #[cfg(any(feature = "wasm", test))]
1864 pub(crate) fn load_chain_from(root: &Path, source: &dyn ConfigFileSource) -> Result<Self, ConfigError> {
1865 let mut sourced_config = SourcedConfig::default();
1866 let filename = root.file_name().and_then(|n| n.to_str()).unwrap_or("");
1867 let mut chain = ExtendsChain::default();
1868 load_config_with_extends(
1869 &mut sourced_config,
1870 root,
1871 &mut chain,
1872 source_from_filename(filename),
1873 ConfigOrigin::Direct,
1874 source,
1875 )?;
1876 Ok(sourced_config)
1877 }
1878}
1879
1880impl From<SourcedConfig<ConfigValidated>> for Config {
1885 fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
1886 let mut rules = BTreeMap::new();
1887 let mut withheld_rule_values = std::collections::BTreeSet::new();
1888 for (rule_name, sourced_rule_cfg) in sourced.rules {
1889 let normalized_rule_name = rule_name.to_ascii_uppercase();
1891 let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
1892 let mut values = BTreeMap::new();
1893 for (key, sourced_val) in sourced_rule_cfg.values {
1894 values.insert(key, sourced_val.value);
1895 }
1896 if values.keys().any(|key| sourced_rule_cfg.withheld_keys.contains(key)) {
1897 withheld_rule_values.insert(normalized_rule_name.clone());
1898 }
1899 rules.insert(normalized_rule_name, RuleConfig { severity, values });
1900 }
1901 let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
1903
1904 #[allow(deprecated)]
1905 let global = GlobalConfig {
1906 enable: sourced.global.enable.value,
1907 disable: sourced.global.disable.value,
1908 exclude: sourced.global.exclude.value,
1909 include: sourced.global.include.value,
1910 respect_gitignore: sourced.global.respect_gitignore.value,
1911 line_length: sourced.global.line_length.value,
1912 output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
1913 fixable: sourced.global.fixable.value,
1914 unfixable: sourced.global.unfixable.value,
1915 flavor: sourced.global.flavor.value,
1916 force_exclude: sourced.global.force_exclude.value,
1917 cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
1918 cache: sourced.global.cache.value,
1919 extend_enable: sourced.global.extend_enable.value,
1920 extend_disable: sourced.global.extend_disable.value,
1921 editorconfig: sourced.global.editorconfig.value,
1922 enable_is_explicit,
1923 include_withheld: sourced.global.include_withheld,
1924 };
1925
1926 let mut config = Config {
1927 extends: None,
1928 global,
1929 cli2_ignores: sourced.cli2_ignores,
1930 per_file_ignores: sourced.per_file_ignores.value,
1931 per_file_flavor: sourced.per_file_flavor.value,
1932 code_block_tools: sourced.code_block_tools.value,
1933 rules,
1934 withheld_rule_values,
1935 project_root: sourced.project_root,
1936 per_file_ignores_cache: Arc::new(OnceLock::new()),
1937 per_file_flavor_cache: Arc::new(OnceLock::new()),
1938 canonical_project_root_cache: Arc::new(OnceLock::new()),
1939 };
1940
1941 config.apply_per_rule_enabled();
1943
1944 config.canonicalize_rule_lists();
1951
1952 config
1953 }
1954}
1955
1956#[cfg(test)]
1957mod tests {
1958 use super::pyproject_declares_rumdl_config;
1959
1960 #[test]
1961 fn detects_flat_and_dotted_rumdl_sections() {
1962 assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
1963 assert!(pyproject_declares_rumdl_config(
1965 "[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
1966 ));
1967 assert!(pyproject_declares_rumdl_config(
1968 "[tool.rumdl.rules.MD007]\nindent = 4\n"
1969 ));
1970 }
1971
1972 #[test]
1973 fn ignores_incidental_mentions() {
1974 assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
1977 assert!(!pyproject_declares_rumdl_config(
1978 "[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
1979 ));
1980 assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
1981 }
1982
1983 mod expand_env_vars {
1986 use super::super::expand_env_vars;
1987 use std::collections::HashMap;
1988
1989 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
1991 let map: HashMap<String, String> = pairs
1992 .iter()
1993 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1994 .collect();
1995 move |k: &str| map.get(k).cloned()
1996 }
1997
1998 #[test]
1999 fn expands_bare_and_braced_forms() {
2000 let e = env(&[("VAR", "val"), ("FOO_BAR", "fb")]);
2001 assert_eq!(expand_env_vars("$VAR", &e).unwrap(), "val");
2002 assert_eq!(expand_env_vars("${VAR}", &e).unwrap(), "val");
2003 assert_eq!(expand_env_vars("$FOO_BAR", &e).unwrap(), "fb");
2005 }
2006
2007 #[test]
2008 fn expands_within_paths() {
2009 let e = env(&[("BASE", "/opt/cfg"), ("A", "x"), ("B", "y")]);
2010 assert_eq!(expand_env_vars("$BASE/x/y.toml", &e).unwrap(), "/opt/cfg/x/y.toml");
2011 assert_eq!(expand_env_vars("$A/$B", &e).unwrap(), "x/y");
2012 assert_eq!(expand_env_vars("${A}suffix", &e).unwrap(), "xsuffix");
2013 }
2014
2015 #[test]
2016 fn dollar_dollar_is_a_literal_dollar() {
2017 let e = env(&[("VAR", "val")]);
2018 assert_eq!(expand_env_vars("$$", &e).unwrap(), "$");
2019 assert_eq!(expand_env_vars("$$VAR", &e).unwrap(), "$VAR");
2021 assert_eq!(expand_env_vars("$${VAR}", &e).unwrap(), "${VAR}");
2022 assert_eq!(expand_env_vars("file-$$name.toml", &e).unwrap(), "file-$name.toml");
2024 }
2025
2026 #[test]
2027 fn bare_dollar_name_in_path_is_a_variable_reference() {
2028 let e = env(&[("name", "core")]);
2031 assert_eq!(expand_env_vars("file-$name.toml", &e).unwrap(), "file-core.toml");
2032 }
2033
2034 #[test]
2035 fn incidental_dollar_stays_literal() {
2036 let e = env(&[]);
2037 assert_eq!(expand_env_vars("$5", &e).unwrap(), "$5");
2039 assert_eq!(expand_env_vars("cost$", &e).unwrap(), "cost$");
2040 assert_eq!(expand_env_vars("a$/b", &e).unwrap(), "a$/b");
2041 }
2042
2043 #[test]
2044 fn malformed_braces_stay_literal() {
2045 let e = env(&[("B", "x")]);
2046 assert_eq!(expand_env_vars("${}", &e).unwrap(), "${}");
2047 assert_eq!(expand_env_vars("${VAR", &e).unwrap(), "${VAR");
2048 assert_eq!(expand_env_vars("${A${B}}", &e).unwrap(), "${A${B}}");
2050 }
2051
2052 #[test]
2053 fn undefined_variable_is_an_error() {
2054 let e = env(&[]);
2055 assert_eq!(expand_env_vars("$NOPE", &e).unwrap_err(), "NOPE");
2056 assert_eq!(expand_env_vars("${NOPE}", &e).unwrap_err(), "NOPE");
2057 assert_eq!(expand_env_vars("prefix/$NOPE/x", &e).unwrap_err(), "NOPE");
2058 }
2059
2060 #[test]
2061 fn replacement_is_not_rescanned() {
2062 let e = env(&[("A", "$B"), ("B", "should-not-appear")]);
2064 assert_eq!(expand_env_vars("$A", &e).unwrap(), "$B");
2065 assert_eq!(expand_env_vars("${A}", &e).unwrap(), "$B");
2066 }
2067
2068 #[test]
2069 fn identifiers_are_ascii_only_unicode_stays_literal() {
2070 let e = env(&[("VAR", "v")]);
2071 assert_eq!(expand_env_vars("${föö}", &e).unwrap(), "${föö}");
2073 assert_eq!(expand_env_vars("$VARö", &e).unwrap(), "vö");
2075 assert_eq!(expand_env_vars("café/$VAR", &e).unwrap(), "café/v");
2077 }
2078
2079 #[test]
2080 fn passthrough_for_plain_input() {
2081 let e = env(&[]);
2082 assert_eq!(expand_env_vars("", &e).unwrap(), "");
2083 assert_eq!(expand_env_vars("/plain/path.toml", &e).unwrap(), "/plain/path.toml");
2084 }
2085 }
2086
2087 #[cfg(unix)]
2096 #[test]
2097 fn discover_stops_at_project_root_across_path_representations() {
2098 use super::SourcedConfig;
2099 use std::os::unix::fs::symlink;
2100 use tempfile::tempdir;
2101
2102 let tmp = tempdir().unwrap();
2103 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
2105
2106 let real_root = tmp.path().join("project");
2107 let subdir = real_root.join("docs");
2108 std::fs::create_dir_all(&subdir).unwrap();
2109
2110 let linked_root = tmp.path().join("project-link");
2113 symlink(&real_root, &linked_root).unwrap();
2114
2115 let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
2116 assert_eq!(
2117 found, None,
2118 "discovery must stop at the project root, not overshoot to the parent config"
2119 );
2120 }
2121
2122 #[test]
2123 #[serial_test::serial]
2124 fn project_candidates_absolutize_a_relative_start() {
2125 let cwd = std::env::current_dir().unwrap();
2126 let temp = tempfile::Builder::new()
2127 .prefix("rumdl-relative-config-")
2128 .tempdir_in(&cwd)
2129 .unwrap();
2130 let nested = temp.path().join("docs");
2131 std::fs::create_dir(&nested).unwrap();
2132 let config = temp.path().join(".rumdl.toml");
2133 std::fs::write(&config, "").unwrap();
2134
2135 let relative_root = temp.path().strip_prefix(&cwd).unwrap();
2136 let relative_nested = nested.strip_prefix(&cwd).unwrap();
2137 let candidates = super::collect_project_config_candidates(relative_nested, Some(relative_root), None);
2138
2139 assert_eq!(candidates.first(), Some(&config));
2140 assert!(candidates.iter().all(|path| path.is_absolute()));
2141 }
2142
2143 mod shadowed_configs {
2144 use super::super::{ShadowedConfigs, detect_shadowed_configs, format_shadow_warning, rumdl_configs_in_dir};
2145 use tempfile::tempdir;
2146
2147 fn names(paths: &[std::path::PathBuf]) -> Vec<String> {
2148 paths
2149 .iter()
2150 .map(|p| {
2151 let file = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
2154 let parent = p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str());
2155 match parent {
2156 Some(".config") => format!(".config/{file}"),
2157 _ => file.to_string(),
2158 }
2159 })
2160 .collect()
2161 }
2162
2163 #[test]
2164 fn empty_directory_has_no_configs_and_no_shadow() {
2165 let tmp = tempdir().unwrap();
2166 assert!(rumdl_configs_in_dir(tmp.path()).is_empty());
2167 assert!(detect_shadowed_configs(tmp.path()).is_none());
2168 }
2169
2170 #[test]
2171 fn single_config_does_not_shadow() {
2172 let tmp = tempdir().unwrap();
2173 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
2174 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
2175 assert!(detect_shadowed_configs(tmp.path()).is_none());
2176 }
2177
2178 #[test]
2179 fn dot_wins_over_non_dot_and_non_dot_is_shadowed() {
2180 let tmp = tempdir().unwrap();
2181 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
2182 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
2183
2184 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
2185 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
2186 assert_eq!(names(&shadowed), vec!["rumdl.toml"]);
2187 }
2188
2189 #[test]
2190 fn config_subdir_counts_as_same_level_shadow() {
2191 let tmp = tempdir().unwrap();
2192 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
2193 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
2194 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
2195
2196 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
2197 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
2198 assert_eq!(names(&shadowed), vec![".config/rumdl.toml"]);
2199 }
2200
2201 #[test]
2202 fn pyproject_counts_only_when_it_declares_rumdl() {
2203 let bare = tempdir().unwrap();
2205 std::fs::write(bare.path().join(".rumdl.toml"), "").unwrap();
2206 std::fs::write(bare.path().join("pyproject.toml"), "[tool.black]\nline-length = 88\n").unwrap();
2207 assert_eq!(names(&rumdl_configs_in_dir(bare.path())), vec![".rumdl.toml"]);
2208 assert!(detect_shadowed_configs(bare.path()).is_none());
2209
2210 let declared = tempdir().unwrap();
2212 std::fs::write(declared.path().join(".rumdl.toml"), "").unwrap();
2213 std::fs::write(
2214 declared.path().join("pyproject.toml"),
2215 "[tool.rumdl]\nline-length = 80\n",
2216 )
2217 .unwrap();
2218 let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(declared.path()).unwrap();
2219 assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
2220 assert_eq!(names(&shadowed), vec!["pyproject.toml"]);
2221 }
2222
2223 #[test]
2224 fn markdownlint_configs_are_not_rumdl_native_and_never_shadow() {
2225 let tmp = tempdir().unwrap();
2226 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
2227 std::fs::write(tmp.path().join(".markdownlint.json"), "{}").unwrap();
2228 assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
2229 assert!(detect_shadowed_configs(tmp.path()).is_none());
2230 }
2231
2232 #[test]
2233 fn configs_returned_in_precedence_order() {
2234 let tmp = tempdir().unwrap();
2235 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
2236 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
2237 std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
2238 std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
2239 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
2240
2241 assert_eq!(
2242 names(&rumdl_configs_in_dir(tmp.path())),
2243 vec![".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]
2244 );
2245 }
2246
2247 #[test]
2248 fn warning_names_dir_once_with_relative_filenames() {
2249 let tmp = tempdir().unwrap();
2250 std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
2251 std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
2252 std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
2253
2254 let shadow = detect_shadowed_configs(tmp.path()).unwrap();
2255 let msg = format_shadow_warning(&shadow);
2256
2257 let dir = {
2258 let s = tmp.path().to_string_lossy().into_owned();
2259 if cfg!(windows) { s.replace('\\', "/") } else { s }
2260 };
2261 assert!(msg.contains("multiple rumdl config files"), "got: {msg}");
2262 assert_eq!(
2265 msg.matches(dir.as_str()).count(),
2266 1,
2267 "directory should appear exactly once, got: {msg}"
2268 );
2269 assert!(
2270 msg.contains("using .rumdl.toml, ignoring rumdl.toml, pyproject.toml"),
2271 "winner and shadowed files should be relative names in precedence order, got: {msg}"
2272 );
2273 assert!(!msg.contains('\\'), "paths must be normalized to '/': {msg}");
2275 }
2276 }
2277}
2278
2279#[cfg(test)]
2280mod cli2_tests {
2281 use super::*;
2282
2283 #[test]
2284 fn inherited_rules_keep_their_actual_sources() {
2285 let temp = tempfile::tempdir().unwrap();
2286 let root = temp.path();
2287 let docs = root.join("docs");
2288 std::fs::create_dir(&docs).unwrap();
2289 let parent = root.join(".markdownlint-cli2.yaml");
2290 let child = docs.join(".markdownlint-cli2.yaml");
2291 std::fs::write(
2292 &parent,
2293 "config:\n MD013:\n line_length: 100\n MD007:\n indent: 4\n",
2294 )
2295 .unwrap();
2296 std::fs::write(&child, "config:\n MD007:\n indent: 2\n").unwrap();
2297 let mut sourced = SourcedConfig::default();
2298 load_discovered_markdownlint(&mut sourced, &child, Some(root), Some(root.to_path_buf())).unwrap();
2299 assert_eq!(
2300 sourced.rules["MD013"].values["line-length"].origin.as_deref(),
2301 parent.to_str()
2302 );
2303 assert_eq!(
2304 sourced.rules["MD007"].values["indent"].origin.as_deref(),
2305 child.to_str()
2306 );
2307 assert_eq!(sourced.rules["MD007"].values["style"].origin.as_deref(), child.to_str());
2308 assert_eq!(
2309 sourced.loaded_files,
2310 [parent.display().to_string(), child.display().to_string()]
2311 );
2312 }
2313}