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}
47
48impl DiffResult {
49 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 pub fn calculate_summary(&mut self) {
66 self.summary.components_added = self.components.added.len();
67 self.summary.components_removed = self.components.removed.len();
68 self.summary.components_modified = self.components.modified.len();
69
70 self.summary.dependencies_added = self.dependencies.added.len();
71 self.summary.dependencies_removed = self.dependencies.removed.len();
72 self.summary.graph_changes_count = self.graph_changes.len();
73
74 self.summary.total_changes = self.summary.components_added
75 + self.summary.components_removed
76 + self.summary.components_modified
77 + self.summary.dependencies_added
78 + self.summary.dependencies_removed
79 + self.summary.graph_changes_count;
80
81 self.summary.vulnerabilities_introduced = self.vulnerabilities.introduced.len();
82 self.summary.vulnerabilities_resolved = self.vulnerabilities.resolved.len();
83 self.summary.vulnerabilities_persistent = self.vulnerabilities.persistent.len();
84
85 self.summary.licenses_added = self.licenses.new_licenses.len();
86 self.summary.licenses_removed = self.licenses.removed_licenses.len();
87 }
88
89 #[must_use]
94 pub fn has_changes(&self) -> bool {
95 self.summary.total_changes > 0
96 || !self.components.is_empty()
97 || !self.dependencies.is_empty()
98 || !self.graph_changes.is_empty()
99 || !self.vulnerabilities.introduced.is_empty()
100 || !self.vulnerabilities.resolved.is_empty()
101 }
102
103 #[must_use]
105 pub fn find_component_by_id(&self, id: &CanonicalId) -> Option<&ComponentChange> {
106 let id_str = id.value();
107 self.components
108 .added
109 .iter()
110 .chain(self.components.removed.iter())
111 .chain(self.components.modified.iter())
112 .find(|c| c.id == id_str)
113 }
114
115 #[must_use]
117 pub fn find_component_by_id_str(&self, id_str: &str) -> Option<&ComponentChange> {
118 self.components
119 .added
120 .iter()
121 .chain(self.components.removed.iter())
122 .chain(self.components.modified.iter())
123 .find(|c| c.id == id_str)
124 }
125
126 #[must_use]
128 pub fn all_component_changes(&self) -> Vec<&ComponentChange> {
129 self.components
130 .added
131 .iter()
132 .chain(self.components.removed.iter())
133 .chain(self.components.modified.iter())
134 .collect()
135 }
136
137 #[must_use]
139 pub fn find_vulns_for_component(
140 &self,
141 component_id: &CanonicalId,
142 ) -> Vec<&VulnerabilityDetail> {
143 let id_str = component_id.value();
144 self.vulnerabilities
145 .introduced
146 .iter()
147 .chain(self.vulnerabilities.resolved.iter())
148 .chain(self.vulnerabilities.persistent.iter())
149 .filter(|v| v.component_id == id_str)
150 .collect()
151 }
152
153 #[must_use]
155 pub fn build_component_id_index(&self) -> HashMap<String, &ComponentChange> {
156 self.components
157 .added
158 .iter()
159 .chain(&self.components.removed)
160 .chain(&self.components.modified)
161 .map(|c| (c.id.clone(), c))
162 .collect()
163 }
164
165 pub fn filter_by_severity(&mut self, min_severity: &str) {
167 let min_sev = severity_rank(min_severity);
168
169 self.vulnerabilities
170 .introduced
171 .retain(|v| severity_rank(&v.severity) >= min_sev);
172 self.vulnerabilities
173 .resolved
174 .retain(|v| severity_rank(&v.severity) >= min_sev);
175 self.vulnerabilities
176 .persistent
177 .retain(|v| severity_rank(&v.severity) >= min_sev);
178
179 self.calculate_summary();
181 }
182
183 pub fn filter_by_vex(&mut self) {
187 self.vulnerabilities
188 .introduced
189 .retain(VulnerabilityDetail::is_vex_actionable);
190 self.vulnerabilities
191 .resolved
192 .retain(VulnerabilityDetail::is_vex_actionable);
193 self.vulnerabilities
194 .persistent
195 .retain(VulnerabilityDetail::is_vex_actionable);
196
197 self.calculate_summary();
198 }
199}
200
201impl Default for DiffResult {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
209pub struct DiffSummary {
210 pub total_changes: usize,
211 pub components_added: usize,
212 pub components_removed: usize,
213 pub components_modified: usize,
214 pub dependencies_added: usize,
215 pub dependencies_removed: usize,
216 pub graph_changes_count: usize,
217 pub vulnerabilities_introduced: usize,
218 pub vulnerabilities_resolved: usize,
219 pub vulnerabilities_persistent: usize,
220 pub licenses_added: usize,
221 pub licenses_removed: usize,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct ChangeSet<T> {
227 pub added: Vec<T>,
228 pub removed: Vec<T>,
229 pub modified: Vec<T>,
230}
231
232impl<T> ChangeSet<T> {
233 #[must_use]
234 pub const fn new() -> Self {
235 Self {
236 added: Vec::new(),
237 removed: Vec::new(),
238 modified: Vec::new(),
239 }
240 }
241
242 #[must_use]
243 pub fn is_empty(&self) -> bool {
244 self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
245 }
246
247 #[must_use]
248 pub fn total(&self) -> usize {
249 self.added.len() + self.removed.len() + self.modified.len()
250 }
251}
252
253impl<T> Default for ChangeSet<T> {
254 fn default() -> Self {
255 Self::new()
256 }
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct MatchInfo {
264 pub score: f64,
266 pub method: String,
268 pub reason: String,
270 #[serde(skip_serializing_if = "Vec::is_empty")]
272 pub score_breakdown: Vec<MatchScoreComponent>,
273 #[serde(skip_serializing_if = "Vec::is_empty")]
275 pub normalizations: Vec<String>,
276 #[serde(skip_serializing_if = "Option::is_none")]
278 pub confidence_interval: Option<ConfidenceInterval>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ConfidenceInterval {
287 pub lower: f64,
289 pub upper: f64,
291 pub level: f64,
293}
294
295impl ConfidenceInterval {
296 #[must_use]
298 pub const fn new(lower: f64, upper: f64, level: f64) -> Self {
299 Self {
300 lower: lower.clamp(0.0, 1.0),
301 upper: upper.clamp(0.0, 1.0),
302 level,
303 }
304 }
305
306 #[must_use]
310 pub fn from_score_and_error(score: f64, std_error: f64) -> Self {
311 let margin = 1.96 * std_error;
312 Self::new(score - margin, score + margin, 0.95)
313 }
314
315 #[must_use]
319 pub fn from_tier(score: f64, tier: &str) -> Self {
320 let margin = match tier {
321 "ExactIdentifier" => 0.0,
322 "Alias" => 0.02,
323 "EcosystemRule" => 0.03,
324 "CustomRule" => 0.05,
325 "Fuzzy" => 0.08,
326 _ => 0.10,
327 };
328 Self::new(score - margin, score + margin, 0.95)
329 }
330
331 #[must_use]
333 pub fn width(&self) -> f64 {
334 self.upper - self.lower
335 }
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct MatchScoreComponent {
341 pub name: String,
343 pub weight: f64,
345 pub raw_score: f64,
347 pub weighted_score: f64,
349 pub description: String,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct ComponentChange {
356 pub id: String,
358 #[serde(skip)]
360 pub canonical_id: Option<CanonicalId>,
361 #[serde(skip)]
363 pub component_ref: Option<ComponentRef>,
364 #[serde(skip)]
366 pub old_canonical_id: Option<CanonicalId>,
367 pub name: String,
369 pub old_version: Option<String>,
371 pub new_version: Option<String>,
373 pub ecosystem: Option<String>,
375 pub change_type: ChangeType,
377 pub field_changes: Vec<FieldChange>,
379 pub cost: u32,
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub match_info: Option<MatchInfo>,
384}
385
386impl ComponentChange {
387 pub fn added(component: &Component, cost: u32) -> Self {
389 Self {
390 id: component.canonical_id.to_string(),
391 canonical_id: Some(component.canonical_id.clone()),
392 component_ref: Some(ComponentRef::from_component(component)),
393 old_canonical_id: None,
394 name: component.name.clone(),
395 old_version: None,
396 new_version: component.version.clone(),
397 ecosystem: component
398 .ecosystem
399 .as_ref()
400 .map(std::string::ToString::to_string),
401 change_type: ChangeType::Added,
402 field_changes: Vec::new(),
403 cost,
404 match_info: None,
405 }
406 }
407
408 pub fn removed(component: &Component, cost: u32) -> Self {
410 Self {
411 id: component.canonical_id.to_string(),
412 canonical_id: Some(component.canonical_id.clone()),
413 component_ref: Some(ComponentRef::from_component(component)),
414 old_canonical_id: Some(component.canonical_id.clone()),
415 name: component.name.clone(),
416 old_version: component.version.clone(),
417 new_version: None,
418 ecosystem: component
419 .ecosystem
420 .as_ref()
421 .map(std::string::ToString::to_string),
422 change_type: ChangeType::Removed,
423 field_changes: Vec::new(),
424 cost,
425 match_info: None,
426 }
427 }
428
429 pub fn modified(
431 old: &Component,
432 new: &Component,
433 field_changes: Vec<FieldChange>,
434 cost: u32,
435 ) -> Self {
436 Self {
437 id: new.canonical_id.to_string(),
438 canonical_id: Some(new.canonical_id.clone()),
439 component_ref: Some(ComponentRef::from_component(new)),
440 old_canonical_id: Some(old.canonical_id.clone()),
441 name: new.name.clone(),
442 old_version: old.version.clone(),
443 new_version: new.version.clone(),
444 ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
445 change_type: ChangeType::Modified,
446 field_changes,
447 cost,
448 match_info: None,
449 }
450 }
451
452 pub fn modified_with_match(
454 old: &Component,
455 new: &Component,
456 field_changes: Vec<FieldChange>,
457 cost: u32,
458 match_info: MatchInfo,
459 ) -> Self {
460 Self {
461 id: new.canonical_id.to_string(),
462 canonical_id: Some(new.canonical_id.clone()),
463 component_ref: Some(ComponentRef::from_component(new)),
464 old_canonical_id: Some(old.canonical_id.clone()),
465 name: new.name.clone(),
466 old_version: old.version.clone(),
467 new_version: new.version.clone(),
468 ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
469 change_type: ChangeType::Modified,
470 field_changes,
471 cost,
472 match_info: Some(match_info),
473 }
474 }
475
476 #[must_use]
478 pub fn with_match_info(mut self, match_info: MatchInfo) -> Self {
479 self.match_info = Some(match_info);
480 self
481 }
482
483 #[must_use]
485 pub fn get_canonical_id(&self) -> CanonicalId {
486 self.canonical_id.clone().unwrap_or_else(|| {
487 CanonicalId::from_name_version(
488 &self.name,
489 self.new_version.as_deref().or(self.old_version.as_deref()),
490 )
491 })
492 }
493
494 #[must_use]
496 pub fn get_component_ref(&self) -> ComponentRef {
497 self.component_ref.clone().unwrap_or_else(|| {
498 ComponentRef::with_version(
499 self.get_canonical_id(),
500 &self.name,
501 self.new_version
502 .clone()
503 .or_else(|| self.old_version.clone()),
504 )
505 })
506 }
507}
508
509impl MatchInfo {
510 #[must_use]
512 pub fn from_explanation(explanation: &crate::matching::MatchExplanation) -> Self {
513 let method = format!("{:?}", explanation.tier);
514 let ci = ConfidenceInterval::from_tier(explanation.score, &method);
515 Self {
516 score: explanation.score,
517 method,
518 reason: explanation.reason.clone(),
519 score_breakdown: explanation
520 .score_breakdown
521 .iter()
522 .map(|c| MatchScoreComponent {
523 name: c.name.clone(),
524 weight: c.weight,
525 raw_score: c.raw_score,
526 weighted_score: c.weighted_score,
527 description: c.description.clone(),
528 })
529 .collect(),
530 normalizations: explanation.normalizations_applied.clone(),
531 confidence_interval: Some(ci),
532 }
533 }
534
535 #[must_use]
537 pub fn simple(score: f64, method: &str, reason: &str) -> Self {
538 let ci = ConfidenceInterval::from_tier(score, method);
539 Self {
540 score,
541 method: method.to_string(),
542 reason: reason.to_string(),
543 score_breakdown: Vec::new(),
544 normalizations: Vec::new(),
545 confidence_interval: Some(ci),
546 }
547 }
548
549 #[must_use]
551 pub const fn with_confidence_interval(mut self, ci: ConfidenceInterval) -> Self {
552 self.confidence_interval = Some(ci);
553 self
554 }
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
559pub enum ChangeType {
560 Added,
561 Removed,
562 Modified,
563 Unchanged,
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct FieldChange {
569 pub field: String,
570 pub old_value: Option<String>,
571 pub new_value: Option<String>,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
576pub struct DependencyChange {
577 pub from: String,
579 pub to: String,
581 pub relationship: String,
583 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub scope: Option<String>,
586 pub change_type: ChangeType,
588}
589
590impl DependencyChange {
591 #[must_use]
592 pub fn added(edge: &DependencyEdge) -> Self {
593 Self {
594 from: edge.from.to_string(),
595 to: edge.to.to_string(),
596 relationship: edge.relationship.to_string(),
597 scope: edge.scope.as_ref().map(std::string::ToString::to_string),
598 change_type: ChangeType::Added,
599 }
600 }
601
602 #[must_use]
603 pub fn removed(edge: &DependencyEdge) -> Self {
604 Self {
605 from: edge.from.to_string(),
606 to: edge.to.to_string(),
607 relationship: edge.relationship.to_string(),
608 scope: edge.scope.as_ref().map(std::string::ToString::to_string),
609 change_type: ChangeType::Removed,
610 }
611 }
612}
613
614#[derive(Debug, Clone, Default, Serialize, Deserialize)]
616pub struct LicenseChanges {
617 pub new_licenses: Vec<LicenseChange>,
619 pub removed_licenses: Vec<LicenseChange>,
621 pub conflicts: Vec<LicenseConflict>,
623 pub component_changes: Vec<ComponentLicenseChange>,
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct LicenseChange {
630 pub license: String,
632 pub components: Vec<String>,
634 pub family: String,
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct LicenseConflict {
641 pub license_a: String,
642 pub license_b: String,
643 pub component: String,
644 pub description: String,
645}
646
647#[derive(Debug, Clone, Serialize, Deserialize)]
649pub struct ComponentLicenseChange {
650 pub component_id: String,
651 pub component_name: String,
652 pub old_licenses: Vec<String>,
653 pub new_licenses: Vec<String>,
654}
655
656#[derive(Debug, Clone, Default, Serialize, Deserialize)]
658pub struct VulnerabilityChanges {
659 pub introduced: Vec<VulnerabilityDetail>,
661 pub resolved: Vec<VulnerabilityDetail>,
663 pub persistent: Vec<VulnerabilityDetail>,
665}
666
667impl VulnerabilityChanges {
668 #[must_use]
670 pub fn introduced_by_severity(&self) -> HashMap<String, usize> {
671 let mut counts = HashMap::with_capacity(5);
673 for vuln in &self.introduced {
674 *counts.entry(vuln.severity.clone()).or_insert(0) += 1;
675 }
676 counts
677 }
678
679 #[must_use]
681 pub fn critical_and_high_introduced(&self) -> Vec<&VulnerabilityDetail> {
682 self.introduced
683 .iter()
684 .filter(|v| v.severity == "Critical" || v.severity == "High")
685 .collect()
686 }
687
688 pub fn vex_summary(&self) -> VexCoverageSummary {
690 let all_vulns: Vec<&VulnerabilityDetail> = self
691 .introduced
692 .iter()
693 .chain(&self.resolved)
694 .chain(&self.persistent)
695 .collect();
696
697 let total = all_vulns.len();
698 let mut with_vex = 0;
699 let mut by_state: HashMap<crate::model::VexState, usize> = HashMap::with_capacity(4);
700 let mut actionable = 0;
701
702 for vuln in &all_vulns {
703 if let Some(ref state) = vuln.vex_state {
704 with_vex += 1;
705 *by_state.entry(state.clone()).or_insert(0) += 1;
706 }
707 if vuln.is_vex_actionable() {
708 actionable += 1;
709 }
710 }
711
712 let introduced_without_vex = self
714 .introduced
715 .iter()
716 .filter(|v| v.vex_state.is_none())
717 .count();
718
719 let persistent_without_vex = self
720 .persistent
721 .iter()
722 .filter(|v| v.vex_state.is_none())
723 .count();
724
725 VexCoverageSummary {
726 total_vulns: total,
727 with_vex,
728 without_vex: total - with_vex,
729 actionable,
730 coverage_pct: if total > 0 {
731 (with_vex as f64 / total as f64) * 100.0
732 } else {
733 100.0
734 },
735 by_state,
736 introduced_without_vex,
737 persistent_without_vex,
738 }
739 }
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize)]
744#[must_use]
745pub struct VexCoverageSummary {
746 pub total_vulns: usize,
748 pub with_vex: usize,
750 pub without_vex: usize,
752 pub actionable: usize,
754 pub coverage_pct: f64,
756 pub by_state: HashMap<crate::model::VexState, usize>,
758 pub introduced_without_vex: usize,
760 pub persistent_without_vex: usize,
762}
763
764#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
766pub enum SlaStatus {
767 Overdue(i64),
769 DueSoon(i64),
771 OnTrack(i64),
773 NoDueDate,
775}
776
777impl SlaStatus {
778 #[must_use]
780 pub fn display(&self, days_since_published: Option<i64>) -> String {
781 match self {
782 Self::Overdue(days) => format!("{days}d late"),
783 Self::DueSoon(days) | Self::OnTrack(days) => format!("{days}d left"),
784 Self::NoDueDate => {
785 days_since_published.map_or_else(|| "-".to_string(), |age| format!("{age}d old"))
786 }
787 }
788 }
789
790 #[must_use]
792 pub const fn is_overdue(&self) -> bool {
793 matches!(self, Self::Overdue(_))
794 }
795
796 #[must_use]
798 pub const fn is_due_soon(&self) -> bool {
799 matches!(self, Self::DueSoon(_))
800 }
801}
802
803#[derive(Debug, Clone, Serialize, Deserialize)]
805pub struct VulnerabilityDetail {
806 pub id: String,
808 pub source: String,
810 pub severity: String,
812 pub cvss_score: Option<f32>,
814 pub component_id: String,
816 #[serde(skip)]
818 pub component_canonical_id: Option<CanonicalId>,
819 #[serde(skip)]
821 pub component_ref: Option<ComponentRef>,
822 pub component_name: String,
824 pub version: Option<String>,
826 pub cwes: Vec<String>,
828 pub description: Option<String>,
830 pub remediation: Option<String>,
832 #[serde(default)]
834 pub is_kev: bool,
835 #[serde(default)]
837 pub component_depth: Option<u32>,
838 #[serde(default)]
840 pub published_date: Option<String>,
841 #[serde(default)]
843 pub kev_due_date: Option<String>,
844 #[serde(default)]
846 pub days_since_published: Option<i64>,
847 #[serde(default)]
849 pub days_until_due: Option<i64>,
850 #[serde(default, skip_serializing_if = "Option::is_none")]
852 pub vex_state: Option<crate::model::VexState>,
853 #[serde(default, skip_serializing_if = "Option::is_none")]
855 pub vex_justification: Option<crate::model::VexJustification>,
856 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub vex_impact_statement: Option<String>,
859}
860
861impl VulnerabilityDetail {
862 #[must_use]
867 pub const fn is_vex_actionable(&self) -> bool {
868 !matches!(
869 self.vex_state,
870 Some(crate::model::VexState::NotAffected | crate::model::VexState::Fixed)
871 )
872 }
873
874 pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
876 let days_since_published = vuln.published.map(|dt| {
878 let today = chrono::Utc::now().date_naive();
879 (today - dt.date_naive()).num_days()
880 });
881
882 let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
884
885 let (kev_due_date, days_until_due) = vuln.kev_info.as_ref().map_or((None, None), |kev| {
887 (
888 Some(kev.due_date.format("%Y-%m-%d").to_string()),
889 Some(kev.days_until_due()),
890 )
891 });
892
893 Self {
894 id: vuln.id.clone(),
895 source: vuln.source.to_string(),
896 severity: vuln
897 .severity
898 .as_ref()
899 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string),
900 cvss_score: vuln.max_cvss_score(),
901 component_id: component.canonical_id.to_string(),
902 component_canonical_id: Some(component.canonical_id.clone()),
903 component_ref: Some(ComponentRef::from_component(component)),
904 component_name: component.name.clone(),
905 version: component.version.clone(),
906 cwes: vuln.cwes.clone(),
907 description: vuln.description.clone(),
908 remediation: vuln.remediation.as_ref().map(|r| {
909 format!(
910 "{}: {}",
911 r.remediation_type,
912 r.description.as_deref().unwrap_or("")
913 )
914 }),
915 is_kev: vuln.is_kev,
916 component_depth: None,
917 published_date,
918 kev_due_date,
919 days_since_published,
920 days_until_due,
921 vex_state: {
922 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
923 vex_source.map(|v| v.status.clone())
924 },
925 vex_justification: {
926 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
927 vex_source.and_then(|v| v.justification.clone())
928 },
929 vex_impact_statement: {
930 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
931 vex_source.and_then(|v| v.impact_statement.clone())
932 },
933 }
934 }
935
936 #[must_use]
938 pub fn from_ref_with_depth(
939 vuln: &VulnerabilityRef,
940 component: &Component,
941 depth: Option<u32>,
942 ) -> Self {
943 let mut detail = Self::from_ref(vuln, component);
944 detail.component_depth = depth;
945 detail
946 }
947
948 #[must_use]
954 pub fn sla_status(&self) -> SlaStatus {
955 if let Some(days) = self.days_until_due {
957 if days < 0 {
958 return SlaStatus::Overdue(-days);
959 } else if days <= 3 {
960 return SlaStatus::DueSoon(days);
961 }
962 return SlaStatus::OnTrack(days);
963 }
964
965 if let Some(age_days) = self.days_since_published {
967 let sla_days = match self.severity.to_lowercase().as_str() {
968 "critical" => 1,
969 "high" => 7,
970 "medium" => 30,
971 "low" => 90,
972 _ => return SlaStatus::NoDueDate,
973 };
974 let remaining = sla_days - age_days;
975 if remaining < 0 {
976 return SlaStatus::Overdue(-remaining);
977 } else if remaining <= 3 {
978 return SlaStatus::DueSoon(remaining);
979 }
980 return SlaStatus::OnTrack(remaining);
981 }
982
983 SlaStatus::NoDueDate
984 }
985
986 #[must_use]
988 pub fn get_component_id(&self) -> CanonicalId {
989 self.component_canonical_id.clone().unwrap_or_else(|| {
990 CanonicalId::from_name_version(&self.component_name, self.version.as_deref())
991 })
992 }
993
994 #[must_use]
996 pub fn get_component_ref(&self) -> ComponentRef {
997 self.component_ref.clone().unwrap_or_else(|| {
998 ComponentRef::with_version(
999 self.get_component_id(),
1000 &self.component_name,
1001 self.version.clone(),
1002 )
1003 })
1004 }
1005}
1006
1007#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1013pub struct DependencyGraphChange {
1014 pub component_id: CanonicalId,
1016 pub component_name: String,
1018 pub change: DependencyChangeType,
1020 pub impact: GraphChangeImpact,
1022}
1023
1024#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1026#[non_exhaustive]
1027pub enum DependencyChangeType {
1028 DependencyAdded {
1030 dependency_id: CanonicalId,
1031 dependency_name: String,
1032 },
1033
1034 DependencyRemoved {
1036 dependency_id: CanonicalId,
1037 dependency_name: String,
1038 },
1039
1040 RelationshipChanged {
1042 dependency_id: CanonicalId,
1043 dependency_name: String,
1044 old_relationship: String,
1045 new_relationship: String,
1046 old_scope: Option<String>,
1047 new_scope: Option<String>,
1048 },
1049
1050 Reparented {
1052 dependency_id: CanonicalId,
1053 dependency_name: String,
1054 old_parent_id: CanonicalId,
1055 old_parent_name: String,
1056 new_parent_id: CanonicalId,
1057 new_parent_name: String,
1058 },
1059
1060 DepthChanged {
1062 old_depth: u32, new_depth: u32,
1064 },
1065}
1066
1067impl DependencyChangeType {
1068 #[must_use]
1070 pub const fn kind(&self) -> &'static str {
1071 match self {
1072 Self::DependencyAdded { .. } => "added",
1073 Self::DependencyRemoved { .. } => "removed",
1074 Self::RelationshipChanged { .. } => "relationship_changed",
1075 Self::Reparented { .. } => "reparented",
1076 Self::DepthChanged { .. } => "depth_changed",
1077 }
1078 }
1079}
1080
1081#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1083pub enum GraphChangeImpact {
1084 Low,
1086 Medium,
1088 High,
1090 Critical,
1092}
1093
1094impl GraphChangeImpact {
1095 #[must_use]
1096 pub const fn as_str(&self) -> &'static str {
1097 match self {
1098 Self::Low => "low",
1099 Self::Medium => "medium",
1100 Self::High => "high",
1101 Self::Critical => "critical",
1102 }
1103 }
1104
1105 #[must_use]
1107 pub fn from_label(s: &str) -> Self {
1108 match s.to_lowercase().as_str() {
1109 "critical" => Self::Critical,
1110 "high" => Self::High,
1111 "medium" => Self::Medium,
1112 _ => Self::Low,
1113 }
1114 }
1115}
1116
1117impl std::fmt::Display for GraphChangeImpact {
1118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1119 write!(f, "{}", self.as_str())
1120 }
1121}
1122
1123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1125pub struct GraphChangeSummary {
1126 pub total_changes: usize,
1127 pub dependencies_added: usize,
1128 pub dependencies_removed: usize,
1129 pub relationship_changed: usize,
1130 pub reparented: usize,
1131 pub depth_changed: usize,
1132 pub by_impact: GraphChangesByImpact,
1133}
1134
1135impl GraphChangeSummary {
1136 #[must_use]
1138 pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
1139 let mut summary = Self {
1140 total_changes: changes.len(),
1141 ..Default::default()
1142 };
1143
1144 for change in changes {
1145 match &change.change {
1146 DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
1147 DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
1148 DependencyChangeType::RelationshipChanged { .. } => {
1149 summary.relationship_changed += 1;
1150 }
1151 DependencyChangeType::Reparented { .. } => summary.reparented += 1,
1152 DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
1153 }
1154
1155 match change.impact {
1156 GraphChangeImpact::Low => summary.by_impact.low += 1,
1157 GraphChangeImpact::Medium => summary.by_impact.medium += 1,
1158 GraphChangeImpact::High => summary.by_impact.high += 1,
1159 GraphChangeImpact::Critical => summary.by_impact.critical += 1,
1160 }
1161 }
1162
1163 summary
1164 }
1165}
1166
1167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1168pub struct GraphChangesByImpact {
1169 pub low: usize,
1170 pub medium: usize,
1171 pub high: usize,
1172 pub critical: usize,
1173}