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)]
23pub struct DiffResult {
24    /// Summary statistics
25    pub summary: DiffSummary,
26    /// Component changes
27    pub components: ChangeSet<ComponentChange>,
28    /// Dependency changes
29    pub dependencies: ChangeSet<DependencyChange>,
30    /// License changes
31    pub licenses: LicenseChanges,
32    /// Vulnerability changes
33    pub vulnerabilities: VulnerabilityChanges,
34    /// Total semantic score
35    pub semantic_score: f64,
36    /// Graph structural changes (only populated if graph diffing is enabled)
37    #[serde(default)]
38    pub graph_changes: Vec<DependencyGraphChange>,
39    /// Summary of graph changes
40    #[serde(default)]
41    pub graph_summary: Option<GraphChangeSummary>,
42    /// Number of custom matching rules applied
43    #[serde(default)]
44    pub rules_applied: usize,
45}
46
47impl DiffResult {
48    /// Create a new empty diff result
49    pub fn new() -> Self {
50        Self {
51            summary: DiffSummary::default(),
52            components: ChangeSet::new(),
53            dependencies: ChangeSet::new(),
54            licenses: LicenseChanges::default(),
55            vulnerabilities: VulnerabilityChanges::default(),
56            semantic_score: 0.0,
57            graph_changes: Vec::new(),
58            graph_summary: None,
59            rules_applied: 0,
60        }
61    }
62
63    /// Calculate and update summary statistics
64    pub fn calculate_summary(&mut self) {
65        self.summary.components_added = self.components.added.len();
66        self.summary.components_removed = self.components.removed.len();
67        self.summary.components_modified = self.components.modified.len();
68        self.summary.total_changes = self.summary.components_added
69            + self.summary.components_removed
70            + self.summary.components_modified;
71
72        self.summary.dependencies_added = self.dependencies.added.len();
73        self.summary.dependencies_removed = self.dependencies.removed.len();
74
75        self.summary.vulnerabilities_introduced = self.vulnerabilities.introduced.len();
76        self.summary.vulnerabilities_resolved = self.vulnerabilities.resolved.len();
77        self.summary.vulnerabilities_persistent = self.vulnerabilities.persistent.len();
78
79        self.summary.licenses_added = self.licenses.new_licenses.len();
80        self.summary.licenses_removed = self.licenses.removed_licenses.len();
81    }
82
83    /// Check if there are any changes
84    pub fn has_changes(&self) -> bool {
85        self.summary.total_changes > 0
86            || !self.dependencies.is_empty()
87            || !self.vulnerabilities.introduced.is_empty()
88            || !self.vulnerabilities.resolved.is_empty()
89            || !self.graph_changes.is_empty()
90    }
91
92    /// Set graph changes and compute summary
93    pub fn set_graph_changes(&mut self, changes: Vec<DependencyGraphChange>) {
94        self.graph_summary = Some(GraphChangeSummary::from_changes(&changes));
95        self.graph_changes = changes;
96    }
97
98    /// Find a component change by canonical ID
99    pub fn find_component_by_id(&self, id: &CanonicalId) -> Option<&ComponentChange> {
100        let id_str = id.value();
101        self.components
102            .added
103            .iter()
104            .chain(self.components.removed.iter())
105            .chain(self.components.modified.iter())
106            .find(|c| c.id == id_str)
107    }
108
109    /// Find a component change by ID string
110    pub fn find_component_by_id_str(&self, id_str: &str) -> Option<&ComponentChange> {
111        self.components
112            .added
113            .iter()
114            .chain(self.components.removed.iter())
115            .chain(self.components.modified.iter())
116            .find(|c| c.id == id_str)
117    }
118
119    /// Get all component changes as a flat list with their indices for navigation
120    pub fn all_component_changes(&self) -> Vec<&ComponentChange> {
121        self.components
122            .added
123            .iter()
124            .chain(self.components.removed.iter())
125            .chain(self.components.modified.iter())
126            .collect()
127    }
128
129    /// Find vulnerabilities affecting a specific component by ID
130    pub fn find_vulns_for_component(&self, component_id: &CanonicalId) -> Vec<&VulnerabilityDetail> {
131        let id_str = component_id.value();
132        self.vulnerabilities
133            .introduced
134            .iter()
135            .chain(self.vulnerabilities.resolved.iter())
136            .chain(self.vulnerabilities.persistent.iter())
137            .filter(|v| v.component_id == id_str)
138            .collect()
139    }
140
141    /// Build an index of component IDs to their changes for fast lookup
142    pub fn build_component_id_index(&self) -> HashMap<String, &ComponentChange> {
143        let capacity = self.components.added.len()
144            + self.components.removed.len()
145            + self.components.modified.len();
146        let mut index = HashMap::with_capacity(capacity);
147        for c in self.components.added.iter() {
148            index.insert(c.id.clone(), c);
149        }
150        for c in self.components.removed.iter() {
151            index.insert(c.id.clone(), c);
152        }
153        for c in self.components.modified.iter() {
154            index.insert(c.id.clone(), c);
155        }
156        index
157    }
158
159    /// Filter vulnerabilities by minimum severity level
160    pub fn filter_by_severity(&mut self, min_severity: &str) {
161        let min_sev = severity_rank(min_severity);
162
163        self.vulnerabilities
164            .introduced
165            .retain(|v| severity_rank(&v.severity) >= min_sev);
166        self.vulnerabilities
167            .resolved
168            .retain(|v| severity_rank(&v.severity) >= min_sev);
169        self.vulnerabilities
170            .persistent
171            .retain(|v| severity_rank(&v.severity) >= min_sev);
172
173        // Recalculate summary
174        self.calculate_summary();
175    }
176
177    /// Filter out vulnerabilities where VEX status is `NotAffected` or `Fixed`.
178    ///
179    /// Keeps vulnerabilities that are `Affected`, `UnderInvestigation`, or have no VEX status.
180    pub fn filter_by_vex(&mut self) {
181        self.vulnerabilities
182            .introduced
183            .retain(|v| v.is_vex_actionable());
184        self.vulnerabilities
185            .resolved
186            .retain(|v| v.is_vex_actionable());
187        self.vulnerabilities
188            .persistent
189            .retain(|v| v.is_vex_actionable());
190
191        self.calculate_summary();
192    }
193}
194
195impl Default for DiffResult {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201/// Summary statistics for the diff
202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
203pub struct DiffSummary {
204    pub total_changes: usize,
205    pub components_added: usize,
206    pub components_removed: usize,
207    pub components_modified: usize,
208    pub dependencies_added: usize,
209    pub dependencies_removed: usize,
210    pub vulnerabilities_introduced: usize,
211    pub vulnerabilities_resolved: usize,
212    pub vulnerabilities_persistent: usize,
213    pub licenses_added: usize,
214    pub licenses_removed: usize,
215}
216
217/// Generic change set for added/removed/modified items
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct ChangeSet<T> {
220    pub added: Vec<T>,
221    pub removed: Vec<T>,
222    pub modified: Vec<T>,
223}
224
225impl<T> ChangeSet<T> {
226    pub fn new() -> Self {
227        Self {
228            added: Vec::new(),
229            removed: Vec::new(),
230            modified: Vec::new(),
231        }
232    }
233
234    pub fn is_empty(&self) -> bool {
235        self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
236    }
237
238    pub fn total(&self) -> usize {
239        self.added.len() + self.removed.len() + self.modified.len()
240    }
241}
242
243impl<T> Default for ChangeSet<T> {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249/// Information about how a component was matched.
250///
251/// Included in JSON output to explain why components were correlated.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct MatchInfo {
254    /// Match confidence score (0.0 - 1.0)
255    pub score: f64,
256    /// Matching method used (ExactIdentifier, Alias, Fuzzy, etc.)
257    pub method: String,
258    /// Human-readable explanation
259    pub reason: String,
260    /// Detailed score breakdown (optional)
261    #[serde(skip_serializing_if = "Vec::is_empty")]
262    pub score_breakdown: Vec<MatchScoreComponent>,
263    /// Normalizations applied during matching
264    #[serde(skip_serializing_if = "Vec::is_empty")]
265    pub normalizations: Vec<String>,
266    /// Confidence interval for the match score
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub confidence_interval: Option<ConfidenceInterval>,
269}
270
271/// Confidence interval for match score.
272///
273/// Provides uncertainty bounds around the match score, useful for
274/// understanding match reliability.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct ConfidenceInterval {
277    /// Lower bound of confidence (0.0 - 1.0)
278    pub lower: f64,
279    /// Upper bound of confidence (0.0 - 1.0)
280    pub upper: f64,
281    /// Confidence level (e.g., 0.95 for 95% CI)
282    pub level: f64,
283}
284
285impl ConfidenceInterval {
286    /// Create a new confidence interval.
287    pub fn new(lower: f64, upper: f64, level: f64) -> Self {
288        Self {
289            lower: lower.clamp(0.0, 1.0),
290            upper: upper.clamp(0.0, 1.0),
291            level,
292        }
293    }
294
295    /// Create a 95% confidence interval from a score and standard error.
296    ///
297    /// Uses ±1.96 × SE for 95% CI.
298    pub fn from_score_and_error(score: f64, std_error: f64) -> Self {
299        let margin = 1.96 * std_error;
300        Self::new(score - margin, score + margin, 0.95)
301    }
302
303    /// Create a simple confidence interval based on the matching tier.
304    ///
305    /// Exact matches have tight intervals, fuzzy matches have wider intervals.
306    pub fn from_tier(score: f64, tier: &str) -> Self {
307        let margin = match tier {
308            "ExactIdentifier" => 0.0,
309            "Alias" => 0.02,
310            "EcosystemRule" => 0.03,
311            "CustomRule" => 0.05,
312            "Fuzzy" => 0.08,
313            _ => 0.10,
314        };
315        Self::new(score - margin, score + margin, 0.95)
316    }
317
318    /// Get the width of the interval.
319    pub fn width(&self) -> f64 {
320        self.upper - self.lower
321    }
322}
323
324/// A component of the match score for JSON output.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct MatchScoreComponent {
327    /// Name of this score component
328    pub name: String,
329    /// Weight applied
330    pub weight: f64,
331    /// Raw score
332    pub raw_score: f64,
333    /// Weighted contribution
334    pub weighted_score: f64,
335    /// Description
336    pub description: String,
337}
338
339/// Component change information
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct ComponentChange {
342    /// Component canonical ID (string for serialization)
343    pub id: String,
344    /// Typed canonical ID for navigation (skipped in JSON output for backward compat)
345    #[serde(skip)]
346    pub canonical_id: Option<CanonicalId>,
347    /// Component reference with ID and name together
348    #[serde(skip)]
349    pub component_ref: Option<ComponentRef>,
350    /// Old component ID (for modified components)
351    #[serde(skip)]
352    pub old_canonical_id: Option<CanonicalId>,
353    /// Component name
354    pub name: String,
355    /// Old version (if existed)
356    pub old_version: Option<String>,
357    /// New version (if exists)
358    pub new_version: Option<String>,
359    /// Ecosystem
360    pub ecosystem: Option<String>,
361    /// Change type
362    pub change_type: ChangeType,
363    /// Detailed field changes
364    pub field_changes: Vec<FieldChange>,
365    /// Associated cost
366    pub cost: u32,
367    /// Match information (for modified components, explains how old/new were correlated)
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub match_info: Option<MatchInfo>,
370}
371
372impl ComponentChange {
373    /// Create a new component addition
374    pub fn added(component: &Component, cost: u32) -> Self {
375        Self {
376            id: component.canonical_id.to_string(),
377            canonical_id: Some(component.canonical_id.clone()),
378            component_ref: Some(ComponentRef::from_component(component)),
379            old_canonical_id: None,
380            name: component.name.clone(),
381            old_version: None,
382            new_version: component.version.clone(),
383            ecosystem: component.ecosystem.as_ref().map(|e| e.to_string()),
384            change_type: ChangeType::Added,
385            field_changes: Vec::new(),
386            cost,
387            match_info: None,
388        }
389    }
390
391    /// Create a new component removal
392    pub fn removed(component: &Component, cost: u32) -> Self {
393        Self {
394            id: component.canonical_id.to_string(),
395            canonical_id: Some(component.canonical_id.clone()),
396            component_ref: Some(ComponentRef::from_component(component)),
397            old_canonical_id: Some(component.canonical_id.clone()),
398            name: component.name.clone(),
399            old_version: component.version.clone(),
400            new_version: None,
401            ecosystem: component.ecosystem.as_ref().map(|e| e.to_string()),
402            change_type: ChangeType::Removed,
403            field_changes: Vec::new(),
404            cost,
405            match_info: None,
406        }
407    }
408
409    /// Create a component modification
410    pub fn modified(
411        old: &Component,
412        new: &Component,
413        field_changes: Vec<FieldChange>,
414        cost: u32,
415    ) -> Self {
416        Self {
417            id: new.canonical_id.to_string(),
418            canonical_id: Some(new.canonical_id.clone()),
419            component_ref: Some(ComponentRef::from_component(new)),
420            old_canonical_id: Some(old.canonical_id.clone()),
421            name: new.name.clone(),
422            old_version: old.version.clone(),
423            new_version: new.version.clone(),
424            ecosystem: new.ecosystem.as_ref().map(|e| e.to_string()),
425            change_type: ChangeType::Modified,
426            field_changes,
427            cost,
428            match_info: None,
429        }
430    }
431
432    /// Create a component modification with match explanation
433    pub fn modified_with_match(
434        old: &Component,
435        new: &Component,
436        field_changes: Vec<FieldChange>,
437        cost: u32,
438        match_info: MatchInfo,
439    ) -> Self {
440        Self {
441            id: new.canonical_id.to_string(),
442            canonical_id: Some(new.canonical_id.clone()),
443            component_ref: Some(ComponentRef::from_component(new)),
444            old_canonical_id: Some(old.canonical_id.clone()),
445            name: new.name.clone(),
446            old_version: old.version.clone(),
447            new_version: new.version.clone(),
448            ecosystem: new.ecosystem.as_ref().map(|e| e.to_string()),
449            change_type: ChangeType::Modified,
450            field_changes,
451            cost,
452            match_info: Some(match_info),
453        }
454    }
455
456    /// Add match information to an existing change
457    pub fn with_match_info(mut self, match_info: MatchInfo) -> Self {
458        self.match_info = Some(match_info);
459        self
460    }
461
462    /// Get the typed canonical ID, falling back to parsing from string if needed
463    pub fn get_canonical_id(&self) -> CanonicalId {
464        self.canonical_id
465            .clone()
466            .unwrap_or_else(|| CanonicalId::from_name_version(&self.name, self.new_version.as_deref().or(self.old_version.as_deref())))
467    }
468
469    /// Get a ComponentRef for this change
470    pub fn get_component_ref(&self) -> ComponentRef {
471        self.component_ref.clone().unwrap_or_else(|| {
472            ComponentRef::with_version(
473                self.get_canonical_id(),
474                &self.name,
475                self.new_version.clone().or_else(|| self.old_version.clone()),
476            )
477        })
478    }
479}
480
481impl MatchInfo {
482    /// Create from a MatchExplanation
483    pub fn from_explanation(explanation: &crate::matching::MatchExplanation) -> Self {
484        let method = format!("{:?}", explanation.tier);
485        let ci = ConfidenceInterval::from_tier(explanation.score, &method);
486        Self {
487            score: explanation.score,
488            method,
489            reason: explanation.reason.clone(),
490            score_breakdown: explanation
491                .score_breakdown
492                .iter()
493                .map(|c| MatchScoreComponent {
494                    name: c.name.clone(),
495                    weight: c.weight,
496                    raw_score: c.raw_score,
497                    weighted_score: c.weighted_score,
498                    description: c.description.clone(),
499                })
500                .collect(),
501            normalizations: explanation.normalizations_applied.clone(),
502            confidence_interval: Some(ci),
503        }
504    }
505
506    /// Create a simple match info without detailed breakdown
507    pub fn simple(score: f64, method: &str, reason: &str) -> Self {
508        let ci = ConfidenceInterval::from_tier(score, method);
509        Self {
510            score,
511            method: method.to_string(),
512            reason: reason.to_string(),
513            score_breakdown: Vec::new(),
514            normalizations: Vec::new(),
515            confidence_interval: Some(ci),
516        }
517    }
518
519    /// Create a match info with a custom confidence interval
520    pub fn with_confidence_interval(mut self, ci: ConfidenceInterval) -> Self {
521        self.confidence_interval = Some(ci);
522        self
523    }
524}
525
526/// Type of change
527#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
528pub enum ChangeType {
529    Added,
530    Removed,
531    Modified,
532    Unchanged,
533}
534
535/// Individual field change
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct FieldChange {
538    pub field: String,
539    pub old_value: Option<String>,
540    pub new_value: Option<String>,
541}
542
543/// Dependency change information
544#[derive(Debug, Clone, Serialize, Deserialize)]
545pub struct DependencyChange {
546    /// Source component
547    pub from: String,
548    /// Target component
549    pub to: String,
550    /// Relationship type
551    pub relationship: String,
552    /// Change type
553    pub change_type: ChangeType,
554}
555
556impl DependencyChange {
557    pub fn added(edge: &DependencyEdge) -> Self {
558        Self {
559            from: edge.from.to_string(),
560            to: edge.to.to_string(),
561            relationship: edge.relationship.to_string(),
562            change_type: ChangeType::Added,
563        }
564    }
565
566    pub fn removed(edge: &DependencyEdge) -> Self {
567        Self {
568            from: edge.from.to_string(),
569            to: edge.to.to_string(),
570            relationship: edge.relationship.to_string(),
571            change_type: ChangeType::Removed,
572        }
573    }
574}
575
576/// License change information
577#[derive(Debug, Clone, Default, Serialize, Deserialize)]
578pub struct LicenseChanges {
579    /// Newly introduced licenses
580    pub new_licenses: Vec<LicenseChange>,
581    /// Removed licenses
582    pub removed_licenses: Vec<LicenseChange>,
583    /// License conflicts
584    pub conflicts: Vec<LicenseConflict>,
585    /// Components with license changes
586    pub component_changes: Vec<ComponentLicenseChange>,
587}
588
589/// Individual license change
590#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct LicenseChange {
592    /// License expression
593    pub license: String,
594    /// Components using this license
595    pub components: Vec<String>,
596    /// License family
597    pub family: String,
598}
599
600/// License conflict information
601#[derive(Debug, Clone, Serialize, Deserialize)]
602pub struct LicenseConflict {
603    pub license_a: String,
604    pub license_b: String,
605    pub component: String,
606    pub description: String,
607}
608
609/// Component-level license change
610#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct ComponentLicenseChange {
612    pub component_id: String,
613    pub component_name: String,
614    pub old_licenses: Vec<String>,
615    pub new_licenses: Vec<String>,
616}
617
618/// Vulnerability change information
619#[derive(Debug, Clone, Default, Serialize, Deserialize)]
620pub struct VulnerabilityChanges {
621    /// Newly introduced vulnerabilities
622    pub introduced: Vec<VulnerabilityDetail>,
623    /// Resolved vulnerabilities
624    pub resolved: Vec<VulnerabilityDetail>,
625    /// Persistent vulnerabilities (present in both)
626    pub persistent: Vec<VulnerabilityDetail>,
627}
628
629impl VulnerabilityChanges {
630    /// Count vulnerabilities by severity
631    pub fn introduced_by_severity(&self) -> HashMap<String, usize> {
632        // Pre-allocate for typical severity levels (critical, high, medium, low, unknown)
633        let mut counts = HashMap::with_capacity(5);
634        for vuln in &self.introduced {
635            *counts.entry(vuln.severity.clone()).or_insert(0) += 1;
636        }
637        counts
638    }
639
640    /// Get critical and high severity introduced vulnerabilities
641    pub fn critical_and_high_introduced(&self) -> Vec<&VulnerabilityDetail> {
642        self.introduced
643            .iter()
644            .filter(|v| v.severity == "Critical" || v.severity == "High")
645            .collect()
646    }
647}
648
649/// SLA status for vulnerability remediation tracking
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
651pub enum SlaStatus {
652    /// Past SLA deadline by N days
653    Overdue(i64),
654    /// Due within 3 days (N days remaining)
655    DueSoon(i64),
656    /// Within SLA window (N days remaining)
657    OnTrack(i64),
658    /// No SLA deadline applicable
659    NoDueDate,
660}
661
662impl SlaStatus {
663    /// Format for display (e.g., "3d late", "2d left", "45d old")
664    pub fn display(&self, days_since_published: Option<i64>) -> String {
665        match self {
666            SlaStatus::Overdue(days) => format!("{}d late", days),
667            SlaStatus::DueSoon(days) => format!("{}d left", days),
668            SlaStatus::OnTrack(days) => format!("{}d left", days),
669            SlaStatus::NoDueDate => {
670                if let Some(age) = days_since_published {
671                    format!("{}d old", age)
672                } else {
673                    "-".to_string()
674                }
675            }
676        }
677    }
678
679    /// Check if this is an overdue status
680    pub fn is_overdue(&self) -> bool {
681        matches!(self, SlaStatus::Overdue(_))
682    }
683
684    /// Check if this is due soon (approaching deadline)
685    pub fn is_due_soon(&self) -> bool {
686        matches!(self, SlaStatus::DueSoon(_))
687    }
688}
689
690/// Detailed vulnerability information
691#[derive(Debug, Clone, Serialize, Deserialize)]
692pub struct VulnerabilityDetail {
693    /// Vulnerability ID
694    pub id: String,
695    /// Source database
696    pub source: String,
697    /// Severity level
698    pub severity: String,
699    /// CVSS score
700    pub cvss_score: Option<f32>,
701    /// Affected component ID (string for serialization)
702    pub component_id: String,
703    /// Typed canonical ID for the component (skipped in JSON for backward compat)
704    #[serde(skip)]
705    pub component_canonical_id: Option<CanonicalId>,
706    /// Component reference with ID and name together
707    #[serde(skip)]
708    pub component_ref: Option<ComponentRef>,
709    /// Affected component name
710    pub component_name: String,
711    /// Affected version
712    pub version: Option<String>,
713    /// CWE identifiers
714    pub cwes: Vec<String>,
715    /// Description
716    pub description: Option<String>,
717    /// Remediation info
718    pub remediation: Option<String>,
719    /// Whether this vulnerability is in CISA's Known Exploited Vulnerabilities catalog
720    #[serde(default)]
721    pub is_kev: bool,
722    /// Dependency depth (1 = direct, 2+ = transitive, None = unknown)
723    #[serde(default)]
724    pub component_depth: Option<u32>,
725    /// Date vulnerability was published (ISO 8601)
726    #[serde(default)]
727    pub published_date: Option<String>,
728    /// KEV due date (CISA mandated remediation deadline)
729    #[serde(default)]
730    pub kev_due_date: Option<String>,
731    /// Days since published (positive = past)
732    #[serde(default)]
733    pub days_since_published: Option<i64>,
734    /// Days until KEV due date (negative = overdue)
735    #[serde(default)]
736    pub days_until_due: Option<i64>,
737    /// VEX state for this vulnerability's component (if available)
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub vex_state: Option<crate::model::VexState>,
740}
741
742impl VulnerabilityDetail {
743    /// Whether this vulnerability is VEX-actionable (not resolved by vendor analysis).
744    ///
745    /// Returns `true` if the VEX state is `Affected`, `UnderInvestigation`, or absent.
746    /// Returns `false` if the VEX state is `NotAffected` or `Fixed`.
747    pub fn is_vex_actionable(&self) -> bool {
748        !matches!(
749            self.vex_state,
750            Some(crate::model::VexState::NotAffected) | Some(crate::model::VexState::Fixed)
751        )
752    }
753
754    /// Create from a vulnerability reference and component
755    pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
756        // Calculate days since published (published is DateTime<Utc>)
757        let days_since_published = vuln.published.map(|dt| {
758            let today = chrono::Utc::now().date_naive();
759            (today - dt.date_naive()).num_days()
760        });
761
762        // Format published date as string for serialization
763        let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
764
765        // Get KEV info if present
766        let (kev_due_date, days_until_due) = if let Some(kev) = &vuln.kev_info {
767            (
768                Some(kev.due_date.format("%Y-%m-%d").to_string()),
769                Some(kev.days_until_due()),
770            )
771        } else {
772            (None, None)
773        };
774
775        Self {
776            id: vuln.id.clone(),
777            source: vuln.source.to_string(),
778            severity: vuln
779                .severity
780                .as_ref()
781                .map(|s| s.to_string())
782                .unwrap_or_else(|| "Unknown".to_string()),
783            cvss_score: vuln.max_cvss_score(),
784            component_id: component.canonical_id.to_string(),
785            component_canonical_id: Some(component.canonical_id.clone()),
786            component_ref: Some(ComponentRef::from_component(component)),
787            component_name: component.name.clone(),
788            version: component.version.clone(),
789            cwes: vuln.cwes.clone(),
790            description: vuln.description.clone(),
791            remediation: vuln.remediation.as_ref().map(|r| {
792                format!(
793                    "{}: {}",
794                    r.remediation_type,
795                    r.description.as_deref().unwrap_or("")
796                )
797            }),
798            is_kev: vuln.is_kev,
799            component_depth: None,
800            published_date,
801            kev_due_date,
802            days_since_published,
803            days_until_due,
804            vex_state: component.vex_status.as_ref().map(|v| v.status.clone()),
805        }
806    }
807
808    /// Create from a vulnerability reference and component with known depth
809    pub fn from_ref_with_depth(
810        vuln: &VulnerabilityRef,
811        component: &Component,
812        depth: Option<u32>,
813    ) -> Self {
814        let mut detail = Self::from_ref(vuln, component);
815        detail.component_depth = depth;
816        detail
817    }
818
819    /// Calculate SLA status based on KEV due date or severity-based policy
820    ///
821    /// Priority order:
822    /// 1. KEV due date (CISA mandated deadline)
823    /// 2. Severity-based SLA (Critical=1d, High=7d, Medium=30d, Low=90d)
824    pub fn sla_status(&self) -> SlaStatus {
825        // KEV due date takes priority
826        if let Some(days) = self.days_until_due {
827            if days < 0 {
828                return SlaStatus::Overdue(-days);
829            } else if days <= 3 {
830                return SlaStatus::DueSoon(days);
831            } else {
832                return SlaStatus::OnTrack(days);
833            }
834        }
835
836        // Fall back to severity-based SLA
837        if let Some(age_days) = self.days_since_published {
838            let sla_days = match self.severity.to_lowercase().as_str() {
839                "critical" => 1,
840                "high" => 7,
841                "medium" => 30,
842                "low" => 90,
843                _ => return SlaStatus::NoDueDate,
844            };
845            let remaining = sla_days - age_days;
846            if remaining < 0 {
847                return SlaStatus::Overdue(-remaining);
848            } else if remaining <= 3 {
849                return SlaStatus::DueSoon(remaining);
850            } else {
851                return SlaStatus::OnTrack(remaining);
852            }
853        }
854
855        SlaStatus::NoDueDate
856    }
857
858    /// Get the typed component canonical ID
859    pub fn get_component_id(&self) -> CanonicalId {
860        self.component_canonical_id
861            .clone()
862            .unwrap_or_else(|| CanonicalId::from_name_version(&self.component_name, self.version.as_deref()))
863    }
864
865    /// Get a ComponentRef for the affected component
866    pub fn get_component_ref(&self) -> ComponentRef {
867        self.component_ref.clone().unwrap_or_else(|| {
868            ComponentRef::with_version(
869                self.get_component_id(),
870                &self.component_name,
871                self.version.clone(),
872            )
873        })
874    }
875}
876
877// ============================================================================
878// Graph-Aware Diffing Types
879// ============================================================================
880
881/// Represents a structural change in the dependency graph
882#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
883pub struct DependencyGraphChange {
884    /// The component involved in the change
885    pub component_id: CanonicalId,
886    /// Human-readable component name
887    pub component_name: String,
888    /// The type of structural change
889    pub change: DependencyChangeType,
890    /// Assessed impact of this change
891    pub impact: GraphChangeImpact,
892}
893
894/// Types of dependency graph structural changes
895#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
896pub enum DependencyChangeType {
897    /// A new dependency link was added
898    DependencyAdded {
899        dependency_id: CanonicalId,
900        dependency_name: String,
901    },
902
903    /// A dependency link was removed
904    DependencyRemoved {
905        dependency_id: CanonicalId,
906        dependency_name: String,
907    },
908
909    /// A dependency was reparented (had exactly one parent in both, but different)
910    Reparented {
911        dependency_id: CanonicalId,
912        dependency_name: String,
913        old_parent_id: CanonicalId,
914        old_parent_name: String,
915        new_parent_id: CanonicalId,
916        new_parent_name: String,
917    },
918
919    /// Dependency depth changed (e.g., transitive became direct)
920    DepthChanged {
921        old_depth: u32, // 1 = direct, 2+ = transitive
922        new_depth: u32,
923    },
924}
925
926impl DependencyChangeType {
927    /// Get a short description of the change type
928    pub fn kind(&self) -> &'static str {
929        match self {
930            Self::DependencyAdded { .. } => "added",
931            Self::DependencyRemoved { .. } => "removed",
932            Self::Reparented { .. } => "reparented",
933            Self::DepthChanged { .. } => "depth_changed",
934        }
935    }
936}
937
938/// Impact level of a graph change
939#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
940pub enum GraphChangeImpact {
941    /// Internal reorganization, no functional change
942    Low,
943    /// Depth or type change, may affect build/runtime
944    Medium,
945    /// Security-relevant component relationship changed
946    High,
947    /// Vulnerable component promoted to direct dependency
948    Critical,
949}
950
951impl GraphChangeImpact {
952    pub fn as_str(&self) -> &'static str {
953        match self {
954            Self::Low => "low",
955            Self::Medium => "medium",
956            Self::High => "high",
957            Self::Critical => "critical",
958        }
959    }
960
961    /// Parse from a string label. Returns Low for unrecognized values.
962    pub fn from_label(s: &str) -> Self {
963        match s.to_lowercase().as_str() {
964            "critical" => Self::Critical,
965            "high" => Self::High,
966            "medium" => Self::Medium,
967            _ => Self::Low,
968        }
969    }
970}
971
972impl std::fmt::Display for GraphChangeImpact {
973    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
974        write!(f, "{}", self.as_str())
975    }
976}
977
978/// Summary statistics for graph changes
979#[derive(Debug, Clone, Default, Serialize, Deserialize)]
980pub struct GraphChangeSummary {
981    pub total_changes: usize,
982    pub dependencies_added: usize,
983    pub dependencies_removed: usize,
984    pub reparented: usize,
985    pub depth_changed: usize,
986    pub by_impact: GraphChangesByImpact,
987}
988
989impl GraphChangeSummary {
990    /// Build summary from a list of changes
991    pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
992        let mut summary = Self {
993            total_changes: changes.len(),
994            ..Default::default()
995        };
996
997        for change in changes {
998            match &change.change {
999                DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
1000                DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
1001                DependencyChangeType::Reparented { .. } => summary.reparented += 1,
1002                DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
1003            }
1004
1005            match change.impact {
1006                GraphChangeImpact::Low => summary.by_impact.low += 1,
1007                GraphChangeImpact::Medium => summary.by_impact.medium += 1,
1008                GraphChangeImpact::High => summary.by_impact.high += 1,
1009                GraphChangeImpact::Critical => summary.by_impact.critical += 1,
1010            }
1011        }
1012
1013        summary
1014    }
1015}
1016
1017#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1018pub struct GraphChangesByImpact {
1019    pub low: usize,
1020    pub medium: usize,
1021    pub high: usize,
1022    pub critical: usize,
1023}