Skip to main content

sbom_tools/diff/
result.rs

1//! Diff result structures.
2
3use crate::model::{CanonicalId, Component, ComponentRef, DependencyEdge, VulnerabilityRef};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Map a severity string to a numeric rank for comparison.
8///
9/// Higher values indicate more severe vulnerabilities.
10/// Returns 0 for unrecognized severity strings.
11fn severity_rank(s: &str) -> u8 {
12    match s.to_lowercase().as_str() {
13        "critical" => 4,
14        "high" => 3,
15        "medium" => 2,
16        "low" => 1,
17        _ => 0,
18    }
19}
20
21/// Complete result of an SBOM diff operation.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[must_use]
24pub struct DiffResult {
25    /// Summary statistics
26    pub summary: DiffSummary,
27    /// Component changes
28    pub components: ChangeSet<ComponentChange>,
29    /// Dependency changes
30    pub dependencies: ChangeSet<DependencyChange>,
31    /// License changes
32    pub licenses: LicenseChanges,
33    /// Vulnerability changes
34    pub vulnerabilities: VulnerabilityChanges,
35    /// Total semantic score
36    pub semantic_score: f64,
37    /// Graph structural changes (only populated if graph diffing is enabled)
38    #[serde(default)]
39    pub graph_changes: Vec<DependencyGraphChange>,
40    /// Summary of graph changes
41    #[serde(default)]
42    pub graph_summary: Option<GraphChangeSummary>,
43    /// Number of custom matching rules applied
44    #[serde(default)]
45    pub rules_applied: usize,
46    /// Quality impact of this diff (computed post-diff)
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub quality_delta: Option<QualityDelta>,
49    /// Matching quality metrics (populated during diff)
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub match_metrics: Option<MatchMetrics>,
52}
53
54impl DiffResult {
55    /// Create a new empty diff result
56    pub fn new() -> Self {
57        Self {
58            summary: DiffSummary::default(),
59            components: ChangeSet::new(),
60            dependencies: ChangeSet::new(),
61            licenses: LicenseChanges::default(),
62            vulnerabilities: VulnerabilityChanges::default(),
63            semantic_score: 0.0,
64            graph_changes: Vec::new(),
65            graph_summary: None,
66            rules_applied: 0,
67            quality_delta: None,
68            match_metrics: None,
69        }
70    }
71
72    /// Calculate and update summary statistics
73    pub fn calculate_summary(&mut self) {
74        self.summary.components_added = self.components.added.len();
75        self.summary.components_removed = self.components.removed.len();
76        self.summary.components_modified = self.components.modified.len();
77
78        self.summary.dependencies_added = self.dependencies.added.len();
79        self.summary.dependencies_removed = self.dependencies.removed.len();
80        self.summary.graph_changes_count = self.graph_changes.len();
81
82        self.summary.total_changes = self.summary.components_added
83            + self.summary.components_removed
84            + self.summary.components_modified
85            + self.summary.dependencies_added
86            + self.summary.dependencies_removed
87            + self.summary.graph_changes_count;
88
89        self.summary.vulnerabilities_introduced = self.vulnerabilities.introduced.len();
90        self.summary.vulnerabilities_resolved = self.vulnerabilities.resolved.len();
91        self.summary.vulnerabilities_persistent = self.vulnerabilities.persistent.len();
92
93        self.summary.licenses_added = self.licenses.new_licenses.len();
94        self.summary.licenses_removed = self.licenses.removed_licenses.len();
95    }
96
97    /// Check if there are any changes.
98    ///
99    /// Checks both the pre-computed summary and the source-of-truth fields to be
100    /// safe regardless of whether `calculate_summary()` was called.
101    #[must_use]
102    pub fn has_changes(&self) -> bool {
103        self.summary.total_changes > 0
104            || !self.components.is_empty()
105            || !self.dependencies.is_empty()
106            || !self.graph_changes.is_empty()
107            || !self.vulnerabilities.introduced.is_empty()
108            || !self.vulnerabilities.resolved.is_empty()
109    }
110
111    /// Find a component change by canonical ID
112    #[must_use]
113    pub fn find_component_by_id(&self, id: &CanonicalId) -> Option<&ComponentChange> {
114        let id_str = id.value();
115        self.components
116            .added
117            .iter()
118            .chain(self.components.removed.iter())
119            .chain(self.components.modified.iter())
120            .find(|c| c.id == id_str)
121    }
122
123    /// Find a component change by ID string
124    #[must_use]
125    pub fn find_component_by_id_str(&self, id_str: &str) -> Option<&ComponentChange> {
126        self.components
127            .added
128            .iter()
129            .chain(self.components.removed.iter())
130            .chain(self.components.modified.iter())
131            .find(|c| c.id == id_str)
132    }
133
134    /// Get all component changes as a flat list with their indices for navigation
135    #[must_use]
136    pub fn all_component_changes(&self) -> Vec<&ComponentChange> {
137        self.components
138            .added
139            .iter()
140            .chain(self.components.removed.iter())
141            .chain(self.components.modified.iter())
142            .collect()
143    }
144
145    /// Find vulnerabilities affecting a specific component by ID
146    #[must_use]
147    pub fn find_vulns_for_component(
148        &self,
149        component_id: &CanonicalId,
150    ) -> Vec<&VulnerabilityDetail> {
151        let id_str = component_id.value();
152        self.vulnerabilities
153            .introduced
154            .iter()
155            .chain(self.vulnerabilities.resolved.iter())
156            .chain(self.vulnerabilities.persistent.iter())
157            .filter(|v| v.component_id == id_str)
158            .collect()
159    }
160
161    /// Build an index of component IDs to their changes for fast lookup
162    #[must_use]
163    pub fn build_component_id_index(&self) -> HashMap<String, &ComponentChange> {
164        self.components
165            .added
166            .iter()
167            .chain(&self.components.removed)
168            .chain(&self.components.modified)
169            .map(|c| (c.id.clone(), c))
170            .collect()
171    }
172
173    /// Filter vulnerabilities by minimum severity level
174    pub fn filter_by_severity(&mut self, min_severity: &str) {
175        let min_sev = severity_rank(min_severity);
176
177        self.vulnerabilities
178            .introduced
179            .retain(|v| severity_rank(&v.severity) >= min_sev);
180        self.vulnerabilities
181            .resolved
182            .retain(|v| severity_rank(&v.severity) >= min_sev);
183        self.vulnerabilities
184            .persistent
185            .retain(|v| severity_rank(&v.severity) >= min_sev);
186
187        // Recalculate summary
188        self.calculate_summary();
189    }
190
191    /// Filter out vulnerabilities where VEX status is `NotAffected` or `Fixed`.
192    ///
193    /// Keeps vulnerabilities that are `Affected`, `UnderInvestigation`, or have no VEX status.
194    pub fn filter_by_vex(&mut self) {
195        self.vulnerabilities
196            .introduced
197            .retain(VulnerabilityDetail::is_vex_actionable);
198        self.vulnerabilities
199            .resolved
200            .retain(VulnerabilityDetail::is_vex_actionable);
201        self.vulnerabilities
202            .persistent
203            .retain(VulnerabilityDetail::is_vex_actionable);
204
205        self.calculate_summary();
206    }
207}
208
209impl Default for DiffResult {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215/// Quality and compliance impact of the diff.
216///
217/// Computed by comparing quality scores of old vs new SBOMs.
218/// Enables tracking whether a change improves or degrades SBOM quality.
219#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220pub struct QualityDelta {
221    /// Overall score change (positive = improvement)
222    pub overall_score_delta: f32,
223    /// Old grade
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub old_grade: Option<crate::quality::QualityGrade>,
226    /// New grade
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub new_grade: Option<crate::quality::QualityGrade>,
229    /// Per-category score deltas
230    pub category_deltas: Vec<CategoryDelta>,
231    /// Categories that regressed (score decreased by >1 point)
232    pub regressions: Vec<String>,
233    /// Categories that improved (score increased by >1 point)
234    pub improvements: Vec<String>,
235    /// Compliance violation count change (positive = more violations)
236    pub violation_count_delta: i32,
237}
238
239/// Score delta for a specific quality category.
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct CategoryDelta {
242    /// Category name (e.g., "Completeness", "Identifiers")
243    pub category: String,
244    /// Score in old SBOM
245    pub old_score: f32,
246    /// Score in new SBOM
247    pub new_score: f32,
248    /// Change (new - old)
249    pub delta: f32,
250}
251
252impl QualityDelta {
253    /// Compute quality delta by comparing two quality reports.
254    #[must_use]
255    pub fn from_reports(
256        old: &crate::quality::QualityReport,
257        new: &crate::quality::QualityReport,
258    ) -> Self {
259        let categories = [
260            (
261                "Completeness",
262                old.completeness_score,
263                new.completeness_score,
264            ),
265            ("Identifiers", old.identifier_score, new.identifier_score),
266            ("Licenses", old.license_score, new.license_score),
267            ("Dependencies", old.dependency_score, new.dependency_score),
268            ("Integrity", old.integrity_score, new.integrity_score),
269            ("Provenance", old.provenance_score, new.provenance_score),
270        ];
271
272        let mut category_deltas: Vec<CategoryDelta> = categories
273            .iter()
274            .map(|(name, old_s, new_s)| CategoryDelta {
275                category: (*name).to_string(),
276                old_score: *old_s,
277                new_score: *new_s,
278                delta: new_s - old_s,
279            })
280            .collect();
281
282        // Handle optional categories (VulnDocs and Lifecycle)
283        if let (Some(old_v), Some(new_v)) = (old.vulnerability_score, new.vulnerability_score) {
284            category_deltas.push(CategoryDelta {
285                category: "VulnDocs".to_string(),
286                old_score: old_v,
287                new_score: new_v,
288                delta: new_v - old_v,
289            });
290        }
291        if let (Some(old_l), Some(new_l)) = (old.lifecycle_score, new.lifecycle_score) {
292            category_deltas.push(CategoryDelta {
293                category: "Lifecycle".to_string(),
294                old_score: old_l,
295                new_score: new_l,
296                delta: new_l - old_l,
297            });
298        }
299
300        let regressions: Vec<String> = category_deltas
301            .iter()
302            .filter(|d| d.delta < -1.0)
303            .map(|d| d.category.clone())
304            .collect();
305
306        let improvements: Vec<String> = category_deltas
307            .iter()
308            .filter(|d| d.delta > 1.0)
309            .map(|d| d.category.clone())
310            .collect();
311
312        // Compute compliance violation delta
313        let old_violations = old.compliance.error_count + old.compliance.warning_count;
314        let new_violations = new.compliance.error_count + new.compliance.warning_count;
315
316        Self {
317            overall_score_delta: new.overall_score - old.overall_score,
318            old_grade: Some(old.grade),
319            new_grade: Some(new.grade),
320            category_deltas,
321            regressions,
322            improvements,
323            violation_count_delta: new_violations as i32 - old_violations as i32,
324        }
325    }
326}
327
328/// Metrics about the component matching process.
329///
330/// Provides visibility into matching quality for debugging and tuning.
331#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
332pub struct MatchMetrics {
333    /// Number of exact matches (PURL, CPE, or canonical ID)
334    pub exact_matches: usize,
335    /// Number of fuzzy matches (below exact threshold)
336    pub fuzzy_matches: usize,
337    /// Number of custom rule matches
338    pub rule_matches: usize,
339    /// Components in old SBOM with no match
340    pub unmatched_old: usize,
341    /// Components in new SBOM with no match
342    pub unmatched_new: usize,
343    /// Average match confidence score
344    pub avg_match_score: f64,
345    /// Minimum match confidence score
346    pub min_match_score: f64,
347}
348
349/// Summary statistics for the diff
350#[derive(Debug, Clone, Default, Serialize, Deserialize)]
351pub struct DiffSummary {
352    pub total_changes: usize,
353    pub components_added: usize,
354    pub components_removed: usize,
355    pub components_modified: usize,
356    pub dependencies_added: usize,
357    pub dependencies_removed: usize,
358    pub graph_changes_count: usize,
359    pub vulnerabilities_introduced: usize,
360    pub vulnerabilities_resolved: usize,
361    pub vulnerabilities_persistent: usize,
362    pub licenses_added: usize,
363    pub licenses_removed: usize,
364}
365
366/// Generic change set for added/removed/modified items
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct ChangeSet<T> {
369    pub added: Vec<T>,
370    pub removed: Vec<T>,
371    pub modified: Vec<T>,
372}
373
374impl<T> ChangeSet<T> {
375    #[must_use]
376    pub const fn new() -> Self {
377        Self {
378            added: Vec::new(),
379            removed: Vec::new(),
380            modified: Vec::new(),
381        }
382    }
383
384    #[must_use]
385    pub fn is_empty(&self) -> bool {
386        self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
387    }
388
389    #[must_use]
390    pub fn total(&self) -> usize {
391        self.added.len() + self.removed.len() + self.modified.len()
392    }
393}
394
395impl<T> Default for ChangeSet<T> {
396    fn default() -> Self {
397        Self::new()
398    }
399}
400
401/// Information about how a component was matched.
402///
403/// Included in JSON output to explain why components were correlated.
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct MatchInfo {
406    /// Match confidence score (0.0 - 1.0)
407    pub score: f64,
408    /// Matching method used (`ExactIdentifier`, Alias, Fuzzy, etc.)
409    pub method: String,
410    /// Human-readable explanation
411    pub reason: String,
412    /// Detailed score breakdown (optional)
413    #[serde(skip_serializing_if = "Vec::is_empty")]
414    pub score_breakdown: Vec<MatchScoreComponent>,
415    /// Normalizations applied during matching
416    #[serde(skip_serializing_if = "Vec::is_empty")]
417    pub normalizations: Vec<String>,
418    /// Confidence interval for the match score
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub confidence_interval: Option<ConfidenceInterval>,
421}
422
423/// Confidence interval for match score.
424///
425/// Provides uncertainty bounds around the match score, useful for
426/// understanding match reliability.
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct ConfidenceInterval {
429    /// Lower bound of confidence (0.0 - 1.0)
430    pub lower: f64,
431    /// Upper bound of confidence (0.0 - 1.0)
432    pub upper: f64,
433    /// Confidence level (e.g., 0.95 for 95% CI)
434    pub level: f64,
435}
436
437impl ConfidenceInterval {
438    /// Create a new confidence interval.
439    #[must_use]
440    pub const fn new(lower: f64, upper: f64, level: f64) -> Self {
441        Self {
442            lower: lower.clamp(0.0, 1.0),
443            upper: upper.clamp(0.0, 1.0),
444            level,
445        }
446    }
447
448    /// Create a 95% confidence interval from a score and standard error.
449    ///
450    /// Uses ±1.96 × SE for 95% CI.
451    #[must_use]
452    pub fn from_score_and_error(score: f64, std_error: f64) -> Self {
453        let margin = 1.96 * std_error;
454        Self::new(score - margin, score + margin, 0.95)
455    }
456
457    /// Create a simple confidence interval based on the matching tier.
458    ///
459    /// Exact matches have tight intervals, fuzzy matches have wider intervals.
460    #[must_use]
461    pub fn from_tier(score: f64, tier: &str) -> Self {
462        let margin = match tier {
463            "ExactIdentifier" => 0.0,
464            "Alias" => 0.02,
465            "EcosystemRule" => 0.03,
466            "CustomRule" => 0.05,
467            "Fuzzy" => 0.08,
468            _ => 0.10,
469        };
470        Self::new(score - margin, score + margin, 0.95)
471    }
472
473    /// Get the width of the interval.
474    #[must_use]
475    pub fn width(&self) -> f64 {
476        self.upper - self.lower
477    }
478}
479
480/// A component of the match score for JSON output.
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct MatchScoreComponent {
483    /// Name of this score component
484    pub name: String,
485    /// Weight applied
486    pub weight: f64,
487    /// Raw score
488    pub raw_score: f64,
489    /// Weighted contribution
490    pub weighted_score: f64,
491    /// Description
492    pub description: String,
493}
494
495/// Component change information
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct ComponentChange {
498    /// Component canonical ID (string for serialization)
499    pub id: String,
500    /// Typed canonical ID for navigation (skipped in JSON output for backward compat)
501    #[serde(skip)]
502    pub canonical_id: Option<CanonicalId>,
503    /// Component reference with ID and name together
504    #[serde(skip)]
505    pub component_ref: Option<ComponentRef>,
506    /// Old component ID (for modified components)
507    #[serde(skip)]
508    pub old_canonical_id: Option<CanonicalId>,
509    /// Component name
510    pub name: String,
511    /// Old version (if existed)
512    pub old_version: Option<String>,
513    /// New version (if exists)
514    pub new_version: Option<String>,
515    /// Ecosystem
516    pub ecosystem: Option<String>,
517    /// Change type
518    pub change_type: ChangeType,
519    /// Detailed field changes
520    pub field_changes: Vec<FieldChange>,
521    /// Associated cost
522    pub cost: u32,
523    /// Match information (for modified components, explains how old/new were correlated)
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub match_info: Option<MatchInfo>,
526}
527
528impl ComponentChange {
529    /// Create a new component addition
530    pub fn added(component: &Component, cost: u32) -> Self {
531        Self {
532            id: component.canonical_id.to_string(),
533            canonical_id: Some(component.canonical_id.clone()),
534            component_ref: Some(ComponentRef::from_component(component)),
535            old_canonical_id: None,
536            name: component.name.clone(),
537            old_version: None,
538            new_version: component.version.clone(),
539            ecosystem: component
540                .ecosystem
541                .as_ref()
542                .map(std::string::ToString::to_string),
543            change_type: ChangeType::Added,
544            field_changes: Vec::new(),
545            cost,
546            match_info: None,
547        }
548    }
549
550    /// Create a new component removal
551    pub fn removed(component: &Component, cost: u32) -> Self {
552        Self {
553            id: component.canonical_id.to_string(),
554            canonical_id: Some(component.canonical_id.clone()),
555            component_ref: Some(ComponentRef::from_component(component)),
556            old_canonical_id: Some(component.canonical_id.clone()),
557            name: component.name.clone(),
558            old_version: component.version.clone(),
559            new_version: None,
560            ecosystem: component
561                .ecosystem
562                .as_ref()
563                .map(std::string::ToString::to_string),
564            change_type: ChangeType::Removed,
565            field_changes: Vec::new(),
566            cost,
567            match_info: None,
568        }
569    }
570
571    /// Create a component modification
572    pub fn modified(
573        old: &Component,
574        new: &Component,
575        field_changes: Vec<FieldChange>,
576        cost: u32,
577    ) -> Self {
578        Self {
579            id: new.canonical_id.to_string(),
580            canonical_id: Some(new.canonical_id.clone()),
581            component_ref: Some(ComponentRef::from_component(new)),
582            old_canonical_id: Some(old.canonical_id.clone()),
583            name: new.name.clone(),
584            old_version: old.version.clone(),
585            new_version: new.version.clone(),
586            ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
587            change_type: ChangeType::Modified,
588            field_changes,
589            cost,
590            match_info: None,
591        }
592    }
593
594    /// Create a component modification with match explanation
595    pub fn modified_with_match(
596        old: &Component,
597        new: &Component,
598        field_changes: Vec<FieldChange>,
599        cost: u32,
600        match_info: MatchInfo,
601    ) -> Self {
602        Self {
603            id: new.canonical_id.to_string(),
604            canonical_id: Some(new.canonical_id.clone()),
605            component_ref: Some(ComponentRef::from_component(new)),
606            old_canonical_id: Some(old.canonical_id.clone()),
607            name: new.name.clone(),
608            old_version: old.version.clone(),
609            new_version: new.version.clone(),
610            ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
611            change_type: ChangeType::Modified,
612            field_changes,
613            cost,
614            match_info: Some(match_info),
615        }
616    }
617
618    /// Add match information to an existing change
619    #[must_use]
620    pub fn with_match_info(mut self, match_info: MatchInfo) -> Self {
621        self.match_info = Some(match_info);
622        self
623    }
624
625    /// Get the typed canonical ID, falling back to parsing from string if needed
626    #[must_use]
627    pub fn get_canonical_id(&self) -> CanonicalId {
628        self.canonical_id.clone().unwrap_or_else(|| {
629            CanonicalId::from_name_version(
630                &self.name,
631                self.new_version.as_deref().or(self.old_version.as_deref()),
632            )
633        })
634    }
635
636    /// Get a `ComponentRef` for this change
637    #[must_use]
638    pub fn get_component_ref(&self) -> ComponentRef {
639        self.component_ref.clone().unwrap_or_else(|| {
640            ComponentRef::with_version(
641                self.get_canonical_id(),
642                &self.name,
643                self.new_version
644                    .clone()
645                    .or_else(|| self.old_version.clone()),
646            )
647        })
648    }
649}
650
651impl MatchInfo {
652    /// Create from a `MatchExplanation`
653    #[must_use]
654    pub fn from_explanation(explanation: &crate::matching::MatchExplanation) -> Self {
655        let method = format!("{:?}", explanation.tier);
656        let ci = ConfidenceInterval::from_tier(explanation.score, &method);
657        Self {
658            score: explanation.score,
659            method,
660            reason: explanation.reason.clone(),
661            score_breakdown: explanation
662                .score_breakdown
663                .iter()
664                .map(|c| MatchScoreComponent {
665                    name: c.name.clone(),
666                    weight: c.weight,
667                    raw_score: c.raw_score,
668                    weighted_score: c.weighted_score,
669                    description: c.description.clone(),
670                })
671                .collect(),
672            normalizations: explanation.normalizations_applied.clone(),
673            confidence_interval: Some(ci),
674        }
675    }
676
677    /// Create a simple match info without detailed breakdown
678    #[must_use]
679    pub fn simple(score: f64, method: &str, reason: &str) -> Self {
680        let ci = ConfidenceInterval::from_tier(score, method);
681        Self {
682            score,
683            method: method.to_string(),
684            reason: reason.to_string(),
685            score_breakdown: Vec::new(),
686            normalizations: Vec::new(),
687            confidence_interval: Some(ci),
688        }
689    }
690
691    /// Create a match info with a custom confidence interval
692    #[must_use]
693    pub const fn with_confidence_interval(mut self, ci: ConfidenceInterval) -> Self {
694        self.confidence_interval = Some(ci);
695        self
696    }
697}
698
699/// Type of change
700#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
701pub enum ChangeType {
702    Added,
703    Removed,
704    Modified,
705    Unchanged,
706}
707
708/// Individual field change
709#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct FieldChange {
711    pub field: String,
712    pub old_value: Option<String>,
713    pub new_value: Option<String>,
714}
715
716/// Dependency change information
717#[derive(Debug, Clone, Serialize, Deserialize)]
718pub struct DependencyChange {
719    /// Source component
720    pub from: String,
721    /// Target component
722    pub to: String,
723    /// Relationship type
724    pub relationship: String,
725    /// Dependency scope
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub scope: Option<String>,
728    /// Change type
729    pub change_type: ChangeType,
730}
731
732impl DependencyChange {
733    #[must_use]
734    pub fn added(edge: &DependencyEdge) -> Self {
735        Self {
736            from: edge.from.to_string(),
737            to: edge.to.to_string(),
738            relationship: edge.relationship.to_string(),
739            scope: edge.scope.as_ref().map(std::string::ToString::to_string),
740            change_type: ChangeType::Added,
741        }
742    }
743
744    #[must_use]
745    pub fn removed(edge: &DependencyEdge) -> Self {
746        Self {
747            from: edge.from.to_string(),
748            to: edge.to.to_string(),
749            relationship: edge.relationship.to_string(),
750            scope: edge.scope.as_ref().map(std::string::ToString::to_string),
751            change_type: ChangeType::Removed,
752        }
753    }
754}
755
756/// License change information
757#[derive(Debug, Clone, Default, Serialize, Deserialize)]
758pub struct LicenseChanges {
759    /// Newly introduced licenses
760    pub new_licenses: Vec<LicenseChange>,
761    /// Removed licenses
762    pub removed_licenses: Vec<LicenseChange>,
763    /// License conflicts
764    pub conflicts: Vec<LicenseConflict>,
765    /// Components with license changes
766    pub component_changes: Vec<ComponentLicenseChange>,
767}
768
769/// Individual license change
770#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct LicenseChange {
772    /// License expression
773    pub license: String,
774    /// Components using this license
775    pub components: Vec<String>,
776    /// License family
777    pub family: String,
778}
779
780/// License conflict information
781#[derive(Debug, Clone, Serialize, Deserialize)]
782pub struct LicenseConflict {
783    pub license_a: String,
784    pub license_b: String,
785    pub component: String,
786    pub description: String,
787}
788
789/// Component-level license change
790#[derive(Debug, Clone, Serialize, Deserialize)]
791pub struct ComponentLicenseChange {
792    pub component_id: String,
793    pub component_name: String,
794    pub old_licenses: Vec<String>,
795    pub new_licenses: Vec<String>,
796}
797
798/// A VEX state change for a vulnerability between old and new SBOMs.
799#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
800pub struct VexStatusChange {
801    /// Vulnerability ID (e.g., "CVE-2023-1234")
802    pub vuln_id: String,
803    /// Affected component name
804    pub component_name: String,
805    /// Old VEX state (None = no VEX in old SBOM)
806    #[serde(default, skip_serializing_if = "Option::is_none")]
807    pub old_state: Option<crate::model::VexState>,
808    /// New VEX state (None = no VEX in new SBOM)
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    pub new_state: Option<crate::model::VexState>,
811}
812
813/// Vulnerability change information
814#[derive(Debug, Clone, Default, Serialize, Deserialize)]
815pub struct VulnerabilityChanges {
816    /// Newly introduced vulnerabilities
817    pub introduced: Vec<VulnerabilityDetail>,
818    /// Resolved vulnerabilities
819    pub resolved: Vec<VulnerabilityDetail>,
820    /// Persistent vulnerabilities (present in both)
821    pub persistent: Vec<VulnerabilityDetail>,
822    /// VEX state transitions detected across persistent vulnerabilities
823    #[serde(default, skip_serializing_if = "Vec::is_empty")]
824    pub vex_changes: Vec<VexStatusChange>,
825}
826
827impl VulnerabilityChanges {
828    /// Count vulnerabilities by severity
829    #[must_use]
830    pub fn introduced_by_severity(&self) -> HashMap<String, usize> {
831        // Pre-allocate for typical severity levels (critical, high, medium, low, unknown)
832        let mut counts = HashMap::with_capacity(5);
833        for vuln in &self.introduced {
834            *counts.entry(vuln.severity.clone()).or_insert(0) += 1;
835        }
836        counts
837    }
838
839    /// Get critical and high severity introduced vulnerabilities
840    #[must_use]
841    pub fn critical_and_high_introduced(&self) -> Vec<&VulnerabilityDetail> {
842        self.introduced
843            .iter()
844            .filter(|v| v.severity == "Critical" || v.severity == "High")
845            .collect()
846    }
847
848    /// Compute VEX coverage summary across all vulnerability categories.
849    pub fn vex_summary(&self) -> VexCoverageSummary {
850        let all_vulns: Vec<&VulnerabilityDetail> = self
851            .introduced
852            .iter()
853            .chain(&self.resolved)
854            .chain(&self.persistent)
855            .collect();
856
857        let total = all_vulns.len();
858        let mut with_vex = 0;
859        let mut by_state: HashMap<crate::model::VexState, usize> = HashMap::with_capacity(4);
860        let mut actionable = 0;
861
862        for vuln in &all_vulns {
863            if let Some(ref state) = vuln.vex_state {
864                with_vex += 1;
865                *by_state.entry(state.clone()).or_insert(0) += 1;
866            }
867            if vuln.is_vex_actionable() {
868                actionable += 1;
869            }
870        }
871
872        // Vulns without VEX (gaps) — both introduced and persistent are flagged
873        let introduced_without_vex = self
874            .introduced
875            .iter()
876            .filter(|v| v.vex_state.is_none())
877            .count();
878
879        let persistent_without_vex = self
880            .persistent
881            .iter()
882            .filter(|v| v.vex_state.is_none())
883            .count();
884
885        VexCoverageSummary {
886            total_vulns: total,
887            with_vex,
888            without_vex: total - with_vex,
889            actionable,
890            coverage_pct: if total > 0 {
891                (with_vex as f64 / total as f64) * 100.0
892            } else {
893                100.0
894            },
895            by_state,
896            introduced_without_vex,
897            persistent_without_vex,
898        }
899    }
900}
901
902/// VEX coverage summary for vulnerability changes.
903#[derive(Debug, Clone, Serialize, Deserialize)]
904#[must_use]
905pub struct VexCoverageSummary {
906    /// Total vulnerabilities across all categories
907    pub total_vulns: usize,
908    /// Vulnerabilities with a VEX statement
909    pub with_vex: usize,
910    /// Vulnerabilities without a VEX statement
911    pub without_vex: usize,
912    /// Vulnerabilities that are VEX-actionable (no NotAffected/Fixed)
913    pub actionable: usize,
914    /// VEX coverage percentage (0.0-100.0)
915    pub coverage_pct: f64,
916    /// Breakdown by VEX state
917    pub by_state: HashMap<crate::model::VexState, usize>,
918    /// Introduced vulnerabilities without VEX (gaps requiring attention)
919    pub introduced_without_vex: usize,
920    /// Persistent vulnerabilities without VEX (ongoing gaps)
921    pub persistent_without_vex: usize,
922}
923
924/// SLA status for vulnerability remediation tracking
925#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
926pub enum SlaStatus {
927    /// Past SLA deadline by N days
928    Overdue(i64),
929    /// Due within 3 days (N days remaining)
930    DueSoon(i64),
931    /// Within SLA window (N days remaining)
932    OnTrack(i64),
933    /// No SLA deadline applicable
934    NoDueDate,
935}
936
937impl SlaStatus {
938    /// Format for display (e.g., "3d late", "2d left", "45d old")
939    #[must_use]
940    pub fn display(&self, days_since_published: Option<i64>) -> String {
941        match self {
942            Self::Overdue(days) => format!("{days}d late"),
943            Self::DueSoon(days) | Self::OnTrack(days) => format!("{days}d left"),
944            Self::NoDueDate => {
945                days_since_published.map_or_else(|| "-".to_string(), |age| format!("{age}d old"))
946            }
947        }
948    }
949
950    /// Check if this is an overdue status
951    #[must_use]
952    pub const fn is_overdue(&self) -> bool {
953        matches!(self, Self::Overdue(_))
954    }
955
956    /// Check if this is due soon (approaching deadline)
957    #[must_use]
958    pub const fn is_due_soon(&self) -> bool {
959        matches!(self, Self::DueSoon(_))
960    }
961}
962
963/// Detailed vulnerability information
964#[derive(Debug, Clone, Serialize, Deserialize)]
965pub struct VulnerabilityDetail {
966    /// Vulnerability ID
967    pub id: String,
968    /// Source database
969    pub source: String,
970    /// Severity level
971    pub severity: String,
972    /// CVSS score
973    pub cvss_score: Option<f32>,
974    /// Affected component ID (string for serialization)
975    pub component_id: String,
976    /// Typed canonical ID for the component (skipped in JSON for backward compat)
977    #[serde(skip)]
978    pub component_canonical_id: Option<CanonicalId>,
979    /// Component reference with ID and name together
980    #[serde(skip)]
981    pub component_ref: Option<ComponentRef>,
982    /// Affected component name
983    pub component_name: String,
984    /// Affected version
985    pub version: Option<String>,
986    /// CWE identifiers
987    pub cwes: Vec<String>,
988    /// Description
989    pub description: Option<String>,
990    /// Remediation info
991    pub remediation: Option<String>,
992    /// Whether this vulnerability is in CISA's Known Exploited Vulnerabilities catalog
993    #[serde(default)]
994    pub is_kev: bool,
995    /// Dependency depth (1 = direct, 2+ = transitive, None = unknown)
996    #[serde(default)]
997    pub component_depth: Option<u32>,
998    /// Date vulnerability was published (ISO 8601)
999    #[serde(default)]
1000    pub published_date: Option<String>,
1001    /// KEV due date (CISA mandated remediation deadline)
1002    #[serde(default)]
1003    pub kev_due_date: Option<String>,
1004    /// Days since published (positive = past)
1005    #[serde(default)]
1006    pub days_since_published: Option<i64>,
1007    /// Days until KEV due date (negative = overdue)
1008    #[serde(default)]
1009    pub days_until_due: Option<i64>,
1010    /// VEX state for this vulnerability's component (if available)
1011    #[serde(default, skip_serializing_if = "Option::is_none")]
1012    pub vex_state: Option<crate::model::VexState>,
1013    /// VEX justification (from per-vuln or component-level VEX)
1014    #[serde(default, skip_serializing_if = "Option::is_none")]
1015    pub vex_justification: Option<crate::model::VexJustification>,
1016    /// VEX impact statement (from per-vuln or component-level VEX)
1017    #[serde(default, skip_serializing_if = "Option::is_none")]
1018    pub vex_impact_statement: Option<String>,
1019}
1020
1021impl VulnerabilityDetail {
1022    /// Whether this vulnerability is VEX-actionable (not resolved by vendor analysis).
1023    ///
1024    /// Returns `true` if the VEX state is `Affected`, `UnderInvestigation`, or absent.
1025    /// Returns `false` if the VEX state is `NotAffected` or `Fixed`.
1026    #[must_use]
1027    pub const fn is_vex_actionable(&self) -> bool {
1028        !matches!(
1029            self.vex_state,
1030            Some(crate::model::VexState::NotAffected | crate::model::VexState::Fixed)
1031        )
1032    }
1033
1034    /// Create from a vulnerability reference and component
1035    pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
1036        // Calculate days since published (published is DateTime<Utc>)
1037        let days_since_published = vuln.published.map(|dt| {
1038            let today = chrono::Utc::now().date_naive();
1039            (today - dt.date_naive()).num_days()
1040        });
1041
1042        // Format published date as string for serialization
1043        let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
1044
1045        // Get KEV info if present
1046        let (kev_due_date, days_until_due) = vuln.kev_info.as_ref().map_or((None, None), |kev| {
1047            (
1048                Some(kev.due_date.format("%Y-%m-%d").to_string()),
1049                Some(kev.days_until_due()),
1050            )
1051        });
1052
1053        Self {
1054            id: vuln.id.clone(),
1055            source: vuln.source.to_string(),
1056            severity: vuln
1057                .severity
1058                .as_ref()
1059                .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string),
1060            cvss_score: vuln.max_cvss_score(),
1061            component_id: component.canonical_id.to_string(),
1062            component_canonical_id: Some(component.canonical_id.clone()),
1063            component_ref: Some(ComponentRef::from_component(component)),
1064            component_name: component.name.clone(),
1065            version: component.version.clone(),
1066            cwes: vuln.cwes.clone(),
1067            description: vuln.description.clone(),
1068            remediation: vuln.remediation.as_ref().map(|r| {
1069                format!(
1070                    "{}: {}",
1071                    r.remediation_type,
1072                    r.description.as_deref().unwrap_or("")
1073                )
1074            }),
1075            is_kev: vuln.is_kev,
1076            component_depth: None,
1077            published_date,
1078            kev_due_date,
1079            days_since_published,
1080            days_until_due,
1081            vex_state: {
1082                let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1083                vex_source.map(|v| v.status.clone())
1084            },
1085            vex_justification: {
1086                let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1087                vex_source.and_then(|v| v.justification.clone())
1088            },
1089            vex_impact_statement: {
1090                let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1091                vex_source.and_then(|v| v.impact_statement.clone())
1092            },
1093        }
1094    }
1095
1096    /// Create from a vulnerability reference and component with known depth
1097    #[must_use]
1098    pub fn from_ref_with_depth(
1099        vuln: &VulnerabilityRef,
1100        component: &Component,
1101        depth: Option<u32>,
1102    ) -> Self {
1103        let mut detail = Self::from_ref(vuln, component);
1104        detail.component_depth = depth;
1105        detail
1106    }
1107
1108    /// Calculate SLA status based on KEV due date or severity-based policy
1109    ///
1110    /// Priority order:
1111    /// 1. KEV due date (CISA mandated deadline)
1112    /// 2. Severity-based SLA (Critical=1d, High=7d, Medium=30d, Low=90d)
1113    #[must_use]
1114    pub fn sla_status(&self) -> SlaStatus {
1115        // KEV due date takes priority
1116        if let Some(days) = self.days_until_due {
1117            if days < 0 {
1118                return SlaStatus::Overdue(-days);
1119            } else if days <= 3 {
1120                return SlaStatus::DueSoon(days);
1121            }
1122            return SlaStatus::OnTrack(days);
1123        }
1124
1125        // Fall back to severity-based SLA
1126        if let Some(age_days) = self.days_since_published {
1127            let sla_days = match self.severity.to_lowercase().as_str() {
1128                "critical" => 1,
1129                "high" => 7,
1130                "medium" => 30,
1131                "low" => 90,
1132                _ => return SlaStatus::NoDueDate,
1133            };
1134            let remaining = sla_days - age_days;
1135            if remaining < 0 {
1136                return SlaStatus::Overdue(-remaining);
1137            } else if remaining <= 3 {
1138                return SlaStatus::DueSoon(remaining);
1139            }
1140            return SlaStatus::OnTrack(remaining);
1141        }
1142
1143        SlaStatus::NoDueDate
1144    }
1145
1146    /// Get the typed component canonical ID
1147    #[must_use]
1148    pub fn get_component_id(&self) -> CanonicalId {
1149        self.component_canonical_id.clone().unwrap_or_else(|| {
1150            CanonicalId::from_name_version(&self.component_name, self.version.as_deref())
1151        })
1152    }
1153
1154    /// Get a `ComponentRef` for the affected component
1155    #[must_use]
1156    pub fn get_component_ref(&self) -> ComponentRef {
1157        self.component_ref.clone().unwrap_or_else(|| {
1158            ComponentRef::with_version(
1159                self.get_component_id(),
1160                &self.component_name,
1161                self.version.clone(),
1162            )
1163        })
1164    }
1165}
1166
1167// ============================================================================
1168// Graph-Aware Diffing Types
1169// ============================================================================
1170
1171/// Represents a structural change in the dependency graph
1172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1173pub struct DependencyGraphChange {
1174    /// The component involved in the change
1175    pub component_id: CanonicalId,
1176    /// Human-readable component name
1177    pub component_name: String,
1178    /// The type of structural change
1179    pub change: DependencyChangeType,
1180    /// Assessed impact of this change
1181    pub impact: GraphChangeImpact,
1182}
1183
1184/// Types of dependency graph structural changes
1185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1186#[non_exhaustive]
1187pub enum DependencyChangeType {
1188    /// A new dependency link was added
1189    DependencyAdded {
1190        dependency_id: CanonicalId,
1191        dependency_name: String,
1192    },
1193
1194    /// A dependency link was removed
1195    DependencyRemoved {
1196        dependency_id: CanonicalId,
1197        dependency_name: String,
1198    },
1199
1200    /// Dependency relationship or scope changed (same endpoints, different attributes)
1201    RelationshipChanged {
1202        dependency_id: CanonicalId,
1203        dependency_name: String,
1204        old_relationship: String,
1205        new_relationship: String,
1206        old_scope: Option<String>,
1207        new_scope: Option<String>,
1208    },
1209
1210    /// A dependency was reparented (had exactly one parent in both, but different)
1211    Reparented {
1212        dependency_id: CanonicalId,
1213        dependency_name: String,
1214        old_parent_id: CanonicalId,
1215        old_parent_name: String,
1216        new_parent_id: CanonicalId,
1217        new_parent_name: String,
1218    },
1219
1220    /// Dependency depth changed (e.g., transitive became direct)
1221    DepthChanged {
1222        old_depth: u32, // 1 = root, 2 = direct, 3+ = transitive
1223        new_depth: u32,
1224    },
1225}
1226
1227impl DependencyChangeType {
1228    /// Get a short description of the change type
1229    #[must_use]
1230    pub const fn kind(&self) -> &'static str {
1231        match self {
1232            Self::DependencyAdded { .. } => "added",
1233            Self::DependencyRemoved { .. } => "removed",
1234            Self::RelationshipChanged { .. } => "relationship_changed",
1235            Self::Reparented { .. } => "reparented",
1236            Self::DepthChanged { .. } => "depth_changed",
1237        }
1238    }
1239}
1240
1241/// Impact level of a graph change
1242#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1243pub enum GraphChangeImpact {
1244    /// Internal reorganization, no functional change
1245    Low,
1246    /// Depth or type change, may affect build/runtime
1247    Medium,
1248    /// Security-relevant component relationship changed
1249    High,
1250    /// Vulnerable component promoted to direct dependency
1251    Critical,
1252}
1253
1254impl GraphChangeImpact {
1255    #[must_use]
1256    pub const fn as_str(&self) -> &'static str {
1257        match self {
1258            Self::Low => "low",
1259            Self::Medium => "medium",
1260            Self::High => "high",
1261            Self::Critical => "critical",
1262        }
1263    }
1264
1265    /// Parse from a string label. Returns Low for unrecognized values.
1266    #[must_use]
1267    pub fn from_label(s: &str) -> Self {
1268        match s.to_lowercase().as_str() {
1269            "critical" => Self::Critical,
1270            "high" => Self::High,
1271            "medium" => Self::Medium,
1272            _ => Self::Low,
1273        }
1274    }
1275}
1276
1277impl std::fmt::Display for GraphChangeImpact {
1278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1279        write!(f, "{}", self.as_str())
1280    }
1281}
1282
1283/// Summary statistics for graph changes
1284#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1285pub struct GraphChangeSummary {
1286    pub total_changes: usize,
1287    pub dependencies_added: usize,
1288    pub dependencies_removed: usize,
1289    pub relationship_changed: usize,
1290    pub reparented: usize,
1291    pub depth_changed: usize,
1292    pub by_impact: GraphChangesByImpact,
1293}
1294
1295impl GraphChangeSummary {
1296    /// Build summary from a list of changes
1297    #[must_use]
1298    pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
1299        let mut summary = Self {
1300            total_changes: changes.len(),
1301            ..Default::default()
1302        };
1303
1304        for change in changes {
1305            match &change.change {
1306                DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
1307                DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
1308                DependencyChangeType::RelationshipChanged { .. } => {
1309                    summary.relationship_changed += 1;
1310                }
1311                DependencyChangeType::Reparented { .. } => summary.reparented += 1,
1312                DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
1313            }
1314
1315            match change.impact {
1316                GraphChangeImpact::Low => summary.by_impact.low += 1,
1317                GraphChangeImpact::Medium => summary.by_impact.medium += 1,
1318                GraphChangeImpact::High => summary.by_impact.high += 1,
1319                GraphChangeImpact::Critical => summary.by_impact.critical += 1,
1320            }
1321        }
1322
1323        summary
1324    }
1325}
1326
1327#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1328pub struct GraphChangesByImpact {
1329    pub low: usize,
1330    pub medium: usize,
1331    pub high: usize,
1332    pub critical: usize,
1333}