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                    && old_a != new_a
312                {
313                    let dep_name = new_graph.get_component_name(child_id);
314                    changes.push(DependencyGraphChange {
315                        component_id: new_id.clone(),
316                        component_name: component_name.clone(),
317                        change: DependencyChangeType::RelationshipChanged {
318                            dependency_id: child_id.clone(),
319                            dependency_name: dep_name,
320                            old_relationship: old_a.relationship.to_string(),
321                            new_relationship: new_a.relationship.to_string(),
322                            old_scope: old_a.scope.as_ref().map(ToString::to_string),
323                            new_scope: new_a.scope.as_ref().map(ToString::to_string),
324                        },
325                        impact: GraphChangeImpact::Medium,
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            epss_score: None,
983            epss_percentile: None,
984            vex_status: None,
985        });
986
987        let a_id = a.canonical_id.clone();
988        let v_id = vuln_comp.canonical_id.clone();
989
990        let old_sbom = make_sbom(vec![a.clone()], vec![]);
991        let new_sbom = make_sbom(
992            vec![a.clone(), vuln_comp],
993            vec![(a_id.clone(), v_id.clone())],
994        );
995
996        let mut matches = HashMap::new();
997        matches.insert(a_id.clone(), Some(a_id));
998
999        let config = GraphDiffConfig::default();
1000        let (changes, _) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1001
1002        let critical = changes
1003            .iter()
1004            .any(|c| c.impact == GraphChangeImpact::Critical);
1005        assert!(
1006            critical,
1007            "Vulnerable direct dep should be critical impact: {changes:?}"
1008        );
1009    }
1010
1011    #[test]
1012    fn test_empty_sboms_no_changes() {
1013        let old_sbom = NormalizedSbom::default();
1014        let new_sbom = NormalizedSbom::default();
1015        let matches = HashMap::new();
1016        let config = GraphDiffConfig::default();
1017
1018        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1019        assert!(changes.is_empty());
1020        assert_eq!(summary.total_changes, 0);
1021    }
1022
1023    #[test]
1024    fn test_identical_graphs_no_changes() {
1025        let a = make_component("a");
1026        let b = make_component("b");
1027        let a_id = a.canonical_id.clone();
1028        let b_id = b.canonical_id.clone();
1029
1030        let sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
1031
1032        let mut matches = HashMap::new();
1033        matches.insert(a_id.clone(), Some(a_id));
1034        matches.insert(b_id.clone(), Some(b_id));
1035
1036        let config = GraphDiffConfig::default();
1037        let (changes, summary) = diff_dependency_graph(&sbom, &sbom, &matches, &config);
1038
1039        // No added/removed (depth might diff trivially due to same-SBOM comparison)
1040        assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1041        assert_eq!(
1042            summary.dependencies_removed, 0,
1043            "No false removes: {changes:?}"
1044        );
1045    }
1046
1047    #[test]
1048    fn test_removed_child_not_false_positive() {
1049        // Old: A -> B_v1, New: A -> B_v2 (different canonical IDs for B)
1050        // B_v1 is matched to B_v2 in the mapping.
1051        // Should detect no structural change (same logical edge, just version bump).
1052        let a = make_component("a");
1053        let b_v1 = make_component_v("b", "1.0");
1054        let b_v2 = make_component_v("b", "2.0");
1055
1056        let a_id = a.canonical_id.clone();
1057        let b_v1_id = b_v1.canonical_id.clone();
1058        let b_v2_id = b_v2.canonical_id.clone();
1059
1060        let old_sbom = make_sbom(vec![a.clone(), b_v1], vec![(a_id.clone(), b_v1_id.clone())]);
1061        let new_sbom = make_sbom(vec![a.clone(), b_v2], vec![(a_id.clone(), b_v2_id.clone())]);
1062
1063        let mut matches = HashMap::new();
1064        matches.insert(a_id.clone(), Some(a_id));
1065        matches.insert(b_v1_id, Some(b_v2_id));
1066
1067        let config = GraphDiffConfig::default();
1068        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1069
1070        assert_eq!(
1071            summary.dependencies_added, 0,
1072            "Version bump should not be false add: {changes:?}"
1073        );
1074        assert_eq!(
1075            summary.dependencies_removed, 0,
1076            "Version bump should not be false remove: {changes:?}"
1077        );
1078    }
1079
1080    #[test]
1081    fn test_unmatched_old_child_excluded_from_comparison() {
1082        // Old: A -> B, A -> C. New: A -> B. C is removed (matched to None).
1083        // Should detect: C removed as dependency of A. Not: false add of B.
1084        let a = make_component("a");
1085        let b = make_component("b");
1086        let c = make_component("c");
1087
1088        let a_id = a.canonical_id.clone();
1089        let b_id = b.canonical_id.clone();
1090        let c_id = c.canonical_id.clone();
1091
1092        let old_sbom = make_sbom(
1093            vec![a.clone(), b.clone(), c],
1094            vec![(a_id.clone(), b_id.clone()), (a_id.clone(), c_id.clone())],
1095        );
1096        let new_sbom = make_sbom(
1097            vec![a.clone(), b.clone()],
1098            vec![(a_id.clone(), b_id.clone())],
1099        );
1100
1101        let mut matches = HashMap::new();
1102        matches.insert(a_id.clone(), Some(a_id));
1103        matches.insert(b_id.clone(), Some(b_id));
1104        matches.insert(c_id, None); // C is removed
1105
1106        let config = GraphDiffConfig::default();
1107        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1108
1109        // C was removed, so it's excluded from old_children_mapped.
1110        // B is in both → no change for B.
1111        // The graph diff tracks children per matched parent. Since C is excluded from
1112        // the mapped set, it won't appear in the difference. This is correct because
1113        // the component-level diff already reports C as removed.
1114        assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1115    }
1116
1117    #[test]
1118    fn test_reparenting_with_removed_parent() {
1119        // Old: P1 -> C, P2 -> C. New: P1 -> C. P2 removed (matched to None).
1120        // Should NOT report reparenting — parent set simply lost a removed node.
1121        let p1 = make_component("p1");
1122        let p2 = make_component("p2");
1123        let child = make_component("child");
1124
1125        let p1_id = p1.canonical_id.clone();
1126        let p2_id = p2.canonical_id.clone();
1127        let child_id = child.canonical_id.clone();
1128
1129        let old_sbom = make_sbom(
1130            vec![p1.clone(), p2, child.clone()],
1131            vec![
1132                (p1_id.clone(), child_id.clone()),
1133                (p2_id.clone(), child_id.clone()),
1134            ],
1135        );
1136        let new_sbom = make_sbom(
1137            vec![p1.clone(), child.clone()],
1138            vec![(p1_id.clone(), child_id.clone())],
1139        );
1140
1141        let mut matches = HashMap::new();
1142        matches.insert(p1_id.clone(), Some(p1_id));
1143        matches.insert(p2_id, None); // P2 removed
1144        matches.insert(child_id.clone(), Some(child_id));
1145
1146        let config = GraphDiffConfig::default();
1147        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1148
1149        assert_eq!(
1150            summary.reparented, 0,
1151            "Removed parent should not trigger reparenting: {changes:?}"
1152        );
1153    }
1154
1155    #[test]
1156    fn test_relationship_change_detected() {
1157        // Old: A -[DependsOn]-> B. New: A -[DevDependsOn]-> B.
1158        // Same endpoints, different relationship → RelationshipChanged.
1159        let a = make_component("a");
1160        let b = make_component("b");
1161        let a_id = a.canonical_id.clone();
1162        let b_id = b.canonical_id.clone();
1163
1164        let old_sbom = make_sbom_with_rel(
1165            vec![a.clone(), b.clone()],
1166            vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1167        );
1168        let new_sbom = make_sbom_with_rel(
1169            vec![a, b],
1170            vec![(a_id.clone(), b_id.clone(), DependencyType::DevDependsOn)],
1171        );
1172
1173        let mut matches = HashMap::new();
1174        matches.insert(a_id.clone(), Some(a_id));
1175        matches.insert(b_id.clone(), Some(b_id));
1176
1177        let config = GraphDiffConfig::default();
1178        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1179
1180        assert!(
1181            summary.relationship_changed > 0,
1182            "Should detect relationship change: {changes:?}"
1183        );
1184        // Should NOT report add+remove for same endpoints
1185        assert_eq!(
1186            summary.dependencies_added, 0,
1187            "Relationship change is not an add: {changes:?}"
1188        );
1189        assert_eq!(
1190            summary.dependencies_removed, 0,
1191            "Relationship change is not a remove: {changes:?}"
1192        );
1193    }
1194
1195    #[test]
1196    fn test_scope_change_detected() {
1197        // Old: A -[DependsOn, Required]-> B. New: A -[DependsOn, Optional]-> B.
1198        // Same endpoints and relationship, different scope → RelationshipChanged.
1199        use crate::model::DependencyScope;
1200
1201        let a = make_component("a");
1202        let b = make_component("b");
1203        let a_id = a.canonical_id.clone();
1204        let b_id = b.canonical_id.clone();
1205
1206        let mut old_sbom = NormalizedSbom::default();
1207        old_sbom.add_component(a.clone());
1208        old_sbom.add_component(b.clone());
1209        old_sbom.add_edge(
1210            DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
1211                .with_scope(DependencyScope::Required),
1212        );
1213
1214        let mut new_sbom = NormalizedSbom::default();
1215        new_sbom.add_component(a);
1216        new_sbom.add_component(b);
1217        new_sbom.add_edge(
1218            DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
1219                .with_scope(DependencyScope::Optional),
1220        );
1221
1222        let mut matches = HashMap::new();
1223        matches.insert(a_id.clone(), Some(a_id));
1224        matches.insert(b_id.clone(), Some(b_id));
1225
1226        let config = GraphDiffConfig::default();
1227        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1228
1229        assert!(
1230            summary.relationship_changed > 0,
1231            "Should detect scope change: {changes:?}"
1232        );
1233    }
1234
1235    #[test]
1236    fn test_reparenting_does_not_suppress_unrelated_add() {
1237        // Reparenting C from P1→P2 should NOT suppress "C added to P3".
1238        // Old: P1 -> C, P2 exists, P3 exists
1239        // New: P2 -> C, P3 -> C
1240        // P1→C removed, P2→C added (reparenting), P3→C added (unrelated, must survive)
1241        let p1 = make_component("p1");
1242        let p2 = make_component("p2");
1243        let p3 = make_component("p3");
1244        let child = make_component("child");
1245
1246        let p1_id = p1.canonical_id.clone();
1247        let p2_id = p2.canonical_id.clone();
1248        let p3_id = p3.canonical_id.clone();
1249        let child_id = child.canonical_id.clone();
1250
1251        let old_sbom = make_sbom(
1252            vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
1253            vec![(p1_id.clone(), child_id.clone())],
1254        );
1255        let new_sbom = make_sbom(
1256            vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
1257            vec![
1258                (p2_id.clone(), child_id.clone()),
1259                (p3_id.clone(), child_id.clone()),
1260            ],
1261        );
1262
1263        let mut matches = HashMap::new();
1264        matches.insert(p1_id.clone(), Some(p1_id));
1265        matches.insert(p2_id.clone(), Some(p2_id.clone()));
1266        matches.insert(p3_id.clone(), Some(p3_id.clone()));
1267        matches.insert(child_id.clone(), Some(child_id.clone()));
1268
1269        let config = GraphDiffConfig::default();
1270        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1271
1272        // Should have reparenting (P1→P2)
1273        assert!(
1274            summary.reparented > 0,
1275            "Should detect reparenting: {changes:?}"
1276        );
1277
1278        // The reparenting picks one of {P2, P3} as the new parent. The OTHER one's
1279        // DependencyAdded entry must survive — it's unrelated to the reparenting.
1280        let reparent = changes
1281            .iter()
1282            .find(|c| matches!(&c.change, DependencyChangeType::Reparented { .. }))
1283            .expect("Should have a reparent entry");
1284        let reparent_new_parent = match &reparent.change {
1285            DependencyChangeType::Reparented { new_parent_id, .. } => new_parent_id.clone(),
1286            _ => unreachable!(),
1287        };
1288        let other_parent = if reparent_new_parent == p2_id {
1289            &p3_id
1290        } else {
1291            &p2_id
1292        };
1293
1294        let other_added = changes.iter().any(|c| {
1295            c.component_id == *other_parent
1296                && matches!(
1297                    &c.change,
1298                    DependencyChangeType::DependencyAdded { dependency_id, .. }
1299                    if *dependency_id == child_id
1300                )
1301        });
1302        assert!(
1303            other_added,
1304            "The non-reparented parent's add should not be suppressed: {changes:?}"
1305        );
1306    }
1307
1308    #[test]
1309    fn test_root_promotion_not_skipped() {
1310        // Old: P1 -> C (C has a parent)
1311        // New: C is a root (no parents)
1312        // This is NOT reparenting (no added parent), but the code should
1313        // not skip it entirely — it should still detect the parent removal.
1314        let p1 = make_component("p1");
1315        let child = make_component("child");
1316
1317        let p1_id = p1.canonical_id.clone();
1318        let child_id = child.canonical_id.clone();
1319
1320        let old_sbom = make_sbom(
1321            vec![p1.clone(), child.clone()],
1322            vec![(p1_id.clone(), child_id.clone())],
1323        );
1324        let new_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
1325
1326        let mut matches = HashMap::new();
1327        matches.insert(p1_id.clone(), Some(p1_id.clone()));
1328        matches.insert(child_id.clone(), Some(child_id));
1329
1330        let config = GraphDiffConfig::default();
1331        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1332
1333        // Should detect the removed dependency (P1→C removed)
1334        assert!(
1335            summary.dependencies_removed > 0,
1336            "Root promotion: dependency removal should be detected: {changes:?}"
1337        );
1338        // Should NOT report reparenting (no new parent added)
1339        assert_eq!(
1340            summary.reparented, 0,
1341            "Root promotion is not reparenting: {changes:?}"
1342        );
1343    }
1344
1345    #[test]
1346    fn test_root_demotion_not_skipped() {
1347        // Old: C is a root (no parents)
1348        // New: P1 -> C (C now has a parent)
1349        // This is NOT reparenting, just a dependency addition.
1350        let p1 = make_component("p1");
1351        let child = make_component("child");
1352
1353        let p1_id = p1.canonical_id.clone();
1354        let child_id = child.canonical_id.clone();
1355
1356        let old_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
1357        let new_sbom = make_sbom(
1358            vec![p1.clone(), child.clone()],
1359            vec![(p1_id.clone(), child_id.clone())],
1360        );
1361
1362        let mut matches = HashMap::new();
1363        matches.insert(p1_id.clone(), Some(p1_id.clone()));
1364        matches.insert(child_id.clone(), Some(child_id));
1365
1366        let config = GraphDiffConfig::default();
1367        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1368
1369        // Should detect the added dependency
1370        assert!(
1371            summary.dependencies_added > 0,
1372            "Root demotion: dependency addition should be detected: {changes:?}"
1373        );
1374        // Should NOT report reparenting (no old parent removed)
1375        assert_eq!(
1376            summary.reparented, 0,
1377            "Root demotion is not reparenting: {changes:?}"
1378        );
1379    }
1380
1381    #[test]
1382    fn test_parent_added_multi_parent_not_reparenting() {
1383        // Old: P1 -> C. New: P1 -> C, P2 -> C.
1384        // C gains a parent but keeps the old one — this is NOT reparenting.
1385        let p1 = make_component("p1");
1386        let p2 = make_component("p2");
1387        let child = make_component("child");
1388
1389        let p1_id = p1.canonical_id.clone();
1390        let p2_id = p2.canonical_id.clone();
1391        let child_id = child.canonical_id.clone();
1392
1393        let old_sbom = make_sbom(
1394            vec![p1.clone(), p2.clone(), child.clone()],
1395            vec![(p1_id.clone(), child_id.clone())],
1396        );
1397        let new_sbom = make_sbom(
1398            vec![p1.clone(), p2.clone(), child.clone()],
1399            vec![
1400                (p1_id.clone(), child_id.clone()),
1401                (p2_id.clone(), child_id.clone()),
1402            ],
1403        );
1404
1405        let mut matches = HashMap::new();
1406        matches.insert(p1_id.clone(), Some(p1_id));
1407        matches.insert(p2_id.clone(), Some(p2_id));
1408        matches.insert(child_id.clone(), Some(child_id));
1409
1410        let config = GraphDiffConfig::default();
1411        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1412
1413        assert_eq!(
1414            summary.reparented, 0,
1415            "Adding a new parent while keeping old is not reparenting: {changes:?}"
1416        );
1417        // But the P2→C addition should still be detected
1418        assert!(
1419            summary.dependencies_added > 0,
1420            "P2→C should be detected as added: {changes:?}"
1421        );
1422    }
1423
1424    #[test]
1425    fn test_same_relationship_no_change() {
1426        // Old: A -[DependsOn]-> B. New: A -[DependsOn]-> B.
1427        // Same everything → no change.
1428        let a = make_component("a");
1429        let b = make_component("b");
1430        let a_id = a.canonical_id.clone();
1431        let b_id = b.canonical_id.clone();
1432
1433        let old_sbom = make_sbom_with_rel(
1434            vec![a.clone(), b.clone()],
1435            vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1436        );
1437        let new_sbom = make_sbom_with_rel(
1438            vec![a, b],
1439            vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1440        );
1441
1442        let mut matches = HashMap::new();
1443        matches.insert(a_id.clone(), Some(a_id));
1444        matches.insert(b_id.clone(), Some(b_id));
1445
1446        let config = GraphDiffConfig::default();
1447        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1448
1449        assert_eq!(
1450            summary.relationship_changed, 0,
1451            "Same relationship should not be a change: {changes:?}"
1452        );
1453    }
1454
1455    #[test]
1456    fn test_duplicate_edges_different_types() {
1457        // A -[DependsOn]-> B and A -[DevDependsOn]-> B in same SBOM.
1458        // The last edge wins in the edge_attrs map (HashMap insert semantics).
1459        let a = make_component("a");
1460        let b = make_component("b");
1461        let a_id = a.canonical_id.clone();
1462        let b_id = b.canonical_id.clone();
1463
1464        let mut sbom = NormalizedSbom::default();
1465        sbom.add_component(a);
1466        sbom.add_component(b);
1467        sbom.add_edge(DependencyEdge::new(
1468            a_id.clone(),
1469            b_id.clone(),
1470            DependencyType::DependsOn,
1471        ));
1472        sbom.add_edge(DependencyEdge::new(
1473            a_id.clone(),
1474            b_id.clone(),
1475            DependencyType::DevDependsOn,
1476        ));
1477
1478        let config = GraphDiffConfig::default();
1479        let graph = DependencyGraph::from_sbom(&sbom, &config);
1480
1481        // B should appear as child of A (possibly duplicated in children list)
1482        let children = graph.get_children(&a_id);
1483        assert!(children.contains(&b_id), "B should be a child of A");
1484
1485        // Edge attrs should have one entry (last-write-wins for same pair)
1486        let attrs = graph.get_edge_attrs(&a_id, &b_id);
1487        assert!(attrs.is_some(), "Should have edge attrs for A→B");
1488    }
1489
1490    #[test]
1491    fn test_large_graph_completes() {
1492        // 500 nodes in a chain: root → n1 → n2 → ... → n499
1493        let mut components = Vec::new();
1494        let mut edges = Vec::new();
1495        let mut ids = Vec::new();
1496
1497        for i in 0..500 {
1498            let comp = make_component(&format!("node-{i}"));
1499            ids.push(comp.canonical_id.clone());
1500            components.push(comp);
1501        }
1502        for i in 0..499 {
1503            edges.push((ids[i].clone(), ids[i + 1].clone()));
1504        }
1505
1506        let sbom = make_sbom(components, edges);
1507        let config = GraphDiffConfig::default();
1508        let graph = DependencyGraph::from_sbom(&sbom, &config);
1509
1510        // Root should have depth 1, last node should have depth 500
1511        assert_eq!(graph.get_depth(&ids[0]), Some(1));
1512        assert_eq!(graph.get_depth(&ids[499]), Some(500));
1513    }
1514
1515    #[test]
1516    fn test_empty_vs_nonempty_graph() {
1517        // Old: no edges. New: A → B. All deps should be "added".
1518        let a = make_component("a");
1519        let b = make_component("b");
1520        let a_id = a.canonical_id.clone();
1521        let b_id = b.canonical_id.clone();
1522
1523        let old_sbom = make_sbom(vec![a.clone(), b.clone()], vec![]);
1524        let new_sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
1525
1526        let mut matches = HashMap::new();
1527        matches.insert(a_id.clone(), Some(a_id));
1528        matches.insert(b_id.clone(), Some(b_id));
1529
1530        let config = GraphDiffConfig::default();
1531        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1532
1533        assert!(
1534            summary.dependencies_added > 0,
1535            "Should detect added dependency: {changes:?}"
1536        );
1537        assert_eq!(
1538            summary.dependencies_removed, 0,
1539            "No false removes: {changes:?}"
1540        );
1541    }
1542
1543    #[test]
1544    fn test_nonempty_vs_empty_graph() {
1545        // Old: A → B. New: no edges. All deps should be "removed".
1546        let a = make_component("a");
1547        let b = make_component("b");
1548        let a_id = a.canonical_id.clone();
1549        let b_id = b.canonical_id.clone();
1550
1551        let old_sbom = make_sbom(
1552            vec![a.clone(), b.clone()],
1553            vec![(a_id.clone(), b_id.clone())],
1554        );
1555        let new_sbom = make_sbom(vec![a, b], vec![]);
1556
1557        let mut matches = HashMap::new();
1558        matches.insert(a_id.clone(), Some(a_id));
1559        matches.insert(b_id.clone(), Some(b_id));
1560
1561        let config = GraphDiffConfig::default();
1562        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1563
1564        assert!(
1565            summary.dependencies_removed > 0,
1566            "Should detect removed dependency: {changes:?}"
1567        );
1568        assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1569    }
1570
1571    #[test]
1572    fn test_relation_filter() {
1573        // Graph with DependsOn and DevDependsOn edges.
1574        // Filtering to only DependsOn should exclude DevDependsOn edges.
1575        let a = make_component("a");
1576        let b = make_component("b");
1577        let c = make_component("c");
1578        let a_id = a.canonical_id.clone();
1579        let b_id = b.canonical_id.clone();
1580        let c_id = c.canonical_id.clone();
1581
1582        let mut sbom = NormalizedSbom::default();
1583        sbom.add_component(a);
1584        sbom.add_component(b);
1585        sbom.add_component(c);
1586        sbom.add_edge(DependencyEdge::new(
1587            a_id.clone(),
1588            b_id.clone(),
1589            DependencyType::DependsOn,
1590        ));
1591        sbom.add_edge(DependencyEdge::new(
1592            a_id.clone(),
1593            c_id.clone(),
1594            DependencyType::DevDependsOn,
1595        ));
1596
1597        let config = GraphDiffConfig {
1598            relation_filter: vec!["depends-on".to_string()],
1599            ..Default::default()
1600        };
1601        let graph = DependencyGraph::from_sbom(&sbom, &config);
1602
1603        let children = graph.get_children(&a_id);
1604        assert!(
1605            children.contains(&b_id),
1606            "DependsOn edge should be included"
1607        );
1608        assert!(
1609            !children.contains(&c_id),
1610            "DevDependsOn edge should be excluded by filter"
1611        );
1612    }
1613}