use indexmap::IndexSet;
use std::collections::BTreeMap;
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use super::file_source::{ConfigFileSource, FsConfigFiles};
use super::flavor::ConfigLoaded;
use super::flavor::ConfigValidated;
use super::parsers;
use super::registry::RuleRegistry;
use super::source_tracking::{
ConfigSource, ConfigValidationWarning, SourcedConfig, SourcedConfigFragment, SourcedGlobalConfig, SourcedValue,
};
use super::types::{
Config, ConfigError, ConfigOrigin, DiscoveredConfigError, GlobalConfig, MARKDOWNLINT_CONFIG_FILES,
RUMDL_CONFIG_FILES, RuleConfig, WITHHELD,
};
use super::validation::validate_config_sourced_internal;
use crate::utils::upward_walk::UpwardWalk;
const MAX_EXTENDS_DEPTH: usize = 10;
fn pyproject_declares_rumdl_config(content: &str) -> bool {
content.contains("[tool.rumdl]") || content.contains("[tool.rumdl.")
}
fn is_var_name_start(b: u8) -> bool {
b == b'_' || b.is_ascii_alphabetic()
}
fn is_var_name_continue(b: u8) -> bool {
b == b'_' || b.is_ascii_alphanumeric()
}
fn is_valid_var_name(name: &str) -> bool {
let bytes = name.as_bytes();
!bytes.is_empty() && is_var_name_start(bytes[0]) && bytes[1..].iter().all(|&b| is_var_name_continue(b))
}
fn expand_env_vars(input: &str, lookup: impl Fn(&str) -> Option<String>) -> Result<String, String> {
let bytes = input.as_bytes();
let mut out = String::with_capacity(input.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'$' {
let start = i;
while i < bytes.len() && bytes[i] != b'$' {
i += 1;
}
out.push_str(&input[start..i]);
continue;
}
match bytes.get(i + 1).copied() {
Some(b'$') => {
out.push('$');
i += 2;
}
Some(b'{') => {
if let Some(rel) = input[i + 2..].find('}') {
let close = i + 2 + rel;
let name = &input[i + 2..close];
if is_valid_var_name(name) {
match lookup(name) {
Some(value) => out.push_str(&value),
None => return Err(name.to_string()),
}
} else {
out.push_str(&input[i..=close]);
}
i = close + 1;
} else {
out.push('$');
i += 1;
}
}
Some(b) if is_var_name_start(b) => {
let start = i + 1;
let mut j = start;
while j < bytes.len() && is_var_name_continue(bytes[j]) {
j += 1;
}
let name = &input[start..j];
match lookup(name) {
Some(value) => out.push_str(&value),
None => return Err(name.to_string()),
}
i = j;
}
_ => {
out.push('$');
i += 1;
}
}
}
Ok(out)
}
struct ExtendsRef {
written: Option<String>,
from: String,
}
impl ExtendsRef {
fn describe(&self) -> String {
format!("{} (referenced from {})", self.short(), self.from)
}
fn short(&self) -> String {
match &self.written {
Some(written) => format!("'{written}'"),
None => WITHHELD.to_string(),
}
}
}
#[derive(Default)]
struct ExtendsChain {
visited: IndexSet<PathBuf>,
names: Vec<String>,
}
impl ExtendsChain {
fn contains(&self, canonical: &Path) -> bool {
self.visited.contains(canonical)
}
fn len(&self) -> usize {
self.visited.len()
}
fn push(&mut self, canonical: PathBuf, name: String) {
self.visited.insert(canonical);
self.names.push(name);
}
fn names(&self) -> Vec<String> {
self.names.clone()
}
}
fn resolve_extends(
extends_value: &str,
config_file_path: &Path,
from: &str,
declared_by: ConfigOrigin<'_>,
source: &dyn ConfigFileSource,
) -> Result<(PathBuf, ExtendsRef), ConfigError> {
let expanded = expand_env_vars(extends_value, |key| source.env_var(key)).map_err(|var| {
ConfigError::ExtendsUndefinedVar {
var: if declared_by.may_quote_contents() {
format!("${var}")
} else {
WITHHELD.to_string()
},
from: from.to_string(),
}
})?;
let reference = ExtendsRef {
written: declared_by.may_quote_contents().then(|| extends_value.to_string()),
from: from.to_string(),
};
Ok((
resolve_expanded_extends_path(&expanded, config_file_path, source.home_dir().as_deref()),
reference,
))
}
fn resolve_expanded_extends_path(expanded: &str, config_file_path: &Path, home: Option<&Path>) -> PathBuf {
if let Some(suffix) = expanded.strip_prefix("~/") {
match home {
Some(home) => home.join(suffix),
None => PathBuf::from(expanded),
}
} else {
let path = PathBuf::from(expanded);
if path.is_absolute() {
path
} else {
let config_dir = config_file_path.parent().unwrap_or(Path::new("."));
config_dir.join(expanded)
}
}
}
fn source_from_filename(filename: &str) -> ConfigSource {
if filename == "pyproject.toml" {
ConfigSource::PyprojectToml
} else {
ConfigSource::ProjectConfig
}
}
pub(crate) fn rumdl_configs_in_dir(dir: &Path) -> Vec<PathBuf> {
RUMDL_CONFIG_FILES
.iter()
.map(|name| dir.join(name))
.filter(|path| {
if !path.exists() {
return false;
}
if path.file_name().and_then(|n| n.to_str()) == Some("pyproject.toml") {
std::fs::read_to_string(path).is_ok_and(|content| pyproject_declares_rumdl_config(&content))
} else {
true
}
})
.collect()
}
pub(crate) fn collect_project_config_candidates(
search_dir: &Path,
workspace_root: Option<&Path>,
home_dir: Option<&Path>,
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
let walk = UpwardWalk::new(search_dir).stop_below(home_dir.map(Path::to_path_buf));
let walk = match workspace_root {
Some(root) => walk.stop_at(root),
None => walk,
};
for current_dir in walk {
candidates.extend(rumdl_configs_in_dir(¤t_dir));
candidates.extend(
MARKDOWNLINT_CONFIG_FILES
.iter()
.map(|name| current_dir.join(name))
.filter(|path| path.exists()),
);
}
candidates
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ShadowedConfigs {
pub dir: PathBuf,
pub winner: PathBuf,
pub shadowed: Vec<PathBuf>,
}
pub(crate) fn detect_shadowed_configs(dir: &Path) -> Option<ShadowedConfigs> {
let mut configs = rumdl_configs_in_dir(dir);
if configs.len() < 2 {
return None;
}
let winner = configs.remove(0);
Some(ShadowedConfigs {
dir: dir.to_path_buf(),
winner,
shadowed: configs,
})
}
pub(crate) fn format_shadow_warning(shadow: &ShadowedConfigs) -> String {
let norm = |s: String| if cfg!(windows) { s.replace('\\', "/") } else { s };
let rel = |path: &Path| {
let relative = path.strip_prefix(&shadow.dir).unwrap_or(path);
norm(relative.to_string_lossy().into_owned())
};
let shadowed = shadow.shadowed.iter().map(|p| rel(p)).collect::<Vec<_>>().join(", ");
format!(
"multiple rumdl config files in {}: using {}, ignoring {}",
norm(shadow.dir.to_string_lossy().into_owned()),
rel(&shadow.winner),
shadowed,
)
}
fn load_config_with_extends(
sourced_config: &mut SourcedConfig<ConfigLoaded>,
config_file_path: &Path,
chain: &mut ExtendsChain,
chain_source: ConfigSource,
origin: ConfigOrigin<'_>,
source: &dyn ConfigFileSource,
) -> Result<(), ConfigError> {
let canonical = source.canonicalize(config_file_path);
let path_str = config_file_path.display().to_string();
let described = origin.display_name(&path_str);
let short = origin.short_name(&path_str);
if chain.contains(&canonical) {
return Err(ConfigError::CircularExtends {
path: described,
chain: chain.names(),
});
}
if chain.len() >= MAX_EXTENDS_DEPTH {
return Err(ConfigError::ExtendsDepthExceeded {
path: described,
max_depth: MAX_EXTENDS_DEPTH,
});
}
chain.push(canonical, short.clone());
let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let content = source
.read_to_string(config_file_path)
.map_err(|e| ConfigError::IoError {
source: e,
path: described.clone(),
})?;
let fragment = if filename == "pyproject.toml" {
match parsers::parse_pyproject_toml(&content, &path_str, chain_source, origin)? {
Some(f) => f,
None => return Ok(()), }
} else {
parsers::parse_rumdl_toml(&content, &path_str, chain_source, origin)?
};
if let Some(ref extends_value) = fragment.extends {
let (base_path, reference) = resolve_extends(extends_value, config_file_path, &short, origin, source)?;
let base_described = reference.describe();
let base_short = reference.short();
if !source.exists(&base_path) {
return Err(ConfigError::ExtendsNotFound {
path: base_short,
from: short,
});
}
log::debug!(
"[rumdl-config] Config {} extends {}, loading base first",
path_str,
base_path.display()
);
load_config_with_extends(
sourced_config,
&base_path,
chain,
chain_source,
ConfigOrigin::Extends {
described_as: &base_described,
short_name: &base_short,
},
source,
)?;
}
let mut fragment_for_merge = fragment;
fragment_for_merge.extends = None;
sourced_config.merge(fragment_for_merge);
sourced_config.loaded_files.push(path_str);
Ok(())
}
impl SourcedConfig<ConfigLoaded> {
pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
self.global.enable.merge_from(fragment.global.enable);
self.global.disable.merge_from(fragment.global.disable);
self.global
.extend_enable
.merge_union_from(fragment.global.extend_enable);
self.global
.extend_disable
.merge_union_from(fragment.global.extend_disable);
self.global
.disable
.value
.retain(|rule| !self.global.enable.value.contains(rule));
if self.global.include.merge_from(fragment.global.include) {
self.global.include_withheld = fragment.global.include_withheld;
}
self.global.exclude.merge_from(fragment.global.exclude);
self.global
.respect_gitignore
.merge_from(fragment.global.respect_gitignore);
self.global.line_length.merge_from(fragment.global.line_length);
self.global.fixable.merge_from(fragment.global.fixable);
self.global.unfixable.merge_from(fragment.global.unfixable);
self.global.flavor.merge_from(fragment.global.flavor);
self.global.force_exclude.merge_from(fragment.global.force_exclude);
self.global.editorconfig.merge_from(fragment.global.editorconfig);
if let Some(output_format_fragment) = fragment.global.output_format {
if let Some(ref mut output_format) = self.global.output_format {
output_format.merge_from(output_format_fragment);
} else {
self.global.output_format = Some(output_format_fragment);
}
}
if let Some(cache_dir_fragment) = fragment.global.cache_dir {
if let Some(ref mut cache_dir) = self.global.cache_dir {
cache_dir.merge_from(cache_dir_fragment);
} else {
self.global.cache_dir = Some(cache_dir_fragment);
}
}
if fragment.global.cache.source != ConfigSource::Default {
self.global.cache.merge_from(fragment.global.cache);
}
self.per_file_ignores.merge_from(fragment.per_file_ignores);
self.per_file_flavor.merge_from(fragment.per_file_flavor);
self.code_block_tools.merge_from(fragment.code_block_tools);
for (rule_name, rule_fragment) in fragment.rules {
let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_entry = self.rules.entry(norm_rule_name).or_default();
if let Some(severity_fragment) = rule_fragment.severity {
if let Some(ref mut existing_severity) = rule_entry.severity {
existing_severity.merge_from(severity_fragment);
} else {
rule_entry.severity = Some(severity_fragment);
}
}
for (key, sourced_value_fragment) in rule_fragment.values {
let sv_entry = rule_entry
.values
.entry(key.clone())
.or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
if sv_entry.merge_from(sourced_value_fragment) {
if rule_fragment.withheld_keys.contains(&key) {
rule_entry.withheld_keys.insert(key);
} else {
rule_entry.withheld_keys.remove(&key);
}
}
}
}
for warning in fragment.load_warnings {
if !self.discovery_warnings.contains(&warning) {
self.discovery_warnings.push(warning);
}
}
for (section, key, file_path) in fragment.unknown_keys {
if !self.unknown_keys.iter().any(|(s, k, _)| s == §ion && k == &key) {
self.unknown_keys.push((section, key, file_path));
}
}
}
pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
Self::load_with_discovery(config_path, cli_overrides, false)
}
fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
UpwardWalk::new(start_dir)
.find(|dir| dir.join(".git").exists())
.unwrap_or_else(|| {
log::debug!(
"[rumdl-config] No .git found, using config location as project root: {}",
start_dir.display()
);
start_dir.to_path_buf()
})
}
fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
home_override.map(Path::to_path_buf).or_else(|| {
#[cfg(feature = "native")]
{
use etcetera::{BaseStrategy, choose_base_strategy};
choose_base_strategy().ok().map(|s| s.home_dir().to_path_buf())
}
#[cfg(not(feature = "native"))]
{
None
}
})
}
fn resolve_discovery_start(start_override: Option<&Path>) -> Option<std::path::PathBuf> {
if let Some(dir) = start_override {
return Some(dir.to_path_buf());
}
match std::env::current_dir() {
Ok(dir) => Some(dir),
Err(e) => {
log::debug!("[rumdl-config] Failed to get current directory: {e}");
None
}
}
}
fn discover_config_upward(
start_override: Option<&Path>,
home_override: Option<&Path>,
) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
let start_dir = Self::resolve_discovery_start(start_override)?;
let (config_path, config_dir, shadow) = UpwardWalk::new(&start_dir)
.stop_below(Self::resolve_home_boundary(home_override))
.always_yield_start()
.stop_at_git_root()
.find_map(|dir| {
rumdl_configs_in_dir(&dir).into_iter().next().map(|winner| {
log::debug!("[rumdl-config] Found config file: {}", winner.display());
let shadow = detect_shadowed_configs(&dir);
(winner, dir, shadow)
})
})?;
let project_root = Self::find_project_root_from(&config_dir);
Some((config_path, project_root, shadow))
}
fn discover_markdownlint_config_upward(
start_override: Option<&Path>,
home_override: Option<&Path>,
) -> Option<std::path::PathBuf> {
let start_dir = Self::resolve_discovery_start(start_override)?;
UpwardWalk::new(&start_dir)
.stop_below(Self::resolve_home_boundary(home_override))
.always_yield_start()
.stop_at_git_root()
.find_map(|dir| {
MARKDOWNLINT_CONFIG_FILES
.iter()
.map(|name| dir.join(name))
.find(|path| path.exists())
})
}
fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
let config_dir = config_dir.join("rumdl");
const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];
log::debug!(
"[rumdl-config] Checking for user configuration in: {}",
config_dir.display()
);
for filename in USER_CONFIG_FILES {
let config_path = config_dir.join(filename);
if config_path.exists() {
if *filename == "pyproject.toml" {
if let Ok(content) = std::fs::read_to_string(&config_path) {
if pyproject_declares_rumdl_config(&content) {
log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
return Some(config_path);
}
log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
continue;
}
} else {
log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
return Some(config_path);
}
}
}
log::debug!(
"[rumdl-config] No user configuration found in: {}",
config_dir.display()
);
None
}
#[cfg(feature = "native")]
fn user_configuration_path() -> Option<std::path::PathBuf> {
use etcetera::{BaseStrategy, choose_base_strategy};
match choose_base_strategy() {
Ok(strategy) => {
let config_dir = strategy.config_dir();
Self::user_configuration_path_impl(&config_dir)
}
Err(e) => {
log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
None
}
}
}
#[cfg(not(feature = "native"))]
fn user_configuration_path() -> Option<std::path::PathBuf> {
None
}
fn home_configuration_path_impl(home_dir: &Path) -> Option<std::path::PathBuf> {
const HOME_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml"];
log::debug!(
"[rumdl-config] Checking for home-directory configuration in: {}",
home_dir.display()
);
for filename in HOME_CONFIG_FILES {
let config_path = home_dir.join(filename);
if config_path.exists() {
log::debug!(
"[rumdl-config] Found home-directory configuration at: {}",
config_path.display()
);
return Some(config_path);
}
}
log::debug!(
"[rumdl-config] No home-directory configuration found in: {}",
home_dir.display()
);
None
}
#[cfg(feature = "native")]
fn home_configuration_path() -> Option<std::path::PathBuf> {
use etcetera::{BaseStrategy, choose_base_strategy};
match choose_base_strategy() {
Ok(strategy) => Self::home_configuration_path_impl(strategy.home_dir()),
Err(e) => {
log::debug!("[rumdl-config] Failed to determine home directory: {e}");
None
}
}
}
#[cfg(not(feature = "native"))]
fn home_configuration_path() -> Option<std::path::PathBuf> {
None
}
fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
let path_obj = Path::new(path);
let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
let path_str = path.to_string();
log::debug!("[rumdl-config] Loading explicit config file: {filename}");
if let Some(config_parent) = path_obj.parent() {
let project_root = Self::find_project_root_from(config_parent);
log::debug!(
"[rumdl-config] Project root (from explicit config): {}",
project_root.display()
);
sourced_config.project_root = Some(project_root);
}
const MARKDOWNLINT_FILENAMES: &[&str] = &[
".markdownlint-cli2.jsonc",
".markdownlint-cli2.yaml",
".markdownlint-cli2.yml",
".markdownlint.json",
".markdownlint.yaml",
".markdownlint.yml",
];
if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
let mut chain = ExtendsChain::default();
let chain_source = source_from_filename(filename);
load_config_with_extends(
sourced_config,
path_obj,
&mut chain,
chain_source,
ConfigOrigin::Direct,
&FsConfigFiles,
)?;
} else if MARKDOWNLINT_FILENAMES.contains(&filename)
|| path_str.ends_with(".json")
|| path_str.ends_with(".jsonc")
|| path_str.ends_with(".yaml")
|| path_str.ends_with(".yml")
{
let fragment = parsers::load_from_markdownlint(&path_str)?;
sourced_config.merge(fragment);
sourced_config.loaded_files.push(path_str);
} else {
let mut chain = ExtendsChain::default();
let chain_source = source_from_filename(filename);
load_config_with_extends(
sourced_config,
path_obj,
&mut chain,
chain_source,
ConfigOrigin::Direct,
&FsConfigFiles,
)?;
}
Ok(())
}
fn load_user_config(
sourced_config: &mut Self,
user_config_dir: Option<&Path>,
home_dir: Option<&Path>,
) -> Result<(), ConfigError> {
let user_config_path = if let Some(dir) = user_config_dir {
Self::user_configuration_path_impl(dir)
} else {
Self::user_configuration_path()
};
let user_config_path = user_config_path.or_else(|| match home_dir {
Some(home) => Self::home_configuration_path_impl(home),
None => Self::home_configuration_path(),
});
if let Some(user_config_path) = user_config_path {
let path_str = user_config_path.display().to_string();
log::debug!("[rumdl-config] Loading user config: {path_str}");
let mut chain = ExtendsChain::default();
load_config_with_extends(
sourced_config,
&user_config_path,
&mut chain,
ConfigSource::UserConfig,
ConfigOrigin::Direct,
&FsConfigFiles,
)?;
} else {
log::debug!("[rumdl-config] No user configuration file found");
}
Ok(())
}
fn load_discovered_config(
sourced_config: &mut Self,
config_file: &Path,
user_config_dir: Option<&Path>,
home_dir: Option<&Path>,
) -> Result<(), DiscoveredConfigError> {
let filename = config_file.file_name().and_then(|name| name.to_str()).unwrap_or("");
if MARKDOWNLINT_CONFIG_FILES.contains(&filename) {
Self::load_user_config(sourced_config, user_config_dir, home_dir)
.map_err(DiscoveredConfigError::UserConfig)?;
let path_str = config_file.display().to_string();
let fragment = parsers::load_from_markdownlint(&path_str).map_err(DiscoveredConfigError::ProjectConfig)?;
sourced_config.merge(fragment);
sourced_config.loaded_files.push(path_str);
} else {
let mut chain = ExtendsChain::default();
let chain_source = source_from_filename(filename);
load_config_with_extends(
sourced_config,
config_file,
&mut chain,
chain_source,
ConfigOrigin::Direct,
&FsConfigFiles,
)
.map_err(DiscoveredConfigError::ProjectConfig)?;
}
Ok(())
}
pub fn load_discovered(
config_file: &Path,
user_config_dir: Option<&Path>,
home_dir: Option<&Path>,
) -> Result<Self, DiscoveredConfigError> {
let mut sourced_config = SourcedConfig::default();
if let Some(config_parent) = config_file.parent() {
sourced_config.project_root = Some(Self::find_project_root_from(config_parent));
}
Self::load_discovered_config(&mut sourced_config, config_file, user_config_dir, home_dir)?;
Ok(sourced_config)
}
pub fn load_for_workspace(
start_dir: &Path,
config_path: Option<&str>,
user_config_dir: Option<&Path>,
home_dir: Option<&Path>,
) -> Result<Self, ConfigError> {
Self::load_with_discovery_from(Some(start_dir), config_path, None, false, user_config_dir, home_dir)
}
#[doc(hidden)]
pub fn load_with_discovery_impl(
config_path: Option<&str>,
cli_overrides: Option<&SourcedGlobalConfig>,
skip_auto_discovery: bool,
user_config_dir: Option<&Path>,
home_dir: Option<&Path>,
) -> Result<Self, ConfigError> {
Self::load_with_discovery_from(
None,
config_path,
cli_overrides,
skip_auto_discovery,
user_config_dir,
home_dir,
)
}
fn load_with_discovery_from(
start_dir: Option<&Path>,
config_path: Option<&str>,
cli_overrides: Option<&SourcedGlobalConfig>,
skip_auto_discovery: bool,
user_config_dir: Option<&Path>,
home_dir: Option<&Path>,
) -> Result<Self, ConfigError> {
use std::env;
log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());
let mut sourced_config = SourcedConfig::default();
if let Some(path) = config_path {
log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
Self::load_explicit_config(&mut sourced_config, path)?;
} else if skip_auto_discovery {
log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
} else {
log::debug!("[rumdl-config] No explicit config_path, searching default locations");
if let Some((config_file, project_root, shadow)) = Self::discover_config_upward(start_dir, home_dir) {
log::debug!("[rumdl-config] Found project config: {}", config_file.display());
log::debug!("[rumdl-config] Project root: {}", project_root.display());
if let Some(shadow) = shadow {
sourced_config.discovery_warnings.push(format_shadow_warning(&shadow));
}
sourced_config.project_root = Some(project_root);
Self::load_discovered_config(&mut sourced_config, &config_file, user_config_dir, home_dir)?;
} else {
log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");
if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward(start_dir, home_dir) {
log::debug!(
"[rumdl-config] Found markdownlint config: {}",
markdownlint_path.display()
);
if let Err(e) =
Self::load_discovered_config(&mut sourced_config, &markdownlint_path, user_config_dir, home_dir)
{
match e {
DiscoveredConfigError::ProjectConfig(e) => {
log::debug!("[rumdl-config] Failed to load markdownlint config: {e}");
}
DiscoveredConfigError::UserConfig(e) => return Err(e),
}
}
} else {
log::debug!("[rumdl-config] No project config found, using user config as fallback");
Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
}
}
}
if let Some(cli) = cli_overrides {
sourced_config
.global
.enable
.merge_override(cli.enable.value.clone(), ConfigSource::Cli, None);
sourced_config
.global
.disable
.merge_override(cli.disable.value.clone(), ConfigSource::Cli, None);
sourced_config
.global
.exclude
.merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None);
sourced_config
.global
.include
.merge_override(cli.include.value.clone(), ConfigSource::Cli, None);
sourced_config.global.respect_gitignore.merge_override(
cli.respect_gitignore.value,
ConfigSource::Cli,
None,
);
sourced_config
.global
.fixable
.merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None);
sourced_config
.global
.unfixable
.merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None);
}
Ok(sourced_config)
}
pub fn load_with_discovery(
config_path: Option<&str>,
cli_overrides: Option<&SourcedGlobalConfig>,
skip_auto_discovery: bool,
) -> Result<Self, ConfigError> {
Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
}
pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
let warnings = validate_config_sourced_internal(&self, registry);
Ok(SourcedConfig {
global: self.global,
per_file_ignores: self.per_file_ignores,
per_file_flavor: self.per_file_flavor,
code_block_tools: self.code_block_tools,
rules: self.rules,
loaded_files: self.loaded_files,
unknown_keys: self.unknown_keys,
project_root: self.project_root,
discovery_warnings: self.discovery_warnings,
validation_warnings: warnings,
_state: PhantomData,
})
}
pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
let validated = self.validate(registry)?;
let warnings = validated.validation_warnings.clone();
Ok((validated.into(), warnings))
}
pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
SourcedConfig {
global: self.global,
per_file_ignores: self.per_file_ignores,
per_file_flavor: self.per_file_flavor,
code_block_tools: self.code_block_tools,
rules: self.rules,
loaded_files: self.loaded_files,
unknown_keys: self.unknown_keys,
project_root: self.project_root,
discovery_warnings: self.discovery_warnings,
validation_warnings: Vec::new(),
_state: PhantomData,
}
}
pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
collect_project_config_candidates(dir, Some(project_root), Self::resolve_home_boundary(None).as_deref())
.into_iter()
.next()
}
pub fn load_sourced_for_path(
config_path: &Path,
project_root: &Path,
) -> Result<SourcedConfig<ConfigLoaded>, ConfigError> {
let mut sourced_config = SourcedConfig {
project_root: Some(project_root.to_path_buf()),
..SourcedConfig::default()
};
let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let path_str = config_path.display().to_string();
let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
|| (filename != "pyproject.toml"
&& filename != ".rumdl.toml"
&& filename != "rumdl.toml"
&& (path_str.ends_with(".json")
|| path_str.ends_with(".jsonc")
|| path_str.ends_with(".yaml")
|| path_str.ends_with(".yml")));
if is_markdownlint {
let fragment = parsers::load_from_markdownlint(&path_str)?;
sourced_config.merge(fragment);
sourced_config.loaded_files.push(path_str);
} else {
let mut chain = ExtendsChain::default();
let chain_source = source_from_filename(filename);
load_config_with_extends(
&mut sourced_config,
config_path,
&mut chain,
chain_source,
ConfigOrigin::Direct,
&FsConfigFiles,
)?;
}
Ok(sourced_config)
}
pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
Ok(Self::load_sourced_for_path(config_path, project_root)?
.into_validated_unchecked()
.into())
}
#[cfg(any(feature = "wasm", test))]
pub(crate) fn load_chain_from(root: &Path, source: &dyn ConfigFileSource) -> Result<Self, ConfigError> {
let mut sourced_config = SourcedConfig::default();
let filename = root.file_name().and_then(|n| n.to_str()).unwrap_or("");
let mut chain = ExtendsChain::default();
load_config_with_extends(
&mut sourced_config,
root,
&mut chain,
source_from_filename(filename),
ConfigOrigin::Direct,
source,
)?;
Ok(sourced_config)
}
}
impl From<SourcedConfig<ConfigValidated>> for Config {
fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
let mut rules = BTreeMap::new();
let mut withheld_rule_values = std::collections::BTreeSet::new();
for (rule_name, sourced_rule_cfg) in sourced.rules {
let normalized_rule_name = rule_name.to_ascii_uppercase();
let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
let mut values = BTreeMap::new();
for (key, sourced_val) in sourced_rule_cfg.values {
values.insert(key, sourced_val.value);
}
if values.keys().any(|key| sourced_rule_cfg.withheld_keys.contains(key)) {
withheld_rule_values.insert(normalized_rule_name.clone());
}
rules.insert(normalized_rule_name, RuleConfig { severity, values });
}
let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;
#[allow(deprecated)]
let global = GlobalConfig {
enable: sourced.global.enable.value,
disable: sourced.global.disable.value,
exclude: sourced.global.exclude.value,
include: sourced.global.include.value,
respect_gitignore: sourced.global.respect_gitignore.value,
line_length: sourced.global.line_length.value,
output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
fixable: sourced.global.fixable.value,
unfixable: sourced.global.unfixable.value,
flavor: sourced.global.flavor.value,
force_exclude: sourced.global.force_exclude.value,
cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
cache: sourced.global.cache.value,
extend_enable: sourced.global.extend_enable.value,
extend_disable: sourced.global.extend_disable.value,
editorconfig: sourced.global.editorconfig.value,
enable_is_explicit,
include_withheld: sourced.global.include_withheld,
};
let mut config = Config {
extends: None,
global,
per_file_ignores: sourced.per_file_ignores.value,
per_file_flavor: sourced.per_file_flavor.value,
code_block_tools: sourced.code_block_tools.value,
rules,
withheld_rule_values,
project_root: sourced.project_root,
per_file_ignores_cache: Arc::new(OnceLock::new()),
per_file_flavor_cache: Arc::new(OnceLock::new()),
canonical_project_root_cache: Arc::new(OnceLock::new()),
};
config.apply_per_rule_enabled();
config.canonicalize_rule_lists();
config
}
}
#[cfg(test)]
mod tests {
use super::pyproject_declares_rumdl_config;
#[test]
fn detects_flat_and_dotted_rumdl_sections() {
assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
assert!(pyproject_declares_rumdl_config(
"[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
));
assert!(pyproject_declares_rumdl_config(
"[tool.rumdl.rules.MD007]\nindent = 4\n"
));
}
#[test]
fn ignores_incidental_mentions() {
assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
assert!(!pyproject_declares_rumdl_config(
"[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
));
assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
}
mod expand_env_vars {
use super::super::expand_env_vars;
use std::collections::HashMap;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |k: &str| map.get(k).cloned()
}
#[test]
fn expands_bare_and_braced_forms() {
let e = env(&[("VAR", "val"), ("FOO_BAR", "fb")]);
assert_eq!(expand_env_vars("$VAR", &e).unwrap(), "val");
assert_eq!(expand_env_vars("${VAR}", &e).unwrap(), "val");
assert_eq!(expand_env_vars("$FOO_BAR", &e).unwrap(), "fb");
}
#[test]
fn expands_within_paths() {
let e = env(&[("BASE", "/opt/cfg"), ("A", "x"), ("B", "y")]);
assert_eq!(expand_env_vars("$BASE/x/y.toml", &e).unwrap(), "/opt/cfg/x/y.toml");
assert_eq!(expand_env_vars("$A/$B", &e).unwrap(), "x/y");
assert_eq!(expand_env_vars("${A}suffix", &e).unwrap(), "xsuffix");
}
#[test]
fn dollar_dollar_is_a_literal_dollar() {
let e = env(&[("VAR", "val")]);
assert_eq!(expand_env_vars("$$", &e).unwrap(), "$");
assert_eq!(expand_env_vars("$$VAR", &e).unwrap(), "$VAR");
assert_eq!(expand_env_vars("$${VAR}", &e).unwrap(), "${VAR}");
assert_eq!(expand_env_vars("file-$$name.toml", &e).unwrap(), "file-$name.toml");
}
#[test]
fn bare_dollar_name_in_path_is_a_variable_reference() {
let e = env(&[("name", "core")]);
assert_eq!(expand_env_vars("file-$name.toml", &e).unwrap(), "file-core.toml");
}
#[test]
fn incidental_dollar_stays_literal() {
let e = env(&[]);
assert_eq!(expand_env_vars("$5", &e).unwrap(), "$5");
assert_eq!(expand_env_vars("cost$", &e).unwrap(), "cost$");
assert_eq!(expand_env_vars("a$/b", &e).unwrap(), "a$/b");
}
#[test]
fn malformed_braces_stay_literal() {
let e = env(&[("B", "x")]);
assert_eq!(expand_env_vars("${}", &e).unwrap(), "${}");
assert_eq!(expand_env_vars("${VAR", &e).unwrap(), "${VAR");
assert_eq!(expand_env_vars("${A${B}}", &e).unwrap(), "${A${B}}");
}
#[test]
fn undefined_variable_is_an_error() {
let e = env(&[]);
assert_eq!(expand_env_vars("$NOPE", &e).unwrap_err(), "NOPE");
assert_eq!(expand_env_vars("${NOPE}", &e).unwrap_err(), "NOPE");
assert_eq!(expand_env_vars("prefix/$NOPE/x", &e).unwrap_err(), "NOPE");
}
#[test]
fn replacement_is_not_rescanned() {
let e = env(&[("A", "$B"), ("B", "should-not-appear")]);
assert_eq!(expand_env_vars("$A", &e).unwrap(), "$B");
assert_eq!(expand_env_vars("${A}", &e).unwrap(), "$B");
}
#[test]
fn identifiers_are_ascii_only_unicode_stays_literal() {
let e = env(&[("VAR", "v")]);
assert_eq!(expand_env_vars("${föö}", &e).unwrap(), "${föö}");
assert_eq!(expand_env_vars("$VARö", &e).unwrap(), "vö");
assert_eq!(expand_env_vars("café/$VAR", &e).unwrap(), "café/v");
}
#[test]
fn passthrough_for_plain_input() {
let e = env(&[]);
assert_eq!(expand_env_vars("", &e).unwrap(), "");
assert_eq!(expand_env_vars("/plain/path.toml", &e).unwrap(), "/plain/path.toml");
}
}
#[cfg(unix)]
#[test]
fn discover_stops_at_project_root_across_path_representations() {
use super::SourcedConfig;
use std::os::unix::fs::symlink;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
let real_root = tmp.path().join("project");
let subdir = real_root.join("docs");
std::fs::create_dir_all(&subdir).unwrap();
let linked_root = tmp.path().join("project-link");
symlink(&real_root, &linked_root).unwrap();
let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
assert_eq!(
found, None,
"discovery must stop at the project root, not overshoot to the parent config"
);
}
#[test]
#[serial_test::serial]
fn project_candidates_absolutize_a_relative_start() {
let cwd = std::env::current_dir().unwrap();
let temp = tempfile::Builder::new()
.prefix("rumdl-relative-config-")
.tempdir_in(&cwd)
.unwrap();
let nested = temp.path().join("docs");
std::fs::create_dir(&nested).unwrap();
let config = temp.path().join(".rumdl.toml");
std::fs::write(&config, "").unwrap();
let relative_root = temp.path().strip_prefix(&cwd).unwrap();
let relative_nested = nested.strip_prefix(&cwd).unwrap();
let candidates = super::collect_project_config_candidates(relative_nested, Some(relative_root), None);
assert_eq!(candidates.first(), Some(&config));
assert!(candidates.iter().all(|path| path.is_absolute()));
}
mod shadowed_configs {
use super::super::{ShadowedConfigs, detect_shadowed_configs, format_shadow_warning, rumdl_configs_in_dir};
use tempfile::tempdir;
fn names(paths: &[std::path::PathBuf]) -> Vec<String> {
paths
.iter()
.map(|p| {
let file = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
let parent = p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str());
match parent {
Some(".config") => format!(".config/{file}"),
_ => file.to_string(),
}
})
.collect()
}
#[test]
fn empty_directory_has_no_configs_and_no_shadow() {
let tmp = tempdir().unwrap();
assert!(rumdl_configs_in_dir(tmp.path()).is_empty());
assert!(detect_shadowed_configs(tmp.path()).is_none());
}
#[test]
fn single_config_does_not_shadow() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
assert!(detect_shadowed_configs(tmp.path()).is_none());
}
#[test]
fn dot_wins_over_non_dot_and_non_dot_is_shadowed() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
assert_eq!(names(&shadowed), vec!["rumdl.toml"]);
}
#[test]
fn config_subdir_counts_as_same_level_shadow() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
assert_eq!(names(&shadowed), vec![".config/rumdl.toml"]);
}
#[test]
fn pyproject_counts_only_when_it_declares_rumdl() {
let bare = tempdir().unwrap();
std::fs::write(bare.path().join(".rumdl.toml"), "").unwrap();
std::fs::write(bare.path().join("pyproject.toml"), "[tool.black]\nline-length = 88\n").unwrap();
assert_eq!(names(&rumdl_configs_in_dir(bare.path())), vec![".rumdl.toml"]);
assert!(detect_shadowed_configs(bare.path()).is_none());
let declared = tempdir().unwrap();
std::fs::write(declared.path().join(".rumdl.toml"), "").unwrap();
std::fs::write(
declared.path().join("pyproject.toml"),
"[tool.rumdl]\nline-length = 80\n",
)
.unwrap();
let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(declared.path()).unwrap();
assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
assert_eq!(names(&shadowed), vec!["pyproject.toml"]);
}
#[test]
fn markdownlint_configs_are_not_rumdl_native_and_never_shadow() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
std::fs::write(tmp.path().join(".markdownlint.json"), "{}").unwrap();
assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
assert!(detect_shadowed_configs(tmp.path()).is_none());
}
#[test]
fn configs_returned_in_precedence_order() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
assert_eq!(
names(&rumdl_configs_in_dir(tmp.path())),
vec![".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]
);
}
#[test]
fn warning_names_dir_once_with_relative_filenames() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
let shadow = detect_shadowed_configs(tmp.path()).unwrap();
let msg = format_shadow_warning(&shadow);
let dir = {
let s = tmp.path().to_string_lossy().into_owned();
if cfg!(windows) { s.replace('\\', "/") } else { s }
};
assert!(msg.contains("multiple rumdl config files"), "got: {msg}");
assert_eq!(
msg.matches(dir.as_str()).count(),
1,
"directory should appear exactly once, got: {msg}"
);
assert!(
msg.contains("using .rumdl.toml, ignoring rumdl.toml, pyproject.toml"),
"winner and shadowed files should be relative names in precedence order, got: {msg}"
);
assert!(!msg.contains('\\'), "paths must be normalized to '/': {msg}");
}
}
}