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 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub vex_justification: Option<crate::model::VexJustification>,
758 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub vex_impact_statement: Option<String>,
761}
762
763impl VulnerabilityDetail {
764 #[must_use]
769 pub const fn is_vex_actionable(&self) -> bool {
770 !matches!(
771 self.vex_state,
772 Some(crate::model::VexState::NotAffected | crate::model::VexState::Fixed)
773 )
774 }
775
776 pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
778 let days_since_published = vuln.published.map(|dt| {
780 let today = chrono::Utc::now().date_naive();
781 (today - dt.date_naive()).num_days()
782 });
783
784 let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
786
787 let (kev_due_date, days_until_due) = vuln.kev_info.as_ref().map_or(
789 (None, None),
790 |kev| (
791 Some(kev.due_date.format("%Y-%m-%d").to_string()),
792 Some(kev.days_until_due()),
793 ),
794 );
795
796 Self {
797 id: vuln.id.clone(),
798 source: vuln.source.to_string(),
799 severity: vuln
800 .severity
801 .as_ref().map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string),
802 cvss_score: vuln.max_cvss_score(),
803 component_id: component.canonical_id.to_string(),
804 component_canonical_id: Some(component.canonical_id.clone()),
805 component_ref: Some(ComponentRef::from_component(component)),
806 component_name: component.name.clone(),
807 version: component.version.clone(),
808 cwes: vuln.cwes.clone(),
809 description: vuln.description.clone(),
810 remediation: vuln.remediation.as_ref().map(|r| {
811 format!(
812 "{}: {}",
813 r.remediation_type,
814 r.description.as_deref().unwrap_or("")
815 )
816 }),
817 is_kev: vuln.is_kev,
818 component_depth: None,
819 published_date,
820 kev_due_date,
821 days_since_published,
822 days_until_due,
823 vex_state: {
824 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
825 vex_source.map(|v| v.status.clone())
826 },
827 vex_justification: {
828 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
829 vex_source.and_then(|v| v.justification.clone())
830 },
831 vex_impact_statement: {
832 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
833 vex_source.and_then(|v| v.impact_statement.clone())
834 },
835 }
836 }
837
838 #[must_use]
840 pub fn from_ref_with_depth(
841 vuln: &VulnerabilityRef,
842 component: &Component,
843 depth: Option<u32>,
844 ) -> Self {
845 let mut detail = Self::from_ref(vuln, component);
846 detail.component_depth = depth;
847 detail
848 }
849
850 #[must_use]
856 pub fn sla_status(&self) -> SlaStatus {
857 if let Some(days) = self.days_until_due {
859 if days < 0 {
860 return SlaStatus::Overdue(-days);
861 } else if days <= 3 {
862 return SlaStatus::DueSoon(days);
863 }
864 return SlaStatus::OnTrack(days);
865 }
866
867 if let Some(age_days) = self.days_since_published {
869 let sla_days = match self.severity.to_lowercase().as_str() {
870 "critical" => 1,
871 "high" => 7,
872 "medium" => 30,
873 "low" => 90,
874 _ => return SlaStatus::NoDueDate,
875 };
876 let remaining = sla_days - age_days;
877 if remaining < 0 {
878 return SlaStatus::Overdue(-remaining);
879 } else if remaining <= 3 {
880 return SlaStatus::DueSoon(remaining);
881 }
882 return SlaStatus::OnTrack(remaining);
883 }
884
885 SlaStatus::NoDueDate
886 }
887
888 #[must_use]
890 pub fn get_component_id(&self) -> CanonicalId {
891 self.component_canonical_id
892 .clone()
893 .unwrap_or_else(|| CanonicalId::from_name_version(&self.component_name, self.version.as_deref()))
894 }
895
896 #[must_use]
898 pub fn get_component_ref(&self) -> ComponentRef {
899 self.component_ref.clone().unwrap_or_else(|| {
900 ComponentRef::with_version(
901 self.get_component_id(),
902 &self.component_name,
903 self.version.clone(),
904 )
905 })
906 }
907}
908
909#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
915pub struct DependencyGraphChange {
916 pub component_id: CanonicalId,
918 pub component_name: String,
920 pub change: DependencyChangeType,
922 pub impact: GraphChangeImpact,
924}
925
926#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
928pub enum DependencyChangeType {
929 DependencyAdded {
931 dependency_id: CanonicalId,
932 dependency_name: String,
933 },
934
935 DependencyRemoved {
937 dependency_id: CanonicalId,
938 dependency_name: String,
939 },
940
941 Reparented {
943 dependency_id: CanonicalId,
944 dependency_name: String,
945 old_parent_id: CanonicalId,
946 old_parent_name: String,
947 new_parent_id: CanonicalId,
948 new_parent_name: String,
949 },
950
951 DepthChanged {
953 old_depth: u32, new_depth: u32,
955 },
956}
957
958impl DependencyChangeType {
959 #[must_use]
961 pub const fn kind(&self) -> &'static str {
962 match self {
963 Self::DependencyAdded { .. } => "added",
964 Self::DependencyRemoved { .. } => "removed",
965 Self::Reparented { .. } => "reparented",
966 Self::DepthChanged { .. } => "depth_changed",
967 }
968 }
969}
970
971#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
973pub enum GraphChangeImpact {
974 Low,
976 Medium,
978 High,
980 Critical,
982}
983
984impl GraphChangeImpact {
985 #[must_use]
986 pub const fn as_str(&self) -> &'static str {
987 match self {
988 Self::Low => "low",
989 Self::Medium => "medium",
990 Self::High => "high",
991 Self::Critical => "critical",
992 }
993 }
994
995 #[must_use]
997 pub fn from_label(s: &str) -> Self {
998 match s.to_lowercase().as_str() {
999 "critical" => Self::Critical,
1000 "high" => Self::High,
1001 "medium" => Self::Medium,
1002 _ => Self::Low,
1003 }
1004 }
1005}
1006
1007impl std::fmt::Display for GraphChangeImpact {
1008 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1009 write!(f, "{}", self.as_str())
1010 }
1011}
1012
1013#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1015pub struct GraphChangeSummary {
1016 pub total_changes: usize,
1017 pub dependencies_added: usize,
1018 pub dependencies_removed: usize,
1019 pub reparented: usize,
1020 pub depth_changed: usize,
1021 pub by_impact: GraphChangesByImpact,
1022}
1023
1024impl GraphChangeSummary {
1025 #[must_use]
1027 pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
1028 let mut summary = Self {
1029 total_changes: changes.len(),
1030 ..Default::default()
1031 };
1032
1033 for change in changes {
1034 match &change.change {
1035 DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
1036 DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
1037 DependencyChangeType::Reparented { .. } => summary.reparented += 1,
1038 DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
1039 }
1040
1041 match change.impact {
1042 GraphChangeImpact::Low => summary.by_impact.low += 1,
1043 GraphChangeImpact::Medium => summary.by_impact.medium += 1,
1044 GraphChangeImpact::High => summary.by_impact.high += 1,
1045 GraphChangeImpact::Critical => summary.by_impact.critical += 1,
1046 }
1047 }
1048
1049 summary
1050 }
1051}
1052
1053#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1054pub struct GraphChangesByImpact {
1055 pub low: usize,
1056 pub medium: usize,
1057 pub high: usize,
1058 pub critical: usize,
1059}