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)]
23pub struct DiffResult {
24 pub summary: DiffSummary,
26 pub components: ChangeSet<ComponentChange>,
28 pub dependencies: ChangeSet<DependencyChange>,
30 pub licenses: LicenseChanges,
32 pub vulnerabilities: VulnerabilityChanges,
34 pub semantic_score: f64,
36 #[serde(default)]
38 pub graph_changes: Vec<DependencyGraphChange>,
39 #[serde(default)]
41 pub graph_summary: Option<GraphChangeSummary>,
42 #[serde(default)]
44 pub rules_applied: usize,
45}
46
47impl DiffResult {
48 pub fn new() -> Self {
50 Self {
51 summary: DiffSummary::default(),
52 components: ChangeSet::new(),
53 dependencies: ChangeSet::new(),
54 licenses: LicenseChanges::default(),
55 vulnerabilities: VulnerabilityChanges::default(),
56 semantic_score: 0.0,
57 graph_changes: Vec::new(),
58 graph_summary: None,
59 rules_applied: 0,
60 }
61 }
62
63 pub fn calculate_summary(&mut self) {
65 self.summary.components_added = self.components.added.len();
66 self.summary.components_removed = self.components.removed.len();
67 self.summary.components_modified = self.components.modified.len();
68 self.summary.total_changes = self.summary.components_added
69 + self.summary.components_removed
70 + self.summary.components_modified;
71
72 self.summary.dependencies_added = self.dependencies.added.len();
73 self.summary.dependencies_removed = self.dependencies.removed.len();
74
75 self.summary.vulnerabilities_introduced = self.vulnerabilities.introduced.len();
76 self.summary.vulnerabilities_resolved = self.vulnerabilities.resolved.len();
77 self.summary.vulnerabilities_persistent = self.vulnerabilities.persistent.len();
78
79 self.summary.licenses_added = self.licenses.new_licenses.len();
80 self.summary.licenses_removed = self.licenses.removed_licenses.len();
81 }
82
83 pub fn has_changes(&self) -> bool {
85 self.summary.total_changes > 0
86 || !self.dependencies.is_empty()
87 || !self.vulnerabilities.introduced.is_empty()
88 || !self.vulnerabilities.resolved.is_empty()
89 || !self.graph_changes.is_empty()
90 }
91
92 pub fn set_graph_changes(&mut self, changes: Vec<DependencyGraphChange>) {
94 self.graph_summary = Some(GraphChangeSummary::from_changes(&changes));
95 self.graph_changes = changes;
96 }
97
98 pub fn find_component_by_id(&self, id: &CanonicalId) -> Option<&ComponentChange> {
100 let id_str = id.value();
101 self.components
102 .added
103 .iter()
104 .chain(self.components.removed.iter())
105 .chain(self.components.modified.iter())
106 .find(|c| c.id == id_str)
107 }
108
109 pub fn find_component_by_id_str(&self, id_str: &str) -> Option<&ComponentChange> {
111 self.components
112 .added
113 .iter()
114 .chain(self.components.removed.iter())
115 .chain(self.components.modified.iter())
116 .find(|c| c.id == id_str)
117 }
118
119 pub fn all_component_changes(&self) -> Vec<&ComponentChange> {
121 self.components
122 .added
123 .iter()
124 .chain(self.components.removed.iter())
125 .chain(self.components.modified.iter())
126 .collect()
127 }
128
129 pub fn find_vulns_for_component(&self, component_id: &CanonicalId) -> Vec<&VulnerabilityDetail> {
131 let id_str = component_id.value();
132 self.vulnerabilities
133 .introduced
134 .iter()
135 .chain(self.vulnerabilities.resolved.iter())
136 .chain(self.vulnerabilities.persistent.iter())
137 .filter(|v| v.component_id == id_str)
138 .collect()
139 }
140
141 pub fn build_component_id_index(&self) -> HashMap<String, &ComponentChange> {
143 let capacity = self.components.added.len()
144 + self.components.removed.len()
145 + self.components.modified.len();
146 let mut index = HashMap::with_capacity(capacity);
147 for c in self.components.added.iter() {
148 index.insert(c.id.clone(), c);
149 }
150 for c in self.components.removed.iter() {
151 index.insert(c.id.clone(), c);
152 }
153 for c in self.components.modified.iter() {
154 index.insert(c.id.clone(), c);
155 }
156 index
157 }
158
159 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 self.calculate_summary();
175 }
176
177 pub fn filter_by_vex(&mut self) {
181 self.vulnerabilities
182 .introduced
183 .retain(|v| v.is_vex_actionable());
184 self.vulnerabilities
185 .resolved
186 .retain(|v| v.is_vex_actionable());
187 self.vulnerabilities
188 .persistent
189 .retain(|v| v.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#[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#[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 pub fn new() -> Self {
227 Self {
228 added: Vec::new(),
229 removed: Vec::new(),
230 modified: Vec::new(),
231 }
232 }
233
234 pub fn is_empty(&self) -> bool {
235 self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
236 }
237
238 pub fn total(&self) -> usize {
239 self.added.len() + self.removed.len() + self.modified.len()
240 }
241}
242
243impl<T> Default for ChangeSet<T> {
244 fn default() -> Self {
245 Self::new()
246 }
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct MatchInfo {
254 pub score: f64,
256 pub method: String,
258 pub reason: String,
260 #[serde(skip_serializing_if = "Vec::is_empty")]
262 pub score_breakdown: Vec<MatchScoreComponent>,
263 #[serde(skip_serializing_if = "Vec::is_empty")]
265 pub normalizations: Vec<String>,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub confidence_interval: Option<ConfidenceInterval>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct ConfidenceInterval {
277 pub lower: f64,
279 pub upper: f64,
281 pub level: f64,
283}
284
285impl ConfidenceInterval {
286 pub fn new(lower: f64, upper: f64, level: f64) -> Self {
288 Self {
289 lower: lower.clamp(0.0, 1.0),
290 upper: upper.clamp(0.0, 1.0),
291 level,
292 }
293 }
294
295 pub fn from_score_and_error(score: f64, std_error: f64) -> Self {
299 let margin = 1.96 * std_error;
300 Self::new(score - margin, score + margin, 0.95)
301 }
302
303 pub fn from_tier(score: f64, tier: &str) -> Self {
307 let margin = match tier {
308 "ExactIdentifier" => 0.0,
309 "Alias" => 0.02,
310 "EcosystemRule" => 0.03,
311 "CustomRule" => 0.05,
312 "Fuzzy" => 0.08,
313 _ => 0.10,
314 };
315 Self::new(score - margin, score + margin, 0.95)
316 }
317
318 pub fn width(&self) -> f64 {
320 self.upper - self.lower
321 }
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct MatchScoreComponent {
327 pub name: String,
329 pub weight: f64,
331 pub raw_score: f64,
333 pub weighted_score: f64,
335 pub description: String,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct ComponentChange {
342 pub id: String,
344 #[serde(skip)]
346 pub canonical_id: Option<CanonicalId>,
347 #[serde(skip)]
349 pub component_ref: Option<ComponentRef>,
350 #[serde(skip)]
352 pub old_canonical_id: Option<CanonicalId>,
353 pub name: String,
355 pub old_version: Option<String>,
357 pub new_version: Option<String>,
359 pub ecosystem: Option<String>,
361 pub change_type: ChangeType,
363 pub field_changes: Vec<FieldChange>,
365 pub cost: u32,
367 #[serde(skip_serializing_if = "Option::is_none")]
369 pub match_info: Option<MatchInfo>,
370}
371
372impl ComponentChange {
373 pub fn added(component: &Component, cost: u32) -> Self {
375 Self {
376 id: component.canonical_id.to_string(),
377 canonical_id: Some(component.canonical_id.clone()),
378 component_ref: Some(ComponentRef::from_component(component)),
379 old_canonical_id: None,
380 name: component.name.clone(),
381 old_version: None,
382 new_version: component.version.clone(),
383 ecosystem: component.ecosystem.as_ref().map(|e| e.to_string()),
384 change_type: ChangeType::Added,
385 field_changes: Vec::new(),
386 cost,
387 match_info: None,
388 }
389 }
390
391 pub fn removed(component: &Component, cost: u32) -> Self {
393 Self {
394 id: component.canonical_id.to_string(),
395 canonical_id: Some(component.canonical_id.clone()),
396 component_ref: Some(ComponentRef::from_component(component)),
397 old_canonical_id: Some(component.canonical_id.clone()),
398 name: component.name.clone(),
399 old_version: component.version.clone(),
400 new_version: None,
401 ecosystem: component.ecosystem.as_ref().map(|e| e.to_string()),
402 change_type: ChangeType::Removed,
403 field_changes: Vec::new(),
404 cost,
405 match_info: None,
406 }
407 }
408
409 pub fn modified(
411 old: &Component,
412 new: &Component,
413 field_changes: Vec<FieldChange>,
414 cost: u32,
415 ) -> Self {
416 Self {
417 id: new.canonical_id.to_string(),
418 canonical_id: Some(new.canonical_id.clone()),
419 component_ref: Some(ComponentRef::from_component(new)),
420 old_canonical_id: Some(old.canonical_id.clone()),
421 name: new.name.clone(),
422 old_version: old.version.clone(),
423 new_version: new.version.clone(),
424 ecosystem: new.ecosystem.as_ref().map(|e| e.to_string()),
425 change_type: ChangeType::Modified,
426 field_changes,
427 cost,
428 match_info: None,
429 }
430 }
431
432 pub fn modified_with_match(
434 old: &Component,
435 new: &Component,
436 field_changes: Vec<FieldChange>,
437 cost: u32,
438 match_info: MatchInfo,
439 ) -> Self {
440 Self {
441 id: new.canonical_id.to_string(),
442 canonical_id: Some(new.canonical_id.clone()),
443 component_ref: Some(ComponentRef::from_component(new)),
444 old_canonical_id: Some(old.canonical_id.clone()),
445 name: new.name.clone(),
446 old_version: old.version.clone(),
447 new_version: new.version.clone(),
448 ecosystem: new.ecosystem.as_ref().map(|e| e.to_string()),
449 change_type: ChangeType::Modified,
450 field_changes,
451 cost,
452 match_info: Some(match_info),
453 }
454 }
455
456 pub fn with_match_info(mut self, match_info: MatchInfo) -> Self {
458 self.match_info = Some(match_info);
459 self
460 }
461
462 pub fn get_canonical_id(&self) -> CanonicalId {
464 self.canonical_id
465 .clone()
466 .unwrap_or_else(|| CanonicalId::from_name_version(&self.name, self.new_version.as_deref().or(self.old_version.as_deref())))
467 }
468
469 pub fn get_component_ref(&self) -> ComponentRef {
471 self.component_ref.clone().unwrap_or_else(|| {
472 ComponentRef::with_version(
473 self.get_canonical_id(),
474 &self.name,
475 self.new_version.clone().or_else(|| self.old_version.clone()),
476 )
477 })
478 }
479}
480
481impl MatchInfo {
482 pub fn from_explanation(explanation: &crate::matching::MatchExplanation) -> Self {
484 let method = format!("{:?}", explanation.tier);
485 let ci = ConfidenceInterval::from_tier(explanation.score, &method);
486 Self {
487 score: explanation.score,
488 method,
489 reason: explanation.reason.clone(),
490 score_breakdown: explanation
491 .score_breakdown
492 .iter()
493 .map(|c| MatchScoreComponent {
494 name: c.name.clone(),
495 weight: c.weight,
496 raw_score: c.raw_score,
497 weighted_score: c.weighted_score,
498 description: c.description.clone(),
499 })
500 .collect(),
501 normalizations: explanation.normalizations_applied.clone(),
502 confidence_interval: Some(ci),
503 }
504 }
505
506 pub fn simple(score: f64, method: &str, reason: &str) -> Self {
508 let ci = ConfidenceInterval::from_tier(score, method);
509 Self {
510 score,
511 method: method.to_string(),
512 reason: reason.to_string(),
513 score_breakdown: Vec::new(),
514 normalizations: Vec::new(),
515 confidence_interval: Some(ci),
516 }
517 }
518
519 pub fn with_confidence_interval(mut self, ci: ConfidenceInterval) -> Self {
521 self.confidence_interval = Some(ci);
522 self
523 }
524}
525
526#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
528pub enum ChangeType {
529 Added,
530 Removed,
531 Modified,
532 Unchanged,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct FieldChange {
538 pub field: String,
539 pub old_value: Option<String>,
540 pub new_value: Option<String>,
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize)]
545pub struct DependencyChange {
546 pub from: String,
548 pub to: String,
550 pub relationship: String,
552 pub change_type: ChangeType,
554}
555
556impl DependencyChange {
557 pub fn added(edge: &DependencyEdge) -> Self {
558 Self {
559 from: edge.from.to_string(),
560 to: edge.to.to_string(),
561 relationship: edge.relationship.to_string(),
562 change_type: ChangeType::Added,
563 }
564 }
565
566 pub fn removed(edge: &DependencyEdge) -> Self {
567 Self {
568 from: edge.from.to_string(),
569 to: edge.to.to_string(),
570 relationship: edge.relationship.to_string(),
571 change_type: ChangeType::Removed,
572 }
573 }
574}
575
576#[derive(Debug, Clone, Default, Serialize, Deserialize)]
578pub struct LicenseChanges {
579 pub new_licenses: Vec<LicenseChange>,
581 pub removed_licenses: Vec<LicenseChange>,
583 pub conflicts: Vec<LicenseConflict>,
585 pub component_changes: Vec<ComponentLicenseChange>,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct LicenseChange {
592 pub license: String,
594 pub components: Vec<String>,
596 pub family: String,
598}
599
600#[derive(Debug, Clone, Serialize, Deserialize)]
602pub struct LicenseConflict {
603 pub license_a: String,
604 pub license_b: String,
605 pub component: String,
606 pub description: String,
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct ComponentLicenseChange {
612 pub component_id: String,
613 pub component_name: String,
614 pub old_licenses: Vec<String>,
615 pub new_licenses: Vec<String>,
616}
617
618#[derive(Debug, Clone, Default, Serialize, Deserialize)]
620pub struct VulnerabilityChanges {
621 pub introduced: Vec<VulnerabilityDetail>,
623 pub resolved: Vec<VulnerabilityDetail>,
625 pub persistent: Vec<VulnerabilityDetail>,
627}
628
629impl VulnerabilityChanges {
630 pub fn introduced_by_severity(&self) -> HashMap<String, usize> {
632 let mut counts = HashMap::with_capacity(5);
634 for vuln in &self.introduced {
635 *counts.entry(vuln.severity.clone()).or_insert(0) += 1;
636 }
637 counts
638 }
639
640 pub fn critical_and_high_introduced(&self) -> Vec<&VulnerabilityDetail> {
642 self.introduced
643 .iter()
644 .filter(|v| v.severity == "Critical" || v.severity == "High")
645 .collect()
646 }
647}
648
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
651pub enum SlaStatus {
652 Overdue(i64),
654 DueSoon(i64),
656 OnTrack(i64),
658 NoDueDate,
660}
661
662impl SlaStatus {
663 pub fn display(&self, days_since_published: Option<i64>) -> String {
665 match self {
666 SlaStatus::Overdue(days) => format!("{}d late", days),
667 SlaStatus::DueSoon(days) => format!("{}d left", days),
668 SlaStatus::OnTrack(days) => format!("{}d left", days),
669 SlaStatus::NoDueDate => {
670 if let Some(age) = days_since_published {
671 format!("{}d old", age)
672 } else {
673 "-".to_string()
674 }
675 }
676 }
677 }
678
679 pub fn is_overdue(&self) -> bool {
681 matches!(self, SlaStatus::Overdue(_))
682 }
683
684 pub fn is_due_soon(&self) -> bool {
686 matches!(self, SlaStatus::DueSoon(_))
687 }
688}
689
690#[derive(Debug, Clone, Serialize, Deserialize)]
692pub struct VulnerabilityDetail {
693 pub id: String,
695 pub source: String,
697 pub severity: String,
699 pub cvss_score: Option<f32>,
701 pub component_id: String,
703 #[serde(skip)]
705 pub component_canonical_id: Option<CanonicalId>,
706 #[serde(skip)]
708 pub component_ref: Option<ComponentRef>,
709 pub component_name: String,
711 pub version: Option<String>,
713 pub cwes: Vec<String>,
715 pub description: Option<String>,
717 pub remediation: Option<String>,
719 #[serde(default)]
721 pub is_kev: bool,
722 #[serde(default)]
724 pub component_depth: Option<u32>,
725 #[serde(default)]
727 pub published_date: Option<String>,
728 #[serde(default)]
730 pub kev_due_date: Option<String>,
731 #[serde(default)]
733 pub days_since_published: Option<i64>,
734 #[serde(default)]
736 pub days_until_due: Option<i64>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub vex_state: Option<crate::model::VexState>,
740}
741
742impl VulnerabilityDetail {
743 pub fn is_vex_actionable(&self) -> bool {
748 !matches!(
749 self.vex_state,
750 Some(crate::model::VexState::NotAffected) | Some(crate::model::VexState::Fixed)
751 )
752 }
753
754 pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
756 let days_since_published = vuln.published.map(|dt| {
758 let today = chrono::Utc::now().date_naive();
759 (today - dt.date_naive()).num_days()
760 });
761
762 let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
764
765 let (kev_due_date, days_until_due) = if let Some(kev) = &vuln.kev_info {
767 (
768 Some(kev.due_date.format("%Y-%m-%d").to_string()),
769 Some(kev.days_until_due()),
770 )
771 } else {
772 (None, None)
773 };
774
775 Self {
776 id: vuln.id.clone(),
777 source: vuln.source.to_string(),
778 severity: vuln
779 .severity
780 .as_ref()
781 .map(|s| s.to_string())
782 .unwrap_or_else(|| "Unknown".to_string()),
783 cvss_score: vuln.max_cvss_score(),
784 component_id: component.canonical_id.to_string(),
785 component_canonical_id: Some(component.canonical_id.clone()),
786 component_ref: Some(ComponentRef::from_component(component)),
787 component_name: component.name.clone(),
788 version: component.version.clone(),
789 cwes: vuln.cwes.clone(),
790 description: vuln.description.clone(),
791 remediation: vuln.remediation.as_ref().map(|r| {
792 format!(
793 "{}: {}",
794 r.remediation_type,
795 r.description.as_deref().unwrap_or("")
796 )
797 }),
798 is_kev: vuln.is_kev,
799 component_depth: None,
800 published_date,
801 kev_due_date,
802 days_since_published,
803 days_until_due,
804 vex_state: component.vex_status.as_ref().map(|v| v.status.clone()),
805 }
806 }
807
808 pub fn from_ref_with_depth(
810 vuln: &VulnerabilityRef,
811 component: &Component,
812 depth: Option<u32>,
813 ) -> Self {
814 let mut detail = Self::from_ref(vuln, component);
815 detail.component_depth = depth;
816 detail
817 }
818
819 pub fn sla_status(&self) -> SlaStatus {
825 if let Some(days) = self.days_until_due {
827 if days < 0 {
828 return SlaStatus::Overdue(-days);
829 } else if days <= 3 {
830 return SlaStatus::DueSoon(days);
831 } else {
832 return SlaStatus::OnTrack(days);
833 }
834 }
835
836 if let Some(age_days) = self.days_since_published {
838 let sla_days = match self.severity.to_lowercase().as_str() {
839 "critical" => 1,
840 "high" => 7,
841 "medium" => 30,
842 "low" => 90,
843 _ => return SlaStatus::NoDueDate,
844 };
845 let remaining = sla_days - age_days;
846 if remaining < 0 {
847 return SlaStatus::Overdue(-remaining);
848 } else if remaining <= 3 {
849 return SlaStatus::DueSoon(remaining);
850 } else {
851 return SlaStatus::OnTrack(remaining);
852 }
853 }
854
855 SlaStatus::NoDueDate
856 }
857
858 pub fn get_component_id(&self) -> CanonicalId {
860 self.component_canonical_id
861 .clone()
862 .unwrap_or_else(|| CanonicalId::from_name_version(&self.component_name, self.version.as_deref()))
863 }
864
865 pub fn get_component_ref(&self) -> ComponentRef {
867 self.component_ref.clone().unwrap_or_else(|| {
868 ComponentRef::with_version(
869 self.get_component_id(),
870 &self.component_name,
871 self.version.clone(),
872 )
873 })
874 }
875}
876
877#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
883pub struct DependencyGraphChange {
884 pub component_id: CanonicalId,
886 pub component_name: String,
888 pub change: DependencyChangeType,
890 pub impact: GraphChangeImpact,
892}
893
894#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
896pub enum DependencyChangeType {
897 DependencyAdded {
899 dependency_id: CanonicalId,
900 dependency_name: String,
901 },
902
903 DependencyRemoved {
905 dependency_id: CanonicalId,
906 dependency_name: String,
907 },
908
909 Reparented {
911 dependency_id: CanonicalId,
912 dependency_name: String,
913 old_parent_id: CanonicalId,
914 old_parent_name: String,
915 new_parent_id: CanonicalId,
916 new_parent_name: String,
917 },
918
919 DepthChanged {
921 old_depth: u32, new_depth: u32,
923 },
924}
925
926impl DependencyChangeType {
927 pub fn kind(&self) -> &'static str {
929 match self {
930 Self::DependencyAdded { .. } => "added",
931 Self::DependencyRemoved { .. } => "removed",
932 Self::Reparented { .. } => "reparented",
933 Self::DepthChanged { .. } => "depth_changed",
934 }
935 }
936}
937
938#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
940pub enum GraphChangeImpact {
941 Low,
943 Medium,
945 High,
947 Critical,
949}
950
951impl GraphChangeImpact {
952 pub fn as_str(&self) -> &'static str {
953 match self {
954 Self::Low => "low",
955 Self::Medium => "medium",
956 Self::High => "high",
957 Self::Critical => "critical",
958 }
959 }
960
961 pub fn from_label(s: &str) -> Self {
963 match s.to_lowercase().as_str() {
964 "critical" => Self::Critical,
965 "high" => Self::High,
966 "medium" => Self::Medium,
967 _ => Self::Low,
968 }
969 }
970}
971
972impl std::fmt::Display for GraphChangeImpact {
973 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
974 write!(f, "{}", self.as_str())
975 }
976}
977
978#[derive(Debug, Clone, Default, Serialize, Deserialize)]
980pub struct GraphChangeSummary {
981 pub total_changes: usize,
982 pub dependencies_added: usize,
983 pub dependencies_removed: usize,
984 pub reparented: usize,
985 pub depth_changed: usize,
986 pub by_impact: GraphChangesByImpact,
987}
988
989impl GraphChangeSummary {
990 pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
992 let mut summary = Self {
993 total_changes: changes.len(),
994 ..Default::default()
995 };
996
997 for change in changes {
998 match &change.change {
999 DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
1000 DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
1001 DependencyChangeType::Reparented { .. } => summary.reparented += 1,
1002 DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
1003 }
1004
1005 match change.impact {
1006 GraphChangeImpact::Low => summary.by_impact.low += 1,
1007 GraphChangeImpact::Medium => summary.by_impact.medium += 1,
1008 GraphChangeImpact::High => summary.by_impact.high += 1,
1009 GraphChangeImpact::Critical => summary.by_impact.critical += 1,
1010 }
1011 }
1012
1013 summary
1014 }
1015}
1016
1017#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1018pub struct GraphChangesByImpact {
1019 pub low: usize,
1020 pub medium: usize,
1021 pub high: usize,
1022 pub critical: usize,
1023}