use crate::adapters::outbound::uv::UvLockAdapter;
use crate::application::dto::{SbomRequest, SbomResponse};
use crate::application::use_cases::CheckVulnerabilitiesUseCase;
use crate::i18n::{Locale, Messages};
use crate::ports::outbound::{
EnrichedPackage, LicenseRepository, LockfileReader, ProgressReporter, ProjectConfigReader,
VulnerabilityRepository,
};
use crate::sbom_generation::domain::license_policy::LicenseComplianceResult;
use crate::sbom_generation::domain::services::{
LicenseComplianceChecker, ResolutionAnalyzer, ThresholdConfig, UpgradeAdvisor,
VulnerabilityCheckResult, VulnerabilityChecker,
};
use crate::sbom_generation::domain::{Package, PackageName, UpgradeRecommendation};
use crate::sbom_generation::services::{DependencyAnalyzer, PackageFilter, SbomGenerator};
use crate::shared::Result;
use indicatif::{ProgressBar, ProgressStyle};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
type PackagesWithDependencyMap = (Vec<Package>, std::collections::HashMap<String, Vec<String>>);
const LICENSE_FETCH_DELAY_MS: u64 = 100;
pub struct GenerateSbomUseCase<LR, PCR, LREPO, PR, VREPO> {
lockfile_reader: LR,
project_config_reader: PCR,
license_repository: LREPO,
progress_reporter: PR,
vulnerability_repository: Option<VREPO>,
locale: Locale,
}
impl<LR, PCR, LREPO, PR, VREPO> GenerateSbomUseCase<LR, PCR, LREPO, PR, VREPO>
where
LR: LockfileReader,
PCR: ProjectConfigReader,
LREPO: LicenseRepository,
PR: ProgressReporter,
VREPO: VulnerabilityRepository + Clone,
{
pub fn new(
lockfile_reader: LR,
project_config_reader: PCR,
license_repository: LREPO,
progress_reporter: PR,
vulnerability_repository: Option<VREPO>,
locale: Locale,
) -> Self {
Self {
lockfile_reader,
project_config_reader,
license_repository,
progress_reporter,
vulnerability_repository,
locale,
}
}
pub async fn execute(&self, request: SbomRequest) -> Result<SbomResponse> {
let (packages, dependency_map) = self.read_and_report_lockfile(&request)?;
let filtered_packages = self.apply_exclusion_filters(packages, &request)?;
if request.dry_run {
return self.build_dry_run_response();
}
let dependency_graph = self.analyze_dependencies_if_requested(&request, &dependency_map)?;
let enriched_packages = self.fetch_license_info(filtered_packages.clone()).await?;
let vulnerability_report = self
.check_vulnerabilities_if_requested(&request, &filtered_packages)
.await?;
let vulnerability_check_result = vulnerability_report.as_ref().map(|report| {
let threshold_config = Self::build_threshold_config(&request);
VulnerabilityChecker::check(report.clone(), threshold_config, &request.ignore_cves)
});
let license_compliance_result =
self.check_license_compliance_if_requested(&request, &enriched_packages);
let upgrade_recommendations = self
.advise_upgrades_if_requested(
&request,
dependency_graph.as_ref(),
vulnerability_report.as_deref(),
&enriched_packages,
)
.await;
Ok(self.build_response(
enriched_packages,
dependency_graph,
vulnerability_check_result,
license_compliance_result,
upgrade_recommendations,
))
}
fn read_and_report_lockfile(&self, request: &SbomRequest) -> Result<PackagesWithDependencyMap> {
let msgs = Messages::for_locale(self.locale);
self.progress_reporter.report(&Messages::format(
msgs.progress_loading_lockfile,
&[&request.project_path.display().to_string()],
));
let (packages, dependency_map) = self
.lockfile_reader
.read_and_parse_lockfile(&request.project_path)?;
self.progress_reporter.report(&Messages::format(
msgs.progress_detected_packages,
&[&packages.len().to_string()],
));
Ok((packages, dependency_map))
}
fn apply_exclusion_filters(
&self,
packages: Vec<Package>,
request: &SbomRequest,
) -> Result<Vec<Package>> {
if request.exclude_patterns.is_empty() {
return Ok(packages);
}
let filter = PackageFilter::new(request.exclude_patterns.clone())?;
let original_count = packages.len();
let filtered_pkgs = filter.filter_packages(packages);
let excluded_count = original_count - filtered_pkgs.len();
if excluded_count > 0 {
self.progress_reporter.report(&format!(
"🚫 Excluded {} package(s) based on filters",
excluded_count
));
}
if filtered_pkgs.is_empty() {
anyhow::bail!(
"All {} package(s) were excluded by the provided filters. \
The SBOM would be empty. Please adjust your exclusion patterns.",
original_count
);
}
let unmatched_patterns = filter.get_unmatched_patterns();
for pattern in unmatched_patterns {
self.progress_reporter.report_error(&format!(
"⚠️ Warning: Exclude pattern '{}' did not match any dependencies.",
pattern
));
}
Ok(filtered_pkgs)
}
fn build_dry_run_response(&self) -> Result<SbomResponse> {
self.progress_reporter
.report_completion("Success: Configuration validated. No issues found.");
let metadata = SbomGenerator::generate_default_metadata();
Ok(SbomResponse::builder()
.metadata(metadata)
.build()
.expect("dry-run response build should not fail"))
}
fn analyze_dependencies_if_requested(
&self,
request: &SbomRequest,
dependency_map: &std::collections::HashMap<String, Vec<String>>,
) -> Result<Option<crate::sbom_generation::domain::DependencyGraph>> {
if !request.include_dependency_info {
return Ok(None);
}
let msgs = Messages::for_locale(self.locale);
self.progress_reporter.report(msgs.progress_parsing_deps);
let project_name = self
.project_config_reader
.read_project_name(&request.project_path)?;
let project_package_name = PackageName::new(project_name)?;
let graph = DependencyAnalyzer::analyze(&project_package_name, dependency_map)?;
self.progress_reporter.report(&Messages::format(
msgs.progress_direct_deps,
&[&graph.direct_dependency_count().to_string()],
));
self.progress_reporter.report(&Messages::format(
msgs.progress_transitive_deps,
&[&graph.transitive_dependency_count().to_string()],
));
Ok(Some(graph))
}
async fn fetch_license_info(&self, packages: Vec<Package>) -> Result<Vec<EnrichedPackage>> {
self.progress_reporter
.report(Messages::for_locale(self.locale).progress_fetching_license);
self.enrich_packages_with_licenses(packages).await
}
async fn check_vulnerabilities_if_requested(
&self,
request: &SbomRequest,
packages: &[Package],
) -> Result<Option<Vec<crate::sbom_generation::domain::PackageVulnerabilities>>> {
if !request.check_cve {
return Ok(None);
}
let Some(repo) = &self.vulnerability_repository else {
return Ok(None);
};
let msgs = Messages::for_locale(self.locale);
self.progress_reporter.report(msgs.progress_fetching_vulns);
let vuln_use_case = CheckVulnerabilitiesUseCase::new(repo.clone());
let vulnerabilities = vuln_use_case.check_with_progress(packages.to_vec()).await?;
let (total_vulns, affected_packages) =
CheckVulnerabilitiesUseCase::<VREPO>::summarize(&vulnerabilities);
eprintln!(); if total_vulns > 0 {
self.progress_reporter.report_completion(&Messages::format(
msgs.progress_vuln_found,
&[&total_vulns.to_string(), &affected_packages.to_string()],
));
} else {
self.progress_reporter
.report_completion(msgs.progress_vuln_none);
}
Ok(Some(vulnerabilities))
}
fn build_threshold_config(request: &SbomRequest) -> ThresholdConfig {
match (&request.severity_threshold, &request.cvss_threshold) {
(Some(severity), None) => ThresholdConfig::Severity(*severity),
(None, Some(cvss)) => ThresholdConfig::Cvss(*cvss),
_ => ThresholdConfig::None,
}
}
async fn advise_upgrades_if_requested(
&self,
request: &SbomRequest,
dependency_graph: Option<&crate::sbom_generation::domain::DependencyGraph>,
vulnerability_report: Option<&[crate::sbom_generation::domain::PackageVulnerabilities]>,
enriched_packages: &[EnrichedPackage],
) -> Option<Vec<UpgradeRecommendation>> {
if !request.suggest_fix {
return None;
}
let (Some(graph), Some(vuln_report)) = (dependency_graph, vulnerability_report) else {
return Some(vec![]);
};
let entries = ResolutionAnalyzer::analyze(graph, vuln_report, enriched_packages);
if entries.is_empty() {
return Some(vec![]);
}
let unique_dep_count = entries
.iter()
.flat_map(|e| e.introduced_by())
.map(|i| i.package_name())
.collect::<std::collections::HashSet<_>>()
.len();
self.progress_reporter.report(&format!(
"🔍 Analyzing upgrade paths for {} direct dependenc{}...",
unique_dep_count,
if unique_dep_count == 1 { "y" } else { "ies" },
));
let simulator = UvLockAdapter::new();
let recommendations =
UpgradeAdvisor::advise(&simulator, &entries, &request.project_path).await;
for rec in &recommendations {
match rec {
UpgradeRecommendation::Upgradable {
direct_dep_name,
direct_dep_target_version,
transitive_dep_name,
transitive_resolved_version,
vulnerability_id,
..
} => {
self.progress_reporter.report(&format!(
" ✓ Upgrade {} → {} resolves {} to {} ({})",
direct_dep_name,
direct_dep_target_version,
transitive_dep_name,
transitive_resolved_version,
vulnerability_id,
));
}
UpgradeRecommendation::Unresolvable {
direct_dep_name,
reason,
vulnerability_id,
} => {
self.progress_reporter.report(&format!(
" ✗ Cannot resolve via {}: {} ({})",
direct_dep_name, reason, vulnerability_id,
));
}
UpgradeRecommendation::SimulationFailed {
direct_dep_name,
error,
} => {
self.progress_reporter.report(&format!(
" ❓ Simulation failed for {}: {}",
direct_dep_name, error,
));
}
}
}
Some(recommendations)
}
fn check_license_compliance_if_requested(
&self,
request: &SbomRequest,
enriched_packages: &[EnrichedPackage],
) -> Option<LicenseComplianceResult> {
if !request.check_license {
return None;
}
let policy = request.license_policy.as_ref()?;
let packages: Vec<(String, String, Option<String>)> = enriched_packages
.iter()
.map(|ep| {
(
ep.package.name().to_string(),
ep.package.version().to_string(),
ep.license.clone(),
)
})
.collect();
let result = LicenseComplianceChecker::check(&packages, policy);
let msgs = Messages::for_locale(self.locale);
if result.has_violations() {
self.progress_reporter.report(&Messages::format(
msgs.progress_license_violations_found,
&[&result.violations.len().to_string()],
));
} else {
self.progress_reporter
.report(msgs.progress_license_no_violations);
}
if !result.warnings.is_empty() {
self.progress_reporter.report(&Messages::format(
msgs.progress_license_unknown_packages,
&[&result.warnings.len().to_string()],
));
}
Some(result)
}
fn build_response(
&self,
enriched_packages: Vec<EnrichedPackage>,
dependency_graph: Option<crate::sbom_generation::domain::DependencyGraph>,
vulnerability_check_result: Option<VulnerabilityCheckResult>,
license_compliance_result: Option<LicenseComplianceResult>,
upgrade_recommendations: Option<Vec<UpgradeRecommendation>>,
) -> SbomResponse {
let metadata = SbomGenerator::generate_default_metadata();
let has_vulnerabilities_above_threshold = vulnerability_check_result
.as_ref()
.map(|result| result.threshold_exceeded)
.unwrap_or(false);
let has_license_violations = license_compliance_result
.as_ref()
.map(|result| result.has_violations())
.unwrap_or(false);
let mut builder = SbomResponse::builder()
.enriched_packages(enriched_packages)
.metadata(metadata)
.has_vulnerabilities_above_threshold(has_vulnerabilities_above_threshold)
.has_license_violations(has_license_violations);
if let Some(graph) = dependency_graph {
builder = builder.dependency_graph(graph);
}
if let Some(result) = vulnerability_check_result {
builder = builder.vulnerability_check_result(result);
}
if let Some(result) = license_compliance_result {
builder = builder.license_compliance_result(result);
}
if let Some(recommendations) = upgrade_recommendations {
builder = builder.upgrade_recommendations(recommendations);
}
builder.build().expect("response build should not fail")
}
async fn enrich_packages_with_licenses(
&self,
packages: Vec<Package>,
) -> Result<Vec<EnrichedPackage>> {
let total = packages.len();
let progress_current = Arc::new(AtomicUsize::new(0));
let progress_total = Arc::new(AtomicUsize::new(total));
let is_done = Arc::new(AtomicBool::new(false));
let current_clone = progress_current.clone();
let total_clone = progress_total.clone();
let done_clone = is_done.clone();
let progress_handle = thread::spawn(move || {
let pb = ProgressBar::new(total_clone.load(Ordering::Relaxed) as u64);
pb.set_style(
ProgressStyle::default_bar()
.template(" {spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} - {msg}")
.expect("Failed to set progress bar template")
.progress_chars("=>-"),
);
pb.set_message("Fetching license information...");
while !done_clone.load(Ordering::Relaxed) {
let current = current_clone.load(Ordering::Relaxed);
pb.set_position(current as u64);
thread::sleep(Duration::from_millis(50));
}
pb.finish_and_clear();
});
let mut enriched = Vec::new();
let mut successful = 0;
let mut failed = 0;
let mut errors: Vec<(String, String)> = Vec::new();
for (idx, package) in packages.into_iter().enumerate() {
let package_name = package.name().to_string();
match self
.license_repository
.enrich_with_license(package.name(), package.version())
.await
{
Ok(license_info) => {
enriched.push(
EnrichedPackage::new(
package,
license_info.license_text().map(String::from),
license_info.description().map(String::from),
)
.with_sha256_hash(license_info.sha256_hash().map(String::from)),
);
successful += 1;
}
Err(e) => {
errors.push((package_name, e.to_string()));
enriched.push(EnrichedPackage::new(package, None, None));
failed += 1;
}
}
progress_current.store(idx + 1, Ordering::Relaxed);
if idx < total - 1 {
tokio::time::sleep(Duration::from_millis(LICENSE_FETCH_DELAY_MS)).await;
}
}
is_done.store(true, Ordering::Relaxed);
let _ = progress_handle.join();
eprintln!();
let msgs = Messages::for_locale(self.locale);
for (package_name, error_msg) in errors {
self.progress_reporter.report_error(&Messages::format(
msgs.warn_license_fetch_failed,
&[&package_name, &error_msg],
));
}
self.progress_reporter.report_completion(&Messages::format(
msgs.progress_license_complete,
&[
&successful.to_string(),
&total.to_string(),
&failed.to_string(),
],
));
Ok(enriched)
}
}
#[cfg(test)]
mod tests;