use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rumdl_lib::config as rumdl_config;
use rumdl_lib::rule::Rule;
use crate::cache::LintCache;
use crate::file_processor::CacheHashes;
pub struct ConfigGroup {
pub config: rumdl_config::Config,
pub rules: Vec<Box<dyn Rule>>,
pub cache_hashes: Option<Arc<CacheHashes>>,
pub files: Vec<String>,
}
pub struct ResolutionRoots<'a> {
pub grouping_root: Option<&'a Path>,
pub project_root: Option<&'a Path>,
}
fn config_scope_dir(config_path: &Path) -> Option<&Path> {
let parent = config_path.parent()?;
if parent.file_name() == Some(std::ffi::OsStr::new(".config")) {
parent.parent()
} else {
Some(parent)
}
}
fn is_root_level_config(config_path: &Path, project_root: &Path) -> bool {
let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let Some(parent) = config_path.parent() else {
return false;
};
let parent = canon(parent);
parent == canon(project_root) || parent == canon(&project_root.join(".config"))
}
pub fn resolve_config_groups(
file_paths: &[String],
root_config: &rumdl_config::Config,
args: &crate::CheckArgs,
roots: &ResolutionRoots<'_>,
inline_overrides: &[toml::Table],
cache: &Option<Arc<LintCache>>,
bypass_discovery: bool,
) -> Vec<ConfigGroup> {
if bypass_discovery || roots.grouping_root.is_none() {
let enabled_rules = crate::file_processor::get_enabled_rules_from_checkargs(args, root_config);
let cache_hashes = cache
.as_ref()
.map(|_| Arc::new(CacheHashes::new(root_config, &enabled_rules)));
return vec![ConfigGroup {
config: root_config.clone(),
rules: enabled_rules,
cache_hashes,
files: file_paths.to_vec(),
}];
}
let grouping_root = roots.grouping_root.unwrap();
let mut dir_config_cache: HashMap<PathBuf, Option<PathBuf>> = HashMap::new();
let mut file_config_map: BTreeMap<Option<PathBuf>, Vec<String>> = BTreeMap::new();
for file_path in file_paths {
let path = Path::new(file_path);
let parent_dir = match path.parent() {
Some(dir) if dir.is_dir() => dir.to_path_buf(),
_ => grouping_root.to_path_buf(),
};
let config_path = discover_with_cache(&parent_dir, grouping_root, &mut dir_config_cache);
let effective_config = config_path.filter(|cp| !is_root_level_config(cp, grouping_root));
file_config_map
.entry(effective_config)
.or_default()
.push(file_path.clone());
}
let mut groups = Vec::new();
for (config_path, files) in file_config_map {
match config_path {
None => {
let enabled_rules = crate::file_processor::get_enabled_rules_from_checkargs(args, root_config);
let cache_hashes = cache
.as_ref()
.map(|_| Arc::new(CacheHashes::new(root_config, &enabled_rules)));
groups.push(ConfigGroup {
config: root_config.clone(),
rules: enabled_rules,
cache_hashes,
files,
});
}
Some(path) => {
let subconfig_root = roots
.project_root
.or_else(|| config_scope_dir(&path))
.unwrap_or(grouping_root);
match rumdl_config::SourcedConfig::load_sourced_for_path(&path, subconfig_root) {
Ok(mut sourced) => {
crate::cli_config_override::apply_inline_overrides(&mut sourced, inline_overrides);
let mut subdir_config: rumdl_config::Config = sourced.into_validated_unchecked().into();
apply_cli_config_overrides(&mut subdir_config, args);
let enabled_rules =
crate::file_processor::get_enabled_rules_from_checkargs(args, &subdir_config);
let cache_hashes = cache
.as_ref()
.map(|_| Arc::new(CacheHashes::new(&subdir_config, &enabled_rules)));
groups.push(ConfigGroup {
config: subdir_config,
rules: enabled_rules,
cache_hashes,
files,
});
}
Err(e) => {
eprintln!(
"\x1b[33m[config warning]\x1b[0m Failed to load config {}: {}. Using root config for affected files.",
path.display(),
e
);
let enabled_rules = crate::file_processor::get_enabled_rules_from_checkargs(args, root_config);
let cache_hashes = cache
.as_ref()
.map(|_| Arc::new(CacheHashes::new(root_config, &enabled_rules)));
groups.push(ConfigGroup {
config: root_config.clone(),
rules: enabled_rules,
cache_hashes,
files,
});
}
}
}
}
}
groups
}
fn discover_with_cache(
dir: &Path,
project_root: &Path,
cache: &mut HashMap<PathBuf, Option<PathBuf>>,
) -> Option<PathBuf> {
if let Some(cached) = cache.get(dir) {
return cached.clone();
}
let result = rumdl_config::SourcedConfig::discover_config_for_dir(dir, project_root);
cache.insert(dir.to_path_buf(), result.clone());
if let Some(ref config_path) = result {
if let Some(config_dir) = config_path.parent() {
let mut intermediate = dir.to_path_buf();
while intermediate != config_dir && intermediate.starts_with(project_root) {
cache.entry(intermediate.clone()).or_insert_with(|| result.clone());
match intermediate.parent() {
Some(parent) => intermediate = parent.to_path_buf(),
None => break,
}
}
}
} else {
let mut intermediate = dir.to_path_buf();
while intermediate.starts_with(project_root) {
cache.entry(intermediate.clone()).or_insert(None);
if intermediate == project_root.to_path_buf() {
break;
}
match intermediate.parent() {
Some(parent) => intermediate = parent.to_path_buf(),
None => break,
}
}
}
result
}
fn apply_cli_config_overrides(config: &mut rumdl_config::Config, args: &crate::CheckArgs) {
if let Some(flavor) = args.flavor {
config.global.flavor = flavor.into();
}
if let Some(respect_gitignore) = args.respect_gitignore {
config.global.respect_gitignore = respect_gitignore;
}
}
#[cfg(test)]
mod tests {
use super::config_scope_dir;
use std::path::Path;
#[test]
fn config_scope_dir_uses_containing_dir_for_plain_configs() {
for name in ["myproj/.rumdl.toml", "myproj/rumdl.toml", "myproj/pyproject.toml"] {
assert_eq!(
config_scope_dir(Path::new(name)),
Some(Path::new("myproj")),
"{name} should be scoped to its containing directory"
);
}
}
#[test]
fn config_scope_dir_skips_dot_config_directory() {
assert_eq!(
config_scope_dir(Path::new("myproj/.config/rumdl.toml")),
Some(Path::new("myproj"))
);
}
}