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
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
691pub enum SlaStatus {
692 Overdue(i64),
694 DueSoon(i64),
696 OnTrack(i64),
698 NoDueDate,
700}
701
702impl SlaStatus {
703 #[must_use]
705 pub fn display(&self, days_since_published: Option<i64>) -> String {
706 match self {
707 Self::Overdue(days) => format!("{days}d late"),
708 Self::DueSoon(days) | Self::OnTrack(days) => format!("{days}d left"),
709 Self::NoDueDate => {
710 days_since_published.map_or_else(|| "-".to_string(), |age| format!("{age}d old"))
711 }
712 }
713 }
714
715 #[must_use]
717 pub const fn is_overdue(&self) -> bool {
718 matches!(self, Self::Overdue(_))
719 }
720
721 #[must_use]
723 pub const fn is_due_soon(&self) -> bool {
724 matches!(self, Self::DueSoon(_))
725 }
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct VulnerabilityDetail {
731 pub id: String,
733 pub source: String,
735 pub severity: String,
737 pub cvss_score: Option<f32>,
739 pub component_id: String,
741 #[serde(skip)]
743 pub component_canonical_id: Option<CanonicalId>,
744 #[serde(skip)]
746 pub component_ref: Option<ComponentRef>,
747 pub component_name: String,
749 pub version: Option<String>,
751 pub cwes: Vec<String>,
753 pub description: Option<String>,
755 pub remediation: Option<String>,
757 #[serde(default)]
759 pub is_kev: bool,
760 #[serde(default)]
762 pub component_depth: Option<u32>,
763 #[serde(default)]
765 pub published_date: Option<String>,
766 #[serde(default)]
768 pub kev_due_date: Option<String>,
769 #[serde(default)]
771 pub days_since_published: Option<i64>,
772 #[serde(default)]
774 pub days_until_due: Option<i64>,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
777 pub vex_state: Option<crate::model::VexState>,
778 #[serde(default, skip_serializing_if = "Option::is_none")]
780 pub vex_justification: Option<crate::model::VexJustification>,
781 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub vex_impact_statement: Option<String>,
784}
785
786impl VulnerabilityDetail {
787 #[must_use]
792 pub const fn is_vex_actionable(&self) -> bool {
793 !matches!(
794 self.vex_state,
795 Some(crate::model::VexState::NotAffected | crate::model::VexState::Fixed)
796 )
797 }
798
799 pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
801 let days_since_published = vuln.published.map(|dt| {
803 let today = chrono::Utc::now().date_naive();
804 (today - dt.date_naive()).num_days()
805 });
806
807 let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
809
810 let (kev_due_date, days_until_due) = vuln.kev_info.as_ref().map_or((None, None), |kev| {
812 (
813 Some(kev.due_date.format("%Y-%m-%d").to_string()),
814 Some(kev.days_until_due()),
815 )
816 });
817
818 Self {
819 id: vuln.id.clone(),
820 source: vuln.source.to_string(),
821 severity: vuln
822 .severity
823 .as_ref()
824 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string),
825 cvss_score: vuln.max_cvss_score(),
826 component_id: component.canonical_id.to_string(),
827 component_canonical_id: Some(component.canonical_id.clone()),
828 component_ref: Some(ComponentRef::from_component(component)),
829 component_name: component.name.clone(),
830 version: component.version.clone(),
831 cwes: vuln.cwes.clone(),
832 description: vuln.description.clone(),
833 remediation: vuln.remediation.as_ref().map(|r| {
834 format!(
835 "{}: {}",
836 r.remediation_type,
837 r.description.as_deref().unwrap_or("")
838 )
839 }),
840 is_kev: vuln.is_kev,
841 component_depth: None,
842 published_date,
843 kev_due_date,
844 days_since_published,
845 days_until_due,
846 vex_state: {
847 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
848 vex_source.map(|v| v.status.clone())
849 },
850 vex_justification: {
851 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
852 vex_source.and_then(|v| v.justification.clone())
853 },
854 vex_impact_statement: {
855 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
856 vex_source.and_then(|v| v.impact_statement.clone())
857 },
858 }
859 }
860
861 #[must_use]
863 pub fn from_ref_with_depth(
864 vuln: &VulnerabilityRef,
865 component: &Component,
866 depth: Option<u32>,
867 ) -> Self {
868 let mut detail = Self::from_ref(vuln, component);
869 detail.component_depth = depth;
870 detail
871 }
872
873 #[must_use]
879 pub fn sla_status(&self) -> SlaStatus {
880 if let Some(days) = self.days_until_due {
882 if days < 0 {
883 return SlaStatus::Overdue(-days);
884 } else if days <= 3 {
885 return SlaStatus::DueSoon(days);
886 }
887 return SlaStatus::OnTrack(days);
888 }
889
890 if let Some(age_days) = self.days_since_published {
892 let sla_days = match self.severity.to_lowercase().as_str() {
893 "critical" => 1,
894 "high" => 7,
895 "medium" => 30,
896 "low" => 90,
897 _ => return SlaStatus::NoDueDate,
898 };
899 let remaining = sla_days - age_days;
900 if remaining < 0 {
901 return SlaStatus::Overdue(-remaining);
902 } else if remaining <= 3 {
903 return SlaStatus::DueSoon(remaining);
904 }
905 return SlaStatus::OnTrack(remaining);
906 }
907
908 SlaStatus::NoDueDate
909 }
910
911 #[must_use]
913 pub fn get_component_id(&self) -> CanonicalId {
914 self.component_canonical_id.clone().unwrap_or_else(|| {
915 CanonicalId::from_name_version(&self.component_name, self.version.as_deref())
916 })
917 }
918
919 #[must_use]
921 pub fn get_component_ref(&self) -> ComponentRef {
922 self.component_ref.clone().unwrap_or_else(|| {
923 ComponentRef::with_version(
924 self.get_component_id(),
925 &self.component_name,
926 self.version.clone(),
927 )
928 })
929 }
930}
931
932#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
938pub struct DependencyGraphChange {
939 pub component_id: CanonicalId,
941 pub component_name: String,
943 pub change: DependencyChangeType,
945 pub impact: GraphChangeImpact,
947}
948
949#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
951#[non_exhaustive]
952pub enum DependencyChangeType {
953 DependencyAdded {
955 dependency_id: CanonicalId,
956 dependency_name: String,
957 },
958
959 DependencyRemoved {
961 dependency_id: CanonicalId,
962 dependency_name: String,
963 },
964
965 RelationshipChanged {
967 dependency_id: CanonicalId,
968 dependency_name: String,
969 old_relationship: String,
970 new_relationship: String,
971 old_scope: Option<String>,
972 new_scope: Option<String>,
973 },
974
975 Reparented {
977 dependency_id: CanonicalId,
978 dependency_name: String,
979 old_parent_id: CanonicalId,
980 old_parent_name: String,
981 new_parent_id: CanonicalId,
982 new_parent_name: String,
983 },
984
985 DepthChanged {
987 old_depth: u32, new_depth: u32,
989 },
990}
991
992impl DependencyChangeType {
993 #[must_use]
995 pub const fn kind(&self) -> &'static str {
996 match self {
997 Self::DependencyAdded { .. } => "added",
998 Self::DependencyRemoved { .. } => "removed",
999 Self::RelationshipChanged { .. } => "relationship_changed",
1000 Self::Reparented { .. } => "reparented",
1001 Self::DepthChanged { .. } => "depth_changed",
1002 }
1003 }
1004}
1005
1006#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1008pub enum GraphChangeImpact {
1009 Low,
1011 Medium,
1013 High,
1015 Critical,
1017}
1018
1019impl GraphChangeImpact {
1020 #[must_use]
1021 pub const fn as_str(&self) -> &'static str {
1022 match self {
1023 Self::Low => "low",
1024 Self::Medium => "medium",
1025 Self::High => "high",
1026 Self::Critical => "critical",
1027 }
1028 }
1029
1030 #[must_use]
1032 pub fn from_label(s: &str) -> Self {
1033 match s.to_lowercase().as_str() {
1034 "critical" => Self::Critical,
1035 "high" => Self::High,
1036 "medium" => Self::Medium,
1037 _ => Self::Low,
1038 }
1039 }
1040}
1041
1042impl std::fmt::Display for GraphChangeImpact {
1043 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1044 write!(f, "{}", self.as_str())
1045 }
1046}
1047
1048#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1050pub struct GraphChangeSummary {
1051 pub total_changes: usize,
1052 pub dependencies_added: usize,
1053 pub dependencies_removed: usize,
1054 pub relationship_changed: usize,
1055 pub reparented: usize,
1056 pub depth_changed: usize,
1057 pub by_impact: GraphChangesByImpact,
1058}
1059
1060impl GraphChangeSummary {
1061 #[must_use]
1063 pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
1064 let mut summary = Self {
1065 total_changes: changes.len(),
1066 ..Default::default()
1067 };
1068
1069 for change in changes {
1070 match &change.change {
1071 DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
1072 DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
1073 DependencyChangeType::RelationshipChanged { .. } => {
1074 summary.relationship_changed += 1;
1075 }
1076 DependencyChangeType::Reparented { .. } => summary.reparented += 1,
1077 DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
1078 }
1079
1080 match change.impact {
1081 GraphChangeImpact::Low => summary.by_impact.low += 1,
1082 GraphChangeImpact::Medium => summary.by_impact.medium += 1,
1083 GraphChangeImpact::High => summary.by_impact.high += 1,
1084 GraphChangeImpact::Critical => summary.by_impact.critical += 1,
1085 }
1086 }
1087
1088 summary
1089 }
1090}
1091
1092#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1093pub struct GraphChangesByImpact {
1094 pub low: usize,
1095 pub medium: usize,
1096 pub high: usize,
1097 pub critical: usize,
1098}