use crate::application::dto::OutputFormat;
use crate::sbom_generation::domain::license_policy::{LicensePolicy, UnknownLicenseHandling};
use crate::sbom_generation::domain::vulnerability::Severity;
use crate::shared::Result;
use std::collections::HashSet;
use uv_sbom::config::{self, ConfigFile, IgnoreCve};
use super::Args;
pub struct MergedConfig {
pub format: OutputFormat,
pub exclude_patterns: Vec<String>,
pub check_cve: bool,
pub severity_threshold: Option<Severity>,
pub cvss_threshold: Option<f32>,
pub ignore_cves: Vec<IgnoreCve>,
pub check_license: bool,
pub license_policy: Option<LicensePolicy>,
pub suggest_fix: bool,
}
pub fn load_config(args: &Args, project_path: &std::path::Path) -> Result<Option<ConfigFile>> {
if let Some(ref config_path) = args.config {
let path = std::path::Path::new(config_path);
let cfg = config::load_config_from_path(path)?;
eprintln!("📄 Loaded config from: {}", path.display());
Ok(Some(cfg))
} else {
let cfg = config::discover_config(project_path)?;
if cfg.is_some() {
eprintln!("📄 Auto-discovered config file in project directory.");
}
Ok(cfg)
}
}
pub fn merge_string_lists(cli: &[String], config: &Option<Vec<String>>) -> Vec<String> {
let mut seen = HashSet::new();
let mut result = Vec::new();
for item in cli {
if seen.insert(item.clone()) {
result.push(item.clone());
}
}
if let Some(config_items) = config {
for item in config_items {
if seen.insert(item.clone()) {
result.push(item.clone());
}
}
}
result
}
pub fn merge_ignore_cves(cli: &[IgnoreCve], config: &Option<Vec<IgnoreCve>>) -> Vec<IgnoreCve> {
let mut seen = HashSet::new();
let mut result = Vec::new();
for cve in cli {
if seen.insert(cve.id.clone()) {
result.push(cve.clone());
}
}
if let Some(config_cves) = config {
for cve in config_cves {
if seen.insert(cve.id.clone()) {
result.push(cve.clone());
}
}
}
result
}
pub fn merge_config(args: &Args, config: &Option<ConfigFile>) -> MergedConfig {
let config = match config {
Some(c) => c,
None => {
let license_policy = if args.check_license
&& (!args.license_allow.is_empty() || !args.license_deny.is_empty())
{
Some(LicensePolicy::new(
&args.license_allow,
&args.license_deny,
UnknownLicenseHandling::default(),
))
} else if args.check_license {
Some(LicensePolicy::new(
&[],
&[],
UnknownLicenseHandling::default(),
))
} else {
None
};
return MergedConfig {
format: args.format,
exclude_patterns: args.exclude.clone(),
check_cve: !args.no_check_cve,
severity_threshold: args.severity_threshold,
cvss_threshold: args.cvss_threshold,
ignore_cves: args
.ignore_cve
.iter()
.map(|id| IgnoreCve {
id: id.clone(),
reason: None,
})
.collect(),
check_license: args.check_license,
license_policy,
suggest_fix: args.suggest_fix,
};
}
};
let exclude_patterns = merge_string_lists(&args.exclude, &config.exclude_packages);
let cli_ignore_cves: Vec<IgnoreCve> = args
.ignore_cve
.iter()
.map(|id| IgnoreCve {
id: id.clone(),
reason: None,
})
.collect();
let ignore_cves = merge_ignore_cves(&cli_ignore_cves, &config.ignore_cves);
let format = if let Some(ref config_format) = config.format {
if args.format != OutputFormat::Json {
args.format
} else {
config_format.parse::<OutputFormat>().unwrap_or(args.format)
}
} else {
args.format
};
let check_cve = if args.no_check_cve {
false
} else {
config.check_cve.unwrap_or(true)
};
let severity_threshold = args.severity_threshold.or_else(|| {
config
.severity_threshold
.as_ref()
.and_then(|s| match s.to_lowercase().as_str() {
"low" => Some(Severity::Low),
"medium" => Some(Severity::Medium),
"high" => Some(Severity::High),
"critical" => Some(Severity::Critical),
_ => None,
})
});
let cvss_threshold = args
.cvss_threshold
.or(config.cvss_threshold.map(|v| v as f32));
let check_license = args.check_license || config.check_license.unwrap_or(false);
let license_policy = if check_license {
if !args.license_allow.is_empty() || !args.license_deny.is_empty() {
Some(LicensePolicy::new(
&args.license_allow,
&args.license_deny,
UnknownLicenseHandling::default(),
))
} else if let Some(ref lp_config) = config.license_policy {
let unknown = lp_config
.unknown
.as_ref()
.map(|s| match s.to_lowercase().as_str() {
"deny" => UnknownLicenseHandling::Deny,
"allow" => UnknownLicenseHandling::Allow,
_ => UnknownLicenseHandling::Warn,
})
.unwrap_or_default();
let allow = lp_config.allow.clone().unwrap_or_default();
let deny = lp_config.deny.clone().unwrap_or_default();
Some(LicensePolicy::new(&allow, &deny, unknown))
} else {
Some(LicensePolicy::new(
&[],
&[],
UnknownLicenseHandling::default(),
))
}
} else {
None
};
let suggest_fix = args.suggest_fix || config.suggest_fix.unwrap_or(false);
MergedConfig {
format,
exclude_patterns,
check_cve,
severity_threshold,
cvss_threshold,
ignore_cves,
check_license,
license_policy,
suggest_fix,
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn test_merge_config_no_config_file() {
let args = Args::parse_from(["uv-sbom"]);
let result = merge_config(&args, &None);
assert_eq!(result.format, OutputFormat::Json);
assert!(result.exclude_patterns.is_empty());
assert!(result.check_cve); assert!(result.severity_threshold.is_none());
assert!(result.cvss_threshold.is_none());
assert!(result.ignore_cves.is_empty());
}
#[test]
fn test_merge_config_config_provides_defaults() {
let args = Args::parse_from(["uv-sbom"]);
let config = Some(ConfigFile {
format: Some("markdown".to_string()),
exclude_packages: Some(vec!["pkg-a".to_string()]),
check_cve: Some(true),
severity_threshold: Some("high".to_string()),
cvss_threshold: Some(7.0),
ignore_cves: Some(vec![IgnoreCve {
id: "CVE-2024-1".to_string(),
reason: Some("not applicable".to_string()),
}]),
..Default::default()
});
let result = merge_config(&args, &config);
assert_eq!(result.format, OutputFormat::Markdown);
assert_eq!(result.exclude_patterns, vec!["pkg-a"]);
assert!(result.check_cve);
assert_eq!(result.severity_threshold, Some(Severity::High));
assert_eq!(result.cvss_threshold, Some(7.0));
assert_eq!(result.ignore_cves.len(), 1);
assert_eq!(result.ignore_cves[0].id, "CVE-2024-1");
}
#[test]
fn test_merge_config_cli_overrides_format() {
let args = Args::parse_from(["uv-sbom", "--format", "markdown"]);
let config = Some(ConfigFile {
format: Some("json".to_string()),
..Default::default()
});
let result = merge_config(&args, &config);
assert_eq!(result.format, OutputFormat::Markdown);
}
#[test]
fn test_merge_config_no_check_cve_cli_flag() {
let args = Args::parse_from(["uv-sbom", "--no-check-cve"]);
let config = Some(ConfigFile {
check_cve: Some(true),
..Default::default()
});
let result = merge_config(&args, &config);
assert!(!result.check_cve);
}
#[test]
fn test_merge_config_no_check_cve_overrides_config() {
let args = Args::parse_from(["uv-sbom", "--no-check-cve"]);
let config = Some(ConfigFile {
check_cve: Some(true),
..Default::default()
});
let result = merge_config(&args, &config);
assert!(!result.check_cve);
}
#[test]
fn test_merge_config_check_cve_from_config() {
let args = Args::parse_from(["uv-sbom"]);
let config = Some(ConfigFile {
check_cve: Some(true),
..Default::default()
});
let result = merge_config(&args, &config);
assert!(result.check_cve);
}
#[test]
fn test_merge_config_exclude_patterns_merged() {
let args = Args::parse_from(["uv-sbom", "-e", "cli-pkg"]);
let config = Some(ConfigFile {
exclude_packages: Some(vec!["config-pkg".to_string(), "cli-pkg".to_string()]),
..Default::default()
});
let result = merge_config(&args, &config);
assert_eq!(result.exclude_patterns, vec!["cli-pkg", "config-pkg"]);
}
#[test]
fn test_merge_config_ignore_cves_merged() {
let args = Args::parse_from(["uv-sbom", "-i", "CVE-2024-1"]);
let config = Some(ConfigFile {
ignore_cves: Some(vec![
IgnoreCve {
id: "CVE-2024-1".to_string(),
reason: Some("config reason".to_string()),
},
IgnoreCve {
id: "CVE-2024-2".to_string(),
reason: None,
},
]),
..Default::default()
});
let result = merge_config(&args, &config);
assert_eq!(result.ignore_cves.len(), 2);
assert_eq!(result.ignore_cves[0].id, "CVE-2024-1");
assert!(result.ignore_cves[0].reason.is_none());
assert_eq!(result.ignore_cves[1].id, "CVE-2024-2");
}
#[test]
fn test_merge_config_severity_threshold_cli_wins() {
let args = Args::parse_from(["uv-sbom", "--severity-threshold", "critical"]);
let config = Some(ConfigFile {
severity_threshold: Some("low".to_string()),
..Default::default()
});
let result = merge_config(&args, &config);
assert_eq!(result.severity_threshold, Some(Severity::Critical));
}
#[test]
fn test_merge_config_severity_threshold_from_config() {
let args = Args::parse_from(["uv-sbom"]);
let config = Some(ConfigFile {
severity_threshold: Some("medium".to_string()),
..Default::default()
});
let result = merge_config(&args, &config);
assert_eq!(result.severity_threshold, Some(Severity::Medium));
}
#[test]
fn test_merge_config_cvss_threshold_cli_wins() {
let args = Args::parse_from(["uv-sbom", "--cvss-threshold", "8.5"]);
let config = Some(ConfigFile {
cvss_threshold: Some(5.0),
..Default::default()
});
let result = merge_config(&args, &config);
assert_eq!(result.cvss_threshold, Some(8.5));
}
#[test]
fn test_merge_config_cvss_threshold_from_config() {
let args = Args::parse_from(["uv-sbom"]);
let config = Some(ConfigFile {
cvss_threshold: Some(6.0),
..Default::default()
});
let result = merge_config(&args, &config);
assert_eq!(result.cvss_threshold, Some(6.0));
}
#[test]
fn test_merge_config_suggest_fix_from_config() {
let args = Args::parse_from(["uv-sbom"]);
let config = Some(ConfigFile {
suggest_fix: Some(true),
..Default::default()
});
let result = merge_config(&args, &config);
assert!(result.suggest_fix);
}
#[test]
fn test_merge_config_suggest_fix_cli_flag() {
let args = Args::parse_from(["uv-sbom", "--suggest-fix"]);
let config = Some(ConfigFile {
suggest_fix: Some(true),
..Default::default()
});
let result = merge_config(&args, &config);
assert!(result.suggest_fix);
}
#[test]
fn test_merge_config_suggest_fix_cli_wins_over_config_false() {
let args = Args::parse_from(["uv-sbom", "--suggest-fix"]);
let config = Some(ConfigFile {
suggest_fix: Some(false),
..Default::default()
});
let result = merge_config(&args, &config);
assert!(result.suggest_fix);
}
#[test]
fn test_merge_config_suggest_fix_default_false() {
let args = Args::parse_from(["uv-sbom"]);
let config = Some(ConfigFile {
..Default::default()
});
let result = merge_config(&args, &config);
assert!(!result.suggest_fix);
}
#[test]
fn test_merge_string_lists_both_empty() {
let result = merge_string_lists(&[], &None);
assert!(result.is_empty());
}
#[test]
fn test_merge_string_lists_cli_only() {
let cli = vec!["a".to_string(), "b".to_string()];
let result = merge_string_lists(&cli, &None);
assert_eq!(result, vec!["a", "b"]);
}
#[test]
fn test_merge_string_lists_config_only() {
let config = Some(vec!["x".to_string(), "y".to_string()]);
let result = merge_string_lists(&[], &config);
assert_eq!(result, vec!["x", "y"]);
}
#[test]
fn test_merge_string_lists_deduplication() {
let cli = vec!["a".to_string(), "b".to_string()];
let config = Some(vec!["b".to_string(), "c".to_string()]);
let result = merge_string_lists(&cli, &config);
assert_eq!(result, vec!["a", "b", "c"]);
}
#[test]
fn test_merge_ignore_cves_both_empty() {
let result = merge_ignore_cves(&[], &None);
assert!(result.is_empty());
}
#[test]
fn test_merge_ignore_cves_cli_only() {
let cli = vec![IgnoreCve {
id: "CVE-2024-1".to_string(),
reason: None,
}];
let result = merge_ignore_cves(&cli, &None);
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "CVE-2024-1");
}
#[test]
fn test_merge_ignore_cves_config_only() {
let config = Some(vec![IgnoreCve {
id: "CVE-2024-2".to_string(),
reason: Some("reason".to_string()),
}]);
let result = merge_ignore_cves(&[], &config);
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "CVE-2024-2");
assert_eq!(result[0].reason.as_deref(), Some("reason"));
}
#[test]
fn test_merge_ignore_cves_deduplication_cli_wins() {
let cli = vec![IgnoreCve {
id: "CVE-2024-1".to_string(),
reason: Some("cli reason".to_string()),
}];
let config = Some(vec![
IgnoreCve {
id: "CVE-2024-1".to_string(),
reason: Some("config reason".to_string()),
},
IgnoreCve {
id: "CVE-2024-2".to_string(),
reason: None,
},
]);
let result = merge_ignore_cves(&cli, &config);
assert_eq!(result.len(), 2);
assert_eq!(result[0].id, "CVE-2024-1");
assert_eq!(result[0].reason.as_deref(), Some("cli reason"));
assert_eq!(result[1].id, "CVE-2024-2");
}
}