1use crate::model::{CanonicalId, Component, ComponentRef, DependencyEdge, VulnerabilityRef};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
23#[must_use]
24pub struct DiffResult {
25 pub summary: DiffSummary,
27 pub components: ChangeSet<ComponentChange>,
29 pub dependencies: ChangeSet<DependencyChange>,
31 pub licenses: LicenseChanges,
33 pub vulnerabilities: VulnerabilityChanges,
35 pub semantic_score: f64,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub metadata_changes: Vec<MetadataChange>,
41 #[serde(default)]
43 pub graph_changes: Vec<DependencyGraphChange>,
44 #[serde(default)]
46 pub graph_summary: Option<GraphChangeSummary>,
47 #[serde(default)]
49 pub rules_applied: usize,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub quality_delta: Option<QualityDelta>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub match_metrics: Option<MatchMetrics>,
56}
57
58impl DiffResult {
59 pub fn new() -> Self {
61 Self {
62 summary: DiffSummary::default(),
63 components: ChangeSet::new(),
64 dependencies: ChangeSet::new(),
65 licenses: LicenseChanges::default(),
66 vulnerabilities: VulnerabilityChanges::default(),
67 semantic_score: 0.0,
68 metadata_changes: Vec::new(),
69 graph_changes: Vec::new(),
70 graph_summary: None,
71 rules_applied: 0,
72 quality_delta: None,
73 match_metrics: None,
74 }
75 }
76
77 pub fn calculate_summary(&mut self) {
79 self.summary.components_added = self.components.added.len();
80 self.summary.components_removed = self.components.removed.len();
81 self.summary.components_modified = self.components.modified.len();
82
83 self.summary.dependencies_added = self.dependencies.added.len();
84 self.summary.dependencies_removed = self.dependencies.removed.len();
85 self.summary.graph_changes_count = self.graph_changes.len();
86 self.summary.metadata_changes_count = self.metadata_changes.len();
87
88 self.summary.total_changes = self.summary.components_added
89 + self.summary.components_removed
90 + self.summary.components_modified
91 + self.summary.dependencies_added
92 + self.summary.dependencies_removed
93 + self.summary.graph_changes_count
94 + self.summary.metadata_changes_count;
95
96 self.summary.vulnerabilities_introduced = self.vulnerabilities.introduced.len();
97 self.summary.vulnerabilities_resolved = self.vulnerabilities.resolved.len();
98 self.summary.vulnerabilities_persistent = self.vulnerabilities.persistent.len();
99
100 self.summary.licenses_added = self.licenses.new_licenses.len();
101 self.summary.licenses_removed = self.licenses.removed_licenses.len();
102 }
103
104 #[must_use]
109 pub fn has_changes(&self) -> bool {
110 self.summary.total_changes > 0
111 || !self.components.is_empty()
112 || !self.dependencies.is_empty()
113 || !self.graph_changes.is_empty()
114 || !self.metadata_changes.is_empty()
115 || !self.vulnerabilities.introduced.is_empty()
116 || !self.vulnerabilities.resolved.is_empty()
117 }
118
119 #[must_use]
121 pub fn find_component_by_id(&self, id: &CanonicalId) -> Option<&ComponentChange> {
122 let id_str = id.value();
123 self.components
124 .added
125 .iter()
126 .chain(self.components.removed.iter())
127 .chain(self.components.modified.iter())
128 .find(|c| c.id == id_str)
129 }
130
131 #[must_use]
133 pub fn find_component_by_id_str(&self, id_str: &str) -> Option<&ComponentChange> {
134 self.components
135 .added
136 .iter()
137 .chain(self.components.removed.iter())
138 .chain(self.components.modified.iter())
139 .find(|c| c.id == id_str)
140 }
141
142 #[must_use]
144 pub fn all_component_changes(&self) -> Vec<&ComponentChange> {
145 self.components
146 .added
147 .iter()
148 .chain(self.components.removed.iter())
149 .chain(self.components.modified.iter())
150 .collect()
151 }
152
153 #[must_use]
155 pub fn find_vulns_for_component(
156 &self,
157 component_id: &CanonicalId,
158 ) -> Vec<&VulnerabilityDetail> {
159 let id_str = component_id.value();
160 self.vulnerabilities
161 .introduced
162 .iter()
163 .chain(self.vulnerabilities.resolved.iter())
164 .chain(self.vulnerabilities.persistent.iter())
165 .filter(|v| v.component_id == id_str)
166 .collect()
167 }
168
169 #[must_use]
171 pub fn build_component_id_index(&self) -> HashMap<String, &ComponentChange> {
172 self.components
173 .added
174 .iter()
175 .chain(&self.components.removed)
176 .chain(&self.components.modified)
177 .map(|c| (c.id.clone(), c))
178 .collect()
179 }
180
181 pub fn filter_by_severity(&mut self, min_severity: &str) {
183 let min_sev = severity_rank(min_severity);
184
185 self.vulnerabilities
186 .introduced
187 .retain(|v| severity_rank(&v.severity) >= min_sev);
188 self.vulnerabilities
189 .resolved
190 .retain(|v| severity_rank(&v.severity) >= min_sev);
191 self.vulnerabilities
192 .persistent
193 .retain(|v| severity_rank(&v.severity) >= min_sev);
194
195 self.calculate_summary();
197 }
198
199 pub fn filter_by_vex(&mut self) {
203 self.vulnerabilities
204 .introduced
205 .retain(VulnerabilityDetail::is_vex_actionable);
206 self.vulnerabilities
207 .resolved
208 .retain(VulnerabilityDetail::is_vex_actionable);
209 self.vulnerabilities
210 .persistent
211 .retain(VulnerabilityDetail::is_vex_actionable);
212
213 self.calculate_summary();
214 }
215}
216
217impl Default for DiffResult {
218 fn default() -> Self {
219 Self::new()
220 }
221}
222
223#[derive(Debug, Clone, Default, Serialize, Deserialize)]
228pub struct QualityDelta {
229 pub overall_score_delta: f32,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub old_grade: Option<crate::quality::QualityGrade>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub new_grade: Option<crate::quality::QualityGrade>,
237 pub category_deltas: Vec<CategoryDelta>,
239 pub regressions: Vec<String>,
241 pub improvements: Vec<String>,
243 pub violation_count_delta: i32,
245}
246
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249pub struct CategoryDelta {
250 pub category: String,
252 pub old_score: f32,
254 pub new_score: f32,
256 pub delta: f32,
258}
259
260impl QualityDelta {
261 #[must_use]
263 pub fn from_reports(
264 old: &crate::quality::QualityReport,
265 new: &crate::quality::QualityReport,
266 ) -> Self {
267 let categories = [
268 (
269 "Completeness",
270 old.completeness_score,
271 new.completeness_score,
272 ),
273 ("Identifiers", old.identifier_score, new.identifier_score),
274 ("Licenses", old.license_score, new.license_score),
275 ("Dependencies", old.dependency_score, new.dependency_score),
276 ("Integrity", old.integrity_score, new.integrity_score),
277 ("Provenance", old.provenance_score, new.provenance_score),
278 ];
279
280 let mut category_deltas: Vec<CategoryDelta> = categories
281 .iter()
282 .map(|(name, old_s, new_s)| CategoryDelta {
283 category: (*name).to_string(),
284 old_score: *old_s,
285 new_score: *new_s,
286 delta: new_s - old_s,
287 })
288 .collect();
289
290 if let (Some(old_v), Some(new_v)) = (old.vulnerability_score, new.vulnerability_score) {
292 category_deltas.push(CategoryDelta {
293 category: "VulnDocs".to_string(),
294 old_score: old_v,
295 new_score: new_v,
296 delta: new_v - old_v,
297 });
298 }
299 if let (Some(old_l), Some(new_l)) = (old.lifecycle_score, new.lifecycle_score) {
300 category_deltas.push(CategoryDelta {
301 category: "Lifecycle".to_string(),
302 old_score: old_l,
303 new_score: new_l,
304 delta: new_l - old_l,
305 });
306 }
307
308 let regressions: Vec<String> = category_deltas
309 .iter()
310 .filter(|d| d.delta < -1.0)
311 .map(|d| d.category.clone())
312 .collect();
313
314 let improvements: Vec<String> = category_deltas
315 .iter()
316 .filter(|d| d.delta > 1.0)
317 .map(|d| d.category.clone())
318 .collect();
319
320 let old_violations = old.compliance.error_count + old.compliance.warning_count;
322 let new_violations = new.compliance.error_count + new.compliance.warning_count;
323
324 Self {
325 overall_score_delta: new.overall_score - old.overall_score,
326 old_grade: Some(old.grade),
327 new_grade: Some(new.grade),
328 category_deltas,
329 regressions,
330 improvements,
331 violation_count_delta: new_violations as i32 - old_violations as i32,
332 }
333 }
334}
335
336#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
340pub struct MatchMetrics {
341 pub exact_matches: usize,
343 pub fuzzy_matches: usize,
345 pub rule_matches: usize,
347 pub unmatched_old: usize,
349 pub unmatched_new: usize,
351 pub avg_match_score: f64,
353 pub min_match_score: f64,
355}
356
357#[derive(Debug, Clone, Default, Serialize, Deserialize)]
359pub struct DiffSummary {
360 pub total_changes: usize,
361 pub components_added: usize,
362 pub components_removed: usize,
363 pub components_modified: usize,
364 pub dependencies_added: usize,
365 pub dependencies_removed: usize,
366 pub graph_changes_count: usize,
367 pub metadata_changes_count: usize,
368 pub vulnerabilities_introduced: usize,
369 pub vulnerabilities_resolved: usize,
370 pub vulnerabilities_persistent: usize,
371 pub licenses_added: usize,
372 pub licenses_removed: usize,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize)]
377pub struct ChangeSet<T> {
378 pub added: Vec<T>,
379 pub removed: Vec<T>,
380 pub modified: Vec<T>,
381}
382
383impl<T> ChangeSet<T> {
384 #[must_use]
385 pub const fn new() -> Self {
386 Self {
387 added: Vec::new(),
388 removed: Vec::new(),
389 modified: Vec::new(),
390 }
391 }
392
393 #[must_use]
394 pub fn is_empty(&self) -> bool {
395 self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
396 }
397
398 #[must_use]
399 pub fn total(&self) -> usize {
400 self.added.len() + self.removed.len() + self.modified.len()
401 }
402}
403
404impl<T> Default for ChangeSet<T> {
405 fn default() -> Self {
406 Self::new()
407 }
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct MatchInfo {
415 pub score: f64,
417 pub method: String,
419 pub reason: String,
421 #[serde(skip_serializing_if = "Vec::is_empty")]
423 pub score_breakdown: Vec<MatchScoreComponent>,
424 #[serde(skip_serializing_if = "Vec::is_empty")]
426 pub normalizations: Vec<String>,
427 #[serde(skip_serializing_if = "Option::is_none")]
429 pub confidence_interval: Option<ConfidenceInterval>,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct ConfidenceInterval {
438 pub lower: f64,
440 pub upper: f64,
442 pub level: f64,
444}
445
446impl ConfidenceInterval {
447 #[must_use]
449 pub const fn new(lower: f64, upper: f64, level: f64) -> Self {
450 Self {
451 lower: lower.clamp(0.0, 1.0),
452 upper: upper.clamp(0.0, 1.0),
453 level,
454 }
455 }
456
457 #[must_use]
461 pub fn from_score_and_error(score: f64, std_error: f64) -> Self {
462 let margin = 1.96 * std_error;
463 Self::new(score - margin, score + margin, 0.95)
464 }
465
466 #[must_use]
470 pub fn from_tier(score: f64, tier: &str) -> Self {
471 let margin = match tier {
472 "ExactIdentifier" => 0.0,
473 "Alias" => 0.02,
474 "EcosystemRule" => 0.03,
475 "CustomRule" => 0.05,
476 "Fuzzy" => 0.08,
477 _ => 0.10,
478 };
479 Self::new(score - margin, score + margin, 0.95)
480 }
481
482 #[must_use]
484 pub fn width(&self) -> f64 {
485 self.upper - self.lower
486 }
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct MatchScoreComponent {
492 pub name: String,
494 pub weight: f64,
496 pub raw_score: f64,
498 pub weighted_score: f64,
500 pub description: String,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct ComponentChange {
507 pub id: String,
509 #[serde(skip)]
511 pub canonical_id: Option<CanonicalId>,
512 #[serde(skip)]
514 pub component_ref: Option<ComponentRef>,
515 #[serde(skip)]
517 pub old_canonical_id: Option<CanonicalId>,
518 pub name: String,
520 pub old_version: Option<String>,
522 pub new_version: Option<String>,
524 pub ecosystem: Option<String>,
526 pub change_type: ChangeType,
528 pub field_changes: Vec<FieldChange>,
530 pub cost: u32,
532 #[serde(skip_serializing_if = "Option::is_none")]
534 pub match_info: Option<MatchInfo>,
535}
536
537impl ComponentChange {
538 pub fn added(component: &Component, cost: u32) -> Self {
540 Self {
541 id: component.canonical_id.to_string(),
542 canonical_id: Some(component.canonical_id.clone()),
543 component_ref: Some(ComponentRef::from_component(component)),
544 old_canonical_id: None,
545 name: component.name.clone(),
546 old_version: None,
547 new_version: component.version.clone(),
548 ecosystem: component
549 .ecosystem
550 .as_ref()
551 .map(std::string::ToString::to_string),
552 change_type: ChangeType::Added,
553 field_changes: Vec::new(),
554 cost,
555 match_info: None,
556 }
557 }
558
559 pub fn removed(component: &Component, cost: u32) -> Self {
561 Self {
562 id: component.canonical_id.to_string(),
563 canonical_id: Some(component.canonical_id.clone()),
564 component_ref: Some(ComponentRef::from_component(component)),
565 old_canonical_id: Some(component.canonical_id.clone()),
566 name: component.name.clone(),
567 old_version: component.version.clone(),
568 new_version: None,
569 ecosystem: component
570 .ecosystem
571 .as_ref()
572 .map(std::string::ToString::to_string),
573 change_type: ChangeType::Removed,
574 field_changes: Vec::new(),
575 cost,
576 match_info: None,
577 }
578 }
579
580 pub fn modified(
582 old: &Component,
583 new: &Component,
584 field_changes: Vec<FieldChange>,
585 cost: u32,
586 ) -> Self {
587 Self {
588 id: new.canonical_id.to_string(),
589 canonical_id: Some(new.canonical_id.clone()),
590 component_ref: Some(ComponentRef::from_component(new)),
591 old_canonical_id: Some(old.canonical_id.clone()),
592 name: new.name.clone(),
593 old_version: old.version.clone(),
594 new_version: new.version.clone(),
595 ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
596 change_type: ChangeType::Modified,
597 field_changes,
598 cost,
599 match_info: None,
600 }
601 }
602
603 pub fn modified_with_match(
605 old: &Component,
606 new: &Component,
607 field_changes: Vec<FieldChange>,
608 cost: u32,
609 match_info: MatchInfo,
610 ) -> Self {
611 Self {
612 id: new.canonical_id.to_string(),
613 canonical_id: Some(new.canonical_id.clone()),
614 component_ref: Some(ComponentRef::from_component(new)),
615 old_canonical_id: Some(old.canonical_id.clone()),
616 name: new.name.clone(),
617 old_version: old.version.clone(),
618 new_version: new.version.clone(),
619 ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
620 change_type: ChangeType::Modified,
621 field_changes,
622 cost,
623 match_info: Some(match_info),
624 }
625 }
626
627 #[must_use]
629 pub fn with_match_info(mut self, match_info: MatchInfo) -> Self {
630 self.match_info = Some(match_info);
631 self
632 }
633
634 #[must_use]
636 pub fn get_canonical_id(&self) -> CanonicalId {
637 self.canonical_id.clone().unwrap_or_else(|| {
638 CanonicalId::from_name_version(
639 &self.name,
640 self.new_version.as_deref().or(self.old_version.as_deref()),
641 )
642 })
643 }
644
645 #[must_use]
647 pub fn get_component_ref(&self) -> ComponentRef {
648 self.component_ref.clone().unwrap_or_else(|| {
649 ComponentRef::with_version(
650 self.get_canonical_id(),
651 &self.name,
652 self.new_version
653 .clone()
654 .or_else(|| self.old_version.clone()),
655 )
656 })
657 }
658}
659
660impl MatchInfo {
661 #[must_use]
663 pub fn from_explanation(explanation: &crate::matching::MatchExplanation) -> Self {
664 let method = format!("{:?}", explanation.tier);
665 let ci = ConfidenceInterval::from_tier(explanation.score, &method);
666 Self {
667 score: explanation.score,
668 method,
669 reason: explanation.reason.clone(),
670 score_breakdown: explanation
671 .score_breakdown
672 .iter()
673 .map(|c| MatchScoreComponent {
674 name: c.name.clone(),
675 weight: c.weight,
676 raw_score: c.raw_score,
677 weighted_score: c.weighted_score,
678 description: c.description.clone(),
679 })
680 .collect(),
681 normalizations: explanation.normalizations_applied.clone(),
682 confidence_interval: Some(ci),
683 }
684 }
685
686 #[must_use]
688 pub fn simple(score: f64, method: &str, reason: &str) -> Self {
689 let ci = ConfidenceInterval::from_tier(score, method);
690 Self {
691 score,
692 method: method.to_string(),
693 reason: reason.to_string(),
694 score_breakdown: Vec::new(),
695 normalizations: Vec::new(),
696 confidence_interval: Some(ci),
697 }
698 }
699
700 #[must_use]
702 pub const fn with_confidence_interval(mut self, ci: ConfidenceInterval) -> Self {
703 self.confidence_interval = Some(ci);
704 self
705 }
706}
707
708#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
710pub enum ChangeType {
711 Added,
712 Removed,
713 Modified,
714 Unchanged,
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize)]
719pub struct FieldChange {
720 pub field: String,
721 pub old_value: Option<String>,
722 pub new_value: Option<String>,
723}
724
725#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
727#[serde(rename_all = "lowercase")]
728pub enum MetadataChangeKind {
729 Added,
731 Removed,
733 Modified,
735}
736
737impl std::fmt::Display for MetadataChangeKind {
738 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
739 let s = match self {
740 Self::Added => "added",
741 Self::Removed => "removed",
742 Self::Modified => "modified",
743 };
744 f.write_str(s)
745 }
746}
747
748#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756pub struct MetadataChange {
757 pub field: String,
760 pub old_value: Option<String>,
762 pub new_value: Option<String>,
764 pub kind: MetadataChangeKind,
766}
767
768impl MetadataChange {
769 #[must_use]
773 pub fn from_values(
774 field: impl Into<String>,
775 old_value: Option<String>,
776 new_value: Option<String>,
777 ) -> Option<Self> {
778 if old_value == new_value {
779 return None;
780 }
781 let kind = match (&old_value, &new_value) {
782 (None, Some(_)) => MetadataChangeKind::Added,
783 (Some(_), None) => MetadataChangeKind::Removed,
784 _ => MetadataChangeKind::Modified,
785 };
786 Some(Self {
787 field: field.into(),
788 old_value,
789 new_value,
790 kind,
791 })
792 }
793}
794
795#[derive(Debug, Clone, Serialize, Deserialize)]
797pub struct DependencyChange {
798 pub from: String,
800 pub to: String,
802 pub relationship: String,
804 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub scope: Option<String>,
807 pub change_type: ChangeType,
809}
810
811impl DependencyChange {
812 #[must_use]
813 pub fn added(edge: &DependencyEdge) -> Self {
814 Self {
815 from: edge.from.to_string(),
816 to: edge.to.to_string(),
817 relationship: edge.relationship.to_string(),
818 scope: edge.scope.as_ref().map(std::string::ToString::to_string),
819 change_type: ChangeType::Added,
820 }
821 }
822
823 #[must_use]
824 pub fn removed(edge: &DependencyEdge) -> Self {
825 Self {
826 from: edge.from.to_string(),
827 to: edge.to.to_string(),
828 relationship: edge.relationship.to_string(),
829 scope: edge.scope.as_ref().map(std::string::ToString::to_string),
830 change_type: ChangeType::Removed,
831 }
832 }
833}
834
835#[derive(Debug, Clone, Default, Serialize, Deserialize)]
837pub struct LicenseChanges {
838 pub new_licenses: Vec<LicenseChange>,
840 pub removed_licenses: Vec<LicenseChange>,
842 pub conflicts: Vec<LicenseConflict>,
844 pub component_changes: Vec<ComponentLicenseChange>,
846}
847
848#[derive(Debug, Clone, Serialize, Deserialize)]
850pub struct LicenseChange {
851 pub license: String,
853 pub components: Vec<String>,
855 pub family: String,
857}
858
859#[derive(Debug, Clone, Serialize, Deserialize)]
861pub struct LicenseConflict {
862 pub license_a: String,
863 pub license_b: String,
864 pub component: String,
865 pub description: String,
866}
867
868#[derive(Debug, Clone, Serialize, Deserialize)]
870pub struct ComponentLicenseChange {
871 pub component_id: String,
872 pub component_name: String,
873 pub old_licenses: Vec<String>,
874 pub new_licenses: Vec<String>,
875}
876
877#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
879pub struct VexStatusChange {
880 pub vuln_id: String,
882 pub component_name: String,
884 #[serde(default, skip_serializing_if = "Option::is_none")]
886 pub old_state: Option<crate::model::VexState>,
887 #[serde(default, skip_serializing_if = "Option::is_none")]
889 pub new_state: Option<crate::model::VexState>,
890}
891
892#[derive(Debug, Clone, Default, Serialize, Deserialize)]
894pub struct VulnerabilityChanges {
895 pub introduced: Vec<VulnerabilityDetail>,
897 pub resolved: Vec<VulnerabilityDetail>,
899 pub persistent: Vec<VulnerabilityDetail>,
901 #[serde(default, skip_serializing_if = "Vec::is_empty")]
903 pub vex_changes: Vec<VexStatusChange>,
904}
905
906impl VulnerabilityChanges {
907 #[must_use]
909 pub fn introduced_by_severity(&self) -> HashMap<String, usize> {
910 let mut counts = HashMap::with_capacity(5);
912 for vuln in &self.introduced {
913 *counts.entry(vuln.severity.clone()).or_insert(0) += 1;
914 }
915 counts
916 }
917
918 #[must_use]
920 pub fn critical_and_high_introduced(&self) -> Vec<&VulnerabilityDetail> {
921 self.introduced
922 .iter()
923 .filter(|v| v.severity == "Critical" || v.severity == "High")
924 .collect()
925 }
926
927 pub fn vex_summary(&self) -> VexCoverageSummary {
929 let all_vulns: Vec<&VulnerabilityDetail> = self
930 .introduced
931 .iter()
932 .chain(&self.resolved)
933 .chain(&self.persistent)
934 .collect();
935
936 let total = all_vulns.len();
937 let mut with_vex = 0;
938 let mut by_state: HashMap<crate::model::VexState, usize> = HashMap::with_capacity(4);
939 let mut actionable = 0;
940
941 for vuln in &all_vulns {
942 if let Some(ref state) = vuln.vex_state {
943 with_vex += 1;
944 *by_state.entry(state.clone()).or_insert(0) += 1;
945 }
946 if vuln.is_vex_actionable() {
947 actionable += 1;
948 }
949 }
950
951 let introduced_without_vex = self
953 .introduced
954 .iter()
955 .filter(|v| v.vex_state.is_none())
956 .count();
957
958 let persistent_without_vex = self
959 .persistent
960 .iter()
961 .filter(|v| v.vex_state.is_none())
962 .count();
963
964 VexCoverageSummary {
965 total_vulns: total,
966 with_vex,
967 without_vex: total - with_vex,
968 actionable,
969 coverage_pct: if total > 0 {
970 (with_vex as f64 / total as f64) * 100.0
971 } else {
972 100.0
973 },
974 by_state,
975 introduced_without_vex,
976 persistent_without_vex,
977 }
978 }
979}
980
981#[derive(Debug, Clone, Serialize, Deserialize)]
983#[must_use]
984pub struct VexCoverageSummary {
985 pub total_vulns: usize,
987 pub with_vex: usize,
989 pub without_vex: usize,
991 pub actionable: usize,
993 pub coverage_pct: f64,
995 pub by_state: HashMap<crate::model::VexState, usize>,
997 pub introduced_without_vex: usize,
999 pub persistent_without_vex: usize,
1001}
1002
1003#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1005pub enum SlaStatus {
1006 Overdue(i64),
1008 DueSoon(i64),
1010 OnTrack(i64),
1012 NoDueDate,
1014}
1015
1016impl SlaStatus {
1017 #[must_use]
1019 pub fn display(&self, days_since_published: Option<i64>) -> String {
1020 match self {
1021 Self::Overdue(days) => format!("{days}d late"),
1022 Self::DueSoon(days) | Self::OnTrack(days) => format!("{days}d left"),
1023 Self::NoDueDate => {
1024 days_since_published.map_or_else(|| "-".to_string(), |age| format!("{age}d old"))
1025 }
1026 }
1027 }
1028
1029 #[must_use]
1031 pub const fn is_overdue(&self) -> bool {
1032 matches!(self, Self::Overdue(_))
1033 }
1034
1035 #[must_use]
1037 pub const fn is_due_soon(&self) -> bool {
1038 matches!(self, Self::DueSoon(_))
1039 }
1040}
1041
1042#[derive(Debug, Clone, Serialize, Deserialize)]
1044pub struct VulnerabilityDetail {
1045 pub id: String,
1047 pub source: String,
1049 pub severity: String,
1051 pub cvss_score: Option<f32>,
1053 pub component_id: String,
1055 #[serde(skip)]
1057 pub component_canonical_id: Option<CanonicalId>,
1058 #[serde(skip)]
1060 pub component_ref: Option<ComponentRef>,
1061 pub component_name: String,
1063 pub version: Option<String>,
1065 pub cwes: Vec<String>,
1067 pub description: Option<String>,
1069 pub remediation: Option<String>,
1071 #[serde(default)]
1073 pub is_kev: bool,
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1076 pub epss_score: Option<f64>,
1077 #[serde(default)]
1079 pub component_depth: Option<u32>,
1080 #[serde(default)]
1082 pub published_date: Option<String>,
1083 #[serde(default)]
1085 pub kev_due_date: Option<String>,
1086 #[serde(default)]
1088 pub days_since_published: Option<i64>,
1089 #[serde(default)]
1091 pub days_until_due: Option<i64>,
1092 #[serde(default, skip_serializing_if = "Option::is_none")]
1094 pub vex_state: Option<crate::model::VexState>,
1095 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub vex_justification: Option<crate::model::VexJustification>,
1098 #[serde(default, skip_serializing_if = "Option::is_none")]
1100 pub vex_impact_statement: Option<String>,
1101}
1102
1103impl VulnerabilityDetail {
1104 #[must_use]
1109 pub const fn is_vex_actionable(&self) -> bool {
1110 !matches!(
1111 self.vex_state,
1112 Some(crate::model::VexState::NotAffected | crate::model::VexState::Fixed)
1113 )
1114 }
1115
1116 pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
1118 let days_since_published = vuln.published.map(|dt| {
1120 let today = chrono::Utc::now().date_naive();
1121 (today - dt.date_naive()).num_days()
1122 });
1123
1124 let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
1126
1127 let (kev_due_date, days_until_due) = vuln.kev_info.as_ref().map_or((None, None), |kev| {
1129 (
1130 Some(kev.due_date.format("%Y-%m-%d").to_string()),
1131 Some(kev.days_until_due()),
1132 )
1133 });
1134
1135 Self {
1136 id: vuln.id.clone(),
1137 source: vuln.source.to_string(),
1138 severity: vuln
1139 .severity
1140 .as_ref()
1141 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string),
1142 cvss_score: vuln.max_cvss_score(),
1143 component_id: component.canonical_id.to_string(),
1144 component_canonical_id: Some(component.canonical_id.clone()),
1145 component_ref: Some(ComponentRef::from_component(component)),
1146 component_name: component.name.clone(),
1147 version: component.version.clone(),
1148 cwes: vuln.cwes.clone(),
1149 description: vuln.description.clone(),
1150 remediation: vuln.remediation.as_ref().map(|r| {
1151 format!(
1152 "{}: {}",
1153 r.remediation_type,
1154 r.description.as_deref().unwrap_or("")
1155 )
1156 }),
1157 is_kev: vuln.is_kev,
1158 epss_score: vuln.epss_score,
1159 component_depth: None,
1160 published_date,
1161 kev_due_date,
1162 days_since_published,
1163 days_until_due,
1164 vex_state: {
1165 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1166 vex_source.map(|v| v.status.clone())
1167 },
1168 vex_justification: {
1169 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1170 vex_source.and_then(|v| v.justification.clone())
1171 },
1172 vex_impact_statement: {
1173 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1174 vex_source.and_then(|v| v.impact_statement.clone())
1175 },
1176 }
1177 }
1178
1179 #[must_use]
1181 pub fn from_ref_with_depth(
1182 vuln: &VulnerabilityRef,
1183 component: &Component,
1184 depth: Option<u32>,
1185 ) -> Self {
1186 let mut detail = Self::from_ref(vuln, component);
1187 detail.component_depth = depth;
1188 detail
1189 }
1190
1191 #[must_use]
1197 pub fn sla_status(&self) -> SlaStatus {
1198 if let Some(days) = self.days_until_due {
1200 if days < 0 {
1201 return SlaStatus::Overdue(-days);
1202 } else if days <= 3 {
1203 return SlaStatus::DueSoon(days);
1204 }
1205 return SlaStatus::OnTrack(days);
1206 }
1207
1208 if let Some(age_days) = self.days_since_published {
1210 let sla_days = match self.severity.to_lowercase().as_str() {
1211 "critical" => 1,
1212 "high" => 7,
1213 "medium" => 30,
1214 "low" => 90,
1215 _ => return SlaStatus::NoDueDate,
1216 };
1217 let remaining = sla_days - age_days;
1218 if remaining < 0 {
1219 return SlaStatus::Overdue(-remaining);
1220 } else if remaining <= 3 {
1221 return SlaStatus::DueSoon(remaining);
1222 }
1223 return SlaStatus::OnTrack(remaining);
1224 }
1225
1226 SlaStatus::NoDueDate
1227 }
1228
1229 #[must_use]
1231 pub fn get_component_id(&self) -> CanonicalId {
1232 self.component_canonical_id.clone().unwrap_or_else(|| {
1233 CanonicalId::from_name_version(&self.component_name, self.version.as_deref())
1234 })
1235 }
1236
1237 #[must_use]
1239 pub fn get_component_ref(&self) -> ComponentRef {
1240 self.component_ref.clone().unwrap_or_else(|| {
1241 ComponentRef::with_version(
1242 self.get_component_id(),
1243 &self.component_name,
1244 self.version.clone(),
1245 )
1246 })
1247 }
1248}
1249
1250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1256pub struct DependencyGraphChange {
1257 pub component_id: CanonicalId,
1259 pub component_name: String,
1261 pub change: DependencyChangeType,
1263 pub impact: GraphChangeImpact,
1265}
1266
1267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1269#[non_exhaustive]
1270pub enum DependencyChangeType {
1271 DependencyAdded {
1273 dependency_id: CanonicalId,
1274 dependency_name: String,
1275 },
1276
1277 DependencyRemoved {
1279 dependency_id: CanonicalId,
1280 dependency_name: String,
1281 },
1282
1283 RelationshipChanged {
1285 dependency_id: CanonicalId,
1286 dependency_name: String,
1287 old_relationship: String,
1288 new_relationship: String,
1289 old_scope: Option<String>,
1290 new_scope: Option<String>,
1291 },
1292
1293 Reparented {
1295 dependency_id: CanonicalId,
1296 dependency_name: String,
1297 old_parent_id: CanonicalId,
1298 old_parent_name: String,
1299 new_parent_id: CanonicalId,
1300 new_parent_name: String,
1301 },
1302
1303 DepthChanged {
1305 old_depth: u32, new_depth: u32,
1307 },
1308}
1309
1310impl DependencyChangeType {
1311 #[must_use]
1313 pub const fn kind(&self) -> &'static str {
1314 match self {
1315 Self::DependencyAdded { .. } => "added",
1316 Self::DependencyRemoved { .. } => "removed",
1317 Self::RelationshipChanged { .. } => "relationship_changed",
1318 Self::Reparented { .. } => "reparented",
1319 Self::DepthChanged { .. } => "depth_changed",
1320 }
1321 }
1322}
1323
1324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1326pub enum GraphChangeImpact {
1327 Low,
1329 Medium,
1331 High,
1333 Critical,
1335}
1336
1337impl GraphChangeImpact {
1338 #[must_use]
1339 pub const fn as_str(&self) -> &'static str {
1340 match self {
1341 Self::Low => "low",
1342 Self::Medium => "medium",
1343 Self::High => "high",
1344 Self::Critical => "critical",
1345 }
1346 }
1347
1348 #[must_use]
1350 pub fn from_label(s: &str) -> Self {
1351 match s.to_lowercase().as_str() {
1352 "critical" => Self::Critical,
1353 "high" => Self::High,
1354 "medium" => Self::Medium,
1355 _ => Self::Low,
1356 }
1357 }
1358}
1359
1360impl std::fmt::Display for GraphChangeImpact {
1361 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1362 write!(f, "{}", self.as_str())
1363 }
1364}
1365
1366#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1368pub struct GraphChangeSummary {
1369 pub total_changes: usize,
1370 pub dependencies_added: usize,
1371 pub dependencies_removed: usize,
1372 pub relationship_changed: usize,
1373 pub reparented: usize,
1374 pub depth_changed: usize,
1375 pub by_impact: GraphChangesByImpact,
1376}
1377
1378impl GraphChangeSummary {
1379 #[must_use]
1381 pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
1382 let mut summary = Self {
1383 total_changes: changes.len(),
1384 ..Default::default()
1385 };
1386
1387 for change in changes {
1388 match &change.change {
1389 DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
1390 DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
1391 DependencyChangeType::RelationshipChanged { .. } => {
1392 summary.relationship_changed += 1;
1393 }
1394 DependencyChangeType::Reparented { .. } => summary.reparented += 1,
1395 DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
1396 }
1397
1398 match change.impact {
1399 GraphChangeImpact::Low => summary.by_impact.low += 1,
1400 GraphChangeImpact::Medium => summary.by_impact.medium += 1,
1401 GraphChangeImpact::High => summary.by_impact.high += 1,
1402 GraphChangeImpact::Critical => summary.by_impact.critical += 1,
1403 }
1404 }
1405
1406 summary
1407 }
1408}
1409
1410#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1411pub struct GraphChangesByImpact {
1412 pub low: usize,
1413 pub medium: usize,
1414 pub high: usize,
1415 pub critical: usize,
1416}