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