use crate::config::DiffConfig;
use crate::diff::{DiffEngine, DiffResult, GraphDiffConfig};
use crate::matching::{FuzzyMatchConfig, MatchingRulesConfig};
use crate::model::NormalizedSbom;
use anyhow::Result;
pub fn compute_diff(
config: &DiffConfig,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
) -> Result<DiffResult> {
let quiet = config.behavior.quiet;
let fuzzy_config = config.matching.to_fuzzy_config();
let matching_rules = load_matching_rules(config)?;
if !quiet {
tracing::info!("Computing semantic diff...");
}
let mut engine = DiffEngine::new()
.with_fuzzy_config(fuzzy_config.clone())
.include_unchanged(config.matching.include_unchanged);
if config.graph_diff.enabled {
if !quiet {
tracing::info!("Graph-aware diffing enabled");
}
engine = engine.with_graph_diff(GraphDiffConfig {
detect_reparenting: config.graph_diff.detect_reparenting,
detect_depth_changes: config.graph_diff.detect_depth_changes,
max_depth: config.graph_diff.max_depth,
relation_filter: config.graph_diff.relation_filter.clone(),
});
}
if let Some(rules) = matching_rules
&& !config.rules.dry_run
{
let rule_engine = crate::matching::RuleEngine::new(rules)
.map_err(|e| anyhow::anyhow!("Failed to initialize matching rule engine: {e}"))?;
engine = engine.with_rule_engine(rule_engine);
}
let mut result = engine
.diff(old_sbom, new_sbom)
.map_err(|e| super::PipelineError::DiffFailed { source: e.into() })?;
if config.graph_diff.enabled {
if let Some(ref threshold) = config.graph_diff.impact_threshold {
let min_impact = crate::diff::GraphChangeImpact::from_label(threshold);
let impact_rank = |i: &crate::diff::GraphChangeImpact| match i {
crate::diff::GraphChangeImpact::Critical => 4,
crate::diff::GraphChangeImpact::High => 3,
crate::diff::GraphChangeImpact::Medium => 2,
crate::diff::GraphChangeImpact::Low => 1,
};
let min_rank = impact_rank(&min_impact);
result
.graph_changes
.retain(|c| impact_rank(&c.impact) >= min_rank);
result.graph_summary = Some(crate::diff::GraphChangeSummary::from_changes(
&result.graph_changes,
));
result.calculate_summary();
if !quiet {
tracing::info!("Filtered graph changes to impact >= {threshold}");
}
}
if !quiet && let Some(ref summary) = result.graph_summary {
tracing::info!(
"Graph changes: {} total ({} added, {} removed, {} reparented, {} depth changes)",
summary.total_changes,
summary.dependencies_added,
summary.dependencies_removed,
summary.reparented,
summary.depth_changed
);
}
}
if let Some(ref sev) = config.filtering.min_severity {
result.filter_by_severity(sev);
if !quiet {
tracing::info!("Filtered vulnerabilities to severity >= {}", sev);
}
}
if config.filtering.exclude_vex_resolved {
result.filter_by_vex();
if !quiet {
tracing::info!("Filtered out vulnerabilities with VEX status not_affected or fixed");
}
}
if !quiet {
tracing::info!(
"Diff complete: {} changes, semantic score: {:.1}",
result.summary.total_changes,
result.semantic_score
);
}
if config.behavior.explain_matches {
print_match_explanations(&result);
}
if config.behavior.recommend_threshold {
print_threshold_recommendation(old_sbom, new_sbom, fuzzy_config);
}
{
let scorer = crate::quality::QualityScorer::new(crate::quality::ScoringProfile::Standard);
let old_report = scorer.score(old_sbom);
let new_report = scorer.score(new_sbom);
result.quality_delta = Some(crate::diff::QualityDelta::from_reports(
&old_report,
&new_report,
));
}
Ok(result)
}
fn load_matching_rules(config: &DiffConfig) -> Result<Option<MatchingRulesConfig>> {
let quiet = config.behavior.quiet;
config.rules.rules_file.as_ref().map_or_else(
|| Ok(None),
|rules_path| {
if !quiet {
tracing::info!("Loading matching rules from {:?}", rules_path);
}
match MatchingRulesConfig::from_file(rules_path) {
Ok(rules) => {
let summary = rules.summary();
if !quiet {
tracing::info!("Loaded {}", summary);
}
if config.rules.dry_run {
tracing::info!("Dry-run mode: rules will be shown but not applied");
}
Ok(Some(rules))
}
Err(e) => {
tracing::warn!("Failed to load matching rules: {}", e);
Ok(None)
}
}
},
)
}
fn print_match_explanations(result: &DiffResult) {
println!("\n=== Match Explanations ===\n");
for change in &result.components.modified {
if let Some(ref match_info) = change.match_info {
println!("Component: {}", change.name);
println!(" Score: {:.2} ({})", match_info.score, match_info.method);
println!(" Reason: {}", match_info.reason);
if !match_info.score_breakdown.is_empty() {
println!(" Score breakdown:");
for component in &match_info.score_breakdown {
println!(
" - {}: {:.2} x {:.2} = {:.2}",
component.name,
component.raw_score,
component.weight,
component.weighted_score
);
}
}
if !match_info.normalizations.is_empty() {
println!(" Normalizations: {}", match_info.normalizations.join(", "));
}
println!();
}
}
}
fn print_threshold_recommendation(
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
fuzzy_config: FuzzyMatchConfig,
) {
use crate::matching::{AdaptiveThreshold, AdaptiveThresholdConfig, FuzzyMatcher};
let adaptive = AdaptiveThreshold::new(AdaptiveThresholdConfig::default());
let matcher = FuzzyMatcher::new(fuzzy_config);
let recommendation = adaptive.compute_threshold(old_sbom, new_sbom, &matcher);
println!("\n=== Threshold Recommendation ===\n");
println!("Recommended threshold: {:.2}", recommendation.threshold);
println!("Confidence: {:.0}%", recommendation.confidence * 100.0);
println!("Method used: {:?}", recommendation.method);
println!("Samples analyzed: {}", recommendation.samples);
println!(
"Match ratio at threshold: {:.1}%",
recommendation.match_ratio * 100.0
);
println!("\nScore distribution:");
println!(" Mean: {:.3}", recommendation.score_stats.mean);
println!(" Std dev: {:.3}", recommendation.score_stats.std_dev);
println!(" Median: {:.3}", recommendation.score_stats.median);
println!(
" Min: {:.3}, Max: {:.3}",
recommendation.score_stats.min, recommendation.score_stats.max
);
println!();
}