use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::fmt;
use std::path::{Path, PathBuf};
use super::core::DebtmapConfig;
use super::loader::{directory_ancestors_impl, parse_and_validate_config_impl, read_config_file};
use super::scoring::ScoringWeights;
use super::thresholds::ThresholdsConfig;
use super::validation::validate_config;
macro_rules! merge_optional_field {
($target:expr, $source:expr, $field:ident, $field_name:literal, $source_id:expr, $field_sources:expr) => {
if $source.$field.is_some() {
$target.$field = $source.$field.clone();
$field_sources.insert($field_name.to_string(), $source_id.clone());
}
};
}
use crate::effects::{
validation_failure, validation_failures, validation_success, AnalysisValidation,
};
use crate::errors::AnalysisError;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConfigSource {
Default,
UserConfig(PathBuf),
ProjectConfig(PathBuf),
Environment(String),
CustomPath(PathBuf),
}
impl fmt::Display for ConfigSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigSource::Default => write!(f, "built-in defaults"),
ConfigSource::UserConfig(path) => write!(f, "user config: {}", path.display()),
ConfigSource::ProjectConfig(path) => write!(f, "project config: {}", path.display()),
ConfigSource::Environment(var) => write!(f, "environment variable: {}", var),
ConfigSource::CustomPath(path) => write!(f, "custom config: {}", path.display()),
}
}
}
#[derive(Debug, Clone)]
pub struct TracedValue<T> {
pub value: T,
pub source: ConfigSource,
pub was_overridden: bool,
pub previous_sources: Vec<ConfigSource>,
}
impl<T> TracedValue<T> {
pub fn new(value: T, source: ConfigSource) -> Self {
Self {
value,
source,
was_overridden: false,
previous_sources: Vec::new(),
}
}
pub fn override_from(mut self, previous: ConfigSource) -> Self {
self.was_overridden = true;
self.previous_sources.push(previous);
self
}
}
#[derive(Debug, Clone)]
pub struct TracedConfig {
config: DebtmapConfig,
sources: Vec<ConfigSource>,
field_sources: HashMap<String, ConfigSource>,
}
impl TracedConfig {
pub fn config(&self) -> &DebtmapConfig {
&self.config
}
pub fn into_config(self) -> DebtmapConfig {
self.config
}
pub fn sources(&self) -> &[ConfigSource] {
&self.sources
}
pub fn field_source(&self, path: &str) -> Option<&ConfigSource> {
self.field_sources.get(path)
}
pub fn all_field_sources(&self) -> &HashMap<String, ConfigSource> {
&self.field_sources
}
pub fn has_source(&self, source: &ConfigSource) -> bool {
self.sources.contains(source)
}
}
pub fn load_multi_source_config() -> Result<TracedConfig, Vec<AnalysisError>> {
load_multi_source_config_from(std::env::current_dir().unwrap_or_default())
}
pub fn load_multi_source_config_from(
start_dir: PathBuf,
) -> Result<TracedConfig, Vec<AnalysisError>> {
let mut errors = Vec::new();
let mut sources = Vec::new();
let mut field_sources = HashMap::new();
let mut config = DebtmapConfig::default();
sources.push(ConfigSource::Default);
if let Some(user_config_path) = user_config_path() {
match load_config_from_path(&user_config_path) {
Ok(user_config) => {
let source = ConfigSource::UserConfig(user_config_path);
merge_config(&mut config, &user_config, &source, &mut field_sources);
sources.push(source);
}
Err(e) => {
if user_config_path.exists() {
errors.push(e);
}
}
}
}
if let Some(project_config_path) = find_project_config(&start_dir) {
match load_config_from_path(&project_config_path) {
Ok(project_config) => {
let source = ConfigSource::ProjectConfig(project_config_path);
merge_config(&mut config, &project_config, &source, &mut field_sources);
sources.push(source);
}
Err(e) => errors.push(e),
}
}
if let Ok(custom_path) = env::var("DEBTMAP_CONFIG") {
let custom_path = PathBuf::from(custom_path);
match load_config_from_path(&custom_path) {
Ok(custom_config) => {
let source = ConfigSource::CustomPath(custom_path);
merge_config(&mut config, &custom_config, &source, &mut field_sources);
sources.push(source);
}
Err(e) => errors.push(e),
}
}
apply_env_overrides(&mut config, &mut field_sources, &mut sources);
if !errors.is_empty() {
return Err(errors);
}
match validate_config(&config) {
stillwater::Validation::Success(_) => {}
stillwater::Validation::Failure(validation_errors) => {
return Err(validation_errors.into_iter().collect());
}
}
Ok(TracedConfig {
config,
sources,
field_sources,
})
}
pub fn load_multi_source_config_validated() -> AnalysisValidation<TracedConfig> {
match load_multi_source_config() {
Ok(traced) => validation_success(traced),
Err(errors) if errors.len() == 1 => validation_failure(errors.into_iter().next().unwrap()),
Err(errors) => validation_failures(errors),
}
}
pub fn user_config_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("debtmap").join("config.toml"))
}
fn find_project_config(start_dir: &Path) -> Option<PathBuf> {
const MAX_TRAVERSAL_DEPTH: usize = 10;
directory_ancestors_impl(start_dir.to_path_buf(), MAX_TRAVERSAL_DEPTH)
.map(|dir| dir.join(".debtmap.toml"))
.find(|path| path.exists())
}
fn load_config_from_path(path: &Path) -> Result<DebtmapConfig, AnalysisError> {
let contents = read_config_file(path).map_err(|e| {
AnalysisError::io_with_path(format!("Cannot read config file: {}", e), path)
})?;
parse_and_validate_config_impl(&contents).map_err(|e| AnalysisError::config_with_path(e, path))
}
fn merge_config(
target: &mut DebtmapConfig,
source: &DebtmapConfig,
source_id: &ConfigSource,
field_sources: &mut HashMap<String, ConfigSource>,
) {
if source.scoring.is_some() {
target.scoring = source.scoring.clone();
field_sources.insert("scoring".to_string(), source_id.clone());
if source.scoring.is_some() {
field_sources.insert("scoring.coverage".to_string(), source_id.clone());
field_sources.insert("scoring.complexity".to_string(), source_id.clone());
field_sources.insert("scoring.dependency".to_string(), source_id.clone());
}
}
merge_optional_field!(
target,
source,
thresholds,
"thresholds",
source_id,
field_sources
);
merge_optional_field!(target, source, display, "display", source_id, field_sources);
merge_optional_field!(target, source, ignore, "ignore", source_id, field_sources);
merge_optional_field!(target, source, output, "output", source_id, field_sources);
merge_optional_field!(target, source, entropy, "entropy", source_id, field_sources);
merge_optional_field!(
target,
source,
role_multipliers,
"role_multipliers",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
languages,
"languages",
source_id,
field_sources
);
merge_optional_field!(target, source, context, "context", source_id, field_sources);
merge_optional_field!(
target,
source,
error_handling,
"error_handling",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
normalization,
"normalization",
source_id,
field_sources
);
merge_optional_field!(target, source, loc, "loc", source_id, field_sources);
merge_optional_field!(target, source, tiers, "tiers", source_id, field_sources);
merge_optional_field!(
target,
source,
god_object_detection,
"god_object_detection",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
external_api,
"external_api",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
complexity_thresholds,
"complexity_thresholds",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
role_coverage_weights,
"role_coverage_weights",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
role_multiplier_config,
"role_multiplier_config",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
orchestrator_detection,
"orchestrator_detection",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
orchestration_adjustment,
"orchestration_adjustment",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
classification,
"classification",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
mapping_patterns,
"mapping_patterns",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
coverage_expectations,
"coverage_expectations",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
complexity_weights,
"complexity_weights",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
functional_analysis,
"functional_analysis",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
boilerplate_detection,
"boilerplate_detection",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
scoring_rebalanced,
"scoring_rebalanced",
source_id,
field_sources
);
merge_optional_field!(
target,
source,
context_multipliers,
"context_multipliers",
source_id,
field_sources
);
}
fn apply_env_overrides(
config: &mut DebtmapConfig,
field_sources: &mut HashMap<String, ConfigSource>,
sources: &mut Vec<ConfigSource>,
) {
let mut any_env_override = false;
if let Ok(value) = env::var("DEBTMAP_COMPLEXITY_THRESHOLD") {
if let Ok(threshold) = value.parse::<u32>() {
let thresholds = config
.thresholds
.get_or_insert_with(ThresholdsConfig::default);
thresholds.complexity = Some(threshold);
field_sources.insert(
"thresholds.complexity".to_string(),
ConfigSource::Environment("DEBTMAP_COMPLEXITY_THRESHOLD".to_string()),
);
any_env_override = true;
}
}
if let Ok(value) = env::var("DEBTMAP_COVERAGE_WEIGHT") {
if let Ok(weight) = value.parse::<f64>() {
let scoring = config.scoring.get_or_insert_with(ScoringWeights::default);
scoring.coverage = weight;
field_sources.insert(
"scoring.coverage".to_string(),
ConfigSource::Environment("DEBTMAP_COVERAGE_WEIGHT".to_string()),
);
any_env_override = true;
}
}
if let Ok(value) = env::var("DEBTMAP_COMPLEXITY_WEIGHT") {
if let Ok(weight) = value.parse::<f64>() {
let scoring = config.scoring.get_or_insert_with(ScoringWeights::default);
scoring.complexity = weight;
field_sources.insert(
"scoring.complexity".to_string(),
ConfigSource::Environment("DEBTMAP_COMPLEXITY_WEIGHT".to_string()),
);
any_env_override = true;
}
}
if let Ok(value) = env::var("DEBTMAP_DEPENDENCY_WEIGHT") {
if let Ok(weight) = value.parse::<f64>() {
let scoring = config.scoring.get_or_insert_with(ScoringWeights::default);
scoring.dependency = weight;
field_sources.insert(
"scoring.dependency".to_string(),
ConfigSource::Environment("DEBTMAP_DEPENDENCY_WEIGHT".to_string()),
);
any_env_override = true;
}
}
if any_env_override {
sources.push(ConfigSource::Environment("DEBTMAP_*".to_string()));
}
}
pub fn display_config_sources(traced: &TracedConfig) {
println!("Configuration sources:");
println!();
for (path, source) in traced.all_field_sources() {
println!(" {} = <value>", path);
println!(" from: {}", source);
println!();
}
println!("Source priority (lowest to highest):");
for (i, source) in traced.sources().iter().enumerate() {
println!(" {}. {}", i + 1, source);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_user_config_path() {
let path = user_config_path();
if dirs::config_dir().is_some() {
assert!(path.is_some());
let path = path.unwrap();
assert!(
path.ends_with("debtmap/config.toml") || path.ends_with("debtmap\\config.toml")
);
}
}
#[test]
fn test_find_project_config() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join(".debtmap.toml");
assert!(find_project_config(temp_dir.path()).is_none());
fs::write(&config_path, "[thresholds]\ncomplexity = 15\n").unwrap();
let found = find_project_config(temp_dir.path());
assert!(found.is_some());
assert_eq!(found.unwrap(), config_path);
}
#[test]
fn test_find_project_config_in_parent() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join(".debtmap.toml");
let subdir = temp_dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
fs::write(&config_path, "[thresholds]\ncomplexity = 15\n").unwrap();
let found = find_project_config(&subdir);
assert!(found.is_some());
assert_eq!(found.unwrap(), config_path);
}
#[test]
fn test_load_config_from_path() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("test.toml");
fs::write(
&config_path,
r#"
[thresholds]
complexity = 20
[scoring]
coverage = 0.5
complexity = 0.35
dependency = 0.15
"#,
)
.unwrap();
let config = load_config_from_path(&config_path).unwrap();
assert_eq!(config.thresholds.as_ref().unwrap().complexity, Some(20));
assert!((config.scoring.as_ref().unwrap().coverage - 0.5).abs() < 0.001);
}
#[test]
fn test_load_config_from_path_invalid() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("invalid.toml");
fs::write(&config_path, "invalid [[ toml content").unwrap();
let result = load_config_from_path(&config_path);
assert!(result.is_err());
}
#[test]
fn test_merge_config() {
let mut target = DebtmapConfig::default();
let source = DebtmapConfig {
thresholds: Some(ThresholdsConfig {
complexity: Some(25),
..Default::default()
}),
..Default::default()
};
let source_id = ConfigSource::ProjectConfig(PathBuf::from("/test/.debtmap.toml"));
let mut field_sources = HashMap::new();
merge_config(&mut target, &source, &source_id, &mut field_sources);
assert_eq!(target.thresholds.as_ref().unwrap().complexity, Some(25));
assert_eq!(field_sources.get("thresholds"), Some(&source_id));
}
#[test]
fn test_config_source_display() {
assert_eq!(ConfigSource::Default.to_string(), "built-in defaults");
assert!(
ConfigSource::UserConfig(PathBuf::from("/home/user/.config/debtmap/config.toml"))
.to_string()
.contains("user config")
);
assert!(
ConfigSource::ProjectConfig(PathBuf::from("/project/.debtmap.toml"))
.to_string()
.contains("project config")
);
assert!(
ConfigSource::Environment("DEBTMAP_COMPLEXITY_THRESHOLD".to_string())
.to_string()
.contains("environment variable")
);
}
#[test]
fn test_traced_config_sources() {
let config = DebtmapConfig::default();
let sources = vec![
ConfigSource::Default,
ConfigSource::ProjectConfig(PathBuf::from("/test")),
];
let field_sources = HashMap::new();
let traced = TracedConfig {
config,
sources,
field_sources,
};
assert_eq!(traced.sources().len(), 2);
assert!(traced.has_source(&ConfigSource::Default));
}
#[test]
fn test_load_multi_source_config_from_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let result = load_multi_source_config_from(temp_dir.path().to_path_buf());
assert!(result.is_ok());
let traced = result.unwrap();
assert!(traced.has_source(&ConfigSource::Default));
}
#[test]
fn test_load_multi_source_config_with_project_config() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join(".debtmap.toml");
fs::write(
&config_path,
r#"
[thresholds]
complexity = 30
"#,
)
.unwrap();
let result = load_multi_source_config_from(temp_dir.path().to_path_buf());
assert!(result.is_ok());
let traced = result.unwrap();
assert_eq!(
traced.config().thresholds.as_ref().unwrap().complexity,
Some(30)
);
assert!(traced.has_source(&ConfigSource::ProjectConfig(config_path)));
}
#[test]
fn test_env_overrides() {
let orig_threshold = env::var("DEBTMAP_COMPLEXITY_THRESHOLD").ok();
env::set_var("DEBTMAP_COMPLEXITY_THRESHOLD", "42");
let mut config = DebtmapConfig::default();
let mut field_sources = HashMap::new();
let mut sources = Vec::new();
apply_env_overrides(&mut config, &mut field_sources, &mut sources);
assert_eq!(config.thresholds.as_ref().unwrap().complexity, Some(42));
assert!(field_sources.contains_key("thresholds.complexity"));
match orig_threshold {
Some(v) => env::set_var("DEBTMAP_COMPLEXITY_THRESHOLD", v),
None => env::remove_var("DEBTMAP_COMPLEXITY_THRESHOLD"),
}
}
}