Skip to main content

sbom_tools/diff/
result.rs

1//! Diff result structures.
2
3use crate::model::{CanonicalId, Component, ComponentRef, DependencyEdge, VulnerabilityRef};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Map a severity string to a numeric rank for comparison.
8///
9/// Higher values indicate more severe vulnerabilities.
10/// Returns 0 for unrecognized severity strings.
11fn 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/// Complete result of an SBOM diff operation.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[must_use]
24pub struct DiffResult {
25    /// Summary statistics
26    pub summary: DiffSummary,
27    /// Component changes
28    pub components: ChangeSet<ComponentChange>,
29    /// Dependency changes
30    pub dependencies: ChangeSet<DependencyChange>,
31    /// License changes
32    pub licenses: LicenseChanges,
33    /// Vulnerability changes
34    pub vulnerabilities: VulnerabilityChanges,
35    /// Overall semantic similarity of the two documents, **0.0-100.0**
36    /// (100 = identical). This is the single-diff scale used by every
37    /// `diff` output; the multi-SBOM commands rescale it to a 0.0-1.0
38    /// fraction (`MatrixResult::similarity_scores`, and `1 - similarity`
39    /// for `MultiDiffSummary::deviation_scores`).
40    pub semantic_score: f64,
41    /// Document-level metadata changes (author, tool, timestamp, spec version,
42    /// lifecycle phase, signature, document/primary-component version)
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub metadata_changes: Vec<MetadataChange>,
45    /// Graph structural changes (only populated if graph diffing is enabled)
46    #[serde(default)]
47    pub graph_changes: Vec<DependencyGraphChange>,
48    /// Summary of graph changes
49    #[serde(default)]
50    pub graph_summary: Option<GraphChangeSummary>,
51    /// Number of custom matching rules applied
52    #[serde(default)]
53    pub rules_applied: usize,
54    /// Quality impact of this diff (computed post-diff)
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub quality_delta: Option<QualityDelta>,
57    /// Matching quality metrics (populated during diff)
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub match_metrics: Option<MatchMetrics>,
60    /// Supported numeric ML performance metrics that moved in the adverse direction.
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub ml_regressions: Vec<MlRegression>,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct MlRegression {
67    pub component: String,
68    pub metric: String,
69    pub previous_value: f64,
70    pub new_value: f64,
71}
72
73/// Whether a higher value of the named ML metric is better (`Some(true)`),
74/// worse (`Some(false)`), or unknown (`None`). Slice-qualified names
75/// ("accuracy@validation") key on the base metric. Shared by the CLI
76/// regression gate and the TUI so their notions of "regressed" cannot drift.
77#[must_use]
78pub fn ml_metric_higher_is_better(metric: &str) -> Option<bool> {
79    let metric = metric.split('@').next().unwrap_or(metric);
80    match metric {
81        "accuracy" | "f1" | "f1_score" | "precision" | "recall" | "auc" | "roc_auc" | "bleu"
82        | "rouge" => Some(true),
83        "loss" | "error" | "error_rate" | "perplexity" | "latency" | "latency_ms" => Some(false),
84        _ => None,
85    }
86}
87
88impl DiffResult {
89    /// Create a new empty diff result
90    pub fn new() -> Self {
91        Self {
92            summary: DiffSummary::default(),
93            components: ChangeSet::new(),
94            dependencies: ChangeSet::new(),
95            licenses: LicenseChanges::default(),
96            vulnerabilities: VulnerabilityChanges::default(),
97            semantic_score: 0.0,
98            metadata_changes: Vec::new(),
99            graph_changes: Vec::new(),
100            graph_summary: None,
101            rules_applied: 0,
102            quality_delta: None,
103            match_metrics: None,
104            ml_regressions: Vec::new(),
105        }
106    }
107
108    /// Calculate and update summary statistics
109    pub fn calculate_summary(&mut self) {
110        self.summary.components_added = self.components.added.len();
111        self.summary.components_removed = self.components.removed.len();
112        // Unchanged entries (produced under --include-unchanged) live in the
113        // modified stream for rendering but are not modifications.
114        self.summary.components_modified = self
115            .components
116            .modified
117            .iter()
118            .filter(|c| c.change_type != ChangeType::Unchanged)
119            .count();
120
121        self.summary.dependencies_added = self.dependencies.added.len();
122        self.summary.dependencies_removed = self.dependencies.removed.len();
123        self.summary.graph_changes_count = self.graph_changes.len();
124        self.summary.metadata_changes_count = self.metadata_changes.len();
125
126        self.summary.total_changes = self.summary.components_added
127            + self.summary.components_removed
128            + self.summary.components_modified
129            + self.summary.dependencies_added
130            + self.summary.dependencies_removed
131            + self.summary.graph_changes_count
132            + self.summary.metadata_changes_count;
133
134        self.summary.vulnerabilities_introduced = self.vulnerabilities.introduced.len();
135        self.summary.vulnerabilities_resolved = self.vulnerabilities.resolved.len();
136        self.summary.vulnerabilities_persistent = self.vulnerabilities.persistent.len();
137
138        self.summary.licenses_added = self.licenses.new_licenses.len();
139        self.summary.licenses_removed = self.licenses.removed_licenses.len();
140    }
141
142    /// Check if there are any changes.
143    ///
144    /// Checks both the pre-computed summary and the source-of-truth fields to be
145    /// safe regardless of whether `calculate_summary()` was called.
146    ///
147    /// Unchanged inventory entries (produced under `--include-unchanged`) are
148    /// not changes: a zero-change diff reports `false` regardless of the flag.
149    #[must_use]
150    pub fn has_changes(&self) -> bool {
151        self.summary.total_changes > 0
152            || !self.components.added.is_empty()
153            || !self.components.removed.is_empty()
154            || self
155                .components
156                .modified
157                .iter()
158                .any(|c| c.change_type != ChangeType::Unchanged)
159            || !self.dependencies.is_empty()
160            || !self.graph_changes.is_empty()
161            || !self.metadata_changes.is_empty()
162            || !self.vulnerabilities.introduced.is_empty()
163            || !self.vulnerabilities.resolved.is_empty()
164    }
165
166    /// Recompute date-derived vulnerability day counts for today.
167    ///
168    /// Cached results embed the day they were computed; the incremental
169    /// engine calls this on cache hits so counts (and the SLA statuses
170    /// derived from them) do not go stale across midnight. Returns true if
171    /// anything changed.
172    pub fn refresh_derived_day_counts(&mut self) -> bool {
173        let today = chrono::Utc::now().date_naive();
174        let mut changed = false;
175        for detail in self
176            .vulnerabilities
177            .introduced
178            .iter_mut()
179            .chain(self.vulnerabilities.resolved.iter_mut())
180            .chain(self.vulnerabilities.persistent.iter_mut())
181        {
182            changed |= detail.refresh_day_counts(today);
183        }
184        changed
185    }
186
187    /// Whether any vulnerability day-count field is stale for today.
188    #[must_use]
189    pub fn day_counts_stale(&self) -> bool {
190        let today = chrono::Utc::now().date_naive();
191        self.vulnerabilities
192            .introduced
193            .iter()
194            .chain(self.vulnerabilities.resolved.iter())
195            .chain(self.vulnerabilities.persistent.iter())
196            .any(|detail| {
197                let mut probe = detail.clone();
198                probe.refresh_day_counts(today)
199            })
200    }
201
202    /// Find a component change by canonical ID
203    #[must_use]
204    pub fn find_component_by_id(&self, id: &CanonicalId) -> Option<&ComponentChange> {
205        let id_str = id.value();
206        self.components
207            .added
208            .iter()
209            .chain(self.components.removed.iter())
210            .chain(self.components.modified.iter())
211            .find(|c| c.id == id_str)
212    }
213
214    /// Find a component change by ID string
215    #[must_use]
216    pub fn find_component_by_id_str(&self, id_str: &str) -> Option<&ComponentChange> {
217        self.components
218            .added
219            .iter()
220            .chain(self.components.removed.iter())
221            .chain(self.components.modified.iter())
222            .find(|c| c.id == id_str)
223    }
224
225    /// Get all component changes as a flat list with their indices for navigation
226    #[must_use]
227    pub fn all_component_changes(&self) -> Vec<&ComponentChange> {
228        self.components
229            .added
230            .iter()
231            .chain(self.components.removed.iter())
232            .chain(self.components.modified.iter())
233            .collect()
234    }
235
236    /// Find vulnerabilities affecting a specific component by ID
237    #[must_use]
238    pub fn find_vulns_for_component(
239        &self,
240        component_id: &CanonicalId,
241    ) -> Vec<&VulnerabilityDetail> {
242        let id_str = component_id.value();
243        self.vulnerabilities
244            .introduced
245            .iter()
246            .chain(self.vulnerabilities.resolved.iter())
247            .chain(self.vulnerabilities.persistent.iter())
248            .filter(|v| v.component_id == id_str)
249            .collect()
250    }
251
252    /// Build an index of component IDs to their changes for fast lookup
253    #[must_use]
254    pub fn build_component_id_index(&self) -> HashMap<String, &ComponentChange> {
255        self.components
256            .added
257            .iter()
258            .chain(&self.components.removed)
259            .chain(&self.components.modified)
260            .map(|c| (c.id.clone(), c))
261            .collect()
262    }
263
264    /// Filter vulnerabilities by minimum severity level
265    pub fn filter_by_severity(&mut self, min_severity: &str) {
266        let min_sev = severity_rank(min_severity);
267
268        self.vulnerabilities
269            .introduced
270            .retain(|v| severity_rank(&v.severity) >= min_sev);
271        self.vulnerabilities
272            .resolved
273            .retain(|v| severity_rank(&v.severity) >= min_sev);
274        self.vulnerabilities
275            .persistent
276            .retain(|v| severity_rank(&v.severity) >= min_sev);
277
278        // Recalculate summary
279        self.calculate_summary();
280    }
281
282    /// Filter out vulnerabilities where VEX status is `NotAffected` or `Fixed`.
283    ///
284    /// Keeps vulnerabilities that are `Affected`, `UnderInvestigation`, or have no VEX status.
285    pub fn filter_by_vex(&mut self) {
286        self.vulnerabilities
287            .introduced
288            .retain(VulnerabilityDetail::is_vex_actionable);
289        self.vulnerabilities
290            .resolved
291            .retain(VulnerabilityDetail::is_vex_actionable);
292        self.vulnerabilities
293            .persistent
294            .retain(VulnerabilityDetail::is_vex_actionable);
295
296        self.calculate_summary();
297    }
298}
299
300impl Default for DiffResult {
301    fn default() -> Self {
302        Self::new()
303    }
304}
305
306/// Quality and compliance impact of the diff.
307///
308/// Computed by comparing quality scores of old vs new SBOMs.
309/// Enables tracking whether a change improves or degrades SBOM quality.
310#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311pub struct QualityDelta {
312    /// Overall score change (positive = improvement)
313    pub overall_score_delta: f32,
314    /// Old grade
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub old_grade: Option<crate::quality::QualityGrade>,
317    /// New grade
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub new_grade: Option<crate::quality::QualityGrade>,
320    /// Per-category score deltas
321    pub category_deltas: Vec<CategoryDelta>,
322    /// Categories that regressed (score decreased by >1 point)
323    pub regressions: Vec<String>,
324    /// Categories that improved (score increased by >1 point)
325    pub improvements: Vec<String>,
326    /// Compliance violation count change (positive = more violations)
327    pub violation_count_delta: i32,
328}
329
330/// Score delta for a specific quality category.
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct CategoryDelta {
333    /// Category name (e.g., "Completeness", "Identifiers")
334    pub category: String,
335    /// Score in old SBOM
336    pub old_score: f32,
337    /// Score in new SBOM
338    pub new_score: f32,
339    /// Change (new - old)
340    pub delta: f32,
341}
342
343/// The per-slot scores the CBOM scorer actually used, or `None` when the
344/// report was not scored by the CBOM substitution path.
345///
346/// Mirrors `quality::scorer`'s slot substitution exactly (crypto scores in
347/// slots 1-6, standard provenance/licenses in slots 7-8), including its
348/// gates: a CBOM-profile report with no crypto data falls back to standard
349/// scoring (so its delta must keep the generic category names too), and PQC
350/// readiness is `None` when no algorithms exist — the scorer reweights it
351/// away, so the delta omits it the same way VulnDocs/Lifecycle are omitted.
352/// Slot order matches [`crate::quality::CryptographyMetrics::cbom_category_names`].
353fn cbom_slot_scores(report: &crate::quality::QualityReport) -> Option<[Option<f32>; 8]> {
354    if report.profile != crate::quality::ScoringProfile::Cbom
355        || !report.cryptography_metrics.has_data()
356    {
357        return None;
358    }
359    let cm = &report.cryptography_metrics;
360    Some([
361        Some(cm.crypto_completeness_score()),
362        Some(cm.crypto_identifier_score()),
363        Some(cm.algorithm_strength_score()),
364        Some(cm.crypto_dependency_score()),
365        Some(cm.crypto_lifecycle_score()),
366        cm.pqc_readiness_score(),
367        Some(report.provenance_score),
368        Some(report.license_score),
369    ])
370}
371
372impl QualityDelta {
373    /// Compute quality delta by comparing two quality reports.
374    ///
375    /// Category names are profile-aware: when BOTH reports were scored by the
376    /// CBOM substitution path, the deltas carry the crypto category names the
377    /// scorer's arithmetic actually used
378    /// ([`crate::quality::CryptographyMetrics::cbom_category_names`]) instead
379    /// of the generic ones — a "Completeness" regression on a CBOM diff would
380    /// describe a score that never contributed to either headline number.
381    #[must_use]
382    pub fn from_reports(
383        old: &crate::quality::QualityReport,
384        new: &crate::quality::QualityReport,
385    ) -> Self {
386        let category_deltas: Vec<CategoryDelta> =
387            match (cbom_slot_scores(old), cbom_slot_scores(new)) {
388                (Some(old_slots), Some(new_slots)) => {
389                    crate::quality::CryptographyMetrics::cbom_category_names()
390                        .iter()
391                        .zip(old_slots.iter().zip(new_slots.iter()))
392                        // A slot that is N/A on either side (PQC without
393                        // algorithms) is omitted, like VulnDocs/Lifecycle.
394                        .filter_map(|(name, (old_s, new_s))| match (old_s, new_s) {
395                            (Some(o), Some(n)) => Some(CategoryDelta {
396                                category: (*name).to_string(),
397                                old_score: *o,
398                                new_score: *n,
399                                delta: n - o,
400                            }),
401                            _ => None,
402                        })
403                        .collect()
404                }
405                // Mixed pairs (CBOM vs plain SBOM) keep the generic categories:
406                // per-category deltas need the same scale on both sides.
407                _ => Self::standard_category_deltas(old, new),
408            };
409
410        let regressions: Vec<String> = category_deltas
411            .iter()
412            .filter(|d| d.delta < -1.0)
413            .map(|d| d.category.clone())
414            .collect();
415
416        let improvements: Vec<String> = category_deltas
417            .iter()
418            .filter(|d| d.delta > 1.0)
419            .map(|d| d.category.clone())
420            .collect();
421
422        // Compute compliance violation delta
423        let old_violations = old.compliance.error_count + old.compliance.warning_count;
424        let new_violations = new.compliance.error_count + new.compliance.warning_count;
425
426        Self {
427            overall_score_delta: new.overall_score - old.overall_score,
428            old_grade: Some(old.grade),
429            new_grade: Some(new.grade),
430            category_deltas,
431            regressions,
432            improvements,
433            violation_count_delta: new_violations as i32 - old_violations as i32,
434        }
435    }
436
437    /// The generic (non-CBOM) per-category deltas: the six always-scored
438    /// categories plus VulnDocs/Lifecycle when both sides have them.
439    fn standard_category_deltas(
440        old: &crate::quality::QualityReport,
441        new: &crate::quality::QualityReport,
442    ) -> Vec<CategoryDelta> {
443        let categories = [
444            (
445                "Completeness",
446                old.completeness_score,
447                new.completeness_score,
448            ),
449            ("Identifiers", old.identifier_score, new.identifier_score),
450            ("Licenses", old.license_score, new.license_score),
451            ("Dependencies", old.dependency_score, new.dependency_score),
452            ("Integrity", old.integrity_score, new.integrity_score),
453            ("Provenance", old.provenance_score, new.provenance_score),
454        ];
455
456        let mut category_deltas: Vec<CategoryDelta> = categories
457            .iter()
458            .map(|(name, old_s, new_s)| CategoryDelta {
459                category: (*name).to_string(),
460                old_score: *old_s,
461                new_score: *new_s,
462                delta: new_s - old_s,
463            })
464            .collect();
465
466        // Handle optional categories (VulnDocs and Lifecycle)
467        if let (Some(old_v), Some(new_v)) = (old.vulnerability_score, new.vulnerability_score) {
468            category_deltas.push(CategoryDelta {
469                category: "VulnDocs".to_string(),
470                old_score: old_v,
471                new_score: new_v,
472                delta: new_v - old_v,
473            });
474        }
475        if let (Some(old_l), Some(new_l)) = (old.lifecycle_score, new.lifecycle_score) {
476            category_deltas.push(CategoryDelta {
477                category: "Lifecycle".to_string(),
478                old_score: old_l,
479                new_score: new_l,
480                delta: new_l - old_l,
481            });
482        }
483        category_deltas
484    }
485}
486
487/// Metrics about the component matching process.
488///
489/// Provides visibility into matching quality for debugging and tuning.
490#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
491pub struct MatchMetrics {
492    /// Number of exact matches: identical identifiers or identical names
493    /// (score ≥ 0.995, i.e. the 1.0 identity tiers)
494    pub exact_matches: usize,
495    /// Number of fuzzy matches (below exact threshold)
496    pub fuzzy_matches: usize,
497    /// Matched pairs declared equivalent by user matching rules
498    pub rule_matches: usize,
499    /// Components in old SBOM with no match
500    pub unmatched_old: usize,
501    /// Components in new SBOM with no match
502    pub unmatched_new: usize,
503    /// Average match confidence score
504    pub avg_match_score: f64,
505    /// Minimum match confidence score
506    pub min_match_score: f64,
507}
508
509/// Summary statistics for the diff
510#[derive(Debug, Clone, Default, Serialize, Deserialize)]
511pub struct DiffSummary {
512    pub total_changes: usize,
513    pub components_added: usize,
514    pub components_removed: usize,
515    pub components_modified: usize,
516    pub dependencies_added: usize,
517    pub dependencies_removed: usize,
518    pub graph_changes_count: usize,
519    pub metadata_changes_count: usize,
520    pub vulnerabilities_introduced: usize,
521    pub vulnerabilities_resolved: usize,
522    pub vulnerabilities_persistent: usize,
523    pub licenses_added: usize,
524    pub licenses_removed: usize,
525}
526
527/// Generic change set for added/removed/modified items
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct ChangeSet<T> {
530    pub added: Vec<T>,
531    pub removed: Vec<T>,
532    pub modified: Vec<T>,
533}
534
535impl<T> ChangeSet<T> {
536    #[must_use]
537    pub const fn new() -> Self {
538        Self {
539            added: Vec::new(),
540            removed: Vec::new(),
541            modified: Vec::new(),
542        }
543    }
544
545    #[must_use]
546    pub fn is_empty(&self) -> bool {
547        self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
548    }
549
550    #[must_use]
551    pub fn total(&self) -> usize {
552        self.added.len() + self.removed.len() + self.modified.len()
553    }
554}
555
556impl<T> Default for ChangeSet<T> {
557    fn default() -> Self {
558        Self::new()
559    }
560}
561
562/// Information about how a component was matched.
563///
564/// Included in JSON output to explain why components were correlated.
565#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct MatchInfo {
567    /// Match confidence score (0.0 - 1.0)
568    pub score: f64,
569    /// Matching method used (`ExactIdentifier`, Alias, Fuzzy, etc.)
570    pub method: String,
571    /// Human-readable explanation
572    pub reason: String,
573    /// Detailed score breakdown (optional)
574    #[serde(skip_serializing_if = "Vec::is_empty")]
575    pub score_breakdown: Vec<MatchScoreComponent>,
576    /// Normalizations applied during matching
577    #[serde(skip_serializing_if = "Vec::is_empty")]
578    pub normalizations: Vec<String>,
579    /// Confidence interval for the match score
580    #[serde(skip_serializing_if = "Option::is_none")]
581    pub confidence_interval: Option<ConfidenceInterval>,
582}
583
584/// Confidence interval for match score.
585///
586/// Provides uncertainty bounds around the match score, useful for
587/// understanding match reliability.
588#[derive(Debug, Clone, Serialize, Deserialize)]
589pub struct ConfidenceInterval {
590    /// Lower bound of confidence (0.0 - 1.0)
591    pub lower: f64,
592    /// Upper bound of confidence (0.0 - 1.0)
593    pub upper: f64,
594    /// Confidence level (e.g., 0.95 for 95% CI)
595    pub level: f64,
596}
597
598impl ConfidenceInterval {
599    /// Create a new confidence interval.
600    #[must_use]
601    pub const fn new(lower: f64, upper: f64, level: f64) -> Self {
602        Self {
603            lower: lower.clamp(0.0, 1.0),
604            upper: upper.clamp(0.0, 1.0),
605            level,
606        }
607    }
608
609    /// Create a 95% confidence interval from a score and standard error.
610    ///
611    /// Uses ±1.96 × SE for 95% CI.
612    #[must_use]
613    pub fn from_score_and_error(score: f64, std_error: f64) -> Self {
614        let margin = 1.96 * std_error;
615        Self::new(score - margin, score + margin, 0.95)
616    }
617
618    /// Create a simple confidence interval based on the matching tier.
619    ///
620    /// Exact matches have tight intervals, fuzzy matches have wider intervals.
621    #[must_use]
622    pub fn from_tier(score: f64, tier: &str) -> Self {
623        let margin = match tier {
624            "ExactIdentifier" => 0.0,
625            // A user rule asserts identity outright
626            "EquivalenceRule" => 0.02,
627            "Alias" => 0.02,
628            "EcosystemRule" | "NameIdentity" => 0.03,
629            "CustomRule" => 0.05,
630            "Fuzzy" => 0.08,
631            // Curated equivalence, but across ecosystems: widest interval
632            "CrossEcosystem" => 0.10,
633            _ => 0.10,
634        };
635        Self::new(score - margin, score + margin, 0.95)
636    }
637
638    /// Get the width of the interval.
639    #[must_use]
640    pub fn width(&self) -> f64 {
641        self.upper - self.lower
642    }
643}
644
645/// A component of the match score for JSON output.
646#[derive(Debug, Clone, Serialize, Deserialize)]
647pub struct MatchScoreComponent {
648    /// Name of this score component
649    pub name: String,
650    /// Weight applied
651    pub weight: f64,
652    /// Raw score
653    pub raw_score: f64,
654    /// Weighted contribution
655    pub weighted_score: f64,
656    /// Description
657    pub description: String,
658}
659
660/// Component change information
661#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct ComponentChange {
663    /// Component canonical ID (string for serialization)
664    pub id: String,
665    /// Typed canonical ID for navigation (skipped in JSON output for backward compat)
666    #[serde(skip)]
667    pub canonical_id: Option<CanonicalId>,
668    /// Component reference with ID and name together
669    #[serde(skip)]
670    pub component_ref: Option<ComponentRef>,
671    /// Old component ID (for modified components)
672    #[serde(skip)]
673    pub old_canonical_id: Option<CanonicalId>,
674    /// Component name
675    pub name: String,
676    /// Old version (if existed)
677    pub old_version: Option<String>,
678    /// New version (if exists)
679    pub new_version: Option<String>,
680    /// Ecosystem
681    pub ecosystem: Option<String>,
682    /// Resolved component type ("library", "application",
683    /// "machine-learning-model", ...). CBOM crypto assets are refined to
684    /// their cryptoProperties assetType ("algorithm", "certificate",
685    /// "protocol"), with related-crypto-material narrowed to its material
686    /// type ("private-key", ...) — the uniform "cryptographic" component
687    /// type carries no signal in a CBOM diff. Additive: omitted from JSON
688    /// when unresolved so existing consumers are unaffected.
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub component_type: Option<String>,
691    /// Change type
692    pub change_type: ChangeType,
693    /// Detailed field changes
694    pub field_changes: Vec<FieldChange>,
695    /// Associated cost
696    pub cost: u32,
697    /// Match information (for modified components, explains how old/new were correlated)
698    #[serde(skip_serializing_if = "Option::is_none")]
699    pub match_info: Option<MatchInfo>,
700}
701
702/// Resolve the serialized component type for a change entry.
703///
704/// CycloneDX component type, refined for CBOM crypto assets to the
705/// cryptoProperties assetType (algorithm/certificate/protocol), with
706/// related-crypto-material narrowed to its declared material type
707/// (public-key, private-key, ...). ML models and datasets keep their
708/// CycloneDX type strings ("machine-learning-model" / "data") — the TUI
709/// applies its own display shorthand on top of the same resolution
710/// (`tui::views::components::component_type_label`).
711fn resolved_component_type(component: &Component) -> String {
712    use crate::model::CryptoAssetType;
713    if let Some(cp) = &component.crypto_properties {
714        if cp.asset_type == CryptoAssetType::RelatedCryptoMaterial
715            && let Some(mat) = &cp.related_crypto_material_properties
716        {
717            return mat.material_type.to_string();
718        }
719        return cp.asset_type.to_string();
720    }
721    component.component_type.to_string()
722}
723
724impl ComponentChange {
725    /// Create a new component addition
726    pub fn added(component: &Component, cost: u32) -> Self {
727        Self {
728            id: component.canonical_id.to_string(),
729            canonical_id: Some(component.canonical_id.clone()),
730            component_ref: Some(ComponentRef::from_component(component)),
731            old_canonical_id: None,
732            name: component.name.clone(),
733            old_version: None,
734            new_version: component.version.clone(),
735            ecosystem: component
736                .ecosystem
737                .as_ref()
738                .map(std::string::ToString::to_string),
739            component_type: Some(resolved_component_type(component)),
740            change_type: ChangeType::Added,
741            field_changes: Vec::new(),
742            cost,
743            match_info: None,
744        }
745    }
746
747    /// Create a new component removal
748    pub fn removed(component: &Component, cost: u32) -> Self {
749        Self {
750            id: component.canonical_id.to_string(),
751            canonical_id: Some(component.canonical_id.clone()),
752            component_ref: Some(ComponentRef::from_component(component)),
753            old_canonical_id: Some(component.canonical_id.clone()),
754            name: component.name.clone(),
755            old_version: component.version.clone(),
756            new_version: None,
757            ecosystem: component
758                .ecosystem
759                .as_ref()
760                .map(std::string::ToString::to_string),
761            component_type: Some(resolved_component_type(component)),
762            change_type: ChangeType::Removed,
763            field_changes: Vec::new(),
764            cost,
765            match_info: None,
766        }
767    }
768
769    /// Create an unchanged-component entry (matched, content-equal pair).
770    /// Produced only when `--include-unchanged` is enabled; carries zero cost
771    /// and is excluded from modified counts and semantic scoring.
772    pub fn unchanged(old: &Component, new: &Component) -> Self {
773        Self {
774            id: new.canonical_id.to_string(),
775            canonical_id: Some(new.canonical_id.clone()),
776            component_ref: Some(ComponentRef::from_component(new)),
777            old_canonical_id: Some(old.canonical_id.clone()),
778            name: new.name.clone(),
779            old_version: old.version.clone(),
780            new_version: new.version.clone(),
781            ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
782            // New side: a matched pair's current type (mirrors the TUI's
783            // new-side-first resolution for changed components).
784            component_type: Some(resolved_component_type(new)),
785            change_type: ChangeType::Unchanged,
786            field_changes: Vec::new(),
787            cost: 0,
788            match_info: None,
789        }
790    }
791
792    /// Create a component modification
793    pub fn modified(
794        old: &Component,
795        new: &Component,
796        field_changes: Vec<FieldChange>,
797        cost: u32,
798    ) -> Self {
799        Self {
800            id: new.canonical_id.to_string(),
801            canonical_id: Some(new.canonical_id.clone()),
802            component_ref: Some(ComponentRef::from_component(new)),
803            old_canonical_id: Some(old.canonical_id.clone()),
804            name: new.name.clone(),
805            old_version: old.version.clone(),
806            new_version: new.version.clone(),
807            ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
808            component_type: Some(resolved_component_type(new)),
809            change_type: ChangeType::Modified,
810            field_changes,
811            cost,
812            match_info: None,
813        }
814    }
815
816    /// Create a component modification with match explanation
817    pub fn modified_with_match(
818        old: &Component,
819        new: &Component,
820        field_changes: Vec<FieldChange>,
821        cost: u32,
822        match_info: MatchInfo,
823    ) -> Self {
824        Self {
825            id: new.canonical_id.to_string(),
826            canonical_id: Some(new.canonical_id.clone()),
827            component_ref: Some(ComponentRef::from_component(new)),
828            old_canonical_id: Some(old.canonical_id.clone()),
829            name: new.name.clone(),
830            old_version: old.version.clone(),
831            new_version: new.version.clone(),
832            ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
833            component_type: Some(resolved_component_type(new)),
834            change_type: ChangeType::Modified,
835            field_changes,
836            cost,
837            match_info: Some(match_info),
838        }
839    }
840
841    /// Add match information to an existing change
842    #[must_use]
843    pub fn with_match_info(mut self, match_info: MatchInfo) -> Self {
844        self.match_info = Some(match_info);
845        self
846    }
847
848    /// Get the typed canonical ID, falling back to parsing from string if needed
849    #[must_use]
850    pub fn get_canonical_id(&self) -> CanonicalId {
851        self.canonical_id.clone().unwrap_or_else(|| {
852            CanonicalId::from_name_version(
853                &self.name,
854                self.new_version.as_deref().or(self.old_version.as_deref()),
855            )
856        })
857    }
858
859    /// Get a `ComponentRef` for this change
860    #[must_use]
861    pub fn get_component_ref(&self) -> ComponentRef {
862        self.component_ref.clone().unwrap_or_else(|| {
863            ComponentRef::with_version(
864                self.get_canonical_id(),
865                &self.name,
866                self.new_version
867                    .clone()
868                    .or_else(|| self.old_version.clone()),
869            )
870        })
871    }
872}
873
874impl MatchInfo {
875    /// Create from a `MatchExplanation`
876    #[must_use]
877    pub fn from_explanation(explanation: &crate::matching::MatchExplanation) -> Self {
878        let method = format!("{:?}", explanation.tier);
879        let ci = ConfidenceInterval::from_tier(explanation.score, &method);
880        Self {
881            score: explanation.score,
882            method,
883            reason: explanation.reason.clone(),
884            score_breakdown: explanation
885                .score_breakdown
886                .iter()
887                .map(|c| MatchScoreComponent {
888                    name: c.name.clone(),
889                    weight: c.weight,
890                    raw_score: c.raw_score,
891                    weighted_score: c.weighted_score,
892                    description: c.description.clone(),
893                })
894                .collect(),
895            normalizations: explanation.normalizations_applied.clone(),
896            confidence_interval: Some(ci),
897        }
898    }
899
900    /// Create a simple match info without detailed breakdown
901    #[must_use]
902    pub fn simple(score: f64, method: &str, reason: &str) -> Self {
903        let ci = ConfidenceInterval::from_tier(score, method);
904        Self {
905            score,
906            method: method.to_string(),
907            reason: reason.to_string(),
908            score_breakdown: Vec::new(),
909            normalizations: Vec::new(),
910            confidence_interval: Some(ci),
911        }
912    }
913
914    /// Create a match info with a custom confidence interval
915    #[must_use]
916    pub const fn with_confidence_interval(mut self, ci: ConfidenceInterval) -> Self {
917        self.confidence_interval = Some(ci);
918        self
919    }
920}
921
922/// Type of change
923#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
924pub enum ChangeType {
925    Added,
926    Removed,
927    Modified,
928    Unchanged,
929}
930
931/// Individual field change
932#[derive(Debug, Clone, Serialize, Deserialize)]
933pub struct FieldChange {
934    pub field: String,
935    pub old_value: Option<String>,
936    pub new_value: Option<String>,
937}
938
939/// Whether a document-metadata field was added, removed, or modified.
940#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
941#[serde(rename_all = "lowercase")]
942pub enum MetadataChangeKind {
943    /// Field gained a value (old absent, new present).
944    Added,
945    /// Field lost a value (old present, new absent).
946    Removed,
947    /// Field's value changed (both present, different).
948    Modified,
949}
950
951impl std::fmt::Display for MetadataChangeKind {
952    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
953        let s = match self {
954            Self::Added => "added",
955            Self::Removed => "removed",
956            Self::Modified => "modified",
957        };
958        f.write_str(s)
959    }
960}
961
962/// A document-level metadata change between two SBOMs.
963///
964/// Surfaces changes that are invisible in the component/dependency/vulnerability
965/// passes: author or tool churn, timestamp updates, spec-version upgrades
966/// (e.g. CycloneDX 1.5 -> 1.7), lifecycle-phase transitions, signature presence
967/// or algorithm changes, and document- or primary-component version bumps. This
968/// is the cross-cutting metadata signal the BSI gap analysis flagged as missing.
969#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
970pub struct MetadataChange {
971    /// Stable field key (e.g. `name`, `spec_version`, `created`, `creator.tool`,
972    /// `lifecycle_phase`, `signature.algorithm`, `primary_component_version`).
973    pub field: String,
974    /// Value in the old SBOM (`None` when the field was absent / added).
975    pub old_value: Option<String>,
976    /// Value in the new SBOM (`None` when the field was removed).
977    pub new_value: Option<String>,
978    /// Whether the field was added, removed, or modified.
979    pub kind: MetadataChangeKind,
980}
981
982impl MetadataChange {
983    /// Build a metadata change from a field key and the two optional values,
984    /// inferring the [`MetadataChangeKind`] from presence. Returns `None` when
985    /// the values are equal (no change to report).
986    #[must_use]
987    pub fn from_values(
988        field: impl Into<String>,
989        old_value: Option<String>,
990        new_value: Option<String>,
991    ) -> Option<Self> {
992        if old_value == new_value {
993            return None;
994        }
995        let kind = match (&old_value, &new_value) {
996            (None, Some(_)) => MetadataChangeKind::Added,
997            (Some(_), None) => MetadataChangeKind::Removed,
998            _ => MetadataChangeKind::Modified,
999        };
1000        Some(Self {
1001            field: field.into(),
1002            old_value,
1003            new_value,
1004            kind,
1005        })
1006    }
1007}
1008
1009/// Dependency change information
1010#[derive(Debug, Clone, Serialize, Deserialize)]
1011pub struct DependencyChange {
1012    /// Source component
1013    pub from: String,
1014    /// Target component
1015    pub to: String,
1016    /// Relationship type
1017    pub relationship: String,
1018    /// Dependency scope
1019    #[serde(default, skip_serializing_if = "Option::is_none")]
1020    pub scope: Option<String>,
1021    /// Change type
1022    pub change_type: ChangeType,
1023}
1024
1025impl DependencyChange {
1026    #[must_use]
1027    pub fn added(edge: &DependencyEdge) -> Self {
1028        Self {
1029            from: edge.from.to_string(),
1030            to: edge.to.to_string(),
1031            relationship: edge.relationship.to_string(),
1032            scope: edge.scope.as_ref().map(std::string::ToString::to_string),
1033            change_type: ChangeType::Added,
1034        }
1035    }
1036
1037    #[must_use]
1038    pub fn removed(edge: &DependencyEdge) -> Self {
1039        Self {
1040            from: edge.from.to_string(),
1041            to: edge.to.to_string(),
1042            relationship: edge.relationship.to_string(),
1043            scope: edge.scope.as_ref().map(std::string::ToString::to_string),
1044            change_type: ChangeType::Removed,
1045        }
1046    }
1047}
1048
1049/// License change information
1050#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1051pub struct LicenseChanges {
1052    /// Newly introduced licenses
1053    pub new_licenses: Vec<LicenseChange>,
1054    /// Removed licenses
1055    pub removed_licenses: Vec<LicenseChange>,
1056    /// License conflicts
1057    pub conflicts: Vec<LicenseConflict>,
1058    /// Components with license changes
1059    pub component_changes: Vec<ComponentLicenseChange>,
1060}
1061
1062/// Individual license change
1063#[derive(Debug, Clone, Serialize, Deserialize)]
1064pub struct LicenseChange {
1065    /// License expression
1066    pub license: String,
1067    /// Components using this license
1068    pub components: Vec<String>,
1069    /// License family
1070    pub family: String,
1071}
1072
1073/// License conflict information
1074#[derive(Debug, Clone, Serialize, Deserialize)]
1075pub struct LicenseConflict {
1076    pub license_a: String,
1077    pub license_b: String,
1078    pub component: String,
1079    pub description: String,
1080}
1081
1082/// Component-level license change
1083#[derive(Debug, Clone, Serialize, Deserialize)]
1084pub struct ComponentLicenseChange {
1085    pub component_id: String,
1086    pub component_name: String,
1087    pub old_licenses: Vec<String>,
1088    pub new_licenses: Vec<String>,
1089}
1090
1091/// A VEX state change for a vulnerability between old and new SBOMs.
1092#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1093pub struct VexStatusChange {
1094    /// Vulnerability ID (e.g., "CVE-2023-1234")
1095    pub vuln_id: String,
1096    /// Affected component name
1097    pub component_name: String,
1098    /// Old VEX state (None = no VEX in old SBOM)
1099    #[serde(default, skip_serializing_if = "Option::is_none")]
1100    pub old_state: Option<crate::model::VexState>,
1101    /// New VEX state (None = no VEX in new SBOM)
1102    #[serde(default, skip_serializing_if = "Option::is_none")]
1103    pub new_state: Option<crate::model::VexState>,
1104}
1105
1106/// Vulnerability change information
1107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1108pub struct VulnerabilityChanges {
1109    /// Newly introduced vulnerabilities
1110    pub introduced: Vec<VulnerabilityDetail>,
1111    /// Resolved vulnerabilities
1112    pub resolved: Vec<VulnerabilityDetail>,
1113    /// Persistent vulnerabilities (present in both)
1114    pub persistent: Vec<VulnerabilityDetail>,
1115    /// VEX state transitions detected across persistent vulnerabilities
1116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1117    pub vex_changes: Vec<VexStatusChange>,
1118}
1119
1120impl VulnerabilityChanges {
1121    /// Count vulnerabilities by severity
1122    #[must_use]
1123    pub fn introduced_by_severity(&self) -> HashMap<String, usize> {
1124        // Pre-allocate for typical severity levels (critical, high, medium, low, unknown)
1125        let mut counts = HashMap::with_capacity(5);
1126        for vuln in &self.introduced {
1127            *counts.entry(vuln.severity.clone()).or_insert(0) += 1;
1128        }
1129        counts
1130    }
1131
1132    /// Get critical and high severity introduced vulnerabilities
1133    #[must_use]
1134    pub fn critical_and_high_introduced(&self) -> Vec<&VulnerabilityDetail> {
1135        self.introduced
1136            .iter()
1137            .filter(|v| v.severity == "Critical" || v.severity == "High")
1138            .collect()
1139    }
1140
1141    /// Compute VEX coverage summary across all vulnerability categories.
1142    pub fn vex_summary(&self) -> VexCoverageSummary {
1143        let all_vulns: Vec<&VulnerabilityDetail> = self
1144            .introduced
1145            .iter()
1146            .chain(&self.resolved)
1147            .chain(&self.persistent)
1148            .collect();
1149
1150        let total = all_vulns.len();
1151        let mut with_vex = 0;
1152        let mut by_state: HashMap<crate::model::VexState, usize> = HashMap::with_capacity(4);
1153        let mut actionable = 0;
1154
1155        for vuln in &all_vulns {
1156            if let Some(ref state) = vuln.vex_state {
1157                with_vex += 1;
1158                *by_state.entry(state.clone()).or_insert(0) += 1;
1159            }
1160            if vuln.is_vex_actionable() {
1161                actionable += 1;
1162            }
1163        }
1164
1165        // Vulns without VEX (gaps) — both introduced and persistent are flagged
1166        let introduced_without_vex = self
1167            .introduced
1168            .iter()
1169            .filter(|v| v.vex_state.is_none())
1170            .count();
1171
1172        let persistent_without_vex = self
1173            .persistent
1174            .iter()
1175            .filter(|v| v.vex_state.is_none())
1176            .count();
1177
1178        VexCoverageSummary {
1179            total_vulns: total,
1180            with_vex,
1181            without_vex: total - with_vex,
1182            actionable,
1183            coverage_pct: if total > 0 {
1184                (with_vex as f64 / total as f64) * 100.0
1185            } else {
1186                100.0
1187            },
1188            by_state,
1189            introduced_without_vex,
1190            persistent_without_vex,
1191        }
1192    }
1193}
1194
1195/// VEX coverage summary for vulnerability changes.
1196#[derive(Debug, Clone, Serialize, Deserialize)]
1197#[must_use]
1198pub struct VexCoverageSummary {
1199    /// Total vulnerabilities across all categories
1200    pub total_vulns: usize,
1201    /// Vulnerabilities with a VEX statement
1202    pub with_vex: usize,
1203    /// Vulnerabilities without a VEX statement
1204    pub without_vex: usize,
1205    /// Vulnerabilities that are VEX-actionable (no NotAffected/Fixed)
1206    pub actionable: usize,
1207    /// VEX coverage percentage (0.0-100.0)
1208    pub coverage_pct: f64,
1209    /// Breakdown by VEX state
1210    pub by_state: HashMap<crate::model::VexState, usize>,
1211    /// Introduced vulnerabilities without VEX (gaps requiring attention)
1212    pub introduced_without_vex: usize,
1213    /// Persistent vulnerabilities without VEX (ongoing gaps)
1214    pub persistent_without_vex: usize,
1215}
1216
1217/// SLA status for vulnerability remediation tracking
1218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1219pub enum SlaStatus {
1220    /// Past SLA deadline by N days
1221    Overdue(i64),
1222    /// Due within 3 days (N days remaining)
1223    DueSoon(i64),
1224    /// Within SLA window (N days remaining)
1225    OnTrack(i64),
1226    /// No SLA deadline applicable
1227    NoDueDate,
1228}
1229
1230impl SlaStatus {
1231    /// Format for display (e.g., "3d late", "2d left", "45d old")
1232    #[must_use]
1233    pub fn display(&self, days_since_published: Option<i64>) -> String {
1234        match self {
1235            Self::Overdue(days) => format!("{days}d late"),
1236            Self::DueSoon(days) | Self::OnTrack(days) => format!("{days}d left"),
1237            Self::NoDueDate => {
1238                days_since_published.map_or_else(|| "-".to_string(), |age| format!("{age}d old"))
1239            }
1240        }
1241    }
1242
1243    /// Check if this is an overdue status
1244    #[must_use]
1245    pub const fn is_overdue(&self) -> bool {
1246        matches!(self, Self::Overdue(_))
1247    }
1248
1249    /// Check if this is due soon (approaching deadline)
1250    #[must_use]
1251    pub const fn is_due_soon(&self) -> bool {
1252        matches!(self, Self::DueSoon(_))
1253    }
1254}
1255
1256/// Detailed vulnerability information
1257#[derive(Debug, Clone, Serialize, Deserialize)]
1258pub struct VulnerabilityDetail {
1259    /// Vulnerability ID
1260    pub id: String,
1261    /// Source database
1262    pub source: String,
1263    /// Severity level
1264    pub severity: String,
1265    /// CVSS score
1266    pub cvss_score: Option<f32>,
1267    /// Affected component ID (string for serialization)
1268    pub component_id: String,
1269    /// Typed canonical ID for the component (skipped in JSON for backward compat)
1270    #[serde(skip)]
1271    pub component_canonical_id: Option<CanonicalId>,
1272    /// Component reference with ID and name together
1273    #[serde(skip)]
1274    pub component_ref: Option<ComponentRef>,
1275    /// Affected component name
1276    pub component_name: String,
1277    /// Affected version
1278    pub version: Option<String>,
1279    /// CWE identifiers
1280    pub cwes: Vec<String>,
1281    /// Description
1282    pub description: Option<String>,
1283    /// Remediation info
1284    pub remediation: Option<String>,
1285    /// Whether this vulnerability is in CISA's Known Exploited Vulnerabilities catalog
1286    #[serde(default)]
1287    pub is_kev: bool,
1288    /// Whether the KEV entry is known to be used in ransomware campaigns
1289    #[serde(default)]
1290    pub is_ransomware: bool,
1291    /// FIRST EPSS exploit-probability score (0.0 - 1.0), if enriched
1292    #[serde(default, skip_serializing_if = "Option::is_none")]
1293    pub epss_score: Option<f64>,
1294    /// Dependency depth (1 = direct, 2+ = transitive, None = unknown)
1295    #[serde(default)]
1296    pub component_depth: Option<u32>,
1297    /// Date vulnerability was published (ISO 8601)
1298    #[serde(default)]
1299    pub published_date: Option<String>,
1300    /// KEV due date (CISA mandated remediation deadline)
1301    #[serde(default)]
1302    pub kev_due_date: Option<String>,
1303    /// Days since published (positive = past)
1304    #[serde(default)]
1305    pub days_since_published: Option<i64>,
1306    /// Days until KEV due date (negative = overdue)
1307    #[serde(default)]
1308    pub days_until_due: Option<i64>,
1309    /// VEX state for this vulnerability's component (if available)
1310    #[serde(default, skip_serializing_if = "Option::is_none")]
1311    pub vex_state: Option<crate::model::VexState>,
1312    /// VEX justification (from per-vuln or component-level VEX)
1313    #[serde(default, skip_serializing_if = "Option::is_none")]
1314    pub vex_justification: Option<crate::model::VexJustification>,
1315    /// VEX impact statement (from per-vuln or component-level VEX)
1316    #[serde(default, skip_serializing_if = "Option::is_none")]
1317    pub vex_impact_statement: Option<String>,
1318}
1319
1320impl VulnerabilityDetail {
1321    /// Whether this vulnerability is VEX-actionable (not resolved by vendor analysis).
1322    ///
1323    /// Returns `true` if the VEX state is `Affected`, `UnderInvestigation`, or absent.
1324    /// Returns `false` if the VEX state is `NotAffected` or `Fixed`.
1325    #[must_use]
1326    pub const fn is_vex_actionable(&self) -> bool {
1327        !matches!(
1328            self.vex_state,
1329            Some(crate::model::VexState::NotAffected | crate::model::VexState::Fixed)
1330        )
1331    }
1332
1333    /// Create from a vulnerability reference and component
1334    pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
1335        // Calculate days since published (published is DateTime<Utc>)
1336        let days_since_published = vuln.published.map(|dt| {
1337            let today = chrono::Utc::now().date_naive();
1338            (today - dt.date_naive()).num_days()
1339        });
1340
1341        // Format published date as string for serialization
1342        let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
1343
1344        // Get KEV info if present. days_until_due is DATE-granular
1345        // (matching days_since_published above and refresh_day_counts):
1346        // KevInfo::days_until_due() truncates a DateTime delta, which is one
1347        // day lower for midnight-UTC due dates at any time past 00:00 — a
1348        // fresh value the refresher would immediately "correct", defeating
1349        // the cache's no-clone fast path and making hit/miss disagree.
1350        let (kev_due_date, days_until_due) = vuln.kev_info.as_ref().map_or((None, None), |kev| {
1351            let today = chrono::Utc::now().date_naive();
1352            (
1353                Some(kev.due_date.format("%Y-%m-%d").to_string()),
1354                Some((kev.due_date.date_naive() - today).num_days()),
1355            )
1356        });
1357
1358        Self {
1359            id: vuln.id.clone(),
1360            source: vuln.source.to_string(),
1361            severity: vuln
1362                .severity
1363                .as_ref()
1364                .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string),
1365            cvss_score: vuln.max_cvss_score(),
1366            component_id: component.canonical_id.to_string(),
1367            component_canonical_id: Some(component.canonical_id.clone()),
1368            component_ref: Some(ComponentRef::from_component(component)),
1369            component_name: component.name.clone(),
1370            version: component.version.clone(),
1371            cwes: vuln.cwes.clone(),
1372            description: vuln.description.clone(),
1373            remediation: vuln.remediation.as_ref().map(|r| {
1374                format!(
1375                    "{}: {}",
1376                    r.remediation_type,
1377                    r.description.as_deref().unwrap_or("")
1378                )
1379            }),
1380            is_kev: vuln.is_kev,
1381            is_ransomware: vuln.is_ransomware_related(),
1382            epss_score: vuln.epss_score,
1383            component_depth: None,
1384            published_date,
1385            kev_due_date,
1386            days_since_published,
1387            days_until_due,
1388            vex_state: {
1389                let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1390                vex_source.map(|v| v.status.clone())
1391            },
1392            vex_justification: {
1393                let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1394                vex_source.and_then(|v| v.justification.clone())
1395            },
1396            vex_impact_statement: {
1397                let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1398                vex_source.and_then(|v| v.impact_statement.clone())
1399            },
1400        }
1401    }
1402
1403    /// Recompute the day-count fields from the stored dates.
1404    ///
1405    /// `days_since_published` and `days_until_due` embed the date they were
1406    /// computed on; a cached result served across midnight carries stale
1407    /// counts. Returns true if anything changed. `days_until_due` follows
1408    /// `KevInfo::days_until_due` semantics (whole days, date-granular here
1409    /// since only the date string is stored).
1410    pub(crate) fn refresh_day_counts(&mut self, today: chrono::NaiveDate) -> bool {
1411        let mut changed = false;
1412        if let Some(published) = self.published_date.as_deref()
1413            && let Ok(date) = chrono::NaiveDate::parse_from_str(published, "%Y-%m-%d")
1414        {
1415            let days = (today - date).num_days();
1416            if self.days_since_published != Some(days) {
1417                self.days_since_published = Some(days);
1418                changed = true;
1419            }
1420        }
1421        if let Some(due) = self.kev_due_date.as_deref()
1422            && let Ok(date) = chrono::NaiveDate::parse_from_str(due, "%Y-%m-%d")
1423        {
1424            let days = (date - today).num_days();
1425            if self.days_until_due != Some(days) {
1426                self.days_until_due = Some(days);
1427                changed = true;
1428            }
1429        }
1430        changed
1431    }
1432
1433    /// Create from a vulnerability reference and component with known depth
1434    #[must_use]
1435    pub fn from_ref_with_depth(
1436        vuln: &VulnerabilityRef,
1437        component: &Component,
1438        depth: Option<u32>,
1439    ) -> Self {
1440        let mut detail = Self::from_ref(vuln, component);
1441        detail.component_depth = depth;
1442        detail
1443    }
1444
1445    /// Calculate SLA status based on KEV due date or severity-based policy
1446    ///
1447    /// Priority order:
1448    /// 1. KEV due date (CISA mandated deadline)
1449    /// 2. Severity-based SLA (Critical=1d, High=7d, Medium=30d, Low=90d)
1450    #[must_use]
1451    pub fn sla_status(&self) -> SlaStatus {
1452        // KEV due date takes priority
1453        if let Some(days) = self.days_until_due {
1454            if days < 0 {
1455                return SlaStatus::Overdue(-days);
1456            } else if days <= 3 {
1457                return SlaStatus::DueSoon(days);
1458            }
1459            return SlaStatus::OnTrack(days);
1460        }
1461
1462        // Fall back to severity-based SLA
1463        if let Some(age_days) = self.days_since_published {
1464            let sla_days = match self.severity.to_lowercase().as_str() {
1465                "critical" => 1,
1466                "high" => 7,
1467                "medium" => 30,
1468                "low" => 90,
1469                _ => return SlaStatus::NoDueDate,
1470            };
1471            let remaining = sla_days - age_days;
1472            if remaining < 0 {
1473                return SlaStatus::Overdue(-remaining);
1474            } else if remaining <= 3 {
1475                return SlaStatus::DueSoon(remaining);
1476            }
1477            return SlaStatus::OnTrack(remaining);
1478        }
1479
1480        SlaStatus::NoDueDate
1481    }
1482
1483    /// Get the typed component canonical ID
1484    #[must_use]
1485    pub fn get_component_id(&self) -> CanonicalId {
1486        self.component_canonical_id.clone().unwrap_or_else(|| {
1487            CanonicalId::from_name_version(&self.component_name, self.version.as_deref())
1488        })
1489    }
1490
1491    /// Get a `ComponentRef` for the affected component
1492    #[must_use]
1493    pub fn get_component_ref(&self) -> ComponentRef {
1494        self.component_ref.clone().unwrap_or_else(|| {
1495            ComponentRef::with_version(
1496                self.get_component_id(),
1497                &self.component_name,
1498                self.version.clone(),
1499            )
1500        })
1501    }
1502}
1503
1504// ============================================================================
1505// Graph-Aware Diffing Types
1506// ============================================================================
1507
1508/// Represents a structural change in the dependency graph
1509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1510pub struct DependencyGraphChange {
1511    /// The component involved in the change
1512    pub component_id: CanonicalId,
1513    /// Human-readable component name
1514    pub component_name: String,
1515    /// The type of structural change
1516    pub change: DependencyChangeType,
1517    /// Assessed impact of this change
1518    pub impact: GraphChangeImpact,
1519}
1520
1521/// Types of dependency graph structural changes
1522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1523#[non_exhaustive]
1524pub enum DependencyChangeType {
1525    /// A new dependency link was added
1526    DependencyAdded {
1527        dependency_id: CanonicalId,
1528        dependency_name: String,
1529    },
1530
1531    /// A dependency link was removed
1532    DependencyRemoved {
1533        dependency_id: CanonicalId,
1534        dependency_name: String,
1535    },
1536
1537    /// Dependency relationship or scope changed (same endpoints, different attributes)
1538    RelationshipChanged {
1539        dependency_id: CanonicalId,
1540        dependency_name: String,
1541        old_relationship: String,
1542        new_relationship: String,
1543        old_scope: Option<String>,
1544        new_scope: Option<String>,
1545    },
1546
1547    /// A dependency was reparented (had exactly one parent in both, but different)
1548    Reparented {
1549        dependency_id: CanonicalId,
1550        dependency_name: String,
1551        old_parent_id: CanonicalId,
1552        old_parent_name: String,
1553        new_parent_id: CanonicalId,
1554        new_parent_name: String,
1555    },
1556
1557    /// Dependency depth changed (e.g., transitive became direct)
1558    DepthChanged {
1559        old_depth: u32, // 1 = root, 2 = direct, 3+ = transitive
1560        new_depth: u32,
1561    },
1562}
1563
1564impl DependencyChangeType {
1565    /// Get a short description of the change type
1566    #[must_use]
1567    pub const fn kind(&self) -> &'static str {
1568        match self {
1569            Self::DependencyAdded { .. } => "added",
1570            Self::DependencyRemoved { .. } => "removed",
1571            Self::RelationshipChanged { .. } => "relationship_changed",
1572            Self::Reparented { .. } => "reparented",
1573            Self::DepthChanged { .. } => "depth_changed",
1574        }
1575    }
1576}
1577
1578/// Impact level of a graph change
1579#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1580pub enum GraphChangeImpact {
1581    /// Internal reorganization, no functional change
1582    Low,
1583    /// Depth or type change, may affect build/runtime
1584    Medium,
1585    /// Security-relevant component relationship changed
1586    High,
1587    /// Vulnerable component promoted to direct dependency
1588    Critical,
1589}
1590
1591impl GraphChangeImpact {
1592    #[must_use]
1593    pub const fn as_str(&self) -> &'static str {
1594        match self {
1595            Self::Low => "low",
1596            Self::Medium => "medium",
1597            Self::High => "high",
1598            Self::Critical => "critical",
1599        }
1600    }
1601
1602    /// Parse from a string label. Returns Low for unrecognized values.
1603    #[must_use]
1604    pub fn from_label(s: &str) -> Self {
1605        match s.to_lowercase().as_str() {
1606            "critical" => Self::Critical,
1607            "high" => Self::High,
1608            "medium" => Self::Medium,
1609            _ => Self::Low,
1610        }
1611    }
1612}
1613
1614impl std::fmt::Display for GraphChangeImpact {
1615    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1616        write!(f, "{}", self.as_str())
1617    }
1618}
1619
1620/// Summary statistics for graph changes
1621#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1622pub struct GraphChangeSummary {
1623    pub total_changes: usize,
1624    pub dependencies_added: usize,
1625    pub dependencies_removed: usize,
1626    pub relationship_changed: usize,
1627    pub reparented: usize,
1628    pub depth_changed: usize,
1629    pub by_impact: GraphChangesByImpact,
1630}
1631
1632impl GraphChangeSummary {
1633    /// Build summary from a list of changes
1634    #[must_use]
1635    pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
1636        let mut summary = Self {
1637            total_changes: changes.len(),
1638            ..Default::default()
1639        };
1640
1641        for change in changes {
1642            match &change.change {
1643                DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
1644                DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
1645                DependencyChangeType::RelationshipChanged { .. } => {
1646                    summary.relationship_changed += 1;
1647                }
1648                DependencyChangeType::Reparented { .. } => summary.reparented += 1,
1649                DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
1650            }
1651
1652            match change.impact {
1653                GraphChangeImpact::Low => summary.by_impact.low += 1,
1654                GraphChangeImpact::Medium => summary.by_impact.medium += 1,
1655                GraphChangeImpact::High => summary.by_impact.high += 1,
1656                GraphChangeImpact::Critical => summary.by_impact.critical += 1,
1657            }
1658        }
1659
1660        summary
1661    }
1662}
1663
1664#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1665pub struct GraphChangesByImpact {
1666    pub low: usize,
1667    pub medium: usize,
1668    pub high: usize,
1669    pub critical: usize,
1670}
1671
1672#[cfg(test)]
1673mod quality_delta_tests {
1674    use super::QualityDelta;
1675    use crate::model::{
1676        AlgorithmProperties, Component, ComponentType, CryptoAssetType, CryptoPrimitive,
1677        CryptoProperties, NormalizedSbom,
1678    };
1679    use crate::quality::{CryptographyMetrics, QualityScorer, ScoringProfile};
1680
1681    /// A small CBOM: one crypto algorithm asset with the given family and
1682    /// NIST quantum security level.
1683    fn cbom_sbom(family: &str, quantum_level: u8) -> NormalizedSbom {
1684        let mut sbom = NormalizedSbom::default();
1685        let mut comp = Component::new(family.to_string(), format!("crypto/{family}"));
1686        comp.component_type = ComponentType::Cryptographic;
1687        comp.crypto_properties = Some(
1688            CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
1689                AlgorithmProperties::new(CryptoPrimitive::Hash)
1690                    .with_algorithm_family(family.to_string())
1691                    .with_nist_quantum_security_level(quantum_level),
1692            ),
1693        );
1694        comp.calculate_content_hash();
1695        sbom.add_component(comp);
1696        sbom
1697    }
1698
1699    /// A plain SBOM with one library component.
1700    fn plain_sbom(version: &str) -> NormalizedSbom {
1701        let mut sbom = NormalizedSbom::default();
1702        let mut comp = Component::new("lodash".to_string(), format!("pkg:npm/lodash@{version}"));
1703        comp.version = Some(version.to_string());
1704        comp.calculate_content_hash();
1705        sbom.add_component(comp);
1706        sbom
1707    }
1708
1709    /// CBOM-scored pairs must carry the crypto category names the CBOM
1710    /// scorer's arithmetic actually used — not the generic 8 (whose scores
1711    /// never contributed to either headline number).
1712    #[test]
1713    fn cbom_pair_uses_crypto_category_names() {
1714        let scorer = QualityScorer::new(ScoringProfile::Cbom);
1715        let old_report = scorer.score(&cbom_sbom("SHA-2", 1));
1716        let new_report = scorer.score(&cbom_sbom("MD5", 0));
1717
1718        let delta = QualityDelta::from_reports(&old_report, &new_report);
1719
1720        let names: Vec<&str> = delta
1721            .category_deltas
1722            .iter()
1723            .map(|d| d.category.as_str())
1724            .collect();
1725        assert_eq!(
1726            names,
1727            CryptographyMetrics::cbom_category_names().to_vec(),
1728            "CBOM delta must use the scorer's crypto category names"
1729        );
1730        // Regressions/improvements derive from the same rows, so they can
1731        // only ever name CBOM categories here.
1732        for name in delta.regressions.iter().chain(delta.improvements.iter()) {
1733            assert!(
1734                CryptographyMetrics::cbom_category_names().contains(&name.as_str()),
1735                "unexpected non-CBOM category `{name}` in a CBOM delta"
1736            );
1737        }
1738        // The strong->weak hash family swap must actually surface as an
1739        // Algo Strength regression, not hide under a generic name.
1740        assert!(
1741            delta.regressions.iter().any(|r| r == "Algo Strength"),
1742            "expected an Algo Strength regression, got {:?}",
1743            delta.regressions
1744        );
1745    }
1746
1747    /// Standard-profile pairs keep the generic category names (backward
1748    /// compatible: this is the only shape existing JSON consumers have seen).
1749    #[test]
1750    fn standard_pair_keeps_generic_category_names() {
1751        let scorer = QualityScorer::new(ScoringProfile::Standard);
1752        let old_report = scorer.score(&plain_sbom("1.0.0"));
1753        let new_report = scorer.score(&plain_sbom("2.0.0"));
1754
1755        let delta = QualityDelta::from_reports(&old_report, &new_report);
1756
1757        let names: Vec<&str> = delta
1758            .category_deltas
1759            .iter()
1760            .map(|d| d.category.as_str())
1761            .collect();
1762        for expected in [
1763            "Completeness",
1764            "Identifiers",
1765            "Licenses",
1766            "Dependencies",
1767            "Integrity",
1768            "Provenance",
1769        ] {
1770            assert!(
1771                names.contains(&expected),
1772                "missing `{expected}` in {names:?}"
1773            );
1774        }
1775        assert!(
1776            !names.iter().any(|n| n.starts_with("Crypto")),
1777            "generic delta must not carry CBOM names: {names:?}"
1778        );
1779    }
1780
1781    /// A mixed pair (only one side scored by the CBOM substitution) falls
1782    /// back to the generic categories: per-category deltas need the same
1783    /// scale on both sides.
1784    #[test]
1785    fn mixed_profile_pair_falls_back_to_generic_names() {
1786        let old_report = QualityScorer::new(ScoringProfile::Standard).score(&plain_sbom("1.0.0"));
1787        let new_report = QualityScorer::new(ScoringProfile::Cbom).score(&cbom_sbom("SHA-2", 1));
1788
1789        let delta = QualityDelta::from_reports(&old_report, &new_report);
1790
1791        assert!(
1792            delta
1793                .category_deltas
1794                .iter()
1795                .any(|d| d.category == "Completeness"),
1796            "mixed pair must keep generic categories"
1797        );
1798    }
1799}
1800
1801#[cfg(test)]
1802mod ml_metric_direction_tests {
1803    use super::ml_metric_higher_is_better;
1804
1805    /// Locks the '@slice' strip and the table now shared by engine and TUI.
1806    #[test]
1807    fn ml_metric_direction_table() {
1808        assert_eq!(ml_metric_higher_is_better("accuracy"), Some(true));
1809        assert_eq!(
1810            ml_metric_higher_is_better("accuracy@validation"),
1811            Some(true)
1812        );
1813        assert_eq!(ml_metric_higher_is_better("loss"), Some(false));
1814        assert_eq!(ml_metric_higher_is_better("latency_ms"), Some(false));
1815        assert_eq!(ml_metric_higher_is_better("custom_metric"), None);
1816    }
1817}