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