use super::types::{
AppConfig, BehaviorConfig, DiffConfig, EnrichmentConfig, FilterConfig, MatchingConfig,
MatrixConfig, MultiDiffConfig, OutputConfig, TimelineConfig, TuiConfig, ViewConfig,
};
#[derive(Debug, Clone)]
pub struct ConfigError {
pub field: String,
pub message: String,
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.field, self.message)
}
}
impl std::error::Error for ConfigError {}
pub trait Validatable {
fn validate(&self) -> Vec<ConfigError>;
fn is_valid(&self) -> bool {
self.validate().is_empty()
}
}
impl Validatable for AppConfig {
fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
errors.extend(self.matching.validate());
errors.extend(self.filtering.validate());
errors.extend(self.output.validate());
errors.extend(self.behavior.validate());
errors.extend(self.tui.validate());
if let Some(ref enrichment) = self.enrichment {
errors.extend(enrichment.validate());
}
errors
}
}
impl Validatable for MatchingConfig {
fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
if let Some(threshold) = self.threshold
&& !(0.0..=1.0).contains(&threshold)
{
errors.push(ConfigError {
field: "matching.threshold".to_string(),
message: format!("Threshold must be between 0.0 and 1.0, got {threshold}"),
});
}
errors
}
}
impl Validatable for FilterConfig {
fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
if let Some(ref severity) = self.min_severity {
let valid_severities = ["critical", "high", "medium", "low", "info"];
if !valid_severities.contains(&severity.to_lowercase().as_str()) {
errors.push(ConfigError {
field: "filtering.min_severity".to_string(),
message: format!(
"Invalid severity '{}'. Valid options: {}",
severity,
valid_severities.join(", ")
),
});
}
}
errors
}
}
impl Validatable for OutputConfig {
fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
if let Some(ref file_path) = self.file
&& let Some(parent) = file_path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
errors.push(ConfigError {
field: "output.file".to_string(),
message: format!("Parent directory does not exist: {}", parent.display()),
});
}
if self.streaming.disabled && self.streaming.force {
errors.push(ConfigError {
field: "output.streaming".to_string(),
message: "Contradictory streaming config: both 'disabled' and 'force' are true. \
'disabled' takes precedence."
.to_string(),
});
}
errors
}
}
impl Validatable for BehaviorConfig {
fn validate(&self) -> Vec<ConfigError> {
Vec::new()
}
}
impl Validatable for TuiConfig {
fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
if !(0.0..=1.0).contains(&self.initial_threshold) {
errors.push(ConfigError {
field: "tui.initial_threshold".to_string(),
message: format!(
"Initial threshold must be between 0.0 and 1.0, got {}",
self.initial_threshold
),
});
}
errors
}
}
impl Validatable for EnrichmentConfig {
fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
let valid_providers = ["osv", "nvd"];
if !valid_providers.contains(&self.provider.as_str()) {
errors.push(ConfigError {
field: "enrichment.provider".to_string(),
message: format!(
"Invalid provider '{}'. Valid options: {}",
self.provider,
valid_providers.join(", ")
),
});
}
if self.max_concurrent == 0 {
errors.push(ConfigError {
field: "enrichment.max_concurrent".to_string(),
message: "Max concurrent requests must be at least 1".to_string(),
});
}
errors
}
}
impl Validatable for DiffConfig {
fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
if !self.paths.old.exists() {
errors.push(ConfigError {
field: "paths.old".to_string(),
message: format!("File not found: {}", self.paths.old.display()),
});
}
if !self.paths.new.exists() {
errors.push(ConfigError {
field: "paths.new".to_string(),
message: format!("File not found: {}", self.paths.new.display()),
});
}
errors.extend(self.matching.validate());
errors.extend(self.filtering.validate());
if let Some(ref rules_file) = self.rules.rules_file
&& !rules_file.exists()
{
errors.push(ConfigError {
field: "rules.rules_file".to_string(),
message: format!("Rules file not found: {}", rules_file.display()),
});
}
if let Some(ref config_file) = self.ecosystem_rules.config_file
&& !config_file.exists()
{
errors.push(ConfigError {
field: "ecosystem_rules.config_file".to_string(),
message: format!("Ecosystem rules file not found: {}", config_file.display()),
});
}
errors
}
}
impl Validatable for ViewConfig {
fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
if !self.sbom_path.exists() {
errors.push(ConfigError {
field: "sbom_path".to_string(),
message: format!("File not found: {}", self.sbom_path.display()),
});
}
errors
}
}
impl Validatable for MultiDiffConfig {
fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
if !self.baseline.exists() {
errors.push(ConfigError {
field: "baseline".to_string(),
message: format!("Baseline file not found: {}", self.baseline.display()),
});
}
for (i, target) in self.targets.iter().enumerate() {
if !target.exists() {
errors.push(ConfigError {
field: format!("targets[{i}]"),
message: format!("Target file not found: {}", target.display()),
});
}
}
if self.targets.is_empty() {
errors.push(ConfigError {
field: "targets".to_string(),
message: "At least one target SBOM is required".to_string(),
});
}
errors.extend(self.matching.validate());
errors
}
}
impl Validatable for TimelineConfig {
fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
for (i, path) in self.sbom_paths.iter().enumerate() {
if !path.exists() {
errors.push(ConfigError {
field: format!("sbom_paths[{i}]"),
message: format!("SBOM file not found: {}", path.display()),
});
}
}
if self.sbom_paths.len() < 2 {
errors.push(ConfigError {
field: "sbom_paths".to_string(),
message: "Timeline analysis requires at least 2 SBOMs".to_string(),
});
}
errors.extend(self.matching.validate());
errors
}
}
impl Validatable for MatrixConfig {
fn validate(&self) -> Vec<ConfigError> {
let mut errors = Vec::new();
for (i, path) in self.sbom_paths.iter().enumerate() {
if !path.exists() {
errors.push(ConfigError {
field: format!("sbom_paths[{i}]"),
message: format!("SBOM file not found: {}", path.display()),
});
}
}
if self.sbom_paths.len() < 2 {
errors.push(ConfigError {
field: "sbom_paths".to_string(),
message: "Matrix comparison requires at least 2 SBOMs".to_string(),
});
}
if !(0.0..=1.0).contains(&self.cluster_threshold) {
errors.push(ConfigError {
field: "cluster_threshold".to_string(),
message: format!(
"Cluster threshold must be between 0.0 and 1.0, got {}",
self.cluster_threshold
),
});
}
errors.extend(self.matching.validate());
errors
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_matching_config_validation() {
let config = MatchingConfig {
fuzzy_preset: super::super::FuzzyPreset::Balanced,
threshold: None,
include_unchanged: false,
};
assert!(config.is_valid());
}
#[test]
fn test_matching_config_threshold_validation() {
let valid = MatchingConfig {
fuzzy_preset: super::super::FuzzyPreset::Balanced,
threshold: Some(0.85),
include_unchanged: false,
};
assert!(valid.is_valid());
let invalid = MatchingConfig {
fuzzy_preset: super::super::FuzzyPreset::Balanced,
threshold: Some(1.5),
include_unchanged: false,
};
assert!(!invalid.is_valid());
}
#[test]
fn test_filter_config_validation() {
let config = FilterConfig {
only_changes: true,
min_severity: Some("high".to_string()),
exclude_vex_resolved: false,
fail_on_vex_gap: false,
};
assert!(config.is_valid());
let invalid = FilterConfig {
only_changes: true,
min_severity: Some("invalid".to_string()),
exclude_vex_resolved: false,
fail_on_vex_gap: false,
};
assert!(!invalid.is_valid());
}
#[test]
fn test_tui_config_validation() {
let valid = TuiConfig::default();
assert!(valid.is_valid());
let invalid = TuiConfig {
initial_threshold: 2.0,
..TuiConfig::default()
};
assert!(!invalid.is_valid());
}
#[test]
fn test_enrichment_config_validation() {
let valid = EnrichmentConfig::default();
assert!(valid.is_valid());
let invalid = EnrichmentConfig {
max_concurrent: 0,
..EnrichmentConfig::default()
};
assert!(!invalid.is_valid());
}
#[test]
fn test_config_error_display() {
let error = ConfigError {
field: "test_field".to_string(),
message: "test error message".to_string(),
};
assert_eq!(error.to_string(), "test_field: test error message");
}
#[test]
fn test_app_config_validation() {
let valid = AppConfig::default();
assert!(valid.is_valid());
let mut invalid = AppConfig::default();
invalid.matching.threshold = Some(5.0);
assert!(!invalid.is_valid());
}
}