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}
756
757impl VulnerabilityDetail {
758    /// Whether this vulnerability is VEX-actionable (not resolved by vendor analysis).
759    ///
760    /// Returns `true` if the VEX state is `Affected`, `UnderInvestigation`, or absent.
761    /// Returns `false` if the VEX state is `NotAffected` or `Fixed`.
762    #[must_use] 
763    pub const fn is_vex_actionable(&self) -> bool {
764        !matches!(
765            self.vex_state,
766            Some(crate::model::VexState::NotAffected | crate::model::VexState::Fixed)
767        )
768    }
769
770    /// Create from a vulnerability reference and component
771    pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
772        // Calculate days since published (published is DateTime<Utc>)
773        let days_since_published = vuln.published.map(|dt| {
774            let today = chrono::Utc::now().date_naive();
775            (today - dt.date_naive()).num_days()
776        });
777
778        // Format published date as string for serialization
779        let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
780
781        // Get KEV info if present
782        let (kev_due_date, days_until_due) = vuln.kev_info.as_ref().map_or(
783            (None, None),
784            |kev| (
785                Some(kev.due_date.format("%Y-%m-%d").to_string()),
786                Some(kev.days_until_due()),
787            ),
788        );
789
790        Self {
791            id: vuln.id.clone(),
792            source: vuln.source.to_string(),
793            severity: vuln
794                .severity
795                .as_ref().map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string),
796            cvss_score: vuln.max_cvss_score(),
797            component_id: component.canonical_id.to_string(),
798            component_canonical_id: Some(component.canonical_id.clone()),
799            component_ref: Some(ComponentRef::from_component(component)),
800            component_name: component.name.clone(),
801            version: component.version.clone(),
802            cwes: vuln.cwes.clone(),
803            description: vuln.description.clone(),
804            remediation: vuln.remediation.as_ref().map(|r| {
805                format!(
806                    "{}: {}",
807                    r.remediation_type,
808                    r.description.as_deref().unwrap_or("")
809                )
810            }),
811            is_kev: vuln.is_kev,
812            component_depth: None,
813            published_date,
814            kev_due_date,
815            days_since_published,
816            days_until_due,
817            vex_state: component.vex_status.as_ref().map(|v| v.status.clone()),
818        }
819    }
820
821    /// Create from a vulnerability reference and component with known depth
822    #[must_use] 
823    pub fn from_ref_with_depth(
824        vuln: &VulnerabilityRef,
825        component: &Component,
826        depth: Option<u32>,
827    ) -> Self {
828        let mut detail = Self::from_ref(vuln, component);
829        detail.component_depth = depth;
830        detail
831    }
832
833    /// Calculate SLA status based on KEV due date or severity-based policy
834    ///
835    /// Priority order:
836    /// 1. KEV due date (CISA mandated deadline)
837    /// 2. Severity-based SLA (Critical=1d, High=7d, Medium=30d, Low=90d)
838    #[must_use] 
839    pub fn sla_status(&self) -> SlaStatus {
840        // KEV due date takes priority
841        if let Some(days) = self.days_until_due {
842            if days < 0 {
843                return SlaStatus::Overdue(-days);
844            } else if days <= 3 {
845                return SlaStatus::DueSoon(days);
846            }
847            return SlaStatus::OnTrack(days);
848        }
849
850        // Fall back to severity-based SLA
851        if let Some(age_days) = self.days_since_published {
852            let sla_days = match self.severity.to_lowercase().as_str() {
853                "critical" => 1,
854                "high" => 7,
855                "medium" => 30,
856                "low" => 90,
857                _ => return SlaStatus::NoDueDate,
858            };
859            let remaining = sla_days - age_days;
860            if remaining < 0 {
861                return SlaStatus::Overdue(-remaining);
862            } else if remaining <= 3 {
863                return SlaStatus::DueSoon(remaining);
864            }
865            return SlaStatus::OnTrack(remaining);
866        }
867
868        SlaStatus::NoDueDate
869    }
870
871    /// Get the typed component canonical ID
872    #[must_use] 
873    pub fn get_component_id(&self) -> CanonicalId {
874        self.component_canonical_id
875            .clone()
876            .unwrap_or_else(|| CanonicalId::from_name_version(&self.component_name, self.version.as_deref()))
877    }
878
879    /// Get a `ComponentRef` for the affected component
880    #[must_use] 
881    pub fn get_component_ref(&self) -> ComponentRef {
882        self.component_ref.clone().unwrap_or_else(|| {
883            ComponentRef::with_version(
884                self.get_component_id(),
885                &self.component_name,
886                self.version.clone(),
887            )
888        })
889    }
890}
891
892// ============================================================================
893// Graph-Aware Diffing Types
894// ============================================================================
895
896/// Represents a structural change in the dependency graph
897#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
898pub struct DependencyGraphChange {
899    /// The component involved in the change
900    pub component_id: CanonicalId,
901    /// Human-readable component name
902    pub component_name: String,
903    /// The type of structural change
904    pub change: DependencyChangeType,
905    /// Assessed impact of this change
906    pub impact: GraphChangeImpact,
907}
908
909/// Types of dependency graph structural changes
910#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
911pub enum DependencyChangeType {
912    /// A new dependency link was added
913    DependencyAdded {
914        dependency_id: CanonicalId,
915        dependency_name: String,
916    },
917
918    /// A dependency link was removed
919    DependencyRemoved {
920        dependency_id: CanonicalId,
921        dependency_name: String,
922    },
923
924    /// A dependency was reparented (had exactly one parent in both, but different)
925    Reparented {
926        dependency_id: CanonicalId,
927        dependency_name: String,
928        old_parent_id: CanonicalId,
929        old_parent_name: String,
930        new_parent_id: CanonicalId,
931        new_parent_name: String,
932    },
933
934    /// Dependency depth changed (e.g., transitive became direct)
935    DepthChanged {
936        old_depth: u32, // 1 = direct, 2+ = transitive
937        new_depth: u32,
938    },
939}
940
941impl DependencyChangeType {
942    /// Get a short description of the change type
943    #[must_use] 
944    pub const fn kind(&self) -> &'static str {
945        match self {
946            Self::DependencyAdded { .. } => "added",
947            Self::DependencyRemoved { .. } => "removed",
948            Self::Reparented { .. } => "reparented",
949            Self::DepthChanged { .. } => "depth_changed",
950        }
951    }
952}
953
954/// Impact level of a graph change
955#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
956pub enum GraphChangeImpact {
957    /// Internal reorganization, no functional change
958    Low,
959    /// Depth or type change, may affect build/runtime
960    Medium,
961    /// Security-relevant component relationship changed
962    High,
963    /// Vulnerable component promoted to direct dependency
964    Critical,
965}
966
967impl GraphChangeImpact {
968    #[must_use] 
969    pub const fn as_str(&self) -> &'static str {
970        match self {
971            Self::Low => "low",
972            Self::Medium => "medium",
973            Self::High => "high",
974            Self::Critical => "critical",
975        }
976    }
977
978    /// Parse from a string label. Returns Low for unrecognized values.
979    #[must_use] 
980    pub fn from_label(s: &str) -> Self {
981        match s.to_lowercase().as_str() {
982            "critical" => Self::Critical,
983            "high" => Self::High,
984            "medium" => Self::Medium,
985            _ => Self::Low,
986        }
987    }
988}
989
990impl std::fmt::Display for GraphChangeImpact {
991    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
992        write!(f, "{}", self.as_str())
993    }
994}
995
996/// Summary statistics for graph changes
997#[derive(Debug, Clone, Default, Serialize, Deserialize)]
998pub struct GraphChangeSummary {
999    pub total_changes: usize,
1000    pub dependencies_added: usize,
1001    pub dependencies_removed: usize,
1002    pub reparented: usize,
1003    pub depth_changed: usize,
1004    pub by_impact: GraphChangesByImpact,
1005}
1006
1007impl GraphChangeSummary {
1008    /// Build summary from a list of changes
1009    #[must_use] 
1010    pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
1011        let mut summary = Self {
1012            total_changes: changes.len(),
1013            ..Default::default()
1014        };
1015
1016        for change in changes {
1017            match &change.change {
1018                DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
1019                DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
1020                DependencyChangeType::Reparented { .. } => summary.reparented += 1,
1021                DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
1022            }
1023
1024            match change.impact {
1025                GraphChangeImpact::Low => summary.by_impact.low += 1,
1026                GraphChangeImpact::Medium => summary.by_impact.medium += 1,
1027                GraphChangeImpact::High => summary.by_impact.high += 1,
1028                GraphChangeImpact::Critical => summary.by_impact.critical += 1,
1029            }
1030        }
1031
1032        summary
1033    }
1034}
1035
1036#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1037pub struct GraphChangesByImpact {
1038    pub low: usize,
1039    pub medium: usize,
1040    pub high: usize,
1041    pub critical: usize,
1042}