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 self.summary.total_changes = self.summary.components_added
70 + self.summary.components_removed
71 + self.summary.components_modified;
72
73 self.summary.dependencies_added = self.dependencies.added.len();
74 self.summary.dependencies_removed = self.dependencies.removed.len();
75
76 self.summary.vulnerabilities_introduced = self.vulnerabilities.introduced.len();
77 self.summary.vulnerabilities_resolved = self.vulnerabilities.resolved.len();
78 self.summary.vulnerabilities_persistent = self.vulnerabilities.persistent.len();
79
80 self.summary.licenses_added = self.licenses.new_licenses.len();
81 self.summary.licenses_removed = self.licenses.removed_licenses.len();
82 }
83
84 #[must_use]
86 pub fn has_changes(&self) -> bool {
87 self.summary.total_changes > 0
88 || !self.dependencies.is_empty()
89 || !self.vulnerabilities.introduced.is_empty()
90 || !self.vulnerabilities.resolved.is_empty()
91 || !self.graph_changes.is_empty()
92 }
93
94 pub fn set_graph_changes(&mut self, changes: Vec<DependencyGraphChange>) {
96 self.graph_summary = Some(GraphChangeSummary::from_changes(&changes));
97 self.graph_changes = changes;
98 }
99
100 #[must_use]
102 pub fn find_component_by_id(&self, id: &CanonicalId) -> Option<&ComponentChange> {
103 let id_str = id.value();
104 self.components
105 .added
106 .iter()
107 .chain(self.components.removed.iter())
108 .chain(self.components.modified.iter())
109 .find(|c| c.id == id_str)
110 }
111
112 #[must_use]
114 pub fn find_component_by_id_str(&self, id_str: &str) -> Option<&ComponentChange> {
115 self.components
116 .added
117 .iter()
118 .chain(self.components.removed.iter())
119 .chain(self.components.modified.iter())
120 .find(|c| c.id == id_str)
121 }
122
123 #[must_use]
125 pub fn all_component_changes(&self) -> Vec<&ComponentChange> {
126 self.components
127 .added
128 .iter()
129 .chain(self.components.removed.iter())
130 .chain(self.components.modified.iter())
131 .collect()
132 }
133
134 #[must_use]
136 pub fn find_vulns_for_component(&self, component_id: &CanonicalId) -> Vec<&VulnerabilityDetail> {
137 let id_str = component_id.value();
138 self.vulnerabilities
139 .introduced
140 .iter()
141 .chain(self.vulnerabilities.resolved.iter())
142 .chain(self.vulnerabilities.persistent.iter())
143 .filter(|v| v.component_id == id_str)
144 .collect()
145 }
146
147 #[must_use]
149 pub fn build_component_id_index(&self) -> HashMap<String, &ComponentChange> {
150 self.components
151 .added
152 .iter()
153 .chain(&self.components.removed)
154 .chain(&self.components.modified)
155 .map(|c| (c.id.clone(), c))
156 .collect()
157 }
158
159 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(VulnerabilityDetail::is_vex_actionable);
184 self.vulnerabilities
185 .resolved
186 .retain(VulnerabilityDetail::is_vex_actionable);
187 self.vulnerabilities
188 .persistent
189 .retain(VulnerabilityDetail::is_vex_actionable);
190
191 self.calculate_summary();
192 }
193}
194
195impl Default for DiffResult {
196 fn default() -> Self {
197 Self::new()
198 }
199}
200
201#[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 #[must_use]
227 pub const fn new() -> Self {
228 Self {
229 added: Vec::new(),
230 removed: Vec::new(),
231 modified: Vec::new(),
232 }
233 }
234
235 #[must_use]
236 pub fn is_empty(&self) -> bool {
237 self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
238 }
239
240 #[must_use]
241 pub fn total(&self) -> usize {
242 self.added.len() + self.removed.len() + self.modified.len()
243 }
244}
245
246impl<T> Default for ChangeSet<T> {
247 fn default() -> Self {
248 Self::new()
249 }
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct MatchInfo {
257 pub score: f64,
259 pub method: String,
261 pub reason: String,
263 #[serde(skip_serializing_if = "Vec::is_empty")]
265 pub score_breakdown: Vec<MatchScoreComponent>,
266 #[serde(skip_serializing_if = "Vec::is_empty")]
268 pub normalizations: Vec<String>,
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub confidence_interval: Option<ConfidenceInterval>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct ConfidenceInterval {
280 pub lower: f64,
282 pub upper: f64,
284 pub level: f64,
286}
287
288impl ConfidenceInterval {
289 #[must_use]
291 pub const fn new(lower: f64, upper: f64, level: f64) -> Self {
292 Self {
293 lower: lower.clamp(0.0, 1.0),
294 upper: upper.clamp(0.0, 1.0),
295 level,
296 }
297 }
298
299 #[must_use]
303 pub fn from_score_and_error(score: f64, std_error: f64) -> Self {
304 let margin = 1.96 * std_error;
305 Self::new(score - margin, score + margin, 0.95)
306 }
307
308 #[must_use]
312 pub fn from_tier(score: f64, tier: &str) -> Self {
313 let margin = match tier {
314 "ExactIdentifier" => 0.0,
315 "Alias" => 0.02,
316 "EcosystemRule" => 0.03,
317 "CustomRule" => 0.05,
318 "Fuzzy" => 0.08,
319 _ => 0.10,
320 };
321 Self::new(score - margin, score + margin, 0.95)
322 }
323
324 #[must_use]
326 pub fn width(&self) -> f64 {
327 self.upper - self.lower
328 }
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct MatchScoreComponent {
334 pub name: String,
336 pub weight: f64,
338 pub raw_score: f64,
340 pub weighted_score: f64,
342 pub description: String,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct ComponentChange {
349 pub id: String,
351 #[serde(skip)]
353 pub canonical_id: Option<CanonicalId>,
354 #[serde(skip)]
356 pub component_ref: Option<ComponentRef>,
357 #[serde(skip)]
359 pub old_canonical_id: Option<CanonicalId>,
360 pub name: String,
362 pub old_version: Option<String>,
364 pub new_version: Option<String>,
366 pub ecosystem: Option<String>,
368 pub change_type: ChangeType,
370 pub field_changes: Vec<FieldChange>,
372 pub cost: u32,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub match_info: Option<MatchInfo>,
377}
378
379impl ComponentChange {
380 pub fn added(component: &Component, cost: u32) -> Self {
382 Self {
383 id: component.canonical_id.to_string(),
384 canonical_id: Some(component.canonical_id.clone()),
385 component_ref: Some(ComponentRef::from_component(component)),
386 old_canonical_id: None,
387 name: component.name.clone(),
388 old_version: None,
389 new_version: component.version.clone(),
390 ecosystem: component.ecosystem.as_ref().map(std::string::ToString::to_string),
391 change_type: ChangeType::Added,
392 field_changes: Vec::new(),
393 cost,
394 match_info: None,
395 }
396 }
397
398 pub fn removed(component: &Component, cost: u32) -> Self {
400 Self {
401 id: component.canonical_id.to_string(),
402 canonical_id: Some(component.canonical_id.clone()),
403 component_ref: Some(ComponentRef::from_component(component)),
404 old_canonical_id: Some(component.canonical_id.clone()),
405 name: component.name.clone(),
406 old_version: component.version.clone(),
407 new_version: None,
408 ecosystem: component.ecosystem.as_ref().map(std::string::ToString::to_string),
409 change_type: ChangeType::Removed,
410 field_changes: Vec::new(),
411 cost,
412 match_info: None,
413 }
414 }
415
416 pub fn modified(
418 old: &Component,
419 new: &Component,
420 field_changes: Vec<FieldChange>,
421 cost: u32,
422 ) -> Self {
423 Self {
424 id: new.canonical_id.to_string(),
425 canonical_id: Some(new.canonical_id.clone()),
426 component_ref: Some(ComponentRef::from_component(new)),
427 old_canonical_id: Some(old.canonical_id.clone()),
428 name: new.name.clone(),
429 old_version: old.version.clone(),
430 new_version: new.version.clone(),
431 ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
432 change_type: ChangeType::Modified,
433 field_changes,
434 cost,
435 match_info: None,
436 }
437 }
438
439 pub fn modified_with_match(
441 old: &Component,
442 new: &Component,
443 field_changes: Vec<FieldChange>,
444 cost: u32,
445 match_info: MatchInfo,
446 ) -> Self {
447 Self {
448 id: new.canonical_id.to_string(),
449 canonical_id: Some(new.canonical_id.clone()),
450 component_ref: Some(ComponentRef::from_component(new)),
451 old_canonical_id: Some(old.canonical_id.clone()),
452 name: new.name.clone(),
453 old_version: old.version.clone(),
454 new_version: new.version.clone(),
455 ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
456 change_type: ChangeType::Modified,
457 field_changes,
458 cost,
459 match_info: Some(match_info),
460 }
461 }
462
463 #[must_use]
465 pub fn with_match_info(mut self, match_info: MatchInfo) -> Self {
466 self.match_info = Some(match_info);
467 self
468 }
469
470 #[must_use]
472 pub fn get_canonical_id(&self) -> CanonicalId {
473 self.canonical_id
474 .clone()
475 .unwrap_or_else(|| CanonicalId::from_name_version(&self.name, self.new_version.as_deref().or(self.old_version.as_deref())))
476 }
477
478 #[must_use]
480 pub fn get_component_ref(&self) -> ComponentRef {
481 self.component_ref.clone().unwrap_or_else(|| {
482 ComponentRef::with_version(
483 self.get_canonical_id(),
484 &self.name,
485 self.new_version.clone().or_else(|| self.old_version.clone()),
486 )
487 })
488 }
489}
490
491impl MatchInfo {
492 #[must_use]
494 pub fn from_explanation(explanation: &crate::matching::MatchExplanation) -> Self {
495 let method = format!("{:?}", explanation.tier);
496 let ci = ConfidenceInterval::from_tier(explanation.score, &method);
497 Self {
498 score: explanation.score,
499 method,
500 reason: explanation.reason.clone(),
501 score_breakdown: explanation
502 .score_breakdown
503 .iter()
504 .map(|c| MatchScoreComponent {
505 name: c.name.clone(),
506 weight: c.weight,
507 raw_score: c.raw_score,
508 weighted_score: c.weighted_score,
509 description: c.description.clone(),
510 })
511 .collect(),
512 normalizations: explanation.normalizations_applied.clone(),
513 confidence_interval: Some(ci),
514 }
515 }
516
517 #[must_use]
519 pub fn simple(score: f64, method: &str, reason: &str) -> Self {
520 let ci = ConfidenceInterval::from_tier(score, method);
521 Self {
522 score,
523 method: method.to_string(),
524 reason: reason.to_string(),
525 score_breakdown: Vec::new(),
526 normalizations: Vec::new(),
527 confidence_interval: Some(ci),
528 }
529 }
530
531 #[must_use]
533 pub const fn with_confidence_interval(mut self, ci: ConfidenceInterval) -> Self {
534 self.confidence_interval = Some(ci);
535 self
536 }
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
541pub enum ChangeType {
542 Added,
543 Removed,
544 Modified,
545 Unchanged,
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct FieldChange {
551 pub field: String,
552 pub old_value: Option<String>,
553 pub new_value: Option<String>,
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
558pub struct DependencyChange {
559 pub from: String,
561 pub to: String,
563 pub relationship: String,
565 pub change_type: ChangeType,
567}
568
569impl DependencyChange {
570 #[must_use]
571 pub fn added(edge: &DependencyEdge) -> Self {
572 Self {
573 from: edge.from.to_string(),
574 to: edge.to.to_string(),
575 relationship: edge.relationship.to_string(),
576 change_type: ChangeType::Added,
577 }
578 }
579
580 #[must_use]
581 pub fn removed(edge: &DependencyEdge) -> Self {
582 Self {
583 from: edge.from.to_string(),
584 to: edge.to.to_string(),
585 relationship: edge.relationship.to_string(),
586 change_type: ChangeType::Removed,
587 }
588 }
589}
590
591#[derive(Debug, Clone, Default, Serialize, Deserialize)]
593pub struct LicenseChanges {
594 pub new_licenses: Vec<LicenseChange>,
596 pub removed_licenses: Vec<LicenseChange>,
598 pub conflicts: Vec<LicenseConflict>,
600 pub component_changes: Vec<ComponentLicenseChange>,
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct LicenseChange {
607 pub license: String,
609 pub components: Vec<String>,
611 pub family: String,
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize)]
617pub struct LicenseConflict {
618 pub license_a: String,
619 pub license_b: String,
620 pub component: String,
621 pub description: String,
622}
623
624#[derive(Debug, Clone, Serialize, Deserialize)]
626pub struct ComponentLicenseChange {
627 pub component_id: String,
628 pub component_name: String,
629 pub old_licenses: Vec<String>,
630 pub new_licenses: Vec<String>,
631}
632
633#[derive(Debug, Clone, Default, Serialize, Deserialize)]
635pub struct VulnerabilityChanges {
636 pub introduced: Vec<VulnerabilityDetail>,
638 pub resolved: Vec<VulnerabilityDetail>,
640 pub persistent: Vec<VulnerabilityDetail>,
642}
643
644impl VulnerabilityChanges {
645 #[must_use]
647 pub fn introduced_by_severity(&self) -> HashMap<String, usize> {
648 let mut counts = HashMap::with_capacity(5);
650 for vuln in &self.introduced {
651 *counts.entry(vuln.severity.clone()).or_insert(0) += 1;
652 }
653 counts
654 }
655
656 #[must_use]
658 pub fn critical_and_high_introduced(&self) -> Vec<&VulnerabilityDetail> {
659 self.introduced
660 .iter()
661 .filter(|v| v.severity == "Critical" || v.severity == "High")
662 .collect()
663 }
664}
665
666#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
668pub enum SlaStatus {
669 Overdue(i64),
671 DueSoon(i64),
673 OnTrack(i64),
675 NoDueDate,
677}
678
679impl SlaStatus {
680 #[must_use]
682 pub fn display(&self, days_since_published: Option<i64>) -> String {
683 match self {
684 Self::Overdue(days) => format!("{days}d late"),
685 Self::DueSoon(days) | Self::OnTrack(days) => format!("{days}d left"),
686 Self::NoDueDate => {
687 days_since_published.map_or_else(|| "-".to_string(), |age| format!("{age}d old"))
688 }
689 }
690 }
691
692 #[must_use]
694 pub const fn is_overdue(&self) -> bool {
695 matches!(self, Self::Overdue(_))
696 }
697
698 #[must_use]
700 pub const fn is_due_soon(&self) -> bool {
701 matches!(self, Self::DueSoon(_))
702 }
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct VulnerabilityDetail {
708 pub id: String,
710 pub source: String,
712 pub severity: String,
714 pub cvss_score: Option<f32>,
716 pub component_id: String,
718 #[serde(skip)]
720 pub component_canonical_id: Option<CanonicalId>,
721 #[serde(skip)]
723 pub component_ref: Option<ComponentRef>,
724 pub component_name: String,
726 pub version: Option<String>,
728 pub cwes: Vec<String>,
730 pub description: Option<String>,
732 pub remediation: Option<String>,
734 #[serde(default)]
736 pub is_kev: bool,
737 #[serde(default)]
739 pub component_depth: Option<u32>,
740 #[serde(default)]
742 pub published_date: Option<String>,
743 #[serde(default)]
745 pub kev_due_date: Option<String>,
746 #[serde(default)]
748 pub days_since_published: Option<i64>,
749 #[serde(default)]
751 pub days_until_due: Option<i64>,
752 #[serde(default, skip_serializing_if = "Option::is_none")]
754 pub vex_state: Option<crate::model::VexState>,
755}
756
757impl VulnerabilityDetail {
758 #[must_use]
763 pub const fn is_vex_actionable(&self) -> bool {
764 !matches!(
765 self.vex_state,
766 Some(crate::model::VexState::NotAffected | crate::model::VexState::Fixed)
767 )
768 }
769
770 pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
772 let days_since_published = vuln.published.map(|dt| {
774 let today = chrono::Utc::now().date_naive();
775 (today - dt.date_naive()).num_days()
776 });
777
778 let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
780
781 let (kev_due_date, days_until_due) = vuln.kev_info.as_ref().map_or(
783 (None, None),
784 |kev| (
785 Some(kev.due_date.format("%Y-%m-%d").to_string()),
786 Some(kev.days_until_due()),
787 ),
788 );
789
790 Self {
791 id: vuln.id.clone(),
792 source: vuln.source.to_string(),
793 severity: vuln
794 .severity
795 .as_ref().map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string),
796 cvss_score: vuln.max_cvss_score(),
797 component_id: component.canonical_id.to_string(),
798 component_canonical_id: Some(component.canonical_id.clone()),
799 component_ref: Some(ComponentRef::from_component(component)),
800 component_name: component.name.clone(),
801 version: component.version.clone(),
802 cwes: vuln.cwes.clone(),
803 description: vuln.description.clone(),
804 remediation: vuln.remediation.as_ref().map(|r| {
805 format!(
806 "{}: {}",
807 r.remediation_type,
808 r.description.as_deref().unwrap_or("")
809 )
810 }),
811 is_kev: vuln.is_kev,
812 component_depth: None,
813 published_date,
814 kev_due_date,
815 days_since_published,
816 days_until_due,
817 vex_state: component.vex_status.as_ref().map(|v| v.status.clone()),
818 }
819 }
820
821 #[must_use]
823 pub fn from_ref_with_depth(
824 vuln: &VulnerabilityRef,
825 component: &Component,
826 depth: Option<u32>,
827 ) -> Self {
828 let mut detail = Self::from_ref(vuln, component);
829 detail.component_depth = depth;
830 detail
831 }
832
833 #[must_use]
839 pub fn sla_status(&self) -> SlaStatus {
840 if let Some(days) = self.days_until_due {
842 if days < 0 {
843 return SlaStatus::Overdue(-days);
844 } else if days <= 3 {
845 return SlaStatus::DueSoon(days);
846 }
847 return SlaStatus::OnTrack(days);
848 }
849
850 if let Some(age_days) = self.days_since_published {
852 let sla_days = match self.severity.to_lowercase().as_str() {
853 "critical" => 1,
854 "high" => 7,
855 "medium" => 30,
856 "low" => 90,
857 _ => return SlaStatus::NoDueDate,
858 };
859 let remaining = sla_days - age_days;
860 if remaining < 0 {
861 return SlaStatus::Overdue(-remaining);
862 } else if remaining <= 3 {
863 return SlaStatus::DueSoon(remaining);
864 }
865 return SlaStatus::OnTrack(remaining);
866 }
867
868 SlaStatus::NoDueDate
869 }
870
871 #[must_use]
873 pub fn get_component_id(&self) -> CanonicalId {
874 self.component_canonical_id
875 .clone()
876 .unwrap_or_else(|| CanonicalId::from_name_version(&self.component_name, self.version.as_deref()))
877 }
878
879 #[must_use]
881 pub fn get_component_ref(&self) -> ComponentRef {
882 self.component_ref.clone().unwrap_or_else(|| {
883 ComponentRef::with_version(
884 self.get_component_id(),
885 &self.component_name,
886 self.version.clone(),
887 )
888 })
889 }
890}
891
892#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
898pub struct DependencyGraphChange {
899 pub component_id: CanonicalId,
901 pub component_name: String,
903 pub change: DependencyChangeType,
905 pub impact: GraphChangeImpact,
907}
908
909#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
911pub enum DependencyChangeType {
912 DependencyAdded {
914 dependency_id: CanonicalId,
915 dependency_name: String,
916 },
917
918 DependencyRemoved {
920 dependency_id: CanonicalId,
921 dependency_name: String,
922 },
923
924 Reparented {
926 dependency_id: CanonicalId,
927 dependency_name: String,
928 old_parent_id: CanonicalId,
929 old_parent_name: String,
930 new_parent_id: CanonicalId,
931 new_parent_name: String,
932 },
933
934 DepthChanged {
936 old_depth: u32, new_depth: u32,
938 },
939}
940
941impl DependencyChangeType {
942 #[must_use]
944 pub const fn kind(&self) -> &'static str {
945 match self {
946 Self::DependencyAdded { .. } => "added",
947 Self::DependencyRemoved { .. } => "removed",
948 Self::Reparented { .. } => "reparented",
949 Self::DepthChanged { .. } => "depth_changed",
950 }
951 }
952}
953
954#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
956pub enum GraphChangeImpact {
957 Low,
959 Medium,
961 High,
963 Critical,
965}
966
967impl GraphChangeImpact {
968 #[must_use]
969 pub const fn as_str(&self) -> &'static str {
970 match self {
971 Self::Low => "low",
972 Self::Medium => "medium",
973 Self::High => "high",
974 Self::Critical => "critical",
975 }
976 }
977
978 #[must_use]
980 pub fn from_label(s: &str) -> Self {
981 match s.to_lowercase().as_str() {
982 "critical" => Self::Critical,
983 "high" => Self::High,
984 "medium" => Self::Medium,
985 _ => Self::Low,
986 }
987 }
988}
989
990impl std::fmt::Display for GraphChangeImpact {
991 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
992 write!(f, "{}", self.as_str())
993 }
994}
995
996#[derive(Debug, Clone, Default, Serialize, Deserialize)]
998pub struct GraphChangeSummary {
999 pub total_changes: usize,
1000 pub dependencies_added: usize,
1001 pub dependencies_removed: usize,
1002 pub reparented: usize,
1003 pub depth_changed: usize,
1004 pub by_impact: GraphChangesByImpact,
1005}
1006
1007impl GraphChangeSummary {
1008 #[must_use]
1010 pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
1011 let mut summary = Self {
1012 total_changes: changes.len(),
1013 ..Default::default()
1014 };
1015
1016 for change in changes {
1017 match &change.change {
1018 DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
1019 DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
1020 DependencyChangeType::Reparented { .. } => summary.reparented += 1,
1021 DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
1022 }
1023
1024 match change.impact {
1025 GraphChangeImpact::Low => summary.by_impact.low += 1,
1026 GraphChangeImpact::Medium => summary.by_impact.medium += 1,
1027 GraphChangeImpact::High => summary.by_impact.high += 1,
1028 GraphChangeImpact::Critical => summary.by_impact.critical += 1,
1029 }
1030 }
1031
1032 summary
1033 }
1034}
1035
1036#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1037pub struct GraphChangesByImpact {
1038 pub low: usize,
1039 pub medium: usize,
1040 pub high: usize,
1041 pub critical: usize,
1042}