use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::effects::{
combine_validations, validation_failure, validation_failures, validation_success,
AnalysisValidation,
};
use crate::errors::AnalysisError;
use super::multi_source::ConfigSource;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalysisConfig {
pub project_path: PathBuf,
#[serde(default)]
pub parallel: bool,
#[serde(default = "default_jobs")]
pub jobs: usize,
#[serde(default)]
pub aggregate_only: bool,
#[serde(default)]
pub no_aggregation: bool,
#[serde(default)]
pub coverage_file: Option<PathBuf>,
#[serde(default)]
pub enable_context: bool,
#[serde(default)]
pub multi_pass: bool,
#[serde(default = "default_complexity_threshold")]
pub complexity_threshold: u32,
#[serde(default)]
pub exclude_patterns: Vec<String>,
#[serde(default)]
pub show_config_sources: bool,
}
fn default_jobs() -> usize {
std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(4)
}
fn default_complexity_threshold() -> u32 {
50
}
impl Default for AnalysisConfig {
fn default() -> Self {
Self {
project_path: PathBuf::from("."),
parallel: false,
jobs: default_jobs(),
aggregate_only: false,
no_aggregation: false,
coverage_file: None,
enable_context: false,
multi_pass: false,
complexity_threshold: default_complexity_threshold(),
exclude_patterns: Vec::new(),
show_config_sources: false,
}
}
}
#[derive(Debug, Clone)]
pub struct TracedAnalysisValue<T> {
pub value: T,
pub source: ConfigSource,
}
impl<T> TracedAnalysisValue<T> {
pub fn new(value: T, source: ConfigSource) -> Self {
Self { value, source }
}
}
#[derive(Debug, Clone)]
pub struct ConfigValidationError {
pub path: String,
pub source: Option<ConfigSource>,
pub value: Option<String>,
pub message: String,
pub suggestion: Option<String>,
}
impl std::fmt::Display for ConfigValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(ref source) = self.source {
write!(f, "[{}] ", source)?;
}
write!(f, "{}: {}", self.path, self.message)?;
if let Some(ref value) = self.value {
write!(f, " (value: {})", value)?;
}
if let Some(ref suggestion) = self.suggestion {
write!(f, "\n suggestion: {}", suggestion)?;
}
Ok(())
}
}
impl AnalysisConfig {
pub fn validate(&self) -> AnalysisValidation<()> {
let validations = vec![
self.validate_project_path(),
self.validate_mutual_exclusions(),
self.validate_parallel_jobs(),
self.validate_context_dependencies(),
self.validate_paths(),
self.validate_complexity_threshold(),
self.validate_exclude_patterns(),
];
combine_validations(validations).map(|_| ())
}
fn validate_project_path(&self) -> AnalysisValidation<()> {
if self.project_path.as_os_str().is_empty() {
validation_failure(AnalysisError::config(
"project_path cannot be empty".to_string(),
))
} else {
validation_success(())
}
}
fn validate_mutual_exclusions(&self) -> AnalysisValidation<()> {
if self.aggregate_only && self.no_aggregation {
validation_failure(AnalysisError::config(
"aggregate_only and no_aggregation are mutually exclusive".to_string(),
))
} else {
validation_success(())
}
}
fn validate_parallel_jobs(&self) -> AnalysisValidation<()> {
if self.parallel && self.jobs == 0 {
return validation_failure(AnalysisError::config(
"jobs must be > 0 when parallel is enabled".to_string(),
));
}
if self.jobs > 256 {
return validation_failure(AnalysisError::config(format!(
"jobs must be <= 256, got {}",
self.jobs
)));
}
validation_success(())
}
fn validate_context_dependencies(&self) -> AnalysisValidation<()> {
if self.multi_pass && !self.enable_context {
validation_failure(AnalysisError::config(
"multi_pass requires enable_context to be true".to_string(),
))
} else {
validation_success(())
}
}
fn validate_paths(&self) -> AnalysisValidation<()> {
let mut errors = Vec::new();
if !self.project_path.is_dir() {
if !self.project_path.exists() {
errors.push(AnalysisError::config(format!(
"project_path directory does not exist: {}",
self.project_path.display()
)));
} else {
errors.push(AnalysisError::config(format!(
"project_path is not a directory: {}",
self.project_path.display()
)));
}
}
if let Some(ref coverage) = self.coverage_file {
if !coverage.exists() {
errors.push(AnalysisError::config(format!(
"coverage_file does not exist: {}",
coverage.display()
)));
}
}
if errors.is_empty() {
validation_success(())
} else {
validation_failures(errors)
}
}
fn validate_complexity_threshold(&self) -> AnalysisValidation<()> {
if self.complexity_threshold == 0 {
return validation_failure(AnalysisError::config(
"complexity_threshold must be > 0".to_string(),
));
}
if self.complexity_threshold > 1000 {
return validation_failure(AnalysisError::config(format!(
"complexity_threshold must be <= 1000, got {}",
self.complexity_threshold
)));
}
validation_success(())
}
fn validate_exclude_patterns(&self) -> AnalysisValidation<()> {
let mut errors = Vec::new();
for (i, pattern) in self.exclude_patterns.iter().enumerate() {
if let Err(e) = glob::Pattern::new(pattern) {
errors.push(AnalysisError::config(format!(
"invalid exclude pattern #{}: '{}' - {}",
i + 1,
pattern,
e
)));
}
}
if errors.is_empty() {
validation_success(())
} else {
validation_failures(errors)
}
}
}
#[derive(Debug)]
pub struct AnalysisConfigBuilder {
config: AnalysisConfig,
sources: std::collections::HashMap<String, ConfigSource>,
}
impl AnalysisConfigBuilder {
pub fn new(project_path: PathBuf) -> Self {
let mut sources = std::collections::HashMap::new();
sources.insert("project_path".to_string(), ConfigSource::Default);
Self {
config: AnalysisConfig {
project_path,
..Default::default()
},
sources,
}
}
pub fn parallel(mut self, parallel: bool) -> Self {
self.config.parallel = parallel;
self
}
pub fn parallel_from(mut self, parallel: bool, source: ConfigSource) -> Self {
self.config.parallel = parallel;
self.sources.insert("parallel".to_string(), source);
self
}
pub fn jobs(mut self, jobs: usize) -> Self {
self.config.jobs = jobs;
self
}
pub fn jobs_from(mut self, jobs: usize, source: ConfigSource) -> Self {
self.config.jobs = jobs;
self.sources.insert("jobs".to_string(), source);
self
}
pub fn aggregate_only(mut self, aggregate_only: bool) -> Self {
self.config.aggregate_only = aggregate_only;
self
}
pub fn no_aggregation(mut self, no_aggregation: bool) -> Self {
self.config.no_aggregation = no_aggregation;
self
}
pub fn coverage_file(mut self, coverage_file: Option<PathBuf>) -> Self {
self.config.coverage_file = coverage_file;
self
}
pub fn coverage_file_from(
mut self,
coverage_file: Option<PathBuf>,
source: ConfigSource,
) -> Self {
self.config.coverage_file = coverage_file;
self.sources.insert("coverage_file".to_string(), source);
self
}
pub fn enable_context(mut self, enable_context: bool) -> Self {
self.config.enable_context = enable_context;
self
}
pub fn multi_pass(mut self, multi_pass: bool) -> Self {
self.config.multi_pass = multi_pass;
self
}
pub fn complexity_threshold(mut self, threshold: u32) -> Self {
self.config.complexity_threshold = threshold;
self
}
pub fn exclude_patterns(mut self, patterns: Vec<String>) -> Self {
self.config.exclude_patterns = patterns;
self
}
pub fn show_config_sources(mut self, show: bool) -> Self {
self.config.show_config_sources = show;
self
}
pub fn build(self) -> Result<AnalysisConfig, Vec<AnalysisError>> {
match self.config.validate() {
stillwater::Validation::Success(_) => Ok(self.config),
stillwater::Validation::Failure(errors) => Err(errors.into_iter().collect()),
}
}
pub fn build_validated(self) -> AnalysisValidation<AnalysisConfig> {
match self.config.validate() {
stillwater::Validation::Success(_) => validation_success(self.config),
stillwater::Validation::Failure(errors) => stillwater::Validation::Failure(errors),
}
}
pub fn sources(&self) -> &std::collections::HashMap<String, ConfigSource> {
&self.sources
}
}
pub fn format_config_errors(errors: &[AnalysisError]) -> String {
let mut output = format!("Configuration errors ({}):\n", errors.len());
for error in errors {
output.push_str(&format!("\n {}\n", error));
}
output.push_str("\nFix all errors and try again.\n");
output
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_valid_config_builds_successfully() {
let temp_dir = TempDir::new().unwrap();
let config = AnalysisConfigBuilder::new(temp_dir.path().to_path_buf())
.parallel(true)
.jobs(4)
.build();
assert!(config.is_ok());
let config = config.unwrap();
assert!(config.parallel);
assert_eq!(config.jobs, 4);
}
#[test]
fn test_mutual_exclusion_error() {
let temp_dir = TempDir::new().unwrap();
let result = AnalysisConfigBuilder::new(temp_dir.path().to_path_buf())
.aggregate_only(true)
.no_aggregation(true)
.build();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.to_string().contains("mutually exclusive")));
}
#[test]
fn test_parallel_jobs_validation() {
let temp_dir = TempDir::new().unwrap();
let result = AnalysisConfigBuilder::new(temp_dir.path().to_path_buf())
.parallel(true)
.jobs(0)
.build();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.to_string().contains("jobs")));
}
#[test]
fn test_jobs_too_high() {
let temp_dir = TempDir::new().unwrap();
let result = AnalysisConfigBuilder::new(temp_dir.path().to_path_buf())
.jobs(500)
.build();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.to_string().contains("256")));
}
#[test]
fn test_context_dependency_validation() {
let temp_dir = TempDir::new().unwrap();
let result = AnalysisConfigBuilder::new(temp_dir.path().to_path_buf())
.multi_pass(true)
.enable_context(false)
.build();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.to_string().contains("multi_pass requires enable_context")));
}
#[test]
fn test_coverage_file_not_found() {
let temp_dir = TempDir::new().unwrap();
let result = AnalysisConfigBuilder::new(temp_dir.path().to_path_buf())
.coverage_file(Some(PathBuf::from("/nonexistent/coverage.lcov")))
.build();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.to_string().contains("does not exist")));
}
#[test]
fn test_project_path_not_directory() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("file.txt");
std::fs::write(&file_path, "test").unwrap();
let result = AnalysisConfigBuilder::new(file_path).build();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.to_string().contains("not a directory")));
}
#[test]
fn test_multiple_errors_accumulated() {
let result = AnalysisConfigBuilder::new(PathBuf::from("/nonexistent/path"))
.parallel(true)
.jobs(0) .aggregate_only(true)
.no_aggregation(true) .multi_pass(true) .build();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(
errors.len() >= 3,
"Expected at least 3 errors, got {}: {:?}",
errors.len(),
errors
);
}
#[test]
fn test_invalid_exclude_pattern() {
let temp_dir = TempDir::new().unwrap();
let result = AnalysisConfigBuilder::new(temp_dir.path().to_path_buf())
.exclude_patterns(vec!["[invalid".to_string()])
.build();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.to_string().contains("invalid exclude pattern")));
}
#[test]
fn test_complexity_threshold_validation() {
let temp_dir = TempDir::new().unwrap();
let result = AnalysisConfigBuilder::new(temp_dir.path().to_path_buf())
.complexity_threshold(0)
.build();
assert!(result.is_err());
let result = AnalysisConfigBuilder::new(temp_dir.path().to_path_buf())
.complexity_threshold(2000)
.build();
assert!(result.is_err());
}
#[test]
fn test_default_config() {
let config = AnalysisConfig::default();
assert!(!config.parallel);
assert!(!config.aggregate_only);
assert!(!config.no_aggregation);
assert!(!config.enable_context);
assert!(!config.multi_pass);
assert_eq!(config.complexity_threshold, 50);
}
#[test]
fn test_format_config_errors() {
let errors = vec![
AnalysisError::config("error 1".to_string()),
AnalysisError::config("error 2".to_string()),
];
let output = format_config_errors(&errors);
assert!(output.contains("Configuration errors (2)"));
assert!(output.contains("error 1"));
assert!(output.contains("error 2"));
}
#[test]
fn test_source_tracking() {
let temp_dir = TempDir::new().unwrap();
let builder = AnalysisConfigBuilder::new(temp_dir.path().to_path_buf())
.jobs_from(8, ConfigSource::Environment("DEBTMAP_JOBS".to_string()));
let sources = builder.sources();
assert!(sources.contains_key("jobs"));
assert!(matches!(
sources.get("jobs"),
Some(ConfigSource::Environment(_))
));
}
}