Skip to main content

sbom_tools/diff/
graph.rs

1//! Graph-aware dependency diffing module.
2//!
3//! This module provides functionality to detect structural changes in the
4//! dependency graph between two SBOMs, going beyond simple component-level
5//! comparisons to identify:
6//! - Dependencies being added or removed
7//! - Dependencies being reparented (moved from one parent to another)
8//! - Depth changes (transitive becoming direct or vice versa)
9
10use std::collections::{HashMap, HashSet, VecDeque};
11
12use crate::model::{CanonicalId, DependencyScope, DependencyType, NormalizedSbom};
13
14use super::result::{
15    DependencyChangeType, DependencyGraphChange, GraphChangeImpact, GraphChangeSummary,
16};
17
18/// Sentinel depth for nodes unreachable from any root via BFS (e.g., pure cycles).
19/// Distinct from real depths (1=root, 2=direct, 3+=transitive) so impact assessment
20/// can handle them separately.
21const CYCLIC_SENTINEL_DEPTH: u32 = u32::MAX;
22
23/// Configuration for graph-aware diffing
24#[derive(Debug, Clone)]
25pub struct GraphDiffConfig {
26    /// Whether to detect reparenting (computationally more expensive)
27    pub detect_reparenting: bool,
28    /// Whether to track depth changes
29    pub detect_depth_changes: bool,
30    /// Maximum depth to analyze (0 = unlimited)
31    pub max_depth: u32,
32    /// Relationship type filter — only include edges matching these types (empty = all)
33    pub relation_filter: Vec<String>,
34}
35
36impl Default for GraphDiffConfig {
37    fn default() -> Self {
38        Self {
39            detect_reparenting: true,
40            detect_depth_changes: true,
41            max_depth: 0,
42            relation_filter: Vec::new(),
43        }
44    }
45}
46
47/// Edge attributes (relationship type and scope) for a dependency edge.
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49struct EdgeAttrs {
50    relationship: DependencyType,
51    scope: Option<DependencyScope>,
52}
53
54/// Internal representation of dependency graph for diffing
55struct DependencyGraph<'a> {
56    /// Reference to the SBOM
57    sbom: &'a NormalizedSbom,
58    /// `parent_id` -> Vec<`child_id`>
59    edges: HashMap<CanonicalId, Vec<CanonicalId>>,
60    /// `child_id` -> Vec<`parent_id`> (reverse index)
61    reverse_edges: HashMap<CanonicalId, Vec<CanonicalId>>,
62    /// `(from_id, to_id)` -> edge attributes
63    edge_attrs: HashMap<(CanonicalId, CanonicalId), EdgeAttrs>,
64    /// `component_id` -> minimum depth from root (1 = direct)
65    /// Uses minimum depth when multiple paths exist (diamond dependencies)
66    depths: HashMap<CanonicalId, u32>,
67    /// `component_id` -> has vulnerabilities
68    vulnerable_components: HashSet<CanonicalId>,
69}
70
71impl<'a> DependencyGraph<'a> {
72    fn from_sbom(sbom: &'a NormalizedSbom, config: &GraphDiffConfig) -> Self {
73        let mut edges: HashMap<CanonicalId, Vec<CanonicalId>> = HashMap::new();
74        let mut reverse_edges: HashMap<CanonicalId, Vec<CanonicalId>> = HashMap::new();
75        let mut edge_attrs: HashMap<(CanonicalId, CanonicalId), EdgeAttrs> = HashMap::new();
76        let mut vulnerable_components = HashSet::new();
77
78        // Build edge maps from SBOM edges, applying relation filter if set
79        for edge in &sbom.edges {
80            if !config.relation_filter.is_empty()
81                && !config
82                    .relation_filter
83                    .iter()
84                    .any(|f| f.eq_ignore_ascii_case(&edge.relationship.to_string()))
85            {
86                continue; // Skip edges not matching the filter
87            }
88
89            edges
90                .entry(edge.from.clone())
91                .or_default()
92                .push(edge.to.clone());
93
94            reverse_edges
95                .entry(edge.to.clone())
96                .or_default()
97                .push(edge.from.clone());
98
99            edge_attrs.insert(
100                (edge.from.clone(), edge.to.clone()),
101                EdgeAttrs {
102                    relationship: edge.relationship.clone(),
103                    scope: edge.scope.clone(),
104                },
105            );
106        }
107
108        // Identify vulnerable components
109        for (id, comp) in &sbom.components {
110            if !comp.vulnerabilities.is_empty() {
111                vulnerable_components.insert(id.clone());
112            }
113        }
114
115        // Calculate depths via BFS from roots (respecting max_depth limit)
116        let all_components: HashSet<_> = sbom.components.keys().cloned().collect();
117        let depths =
118            Self::calculate_depths(&edges, &reverse_edges, &all_components, config.max_depth);
119
120        Self {
121            sbom,
122            edges,
123            reverse_edges,
124            edge_attrs,
125            depths,
126            vulnerable_components,
127        }
128    }
129
130    /// Calculate minimum depths from roots via BFS.
131    ///
132    /// Uses minimum depth when multiple paths exist (diamond dependencies),
133    /// which gives the most accurate "direct vs transitive" classification.
134    /// Respects `max_depth` limit (0 = unlimited).
135    fn calculate_depths(
136        edges: &HashMap<CanonicalId, Vec<CanonicalId>>,
137        reverse_edges: &HashMap<CanonicalId, Vec<CanonicalId>>,
138        all_components: &HashSet<CanonicalId>,
139        max_depth: u32,
140    ) -> HashMap<CanonicalId, u32> {
141        let mut depths = HashMap::new();
142
143        // BFS to calculate minimum depths
144        // We use a queue that may revisit nodes if a shorter path is found
145        let mut queue: VecDeque<(CanonicalId, u32)> = all_components
146            .iter()
147            .filter(|id| reverse_edges.get(*id).is_none_or(std::vec::Vec::is_empty))
148            .cloned()
149            .map(|id| (id, 1))
150            .collect();
151
152        while let Some((id, depth)) = queue.pop_front() {
153            // Check if we've found a shorter path to this node
154            if let Some(&existing_depth) = depths.get(&id)
155                && depth >= existing_depth
156            {
157                continue; // Already have a shorter or equal path
158            }
159
160            // Record this depth (it's either new or shorter than existing)
161            depths.insert(id.clone(), depth);
162
163            // Stop traversing if we've reached max_depth (0 = unlimited)
164            if max_depth > 0 && depth >= max_depth {
165                continue;
166            }
167
168            if let Some(children) = edges.get(&id) {
169                for child_id in children {
170                    let child_depth = depth + 1;
171                    // Only queue if this might be a shorter path
172                    let dominated = depths.get(child_id).is_some_and(|&d| d <= child_depth);
173                    if !dominated {
174                        queue.push_back((child_id.clone(), child_depth));
175                    }
176                }
177            }
178        }
179
180        // Assign sentinel depth to unreachable/cyclic-only nodes so they
181        // participate in impact assessment but are NOT confused with real roots.
182        // u32::MAX means "unreachable from any root via BFS".
183        for id in all_components {
184            depths.entry(id.clone()).or_insert(CYCLIC_SENTINEL_DEPTH);
185        }
186
187        depths
188    }
189
190    fn get_parents(&self, component_id: &CanonicalId) -> Vec<CanonicalId> {
191        self.reverse_edges
192            .get(component_id)
193            .cloned()
194            .unwrap_or_default()
195    }
196
197    fn get_children(&self, component_id: &CanonicalId) -> Vec<CanonicalId> {
198        self.edges.get(component_id).cloned().unwrap_or_default()
199    }
200
201    fn get_edge_attrs(&self, from: &CanonicalId, to: &CanonicalId) -> Option<&EdgeAttrs> {
202        self.edge_attrs.get(&(from.clone(), to.clone()))
203    }
204
205    fn get_depth(&self, component_id: &CanonicalId) -> Option<u32> {
206        self.depths.get(component_id).copied()
207    }
208
209    fn is_vulnerable(&self, component_id: &CanonicalId) -> bool {
210        self.vulnerable_components.contains(component_id)
211    }
212
213    fn get_component_name(&self, component_id: &CanonicalId) -> String {
214        self.sbom.components.get(component_id).map_or_else(
215            || component_id.to_string(),
216            |c| {
217                c.version
218                    .as_ref()
219                    .map_or_else(|| c.name.clone(), |v| format!("{}@{}", c.name, v))
220            },
221        )
222    }
223}
224
225/// Perform graph-aware diff between two SBOMs
226#[allow(clippy::implicit_hasher)]
227#[must_use]
228pub fn diff_dependency_graph(
229    old_sbom: &NormalizedSbom,
230    new_sbom: &NormalizedSbom,
231    component_matches: &HashMap<CanonicalId, Option<CanonicalId>>,
232    config: &GraphDiffConfig,
233) -> (Vec<DependencyGraphChange>, GraphChangeSummary) {
234    let old_graph = DependencyGraph::from_sbom(old_sbom, config);
235    let new_graph = DependencyGraph::from_sbom(new_sbom, config);
236
237    let mut changes = Vec::new();
238
239    // Iterate through matched components to find dependency changes
240    for (old_id, new_id_option) in component_matches {
241        if let Some(new_id) = new_id_option {
242            let component_name = new_graph.get_component_name(new_id);
243
244            // Get children in both graphs, mapping old children through component_matches
245            // so we compare in the new-SBOM ID space.
246            // Children not in the match map or matched to None (removed) are excluded —
247            // they have no new-space representation and should not participate in comparison.
248            let old_children_mapped: HashSet<CanonicalId> = old_graph
249                .get_children(old_id)
250                .into_iter()
251                .filter_map(|old_child| {
252                    component_matches
253                        .get(&old_child)
254                        .and_then(|opt| opt.clone())
255                })
256                .collect();
257            let new_children: HashSet<_> = new_graph.get_children(new_id).into_iter().collect();
258
259            // Build a reverse map from new-space child to old-space child for attr lookup
260            let old_child_to_new: HashMap<CanonicalId, CanonicalId> = old_graph
261                .get_children(old_id)
262                .into_iter()
263                .filter_map(|old_child| {
264                    component_matches
265                        .get(&old_child)
266                        .and_then(|opt| opt.clone())
267                        .map(|new_child_id| (new_child_id, old_child))
268                })
269                .collect();
270
271            // Detect added dependencies
272            for child_id in new_children.difference(&old_children_mapped) {
273                let dep_name = new_graph.get_component_name(child_id);
274                let impact = assess_impact_added(&new_graph, child_id);
275
276                changes.push(DependencyGraphChange {
277                    component_id: new_id.clone(),
278                    component_name: component_name.clone(),
279                    change: DependencyChangeType::DependencyAdded {
280                        dependency_id: child_id.clone(),
281                        dependency_name: dep_name,
282                    },
283                    impact,
284                });
285            }
286
287            // Detect removed dependencies
288            for child_id in old_children_mapped.difference(&new_children) {
289                let dep_name = new_graph.get_component_name(child_id);
290
291                changes.push(DependencyGraphChange {
292                    component_id: new_id.clone(),
293                    component_name: component_name.clone(),
294                    change: DependencyChangeType::DependencyRemoved {
295                        dependency_id: child_id.clone(),
296                        dependency_name: dep_name,
297                    },
298                    impact: GraphChangeImpact::Low,
299                });
300            }
301
302            // Detect relationship/scope changes for children present in both
303            for child_id in old_children_mapped.intersection(&new_children) {
304                // Look up old edge attrs: old_id → old_child_id (in old-space)
305                let old_attrs = old_child_to_new
306                    .get(child_id)
307                    .and_then(|old_child_id| old_graph.get_edge_attrs(old_id, old_child_id));
308                let new_attrs = new_graph.get_edge_attrs(new_id, child_id);
309
310                if let (Some(old_a), Some(new_a)) = (old_attrs, new_attrs) {
311                    if old_a != new_a {
312                        let dep_name = new_graph.get_component_name(child_id);
313                        changes.push(DependencyGraphChange {
314                            component_id: new_id.clone(),
315                            component_name: component_name.clone(),
316                            change: DependencyChangeType::RelationshipChanged {
317                                dependency_id: child_id.clone(),
318                                dependency_name: dep_name,
319                                old_relationship: old_a.relationship.to_string(),
320                                new_relationship: new_a.relationship.to_string(),
321                                old_scope: old_a.scope.as_ref().map(ToString::to_string),
322                                new_scope: new_a.scope.as_ref().map(ToString::to_string),
323                            },
324                            impact: GraphChangeImpact::Medium,
325                        });
326                    }
327                }
328            }
329        }
330    }
331
332    // Detect depth changes
333    if config.detect_depth_changes {
334        detect_depth_changes(&old_graph, &new_graph, component_matches, &mut changes);
335    }
336
337    // Detect reparenting (post-process to find moved dependencies)
338    if config.detect_reparenting {
339        detect_reparenting(&old_graph, &new_graph, component_matches, &mut changes);
340    }
341
342    // Sort changes by impact (critical first)
343    changes.sort_by(|a, b| {
344        let impact_order = |i: &GraphChangeImpact| match i {
345            GraphChangeImpact::Critical => 0,
346            GraphChangeImpact::High => 1,
347            GraphChangeImpact::Medium => 2,
348            GraphChangeImpact::Low => 3,
349        };
350        impact_order(&a.impact).cmp(&impact_order(&b.impact))
351    });
352
353    let summary = GraphChangeSummary::from_changes(&changes);
354    (changes, summary)
355}
356
357/// Assess the impact of adding a dependency.
358///
359/// Depth numbering: 1 = root (no incoming edges), 2 = direct dep, 3+ = transitive.
360/// `CYCLIC_SENTINEL_DEPTH` = unreachable from root (cyclic-only), treated as transitive.
361/// A direct dependency (depth <= 2) that is vulnerable is Critical impact.
362fn assess_impact_added(graph: &DependencyGraph, component_id: &CanonicalId) -> GraphChangeImpact {
363    let depth = graph
364        .get_depth(component_id)
365        .unwrap_or(CYCLIC_SENTINEL_DEPTH);
366    let is_direct = depth > 0 && depth <= 2 && depth != CYCLIC_SENTINEL_DEPTH;
367
368    if graph.is_vulnerable(component_id) {
369        if is_direct {
370            GraphChangeImpact::Critical
371        } else {
372            GraphChangeImpact::High
373        }
374    } else if is_direct {
375        GraphChangeImpact::Medium
376    } else {
377        GraphChangeImpact::Low
378    }
379}
380
381/// Detect depth changes between matched components.
382///
383/// Ignores sentinel↔sentinel transitions (both unreachable).
384/// Reports sentinel→real or real→sentinel transitions appropriately.
385fn detect_depth_changes(
386    old_graph: &DependencyGraph,
387    new_graph: &DependencyGraph,
388    matches: &HashMap<CanonicalId, Option<CanonicalId>>,
389    changes: &mut Vec<DependencyGraphChange>,
390) {
391    for (old_id, new_id_opt) in matches {
392        if let Some(new_id) = new_id_opt {
393            let old_depth = old_graph.get_depth(old_id);
394            let new_depth = new_graph.get_depth(new_id);
395
396            if let (Some(od), Some(nd)) = (old_depth, new_depth)
397                && od != nd
398            {
399                // Skip sentinel↔sentinel (both unreachable, no meaningful change)
400                if od == CYCLIC_SENTINEL_DEPTH && nd == CYCLIC_SENTINEL_DEPTH {
401                    continue;
402                }
403
404                let component_name = new_graph.get_component_name(new_id);
405
406                let impact =
407                    if nd < od && nd != CYCLIC_SENTINEL_DEPTH && new_graph.is_vulnerable(new_id) {
408                        // Vulnerable component moved closer to root
409                        GraphChangeImpact::High
410                    } else if nd <= 2 && (od > 2 || od == CYCLIC_SENTINEL_DEPTH) {
411                        // Became direct dependency (from transitive or unreachable)
412                        GraphChangeImpact::Medium
413                    } else {
414                        GraphChangeImpact::Low
415                    };
416
417                changes.push(DependencyGraphChange {
418                    component_id: new_id.clone(),
419                    component_name,
420                    change: DependencyChangeType::DepthChanged {
421                        old_depth: od,
422                        new_depth: nd,
423                    },
424                    impact,
425                });
426            }
427        }
428    }
429}
430
431/// Detect reparented components (moved from one parent to another).
432///
433/// Handles single-parent, multi-parent, root promotion, and root demotion cases.
434/// For multi-parent scenarios, compares the mapped old parent set against the new
435/// parent set. A "reparenting" requires at least one removed parent AND at least
436/// one added parent. Only the specific add/remove entries involved in the
437/// reparenting are suppressed — unrelated add/remove entries for the same child
438/// are preserved.
439fn detect_reparenting(
440    old_graph: &DependencyGraph,
441    new_graph: &DependencyGraph,
442    matches: &HashMap<CanonicalId, Option<CanonicalId>>,
443    changes: &mut Vec<DependencyGraphChange>,
444) {
445    for (old_id, new_id_opt) in matches {
446        if let Some(new_id) = new_id_opt {
447            let old_parents = old_graph.get_parents(old_id);
448            let new_parents = new_graph.get_parents(new_id);
449
450            // Skip if both have no parents (both are roots — no change)
451            if old_parents.is_empty() && new_parents.is_empty() {
452                continue;
453            }
454
455            // Map old parents through component_matches to new-SBOM ID space.
456            // Parents not in the match map or matched to None (removed) are excluded.
457            let old_parents_mapped: HashSet<CanonicalId> = old_parents
458                .iter()
459                .filter_map(|old_parent| matches.get(old_parent).and_then(|opt| opt.clone()))
460                .collect();
461            let new_parents_set: HashSet<CanonicalId> = new_parents.into_iter().collect();
462
463            // Check if parents differ
464            if old_parents_mapped == new_parents_set {
465                continue;
466            }
467
468            // Determine which parents were removed and added
469            let removed_parents: Vec<_> = old_parents_mapped.difference(&new_parents_set).collect();
470            let added_parents: Vec<_> = new_parents_set.difference(&old_parents_mapped).collect();
471
472            // Need at least one removed AND one added parent for a proper reparenting.
473            // Pure parent-add or parent-remove without the other side is just a
474            // dependency add/remove, which is already captured in the main diff loop.
475            if removed_parents.is_empty() || added_parents.is_empty() {
476                continue;
477            }
478
479            let old_parent = removed_parents[0];
480            let new_parent = added_parents[0];
481
482            let component_name = new_graph.get_component_name(new_id);
483            let old_parent_name = new_graph.get_component_name(old_parent);
484            let new_parent_name = new_graph.get_component_name(new_parent);
485
486            // Only suppress the specific add/remove entries for the primary
487            // reparenting pair (old_parent→child removed, new_parent→child added).
488            // Other add/remove entries for the same child but different parents
489            // are preserved.
490            changes.retain(|c| match &c.change {
491                DependencyChangeType::DependencyAdded { dependency_id, .. } => {
492                    !(dependency_id == new_id && c.component_id == *new_parent)
493                }
494                DependencyChangeType::DependencyRemoved { dependency_id, .. } => {
495                    !(dependency_id == new_id && c.component_id == *old_parent)
496                }
497                _ => true,
498            });
499
500            changes.push(DependencyGraphChange {
501                component_id: new_id.clone(),
502                component_name,
503                change: DependencyChangeType::Reparented {
504                    dependency_id: new_id.clone(),
505                    dependency_name: new_graph.get_component_name(new_id),
506                    old_parent_id: old_parent.clone(),
507                    old_parent_name,
508                    new_parent_id: new_parent.clone(),
509                    new_parent_name,
510                },
511                impact: GraphChangeImpact::Medium,
512            });
513        }
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::model::{
521        Component, DependencyEdge, DependencyType, NormalizedSbom, VulnerabilityRef,
522        VulnerabilitySource,
523    };
524
525    /// Helper to create a test component with a given name
526    fn make_component(name: &str) -> Component {
527        Component::new(name.to_string(), name.to_string())
528    }
529
530    /// Helper to create a component with version
531    fn make_component_v(name: &str, version: &str) -> Component {
532        Component::new(name.to_string(), format!("{name}@{version}"))
533            .with_version(version.to_string())
534    }
535
536    /// Helper to build a simple SBOM with given components and edges
537    fn make_sbom(
538        components: Vec<Component>,
539        edges: Vec<(CanonicalId, CanonicalId)>,
540    ) -> NormalizedSbom {
541        let mut sbom = NormalizedSbom::default();
542        for comp in components {
543            sbom.add_component(comp);
544        }
545        for (from, to) in edges {
546            sbom.add_edge(DependencyEdge::new(from, to, DependencyType::DependsOn));
547        }
548        sbom
549    }
550
551    /// Helper to build an SBOM with explicit relationship types on edges
552    fn make_sbom_with_rel(
553        components: Vec<Component>,
554        edges: Vec<(CanonicalId, CanonicalId, DependencyType)>,
555    ) -> NormalizedSbom {
556        let mut sbom = NormalizedSbom::default();
557        for comp in components {
558            sbom.add_component(comp);
559        }
560        for (from, to, rel) in edges {
561            sbom.add_edge(DependencyEdge::new(from, to, rel));
562        }
563        sbom
564    }
565
566    #[test]
567    fn test_graph_diff_config_default() {
568        let config = GraphDiffConfig::default();
569        assert!(config.detect_reparenting);
570        assert!(config.detect_depth_changes);
571        assert_eq!(config.max_depth, 0);
572    }
573
574    #[test]
575    fn test_graph_change_impact_display() {
576        assert_eq!(GraphChangeImpact::Critical.as_str(), "critical");
577        assert_eq!(GraphChangeImpact::High.as_str(), "high");
578        assert_eq!(GraphChangeImpact::Medium.as_str(), "medium");
579        assert_eq!(GraphChangeImpact::Low.as_str(), "low");
580    }
581
582    #[test]
583    fn test_children_mapped_through_component_matches() {
584        // Old SBOM: A -> B (old IDs)
585        let a_old = make_component("a-old");
586        let b_old = make_component("b-old");
587        let a_old_id = a_old.canonical_id.clone();
588        let b_old_id = b_old.canonical_id.clone();
589
590        let old_sbom = make_sbom(
591            vec![a_old, b_old],
592            vec![(a_old_id.clone(), b_old_id.clone())],
593        );
594
595        // New SBOM: A -> B (new IDs, same logical components)
596        let a_new = make_component("a-new");
597        let b_new = make_component("b-new");
598        let a_new_id = a_new.canonical_id.clone();
599        let b_new_id = b_new.canonical_id.clone();
600
601        let new_sbom = make_sbom(
602            vec![a_new, b_new],
603            vec![(a_new_id.clone(), b_new_id.clone())],
604        );
605
606        // Map: a-old -> a-new, b-old -> b-new
607        let mut matches = HashMap::new();
608        matches.insert(a_old_id, Some(a_new_id));
609        matches.insert(b_old_id, Some(b_new_id));
610
611        let config = GraphDiffConfig::default();
612        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
613
614        // No changes expected: same logical graph structure
615        assert_eq!(summary.dependencies_added, 0, "No false add: {changes:?}");
616        assert_eq!(
617            summary.dependencies_removed, 0,
618            "No false remove: {changes:?}"
619        );
620    }
621
622    #[test]
623    fn test_depth_linear_chain() {
624        // A -> B -> C -> D
625        let a = make_component("a");
626        let b = make_component("b");
627        let c = make_component("c");
628        let d = make_component("d");
629
630        let ids: Vec<_> = [&a, &b, &c, &d]
631            .iter()
632            .map(|c| c.canonical_id.clone())
633            .collect();
634        let sbom = make_sbom(
635            vec![a, b, c, d],
636            vec![
637                (ids[0].clone(), ids[1].clone()),
638                (ids[1].clone(), ids[2].clone()),
639                (ids[2].clone(), ids[3].clone()),
640            ],
641        );
642
643        let config = GraphDiffConfig::default();
644        let graph = DependencyGraph::from_sbom(&sbom, &config);
645
646        assert_eq!(graph.get_depth(&ids[0]), Some(1)); // root
647        assert_eq!(graph.get_depth(&ids[1]), Some(2));
648        assert_eq!(graph.get_depth(&ids[2]), Some(3));
649        assert_eq!(graph.get_depth(&ids[3]), Some(4));
650    }
651
652    #[test]
653    fn test_depth_diamond_dependency() {
654        // A -> B, A -> C, B -> D, C -> D
655        // D should have min depth 3 (via A->B->D or A->C->D)
656        let a = make_component("a");
657        let b = make_component("b");
658        let c = make_component("c");
659        let d = make_component("d");
660
661        let ids: Vec<_> = [&a, &b, &c, &d]
662            .iter()
663            .map(|c| c.canonical_id.clone())
664            .collect();
665        let sbom = make_sbom(
666            vec![a, b, c, d],
667            vec![
668                (ids[0].clone(), ids[1].clone()),
669                (ids[0].clone(), ids[2].clone()),
670                (ids[1].clone(), ids[3].clone()),
671                (ids[2].clone(), ids[3].clone()),
672            ],
673        );
674
675        let config = GraphDiffConfig::default();
676        let graph = DependencyGraph::from_sbom(&sbom, &config);
677
678        assert_eq!(graph.get_depth(&ids[0]), Some(1));
679        assert_eq!(graph.get_depth(&ids[1]), Some(2));
680        assert_eq!(graph.get_depth(&ids[2]), Some(2));
681        assert_eq!(graph.get_depth(&ids[3]), Some(3)); // min of both paths
682    }
683
684    #[test]
685    fn test_depth_rootless_cycle() {
686        // A -> B -> C -> A (pure cycle, no roots)
687        let a = make_component("a");
688        let b = make_component("b");
689        let c = make_component("c");
690
691        let ids: Vec<_> = [&a, &b, &c]
692            .iter()
693            .map(|c| c.canonical_id.clone())
694            .collect();
695        let sbom = make_sbom(
696            vec![a, b, c],
697            vec![
698                (ids[0].clone(), ids[1].clone()),
699                (ids[1].clone(), ids[2].clone()),
700                (ids[2].clone(), ids[0].clone()),
701            ],
702        );
703
704        let config = GraphDiffConfig::default();
705        let graph = DependencyGraph::from_sbom(&sbom, &config);
706
707        // All nodes should get sentinel depth (unreachable from root)
708        for (i, id) in ids.iter().enumerate() {
709            let depth = graph.get_depth(id);
710            assert!(depth.is_some(), "Node {i} should have depth");
711            assert_eq!(
712                depth.unwrap(),
713                CYCLIC_SENTINEL_DEPTH,
714                "Cyclic node {i} should get sentinel depth, not 0"
715            );
716        }
717    }
718
719    #[test]
720    fn test_depth_cycle_reachable_from_root() {
721        // Root -> A -> B -> C -> B (cycle B→C→B reachable from root)
722        let root = make_component("root");
723        let a = make_component("a");
724        let b = make_component("b");
725        let c = make_component("c");
726
727        let ids: Vec<_> = [&root, &a, &b, &c]
728            .iter()
729            .map(|comp| comp.canonical_id.clone())
730            .collect();
731        let sbom = make_sbom(
732            vec![root, a, b, c],
733            vec![
734                (ids[0].clone(), ids[1].clone()), // root → A
735                (ids[1].clone(), ids[2].clone()), // A → B
736                (ids[2].clone(), ids[3].clone()), // B → C
737                (ids[3].clone(), ids[2].clone()), // C → B (cycle)
738            ],
739        );
740
741        let config = GraphDiffConfig::default();
742        let graph = DependencyGraph::from_sbom(&sbom, &config);
743
744        assert_eq!(graph.get_depth(&ids[0]), Some(1)); // root
745        assert_eq!(graph.get_depth(&ids[1]), Some(2)); // A (direct)
746        assert_eq!(graph.get_depth(&ids[2]), Some(3)); // B (transitive, reachable)
747        assert_eq!(graph.get_depth(&ids[3]), Some(4)); // C (transitive, reachable)
748        // Despite being in a cycle, B and C have real depths because
749        // they are reachable from root via BFS
750    }
751
752    #[test]
753    fn test_depth_disconnected_subgraphs() {
754        // Subgraph 1: R1 -> A
755        // Subgraph 2: R2 -> B -> C
756        // Independent depth computation for each
757        let r1 = make_component("r1");
758        let a = make_component("a");
759        let r2 = make_component("r2");
760        let b = make_component("b");
761        let c = make_component("c");
762
763        let ids: Vec<_> = [&r1, &a, &r2, &b, &c]
764            .iter()
765            .map(|comp| comp.canonical_id.clone())
766            .collect();
767        let sbom = make_sbom(
768            vec![r1, a, r2, b, c],
769            vec![
770                (ids[0].clone(), ids[1].clone()), // R1 → A
771                (ids[2].clone(), ids[3].clone()), // R2 → B
772                (ids[3].clone(), ids[4].clone()), // B → C
773            ],
774        );
775
776        let config = GraphDiffConfig::default();
777        let graph = DependencyGraph::from_sbom(&sbom, &config);
778
779        assert_eq!(graph.get_depth(&ids[0]), Some(1)); // R1
780        assert_eq!(graph.get_depth(&ids[1]), Some(2)); // A
781        assert_eq!(graph.get_depth(&ids[2]), Some(1)); // R2
782        assert_eq!(graph.get_depth(&ids[3]), Some(2)); // B
783        assert_eq!(graph.get_depth(&ids[4]), Some(3)); // C
784    }
785
786    #[test]
787    fn test_self_referencing_edge_no_infinite_loop() {
788        // A -> A (self-loop)
789        let a = make_component("a");
790        let a_id = a.canonical_id.clone();
791
792        let sbom = make_sbom(vec![a], vec![(a_id.clone(), a_id.clone())]);
793
794        let config = GraphDiffConfig::default();
795        let graph = DependencyGraph::from_sbom(&sbom, &config);
796
797        // A is its own parent, but it's also a root (no OTHER incoming edges
798        // that would remove it from the root set... actually it HAS an incoming
799        // edge from itself). With the self-loop, A has an incoming edge so it's
800        // NOT a root → gets sentinel depth.
801        let depth = graph.get_depth(&a_id);
802        assert!(depth.is_some(), "A should have a depth");
803        // Self-loop means A has incoming edges, so not a root → sentinel
804        assert_eq!(
805            depth.unwrap(),
806            CYCLIC_SENTINEL_DEPTH,
807            "Self-referencing node should get sentinel depth"
808        );
809    }
810
811    #[test]
812    fn test_depth_max_depth_limit() {
813        // A -> B -> C -> D with max_depth 2
814        let a = make_component("a");
815        let b = make_component("b");
816        let c = make_component("c");
817        let d = make_component("d");
818
819        let ids: Vec<_> = [&a, &b, &c, &d]
820            .iter()
821            .map(|c| c.canonical_id.clone())
822            .collect();
823        let sbom = make_sbom(
824            vec![a, b, c, d],
825            vec![
826                (ids[0].clone(), ids[1].clone()),
827                (ids[1].clone(), ids[2].clone()),
828                (ids[2].clone(), ids[3].clone()),
829            ],
830        );
831
832        let config = GraphDiffConfig {
833            max_depth: 2,
834            ..Default::default()
835        };
836        let graph = DependencyGraph::from_sbom(&sbom, &config);
837
838        assert_eq!(graph.get_depth(&ids[0]), Some(1));
839        assert_eq!(graph.get_depth(&ids[1]), Some(2));
840        // C and D get sentinel depth since BFS stops at depth 2
841        // and they're unreachable from root BFS at that limit
842        assert_eq!(graph.get_depth(&ids[2]), Some(CYCLIC_SENTINEL_DEPTH));
843        assert_eq!(graph.get_depth(&ids[3]), Some(CYCLIC_SENTINEL_DEPTH));
844    }
845
846    #[test]
847    fn test_reparenting_single_parent() {
848        // Old: P1 -> C (P2 exists but not parent of C)
849        // New: P2 -> C (P1 exists but not parent of C)
850        // P1 and P2 are distinct components present in both SBOMs.
851        let p1 = make_component("p1");
852        let p2 = make_component("p2");
853        let child = make_component("child");
854
855        let p1_id = p1.canonical_id.clone();
856        let p2_id = p2.canonical_id.clone();
857        let child_id = child.canonical_id.clone();
858
859        let old_sbom = make_sbom(
860            vec![p1.clone(), p2.clone(), child.clone()],
861            vec![(p1_id.clone(), child_id.clone())],
862        );
863        let new_sbom = make_sbom(
864            vec![p1.clone(), p2.clone(), child.clone()],
865            vec![(p2_id.clone(), child_id.clone())],
866        );
867
868        // Both parents map to themselves — they are distinct logical components
869        let mut matches = HashMap::new();
870        matches.insert(p1_id.clone(), Some(p1_id));
871        matches.insert(p2_id.clone(), Some(p2_id));
872        matches.insert(child_id.clone(), Some(child_id));
873
874        let config = GraphDiffConfig::default();
875        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
876
877        assert!(
878            summary.reparented > 0,
879            "Should detect reparenting: {changes:?}"
880        );
881    }
882
883    #[test]
884    fn test_renamed_parent_is_not_reparenting() {
885        // Old: P1 -> C. New: P2 -> C. P1 matched to P2 (same logical component).
886        // This is a rename, not reparenting — no structural change.
887        let p1 = make_component("p1");
888        let p2 = make_component("p2");
889        let child = make_component("child");
890
891        let p1_id = p1.canonical_id.clone();
892        let p2_id = p2.canonical_id.clone();
893        let child_id = child.canonical_id.clone();
894
895        let old_sbom = make_sbom(
896            vec![p1, child.clone()],
897            vec![(p1_id.clone(), child_id.clone())],
898        );
899        let new_sbom = make_sbom(
900            vec![p2, child.clone()],
901            vec![(p2_id.clone(), child_id.clone())],
902        );
903
904        let mut matches = HashMap::new();
905        matches.insert(p1_id, Some(p2_id));
906        matches.insert(child_id.clone(), Some(child_id));
907
908        let config = GraphDiffConfig::default();
909        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
910
911        assert_eq!(
912            summary.reparented, 0,
913            "Renamed parent should not be reparenting: {changes:?}"
914        );
915    }
916
917    #[test]
918    fn test_reparenting_multi_parent() {
919        // Old: P1 -> C, P2 -> C
920        // New: P1 -> C, P3 -> C
921        // P2 and P3 are distinct components (P2 removed, P3 added).
922        // All components exist in both SBOMs to enable proper matching.
923        let p1 = make_component("p1");
924        let p2 = make_component("p2");
925        let p3 = make_component("p3");
926        let child = make_component("child");
927
928        let p1_id = p1.canonical_id.clone();
929        let p2_id = p2.canonical_id.clone();
930        let p3_id = p3.canonical_id.clone();
931        let child_id = child.canonical_id.clone();
932
933        let old_sbom = make_sbom(
934            vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
935            vec![
936                (p1_id.clone(), child_id.clone()),
937                (p2_id.clone(), child_id.clone()),
938            ],
939        );
940        let new_sbom = make_sbom(
941            vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
942            vec![
943                (p1_id.clone(), child_id.clone()),
944                (p3_id.clone(), child_id.clone()),
945            ],
946        );
947
948        // All map to themselves — they are distinct logical components
949        let mut matches = HashMap::new();
950        matches.insert(p1_id.clone(), Some(p1_id));
951        matches.insert(p2_id.clone(), Some(p2_id));
952        matches.insert(p3_id.clone(), Some(p3_id));
953        matches.insert(child_id.clone(), Some(child_id));
954
955        let config = GraphDiffConfig::default();
956        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
957
958        assert!(
959            summary.reparented > 0,
960            "Should detect multi-parent reparenting: {changes:?}"
961        );
962    }
963
964    #[test]
965    fn test_vulnerable_direct_dep_is_critical() {
966        // A -> V where V has vulnerabilities and depth=1 (direct)
967        let a = make_component("root");
968        let mut vuln_comp = make_component("vuln-lib");
969        vuln_comp.vulnerabilities.push(VulnerabilityRef {
970            id: "CVE-2024-0001".to_string(),
971            source: VulnerabilitySource::Osv,
972            severity: None,
973            cvss: vec![],
974            affected_versions: vec![],
975            remediation: None,
976            description: None,
977            cwes: vec![],
978            published: None,
979            modified: None,
980            is_kev: false,
981            kev_info: None,
982            vex_status: None,
983        });
984
985        let a_id = a.canonical_id.clone();
986        let v_id = vuln_comp.canonical_id.clone();
987
988        let old_sbom = make_sbom(vec![a.clone()], vec![]);
989        let new_sbom = make_sbom(
990            vec![a.clone(), vuln_comp],
991            vec![(a_id.clone(), v_id.clone())],
992        );
993
994        let mut matches = HashMap::new();
995        matches.insert(a_id.clone(), Some(a_id));
996
997        let config = GraphDiffConfig::default();
998        let (changes, _) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
999
1000        let critical = changes
1001            .iter()
1002            .any(|c| c.impact == GraphChangeImpact::Critical);
1003        assert!(
1004            critical,
1005            "Vulnerable direct dep should be critical impact: {changes:?}"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_empty_sboms_no_changes() {
1011        let old_sbom = NormalizedSbom::default();
1012        let new_sbom = NormalizedSbom::default();
1013        let matches = HashMap::new();
1014        let config = GraphDiffConfig::default();
1015
1016        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1017        assert!(changes.is_empty());
1018        assert_eq!(summary.total_changes, 0);
1019    }
1020
1021    #[test]
1022    fn test_identical_graphs_no_changes() {
1023        let a = make_component("a");
1024        let b = make_component("b");
1025        let a_id = a.canonical_id.clone();
1026        let b_id = b.canonical_id.clone();
1027
1028        let sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
1029
1030        let mut matches = HashMap::new();
1031        matches.insert(a_id.clone(), Some(a_id));
1032        matches.insert(b_id.clone(), Some(b_id));
1033
1034        let config = GraphDiffConfig::default();
1035        let (changes, summary) = diff_dependency_graph(&sbom, &sbom, &matches, &config);
1036
1037        // No added/removed (depth might diff trivially due to same-SBOM comparison)
1038        assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1039        assert_eq!(
1040            summary.dependencies_removed, 0,
1041            "No false removes: {changes:?}"
1042        );
1043    }
1044
1045    #[test]
1046    fn test_removed_child_not_false_positive() {
1047        // Old: A -> B_v1, New: A -> B_v2 (different canonical IDs for B)
1048        // B_v1 is matched to B_v2 in the mapping.
1049        // Should detect no structural change (same logical edge, just version bump).
1050        let a = make_component("a");
1051        let b_v1 = make_component_v("b", "1.0");
1052        let b_v2 = make_component_v("b", "2.0");
1053
1054        let a_id = a.canonical_id.clone();
1055        let b_v1_id = b_v1.canonical_id.clone();
1056        let b_v2_id = b_v2.canonical_id.clone();
1057
1058        let old_sbom = make_sbom(vec![a.clone(), b_v1], vec![(a_id.clone(), b_v1_id.clone())]);
1059        let new_sbom = make_sbom(vec![a.clone(), b_v2], vec![(a_id.clone(), b_v2_id.clone())]);
1060
1061        let mut matches = HashMap::new();
1062        matches.insert(a_id.clone(), Some(a_id));
1063        matches.insert(b_v1_id, Some(b_v2_id));
1064
1065        let config = GraphDiffConfig::default();
1066        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1067
1068        assert_eq!(
1069            summary.dependencies_added, 0,
1070            "Version bump should not be false add: {changes:?}"
1071        );
1072        assert_eq!(
1073            summary.dependencies_removed, 0,
1074            "Version bump should not be false remove: {changes:?}"
1075        );
1076    }
1077
1078    #[test]
1079    fn test_unmatched_old_child_excluded_from_comparison() {
1080        // Old: A -> B, A -> C. New: A -> B. C is removed (matched to None).
1081        // Should detect: C removed as dependency of A. Not: false add of B.
1082        let a = make_component("a");
1083        let b = make_component("b");
1084        let c = make_component("c");
1085
1086        let a_id = a.canonical_id.clone();
1087        let b_id = b.canonical_id.clone();
1088        let c_id = c.canonical_id.clone();
1089
1090        let old_sbom = make_sbom(
1091            vec![a.clone(), b.clone(), c],
1092            vec![(a_id.clone(), b_id.clone()), (a_id.clone(), c_id.clone())],
1093        );
1094        let new_sbom = make_sbom(
1095            vec![a.clone(), b.clone()],
1096            vec![(a_id.clone(), b_id.clone())],
1097        );
1098
1099        let mut matches = HashMap::new();
1100        matches.insert(a_id.clone(), Some(a_id));
1101        matches.insert(b_id.clone(), Some(b_id));
1102        matches.insert(c_id, None); // C is removed
1103
1104        let config = GraphDiffConfig::default();
1105        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1106
1107        // C was removed, so it's excluded from old_children_mapped.
1108        // B is in both → no change for B.
1109        // The graph diff tracks children per matched parent. Since C is excluded from
1110        // the mapped set, it won't appear in the difference. This is correct because
1111        // the component-level diff already reports C as removed.
1112        assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1113    }
1114
1115    #[test]
1116    fn test_reparenting_with_removed_parent() {
1117        // Old: P1 -> C, P2 -> C. New: P1 -> C. P2 removed (matched to None).
1118        // Should NOT report reparenting — parent set simply lost a removed node.
1119        let p1 = make_component("p1");
1120        let p2 = make_component("p2");
1121        let child = make_component("child");
1122
1123        let p1_id = p1.canonical_id.clone();
1124        let p2_id = p2.canonical_id.clone();
1125        let child_id = child.canonical_id.clone();
1126
1127        let old_sbom = make_sbom(
1128            vec![p1.clone(), p2, child.clone()],
1129            vec![
1130                (p1_id.clone(), child_id.clone()),
1131                (p2_id.clone(), child_id.clone()),
1132            ],
1133        );
1134        let new_sbom = make_sbom(
1135            vec![p1.clone(), child.clone()],
1136            vec![(p1_id.clone(), child_id.clone())],
1137        );
1138
1139        let mut matches = HashMap::new();
1140        matches.insert(p1_id.clone(), Some(p1_id));
1141        matches.insert(p2_id, None); // P2 removed
1142        matches.insert(child_id.clone(), Some(child_id));
1143
1144        let config = GraphDiffConfig::default();
1145        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1146
1147        assert_eq!(
1148            summary.reparented, 0,
1149            "Removed parent should not trigger reparenting: {changes:?}"
1150        );
1151    }
1152
1153    #[test]
1154    fn test_relationship_change_detected() {
1155        // Old: A -[DependsOn]-> B. New: A -[DevDependsOn]-> B.
1156        // Same endpoints, different relationship → RelationshipChanged.
1157        let a = make_component("a");
1158        let b = make_component("b");
1159        let a_id = a.canonical_id.clone();
1160        let b_id = b.canonical_id.clone();
1161
1162        let old_sbom = make_sbom_with_rel(
1163            vec![a.clone(), b.clone()],
1164            vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1165        );
1166        let new_sbom = make_sbom_with_rel(
1167            vec![a, b],
1168            vec![(a_id.clone(), b_id.clone(), DependencyType::DevDependsOn)],
1169        );
1170
1171        let mut matches = HashMap::new();
1172        matches.insert(a_id.clone(), Some(a_id));
1173        matches.insert(b_id.clone(), Some(b_id));
1174
1175        let config = GraphDiffConfig::default();
1176        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1177
1178        assert!(
1179            summary.relationship_changed > 0,
1180            "Should detect relationship change: {changes:?}"
1181        );
1182        // Should NOT report add+remove for same endpoints
1183        assert_eq!(
1184            summary.dependencies_added, 0,
1185            "Relationship change is not an add: {changes:?}"
1186        );
1187        assert_eq!(
1188            summary.dependencies_removed, 0,
1189            "Relationship change is not a remove: {changes:?}"
1190        );
1191    }
1192
1193    #[test]
1194    fn test_scope_change_detected() {
1195        // Old: A -[DependsOn, Required]-> B. New: A -[DependsOn, Optional]-> B.
1196        // Same endpoints and relationship, different scope → RelationshipChanged.
1197        use crate::model::DependencyScope;
1198
1199        let a = make_component("a");
1200        let b = make_component("b");
1201        let a_id = a.canonical_id.clone();
1202        let b_id = b.canonical_id.clone();
1203
1204        let mut old_sbom = NormalizedSbom::default();
1205        old_sbom.add_component(a.clone());
1206        old_sbom.add_component(b.clone());
1207        old_sbom.add_edge(
1208            DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
1209                .with_scope(DependencyScope::Required),
1210        );
1211
1212        let mut new_sbom = NormalizedSbom::default();
1213        new_sbom.add_component(a);
1214        new_sbom.add_component(b);
1215        new_sbom.add_edge(
1216            DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
1217                .with_scope(DependencyScope::Optional),
1218        );
1219
1220        let mut matches = HashMap::new();
1221        matches.insert(a_id.clone(), Some(a_id));
1222        matches.insert(b_id.clone(), Some(b_id));
1223
1224        let config = GraphDiffConfig::default();
1225        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1226
1227        assert!(
1228            summary.relationship_changed > 0,
1229            "Should detect scope change: {changes:?}"
1230        );
1231    }
1232
1233    #[test]
1234    fn test_reparenting_does_not_suppress_unrelated_add() {
1235        // Reparenting C from P1→P2 should NOT suppress "C added to P3".
1236        // Old: P1 -> C, P2 exists, P3 exists
1237        // New: P2 -> C, P3 -> C
1238        // P1→C removed, P2→C added (reparenting), P3→C added (unrelated, must survive)
1239        let p1 = make_component("p1");
1240        let p2 = make_component("p2");
1241        let p3 = make_component("p3");
1242        let child = make_component("child");
1243
1244        let p1_id = p1.canonical_id.clone();
1245        let p2_id = p2.canonical_id.clone();
1246        let p3_id = p3.canonical_id.clone();
1247        let child_id = child.canonical_id.clone();
1248
1249        let old_sbom = make_sbom(
1250            vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
1251            vec![(p1_id.clone(), child_id.clone())],
1252        );
1253        let new_sbom = make_sbom(
1254            vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
1255            vec![
1256                (p2_id.clone(), child_id.clone()),
1257                (p3_id.clone(), child_id.clone()),
1258            ],
1259        );
1260
1261        let mut matches = HashMap::new();
1262        matches.insert(p1_id.clone(), Some(p1_id));
1263        matches.insert(p2_id.clone(), Some(p2_id.clone()));
1264        matches.insert(p3_id.clone(), Some(p3_id.clone()));
1265        matches.insert(child_id.clone(), Some(child_id.clone()));
1266
1267        let config = GraphDiffConfig::default();
1268        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1269
1270        // Should have reparenting (P1→P2)
1271        assert!(
1272            summary.reparented > 0,
1273            "Should detect reparenting: {changes:?}"
1274        );
1275
1276        // The reparenting picks one of {P2, P3} as the new parent. The OTHER one's
1277        // DependencyAdded entry must survive — it's unrelated to the reparenting.
1278        let reparent = changes
1279            .iter()
1280            .find(|c| matches!(&c.change, DependencyChangeType::Reparented { .. }))
1281            .expect("Should have a reparent entry");
1282        let reparent_new_parent = match &reparent.change {
1283            DependencyChangeType::Reparented { new_parent_id, .. } => new_parent_id.clone(),
1284            _ => unreachable!(),
1285        };
1286        let other_parent = if reparent_new_parent == p2_id {
1287            &p3_id
1288        } else {
1289            &p2_id
1290        };
1291
1292        let other_added = changes.iter().any(|c| {
1293            c.component_id == *other_parent
1294                && matches!(
1295                    &c.change,
1296                    DependencyChangeType::DependencyAdded { dependency_id, .. }
1297                    if *dependency_id == child_id
1298                )
1299        });
1300        assert!(
1301            other_added,
1302            "The non-reparented parent's add should not be suppressed: {changes:?}"
1303        );
1304    }
1305
1306    #[test]
1307    fn test_root_promotion_not_skipped() {
1308        // Old: P1 -> C (C has a parent)
1309        // New: C is a root (no parents)
1310        // This is NOT reparenting (no added parent), but the code should
1311        // not skip it entirely — it should still detect the parent removal.
1312        let p1 = make_component("p1");
1313        let child = make_component("child");
1314
1315        let p1_id = p1.canonical_id.clone();
1316        let child_id = child.canonical_id.clone();
1317
1318        let old_sbom = make_sbom(
1319            vec![p1.clone(), child.clone()],
1320            vec![(p1_id.clone(), child_id.clone())],
1321        );
1322        let new_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
1323
1324        let mut matches = HashMap::new();
1325        matches.insert(p1_id.clone(), Some(p1_id.clone()));
1326        matches.insert(child_id.clone(), Some(child_id));
1327
1328        let config = GraphDiffConfig::default();
1329        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1330
1331        // Should detect the removed dependency (P1→C removed)
1332        assert!(
1333            summary.dependencies_removed > 0,
1334            "Root promotion: dependency removal should be detected: {changes:?}"
1335        );
1336        // Should NOT report reparenting (no new parent added)
1337        assert_eq!(
1338            summary.reparented, 0,
1339            "Root promotion is not reparenting: {changes:?}"
1340        );
1341    }
1342
1343    #[test]
1344    fn test_root_demotion_not_skipped() {
1345        // Old: C is a root (no parents)
1346        // New: P1 -> C (C now has a parent)
1347        // This is NOT reparenting, just a dependency addition.
1348        let p1 = make_component("p1");
1349        let child = make_component("child");
1350
1351        let p1_id = p1.canonical_id.clone();
1352        let child_id = child.canonical_id.clone();
1353
1354        let old_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
1355        let new_sbom = make_sbom(
1356            vec![p1.clone(), child.clone()],
1357            vec![(p1_id.clone(), child_id.clone())],
1358        );
1359
1360        let mut matches = HashMap::new();
1361        matches.insert(p1_id.clone(), Some(p1_id.clone()));
1362        matches.insert(child_id.clone(), Some(child_id));
1363
1364        let config = GraphDiffConfig::default();
1365        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1366
1367        // Should detect the added dependency
1368        assert!(
1369            summary.dependencies_added > 0,
1370            "Root demotion: dependency addition should be detected: {changes:?}"
1371        );
1372        // Should NOT report reparenting (no old parent removed)
1373        assert_eq!(
1374            summary.reparented, 0,
1375            "Root demotion is not reparenting: {changes:?}"
1376        );
1377    }
1378
1379    #[test]
1380    fn test_parent_added_multi_parent_not_reparenting() {
1381        // Old: P1 -> C. New: P1 -> C, P2 -> C.
1382        // C gains a parent but keeps the old one — this is NOT reparenting.
1383        let p1 = make_component("p1");
1384        let p2 = make_component("p2");
1385        let child = make_component("child");
1386
1387        let p1_id = p1.canonical_id.clone();
1388        let p2_id = p2.canonical_id.clone();
1389        let child_id = child.canonical_id.clone();
1390
1391        let old_sbom = make_sbom(
1392            vec![p1.clone(), p2.clone(), child.clone()],
1393            vec![(p1_id.clone(), child_id.clone())],
1394        );
1395        let new_sbom = make_sbom(
1396            vec![p1.clone(), p2.clone(), child.clone()],
1397            vec![
1398                (p1_id.clone(), child_id.clone()),
1399                (p2_id.clone(), child_id.clone()),
1400            ],
1401        );
1402
1403        let mut matches = HashMap::new();
1404        matches.insert(p1_id.clone(), Some(p1_id));
1405        matches.insert(p2_id.clone(), Some(p2_id));
1406        matches.insert(child_id.clone(), Some(child_id));
1407
1408        let config = GraphDiffConfig::default();
1409        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1410
1411        assert_eq!(
1412            summary.reparented, 0,
1413            "Adding a new parent while keeping old is not reparenting: {changes:?}"
1414        );
1415        // But the P2→C addition should still be detected
1416        assert!(
1417            summary.dependencies_added > 0,
1418            "P2→C should be detected as added: {changes:?}"
1419        );
1420    }
1421
1422    #[test]
1423    fn test_same_relationship_no_change() {
1424        // Old: A -[DependsOn]-> B. New: A -[DependsOn]-> B.
1425        // Same everything → no change.
1426        let a = make_component("a");
1427        let b = make_component("b");
1428        let a_id = a.canonical_id.clone();
1429        let b_id = b.canonical_id.clone();
1430
1431        let old_sbom = make_sbom_with_rel(
1432            vec![a.clone(), b.clone()],
1433            vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1434        );
1435        let new_sbom = make_sbom_with_rel(
1436            vec![a, b],
1437            vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1438        );
1439
1440        let mut matches = HashMap::new();
1441        matches.insert(a_id.clone(), Some(a_id));
1442        matches.insert(b_id.clone(), Some(b_id));
1443
1444        let config = GraphDiffConfig::default();
1445        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1446
1447        assert_eq!(
1448            summary.relationship_changed, 0,
1449            "Same relationship should not be a change: {changes:?}"
1450        );
1451    }
1452
1453    #[test]
1454    fn test_duplicate_edges_different_types() {
1455        // A -[DependsOn]-> B and A -[DevDependsOn]-> B in same SBOM.
1456        // The last edge wins in the edge_attrs map (HashMap insert semantics).
1457        let a = make_component("a");
1458        let b = make_component("b");
1459        let a_id = a.canonical_id.clone();
1460        let b_id = b.canonical_id.clone();
1461
1462        let mut sbom = NormalizedSbom::default();
1463        sbom.add_component(a);
1464        sbom.add_component(b);
1465        sbom.add_edge(DependencyEdge::new(
1466            a_id.clone(),
1467            b_id.clone(),
1468            DependencyType::DependsOn,
1469        ));
1470        sbom.add_edge(DependencyEdge::new(
1471            a_id.clone(),
1472            b_id.clone(),
1473            DependencyType::DevDependsOn,
1474        ));
1475
1476        let config = GraphDiffConfig::default();
1477        let graph = DependencyGraph::from_sbom(&sbom, &config);
1478
1479        // B should appear as child of A (possibly duplicated in children list)
1480        let children = graph.get_children(&a_id);
1481        assert!(children.contains(&b_id), "B should be a child of A");
1482
1483        // Edge attrs should have one entry (last-write-wins for same pair)
1484        let attrs = graph.get_edge_attrs(&a_id, &b_id);
1485        assert!(attrs.is_some(), "Should have edge attrs for A→B");
1486    }
1487
1488    #[test]
1489    fn test_large_graph_completes() {
1490        // 500 nodes in a chain: root → n1 → n2 → ... → n499
1491        let mut components = Vec::new();
1492        let mut edges = Vec::new();
1493        let mut ids = Vec::new();
1494
1495        for i in 0..500 {
1496            let comp = make_component(&format!("node-{i}"));
1497            ids.push(comp.canonical_id.clone());
1498            components.push(comp);
1499        }
1500        for i in 0..499 {
1501            edges.push((ids[i].clone(), ids[i + 1].clone()));
1502        }
1503
1504        let sbom = make_sbom(components, edges);
1505        let config = GraphDiffConfig::default();
1506        let graph = DependencyGraph::from_sbom(&sbom, &config);
1507
1508        // Root should have depth 1, last node should have depth 500
1509        assert_eq!(graph.get_depth(&ids[0]), Some(1));
1510        assert_eq!(graph.get_depth(&ids[499]), Some(500));
1511    }
1512
1513    #[test]
1514    fn test_empty_vs_nonempty_graph() {
1515        // Old: no edges. New: A → B. All deps should be "added".
1516        let a = make_component("a");
1517        let b = make_component("b");
1518        let a_id = a.canonical_id.clone();
1519        let b_id = b.canonical_id.clone();
1520
1521        let old_sbom = make_sbom(vec![a.clone(), b.clone()], vec![]);
1522        let new_sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
1523
1524        let mut matches = HashMap::new();
1525        matches.insert(a_id.clone(), Some(a_id));
1526        matches.insert(b_id.clone(), Some(b_id));
1527
1528        let config = GraphDiffConfig::default();
1529        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1530
1531        assert!(
1532            summary.dependencies_added > 0,
1533            "Should detect added dependency: {changes:?}"
1534        );
1535        assert_eq!(
1536            summary.dependencies_removed, 0,
1537            "No false removes: {changes:?}"
1538        );
1539    }
1540
1541    #[test]
1542    fn test_nonempty_vs_empty_graph() {
1543        // Old: A → B. New: no edges. All deps should be "removed".
1544        let a = make_component("a");
1545        let b = make_component("b");
1546        let a_id = a.canonical_id.clone();
1547        let b_id = b.canonical_id.clone();
1548
1549        let old_sbom = make_sbom(
1550            vec![a.clone(), b.clone()],
1551            vec![(a_id.clone(), b_id.clone())],
1552        );
1553        let new_sbom = make_sbom(vec![a, b], vec![]);
1554
1555        let mut matches = HashMap::new();
1556        matches.insert(a_id.clone(), Some(a_id));
1557        matches.insert(b_id.clone(), Some(b_id));
1558
1559        let config = GraphDiffConfig::default();
1560        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1561
1562        assert!(
1563            summary.dependencies_removed > 0,
1564            "Should detect removed dependency: {changes:?}"
1565        );
1566        assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1567    }
1568
1569    #[test]
1570    fn test_relation_filter() {
1571        // Graph with DependsOn and DevDependsOn edges.
1572        // Filtering to only DependsOn should exclude DevDependsOn edges.
1573        let a = make_component("a");
1574        let b = make_component("b");
1575        let c = make_component("c");
1576        let a_id = a.canonical_id.clone();
1577        let b_id = b.canonical_id.clone();
1578        let c_id = c.canonical_id.clone();
1579
1580        let mut sbom = NormalizedSbom::default();
1581        sbom.add_component(a);
1582        sbom.add_component(b);
1583        sbom.add_component(c);
1584        sbom.add_edge(DependencyEdge::new(
1585            a_id.clone(),
1586            b_id.clone(),
1587            DependencyType::DependsOn,
1588        ));
1589        sbom.add_edge(DependencyEdge::new(
1590            a_id.clone(),
1591            c_id.clone(),
1592            DependencyType::DevDependsOn,
1593        ));
1594
1595        let config = GraphDiffConfig {
1596            relation_filter: vec!["depends-on".to_string()],
1597            ..Default::default()
1598        };
1599        let graph = DependencyGraph::from_sbom(&sbom, &config);
1600
1601        let children = graph.get_children(&a_id);
1602        assert!(
1603            children.contains(&b_id),
1604            "DependsOn edge should be included"
1605        );
1606        assert!(
1607            !children.contains(&c_id),
1608            "DevDependsOn edge should be excluded by filter"
1609        );
1610    }
1611}