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)]
39 pub graph_changes: Vec<DependencyGraphChange>,
40 #[serde(default)]
42 pub graph_summary: Option<GraphChangeSummary>,
43 #[serde(default)]
45 pub rules_applied: usize,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub quality_delta: Option<QualityDelta>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub match_metrics: Option<MatchMetrics>,
52}
53
54impl DiffResult {
55 pub fn new() -> Self {
57 Self {
58 summary: DiffSummary::default(),
59 components: ChangeSet::new(),
60 dependencies: ChangeSet::new(),
61 licenses: LicenseChanges::default(),
62 vulnerabilities: VulnerabilityChanges::default(),
63 semantic_score: 0.0,
64 graph_changes: Vec::new(),
65 graph_summary: None,
66 rules_applied: 0,
67 quality_delta: None,
68 match_metrics: None,
69 }
70 }
71
72 pub fn calculate_summary(&mut self) {
74 self.summary.components_added = self.components.added.len();
75 self.summary.components_removed = self.components.removed.len();
76 self.summary.components_modified = self.components.modified.len();
77
78 self.summary.dependencies_added = self.dependencies.added.len();
79 self.summary.dependencies_removed = self.dependencies.removed.len();
80 self.summary.graph_changes_count = self.graph_changes.len();
81
82 self.summary.total_changes = self.summary.components_added
83 + self.summary.components_removed
84 + self.summary.components_modified
85 + self.summary.dependencies_added
86 + self.summary.dependencies_removed
87 + self.summary.graph_changes_count;
88
89 self.summary.vulnerabilities_introduced = self.vulnerabilities.introduced.len();
90 self.summary.vulnerabilities_resolved = self.vulnerabilities.resolved.len();
91 self.summary.vulnerabilities_persistent = self.vulnerabilities.persistent.len();
92
93 self.summary.licenses_added = self.licenses.new_licenses.len();
94 self.summary.licenses_removed = self.licenses.removed_licenses.len();
95 }
96
97 #[must_use]
102 pub fn has_changes(&self) -> bool {
103 self.summary.total_changes > 0
104 || !self.components.is_empty()
105 || !self.dependencies.is_empty()
106 || !self.graph_changes.is_empty()
107 || !self.vulnerabilities.introduced.is_empty()
108 || !self.vulnerabilities.resolved.is_empty()
109 }
110
111 #[must_use]
113 pub fn find_component_by_id(&self, id: &CanonicalId) -> Option<&ComponentChange> {
114 let id_str = id.value();
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 #[must_use]
125 pub fn find_component_by_id_str(&self, id_str: &str) -> Option<&ComponentChange> {
126 self.components
127 .added
128 .iter()
129 .chain(self.components.removed.iter())
130 .chain(self.components.modified.iter())
131 .find(|c| c.id == id_str)
132 }
133
134 #[must_use]
136 pub fn all_component_changes(&self) -> Vec<&ComponentChange> {
137 self.components
138 .added
139 .iter()
140 .chain(self.components.removed.iter())
141 .chain(self.components.modified.iter())
142 .collect()
143 }
144
145 #[must_use]
147 pub fn find_vulns_for_component(
148 &self,
149 component_id: &CanonicalId,
150 ) -> Vec<&VulnerabilityDetail> {
151 let id_str = component_id.value();
152 self.vulnerabilities
153 .introduced
154 .iter()
155 .chain(self.vulnerabilities.resolved.iter())
156 .chain(self.vulnerabilities.persistent.iter())
157 .filter(|v| v.component_id == id_str)
158 .collect()
159 }
160
161 #[must_use]
163 pub fn build_component_id_index(&self) -> HashMap<String, &ComponentChange> {
164 self.components
165 .added
166 .iter()
167 .chain(&self.components.removed)
168 .chain(&self.components.modified)
169 .map(|c| (c.id.clone(), c))
170 .collect()
171 }
172
173 pub fn filter_by_severity(&mut self, min_severity: &str) {
175 let min_sev = severity_rank(min_severity);
176
177 self.vulnerabilities
178 .introduced
179 .retain(|v| severity_rank(&v.severity) >= min_sev);
180 self.vulnerabilities
181 .resolved
182 .retain(|v| severity_rank(&v.severity) >= min_sev);
183 self.vulnerabilities
184 .persistent
185 .retain(|v| severity_rank(&v.severity) >= min_sev);
186
187 self.calculate_summary();
189 }
190
191 pub fn filter_by_vex(&mut self) {
195 self.vulnerabilities
196 .introduced
197 .retain(VulnerabilityDetail::is_vex_actionable);
198 self.vulnerabilities
199 .resolved
200 .retain(VulnerabilityDetail::is_vex_actionable);
201 self.vulnerabilities
202 .persistent
203 .retain(VulnerabilityDetail::is_vex_actionable);
204
205 self.calculate_summary();
206 }
207}
208
209impl Default for DiffResult {
210 fn default() -> Self {
211 Self::new()
212 }
213}
214
215#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220pub struct QualityDelta {
221 pub overall_score_delta: f32,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub old_grade: Option<crate::quality::QualityGrade>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub new_grade: Option<crate::quality::QualityGrade>,
229 pub category_deltas: Vec<CategoryDelta>,
231 pub regressions: Vec<String>,
233 pub improvements: Vec<String>,
235 pub violation_count_delta: i32,
237}
238
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct CategoryDelta {
242 pub category: String,
244 pub old_score: f32,
246 pub new_score: f32,
248 pub delta: f32,
250}
251
252impl QualityDelta {
253 #[must_use]
255 pub fn from_reports(
256 old: &crate::quality::QualityReport,
257 new: &crate::quality::QualityReport,
258 ) -> Self {
259 let categories = [
260 (
261 "Completeness",
262 old.completeness_score,
263 new.completeness_score,
264 ),
265 ("Identifiers", old.identifier_score, new.identifier_score),
266 ("Licenses", old.license_score, new.license_score),
267 ("Dependencies", old.dependency_score, new.dependency_score),
268 ("Integrity", old.integrity_score, new.integrity_score),
269 ("Provenance", old.provenance_score, new.provenance_score),
270 ];
271
272 let mut category_deltas: Vec<CategoryDelta> = categories
273 .iter()
274 .map(|(name, old_s, new_s)| CategoryDelta {
275 category: (*name).to_string(),
276 old_score: *old_s,
277 new_score: *new_s,
278 delta: new_s - old_s,
279 })
280 .collect();
281
282 if let (Some(old_v), Some(new_v)) = (old.vulnerability_score, new.vulnerability_score) {
284 category_deltas.push(CategoryDelta {
285 category: "VulnDocs".to_string(),
286 old_score: old_v,
287 new_score: new_v,
288 delta: new_v - old_v,
289 });
290 }
291 if let (Some(old_l), Some(new_l)) = (old.lifecycle_score, new.lifecycle_score) {
292 category_deltas.push(CategoryDelta {
293 category: "Lifecycle".to_string(),
294 old_score: old_l,
295 new_score: new_l,
296 delta: new_l - old_l,
297 });
298 }
299
300 let regressions: Vec<String> = category_deltas
301 .iter()
302 .filter(|d| d.delta < -1.0)
303 .map(|d| d.category.clone())
304 .collect();
305
306 let improvements: Vec<String> = category_deltas
307 .iter()
308 .filter(|d| d.delta > 1.0)
309 .map(|d| d.category.clone())
310 .collect();
311
312 let old_violations = old.compliance.error_count + old.compliance.warning_count;
314 let new_violations = new.compliance.error_count + new.compliance.warning_count;
315
316 Self {
317 overall_score_delta: new.overall_score - old.overall_score,
318 old_grade: Some(old.grade),
319 new_grade: Some(new.grade),
320 category_deltas,
321 regressions,
322 improvements,
323 violation_count_delta: new_violations as i32 - old_violations as i32,
324 }
325 }
326}
327
328#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
332pub struct MatchMetrics {
333 pub exact_matches: usize,
335 pub fuzzy_matches: usize,
337 pub rule_matches: usize,
339 pub unmatched_old: usize,
341 pub unmatched_new: usize,
343 pub avg_match_score: f64,
345 pub min_match_score: f64,
347}
348
349#[derive(Debug, Clone, Default, Serialize, Deserialize)]
351pub struct DiffSummary {
352 pub total_changes: usize,
353 pub components_added: usize,
354 pub components_removed: usize,
355 pub components_modified: usize,
356 pub dependencies_added: usize,
357 pub dependencies_removed: usize,
358 pub graph_changes_count: usize,
359 pub vulnerabilities_introduced: usize,
360 pub vulnerabilities_resolved: usize,
361 pub vulnerabilities_persistent: usize,
362 pub licenses_added: usize,
363 pub licenses_removed: usize,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct ChangeSet<T> {
369 pub added: Vec<T>,
370 pub removed: Vec<T>,
371 pub modified: Vec<T>,
372}
373
374impl<T> ChangeSet<T> {
375 #[must_use]
376 pub const fn new() -> Self {
377 Self {
378 added: Vec::new(),
379 removed: Vec::new(),
380 modified: Vec::new(),
381 }
382 }
383
384 #[must_use]
385 pub fn is_empty(&self) -> bool {
386 self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
387 }
388
389 #[must_use]
390 pub fn total(&self) -> usize {
391 self.added.len() + self.removed.len() + self.modified.len()
392 }
393}
394
395impl<T> Default for ChangeSet<T> {
396 fn default() -> Self {
397 Self::new()
398 }
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct MatchInfo {
406 pub score: f64,
408 pub method: String,
410 pub reason: String,
412 #[serde(skip_serializing_if = "Vec::is_empty")]
414 pub score_breakdown: Vec<MatchScoreComponent>,
415 #[serde(skip_serializing_if = "Vec::is_empty")]
417 pub normalizations: Vec<String>,
418 #[serde(skip_serializing_if = "Option::is_none")]
420 pub confidence_interval: Option<ConfidenceInterval>,
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct ConfidenceInterval {
429 pub lower: f64,
431 pub upper: f64,
433 pub level: f64,
435}
436
437impl ConfidenceInterval {
438 #[must_use]
440 pub const fn new(lower: f64, upper: f64, level: f64) -> Self {
441 Self {
442 lower: lower.clamp(0.0, 1.0),
443 upper: upper.clamp(0.0, 1.0),
444 level,
445 }
446 }
447
448 #[must_use]
452 pub fn from_score_and_error(score: f64, std_error: f64) -> Self {
453 let margin = 1.96 * std_error;
454 Self::new(score - margin, score + margin, 0.95)
455 }
456
457 #[must_use]
461 pub fn from_tier(score: f64, tier: &str) -> Self {
462 let margin = match tier {
463 "ExactIdentifier" => 0.0,
464 "Alias" => 0.02,
465 "EcosystemRule" => 0.03,
466 "CustomRule" => 0.05,
467 "Fuzzy" => 0.08,
468 _ => 0.10,
469 };
470 Self::new(score - margin, score + margin, 0.95)
471 }
472
473 #[must_use]
475 pub fn width(&self) -> f64 {
476 self.upper - self.lower
477 }
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct MatchScoreComponent {
483 pub name: String,
485 pub weight: f64,
487 pub raw_score: f64,
489 pub weighted_score: f64,
491 pub description: String,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct ComponentChange {
498 pub id: String,
500 #[serde(skip)]
502 pub canonical_id: Option<CanonicalId>,
503 #[serde(skip)]
505 pub component_ref: Option<ComponentRef>,
506 #[serde(skip)]
508 pub old_canonical_id: Option<CanonicalId>,
509 pub name: String,
511 pub old_version: Option<String>,
513 pub new_version: Option<String>,
515 pub ecosystem: Option<String>,
517 pub change_type: ChangeType,
519 pub field_changes: Vec<FieldChange>,
521 pub cost: u32,
523 #[serde(skip_serializing_if = "Option::is_none")]
525 pub match_info: Option<MatchInfo>,
526}
527
528impl ComponentChange {
529 pub fn added(component: &Component, cost: u32) -> Self {
531 Self {
532 id: component.canonical_id.to_string(),
533 canonical_id: Some(component.canonical_id.clone()),
534 component_ref: Some(ComponentRef::from_component(component)),
535 old_canonical_id: None,
536 name: component.name.clone(),
537 old_version: None,
538 new_version: component.version.clone(),
539 ecosystem: component
540 .ecosystem
541 .as_ref()
542 .map(std::string::ToString::to_string),
543 change_type: ChangeType::Added,
544 field_changes: Vec::new(),
545 cost,
546 match_info: None,
547 }
548 }
549
550 pub fn removed(component: &Component, cost: u32) -> Self {
552 Self {
553 id: component.canonical_id.to_string(),
554 canonical_id: Some(component.canonical_id.clone()),
555 component_ref: Some(ComponentRef::from_component(component)),
556 old_canonical_id: Some(component.canonical_id.clone()),
557 name: component.name.clone(),
558 old_version: component.version.clone(),
559 new_version: None,
560 ecosystem: component
561 .ecosystem
562 .as_ref()
563 .map(std::string::ToString::to_string),
564 change_type: ChangeType::Removed,
565 field_changes: Vec::new(),
566 cost,
567 match_info: None,
568 }
569 }
570
571 pub fn modified(
573 old: &Component,
574 new: &Component,
575 field_changes: Vec<FieldChange>,
576 cost: u32,
577 ) -> Self {
578 Self {
579 id: new.canonical_id.to_string(),
580 canonical_id: Some(new.canonical_id.clone()),
581 component_ref: Some(ComponentRef::from_component(new)),
582 old_canonical_id: Some(old.canonical_id.clone()),
583 name: new.name.clone(),
584 old_version: old.version.clone(),
585 new_version: new.version.clone(),
586 ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
587 change_type: ChangeType::Modified,
588 field_changes,
589 cost,
590 match_info: None,
591 }
592 }
593
594 pub fn modified_with_match(
596 old: &Component,
597 new: &Component,
598 field_changes: Vec<FieldChange>,
599 cost: u32,
600 match_info: MatchInfo,
601 ) -> Self {
602 Self {
603 id: new.canonical_id.to_string(),
604 canonical_id: Some(new.canonical_id.clone()),
605 component_ref: Some(ComponentRef::from_component(new)),
606 old_canonical_id: Some(old.canonical_id.clone()),
607 name: new.name.clone(),
608 old_version: old.version.clone(),
609 new_version: new.version.clone(),
610 ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
611 change_type: ChangeType::Modified,
612 field_changes,
613 cost,
614 match_info: Some(match_info),
615 }
616 }
617
618 #[must_use]
620 pub fn with_match_info(mut self, match_info: MatchInfo) -> Self {
621 self.match_info = Some(match_info);
622 self
623 }
624
625 #[must_use]
627 pub fn get_canonical_id(&self) -> CanonicalId {
628 self.canonical_id.clone().unwrap_or_else(|| {
629 CanonicalId::from_name_version(
630 &self.name,
631 self.new_version.as_deref().or(self.old_version.as_deref()),
632 )
633 })
634 }
635
636 #[must_use]
638 pub fn get_component_ref(&self) -> ComponentRef {
639 self.component_ref.clone().unwrap_or_else(|| {
640 ComponentRef::with_version(
641 self.get_canonical_id(),
642 &self.name,
643 self.new_version
644 .clone()
645 .or_else(|| self.old_version.clone()),
646 )
647 })
648 }
649}
650
651impl MatchInfo {
652 #[must_use]
654 pub fn from_explanation(explanation: &crate::matching::MatchExplanation) -> Self {
655 let method = format!("{:?}", explanation.tier);
656 let ci = ConfidenceInterval::from_tier(explanation.score, &method);
657 Self {
658 score: explanation.score,
659 method,
660 reason: explanation.reason.clone(),
661 score_breakdown: explanation
662 .score_breakdown
663 .iter()
664 .map(|c| MatchScoreComponent {
665 name: c.name.clone(),
666 weight: c.weight,
667 raw_score: c.raw_score,
668 weighted_score: c.weighted_score,
669 description: c.description.clone(),
670 })
671 .collect(),
672 normalizations: explanation.normalizations_applied.clone(),
673 confidence_interval: Some(ci),
674 }
675 }
676
677 #[must_use]
679 pub fn simple(score: f64, method: &str, reason: &str) -> Self {
680 let ci = ConfidenceInterval::from_tier(score, method);
681 Self {
682 score,
683 method: method.to_string(),
684 reason: reason.to_string(),
685 score_breakdown: Vec::new(),
686 normalizations: Vec::new(),
687 confidence_interval: Some(ci),
688 }
689 }
690
691 #[must_use]
693 pub const fn with_confidence_interval(mut self, ci: ConfidenceInterval) -> Self {
694 self.confidence_interval = Some(ci);
695 self
696 }
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
701pub enum ChangeType {
702 Added,
703 Removed,
704 Modified,
705 Unchanged,
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct FieldChange {
711 pub field: String,
712 pub old_value: Option<String>,
713 pub new_value: Option<String>,
714}
715
716#[derive(Debug, Clone, Serialize, Deserialize)]
718pub struct DependencyChange {
719 pub from: String,
721 pub to: String,
723 pub relationship: String,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub scope: Option<String>,
728 pub change_type: ChangeType,
730}
731
732impl DependencyChange {
733 #[must_use]
734 pub fn added(edge: &DependencyEdge) -> Self {
735 Self {
736 from: edge.from.to_string(),
737 to: edge.to.to_string(),
738 relationship: edge.relationship.to_string(),
739 scope: edge.scope.as_ref().map(std::string::ToString::to_string),
740 change_type: ChangeType::Added,
741 }
742 }
743
744 #[must_use]
745 pub fn removed(edge: &DependencyEdge) -> Self {
746 Self {
747 from: edge.from.to_string(),
748 to: edge.to.to_string(),
749 relationship: edge.relationship.to_string(),
750 scope: edge.scope.as_ref().map(std::string::ToString::to_string),
751 change_type: ChangeType::Removed,
752 }
753 }
754}
755
756#[derive(Debug, Clone, Default, Serialize, Deserialize)]
758pub struct LicenseChanges {
759 pub new_licenses: Vec<LicenseChange>,
761 pub removed_licenses: Vec<LicenseChange>,
763 pub conflicts: Vec<LicenseConflict>,
765 pub component_changes: Vec<ComponentLicenseChange>,
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct LicenseChange {
772 pub license: String,
774 pub components: Vec<String>,
776 pub family: String,
778}
779
780#[derive(Debug, Clone, Serialize, Deserialize)]
782pub struct LicenseConflict {
783 pub license_a: String,
784 pub license_b: String,
785 pub component: String,
786 pub description: String,
787}
788
789#[derive(Debug, Clone, Serialize, Deserialize)]
791pub struct ComponentLicenseChange {
792 pub component_id: String,
793 pub component_name: String,
794 pub old_licenses: Vec<String>,
795 pub new_licenses: Vec<String>,
796}
797
798#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
800pub struct VexStatusChange {
801 pub vuln_id: String,
803 pub component_name: String,
805 #[serde(default, skip_serializing_if = "Option::is_none")]
807 pub old_state: Option<crate::model::VexState>,
808 #[serde(default, skip_serializing_if = "Option::is_none")]
810 pub new_state: Option<crate::model::VexState>,
811}
812
813#[derive(Debug, Clone, Default, Serialize, Deserialize)]
815pub struct VulnerabilityChanges {
816 pub introduced: Vec<VulnerabilityDetail>,
818 pub resolved: Vec<VulnerabilityDetail>,
820 pub persistent: Vec<VulnerabilityDetail>,
822 #[serde(default, skip_serializing_if = "Vec::is_empty")]
824 pub vex_changes: Vec<VexStatusChange>,
825}
826
827impl VulnerabilityChanges {
828 #[must_use]
830 pub fn introduced_by_severity(&self) -> HashMap<String, usize> {
831 let mut counts = HashMap::with_capacity(5);
833 for vuln in &self.introduced {
834 *counts.entry(vuln.severity.clone()).or_insert(0) += 1;
835 }
836 counts
837 }
838
839 #[must_use]
841 pub fn critical_and_high_introduced(&self) -> Vec<&VulnerabilityDetail> {
842 self.introduced
843 .iter()
844 .filter(|v| v.severity == "Critical" || v.severity == "High")
845 .collect()
846 }
847
848 pub fn vex_summary(&self) -> VexCoverageSummary {
850 let all_vulns: Vec<&VulnerabilityDetail> = self
851 .introduced
852 .iter()
853 .chain(&self.resolved)
854 .chain(&self.persistent)
855 .collect();
856
857 let total = all_vulns.len();
858 let mut with_vex = 0;
859 let mut by_state: HashMap<crate::model::VexState, usize> = HashMap::with_capacity(4);
860 let mut actionable = 0;
861
862 for vuln in &all_vulns {
863 if let Some(ref state) = vuln.vex_state {
864 with_vex += 1;
865 *by_state.entry(state.clone()).or_insert(0) += 1;
866 }
867 if vuln.is_vex_actionable() {
868 actionable += 1;
869 }
870 }
871
872 let introduced_without_vex = self
874 .introduced
875 .iter()
876 .filter(|v| v.vex_state.is_none())
877 .count();
878
879 let persistent_without_vex = self
880 .persistent
881 .iter()
882 .filter(|v| v.vex_state.is_none())
883 .count();
884
885 VexCoverageSummary {
886 total_vulns: total,
887 with_vex,
888 without_vex: total - with_vex,
889 actionable,
890 coverage_pct: if total > 0 {
891 (with_vex as f64 / total as f64) * 100.0
892 } else {
893 100.0
894 },
895 by_state,
896 introduced_without_vex,
897 persistent_without_vex,
898 }
899 }
900}
901
902#[derive(Debug, Clone, Serialize, Deserialize)]
904#[must_use]
905pub struct VexCoverageSummary {
906 pub total_vulns: usize,
908 pub with_vex: usize,
910 pub without_vex: usize,
912 pub actionable: usize,
914 pub coverage_pct: f64,
916 pub by_state: HashMap<crate::model::VexState, usize>,
918 pub introduced_without_vex: usize,
920 pub persistent_without_vex: usize,
922}
923
924#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
926pub enum SlaStatus {
927 Overdue(i64),
929 DueSoon(i64),
931 OnTrack(i64),
933 NoDueDate,
935}
936
937impl SlaStatus {
938 #[must_use]
940 pub fn display(&self, days_since_published: Option<i64>) -> String {
941 match self {
942 Self::Overdue(days) => format!("{days}d late"),
943 Self::DueSoon(days) | Self::OnTrack(days) => format!("{days}d left"),
944 Self::NoDueDate => {
945 days_since_published.map_or_else(|| "-".to_string(), |age| format!("{age}d old"))
946 }
947 }
948 }
949
950 #[must_use]
952 pub const fn is_overdue(&self) -> bool {
953 matches!(self, Self::Overdue(_))
954 }
955
956 #[must_use]
958 pub const fn is_due_soon(&self) -> bool {
959 matches!(self, Self::DueSoon(_))
960 }
961}
962
963#[derive(Debug, Clone, Serialize, Deserialize)]
965pub struct VulnerabilityDetail {
966 pub id: String,
968 pub source: String,
970 pub severity: String,
972 pub cvss_score: Option<f32>,
974 pub component_id: String,
976 #[serde(skip)]
978 pub component_canonical_id: Option<CanonicalId>,
979 #[serde(skip)]
981 pub component_ref: Option<ComponentRef>,
982 pub component_name: String,
984 pub version: Option<String>,
986 pub cwes: Vec<String>,
988 pub description: Option<String>,
990 pub remediation: Option<String>,
992 #[serde(default)]
994 pub is_kev: bool,
995 #[serde(default)]
997 pub component_depth: Option<u32>,
998 #[serde(default)]
1000 pub published_date: Option<String>,
1001 #[serde(default)]
1003 pub kev_due_date: Option<String>,
1004 #[serde(default)]
1006 pub days_since_published: Option<i64>,
1007 #[serde(default)]
1009 pub days_until_due: Option<i64>,
1010 #[serde(default, skip_serializing_if = "Option::is_none")]
1012 pub vex_state: Option<crate::model::VexState>,
1013 #[serde(default, skip_serializing_if = "Option::is_none")]
1015 pub vex_justification: Option<crate::model::VexJustification>,
1016 #[serde(default, skip_serializing_if = "Option::is_none")]
1018 pub vex_impact_statement: Option<String>,
1019}
1020
1021impl VulnerabilityDetail {
1022 #[must_use]
1027 pub const fn is_vex_actionable(&self) -> bool {
1028 !matches!(
1029 self.vex_state,
1030 Some(crate::model::VexState::NotAffected | crate::model::VexState::Fixed)
1031 )
1032 }
1033
1034 pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
1036 let days_since_published = vuln.published.map(|dt| {
1038 let today = chrono::Utc::now().date_naive();
1039 (today - dt.date_naive()).num_days()
1040 });
1041
1042 let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
1044
1045 let (kev_due_date, days_until_due) = vuln.kev_info.as_ref().map_or((None, None), |kev| {
1047 (
1048 Some(kev.due_date.format("%Y-%m-%d").to_string()),
1049 Some(kev.days_until_due()),
1050 )
1051 });
1052
1053 Self {
1054 id: vuln.id.clone(),
1055 source: vuln.source.to_string(),
1056 severity: vuln
1057 .severity
1058 .as_ref()
1059 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string),
1060 cvss_score: vuln.max_cvss_score(),
1061 component_id: component.canonical_id.to_string(),
1062 component_canonical_id: Some(component.canonical_id.clone()),
1063 component_ref: Some(ComponentRef::from_component(component)),
1064 component_name: component.name.clone(),
1065 version: component.version.clone(),
1066 cwes: vuln.cwes.clone(),
1067 description: vuln.description.clone(),
1068 remediation: vuln.remediation.as_ref().map(|r| {
1069 format!(
1070 "{}: {}",
1071 r.remediation_type,
1072 r.description.as_deref().unwrap_or("")
1073 )
1074 }),
1075 is_kev: vuln.is_kev,
1076 component_depth: None,
1077 published_date,
1078 kev_due_date,
1079 days_since_published,
1080 days_until_due,
1081 vex_state: {
1082 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1083 vex_source.map(|v| v.status.clone())
1084 },
1085 vex_justification: {
1086 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1087 vex_source.and_then(|v| v.justification.clone())
1088 },
1089 vex_impact_statement: {
1090 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1091 vex_source.and_then(|v| v.impact_statement.clone())
1092 },
1093 }
1094 }
1095
1096 #[must_use]
1098 pub fn from_ref_with_depth(
1099 vuln: &VulnerabilityRef,
1100 component: &Component,
1101 depth: Option<u32>,
1102 ) -> Self {
1103 let mut detail = Self::from_ref(vuln, component);
1104 detail.component_depth = depth;
1105 detail
1106 }
1107
1108 #[must_use]
1114 pub fn sla_status(&self) -> SlaStatus {
1115 if let Some(days) = self.days_until_due {
1117 if days < 0 {
1118 return SlaStatus::Overdue(-days);
1119 } else if days <= 3 {
1120 return SlaStatus::DueSoon(days);
1121 }
1122 return SlaStatus::OnTrack(days);
1123 }
1124
1125 if let Some(age_days) = self.days_since_published {
1127 let sla_days = match self.severity.to_lowercase().as_str() {
1128 "critical" => 1,
1129 "high" => 7,
1130 "medium" => 30,
1131 "low" => 90,
1132 _ => return SlaStatus::NoDueDate,
1133 };
1134 let remaining = sla_days - age_days;
1135 if remaining < 0 {
1136 return SlaStatus::Overdue(-remaining);
1137 } else if remaining <= 3 {
1138 return SlaStatus::DueSoon(remaining);
1139 }
1140 return SlaStatus::OnTrack(remaining);
1141 }
1142
1143 SlaStatus::NoDueDate
1144 }
1145
1146 #[must_use]
1148 pub fn get_component_id(&self) -> CanonicalId {
1149 self.component_canonical_id.clone().unwrap_or_else(|| {
1150 CanonicalId::from_name_version(&self.component_name, self.version.as_deref())
1151 })
1152 }
1153
1154 #[must_use]
1156 pub fn get_component_ref(&self) -> ComponentRef {
1157 self.component_ref.clone().unwrap_or_else(|| {
1158 ComponentRef::with_version(
1159 self.get_component_id(),
1160 &self.component_name,
1161 self.version.clone(),
1162 )
1163 })
1164 }
1165}
1166
1167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1173pub struct DependencyGraphChange {
1174 pub component_id: CanonicalId,
1176 pub component_name: String,
1178 pub change: DependencyChangeType,
1180 pub impact: GraphChangeImpact,
1182}
1183
1184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1186#[non_exhaustive]
1187pub enum DependencyChangeType {
1188 DependencyAdded {
1190 dependency_id: CanonicalId,
1191 dependency_name: String,
1192 },
1193
1194 DependencyRemoved {
1196 dependency_id: CanonicalId,
1197 dependency_name: String,
1198 },
1199
1200 RelationshipChanged {
1202 dependency_id: CanonicalId,
1203 dependency_name: String,
1204 old_relationship: String,
1205 new_relationship: String,
1206 old_scope: Option<String>,
1207 new_scope: Option<String>,
1208 },
1209
1210 Reparented {
1212 dependency_id: CanonicalId,
1213 dependency_name: String,
1214 old_parent_id: CanonicalId,
1215 old_parent_name: String,
1216 new_parent_id: CanonicalId,
1217 new_parent_name: String,
1218 },
1219
1220 DepthChanged {
1222 old_depth: u32, new_depth: u32,
1224 },
1225}
1226
1227impl DependencyChangeType {
1228 #[must_use]
1230 pub const fn kind(&self) -> &'static str {
1231 match self {
1232 Self::DependencyAdded { .. } => "added",
1233 Self::DependencyRemoved { .. } => "removed",
1234 Self::RelationshipChanged { .. } => "relationship_changed",
1235 Self::Reparented { .. } => "reparented",
1236 Self::DepthChanged { .. } => "depth_changed",
1237 }
1238 }
1239}
1240
1241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1243pub enum GraphChangeImpact {
1244 Low,
1246 Medium,
1248 High,
1250 Critical,
1252}
1253
1254impl GraphChangeImpact {
1255 #[must_use]
1256 pub const fn as_str(&self) -> &'static str {
1257 match self {
1258 Self::Low => "low",
1259 Self::Medium => "medium",
1260 Self::High => "high",
1261 Self::Critical => "critical",
1262 }
1263 }
1264
1265 #[must_use]
1267 pub fn from_label(s: &str) -> Self {
1268 match s.to_lowercase().as_str() {
1269 "critical" => Self::Critical,
1270 "high" => Self::High,
1271 "medium" => Self::Medium,
1272 _ => Self::Low,
1273 }
1274 }
1275}
1276
1277impl std::fmt::Display for GraphChangeImpact {
1278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1279 write!(f, "{}", self.as_str())
1280 }
1281}
1282
1283#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1285pub struct GraphChangeSummary {
1286 pub total_changes: usize,
1287 pub dependencies_added: usize,
1288 pub dependencies_removed: usize,
1289 pub relationship_changed: usize,
1290 pub reparented: usize,
1291 pub depth_changed: usize,
1292 pub by_impact: GraphChangesByImpact,
1293}
1294
1295impl GraphChangeSummary {
1296 #[must_use]
1298 pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
1299 let mut summary = Self {
1300 total_changes: changes.len(),
1301 ..Default::default()
1302 };
1303
1304 for change in changes {
1305 match &change.change {
1306 DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
1307 DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
1308 DependencyChangeType::RelationshipChanged { .. } => {
1309 summary.relationship_changed += 1;
1310 }
1311 DependencyChangeType::Reparented { .. } => summary.reparented += 1,
1312 DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
1313 }
1314
1315 match change.impact {
1316 GraphChangeImpact::Low => summary.by_impact.low += 1,
1317 GraphChangeImpact::Medium => summary.by_impact.medium += 1,
1318 GraphChangeImpact::High => summary.by_impact.high += 1,
1319 GraphChangeImpact::Critical => summary.by_impact.critical += 1,
1320 }
1321 }
1322
1323 summary
1324 }
1325}
1326
1327#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1328pub struct GraphChangesByImpact {
1329 pub low: usize,
1330 pub medium: usize,
1331 pub high: usize,
1332 pub critical: usize,
1333}