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    // Suppressions are collected and applied in ONE retain pass at the end:
446    // retain() inside the per-match loop rescans the whole accumulated
447    // change vector per reparented component — O(reparents × changes),
448    // measured at seconds for lockfile-flattening restructures. Detection
449    // never reads `changes`, so deferral is behavior-identical. Keyed
450    // child → parents so the retain queries by reference (no clones).
451    let mut suppress_added: HashMap<CanonicalId, HashSet<CanonicalId>> = HashMap::new();
452    let mut suppress_removed: HashMap<CanonicalId, HashSet<CanonicalId>> = HashMap::new();
453
454    for (old_id, new_id_opt) in matches {
455        if let Some(new_id) = new_id_opt {
456            let old_parents = old_graph.get_parents(old_id);
457            let new_parents = new_graph.get_parents(new_id);
458
459            // Skip if both have no parents (both are roots — no change)
460            if old_parents.is_empty() && new_parents.is_empty() {
461                continue;
462            }
463
464            // Map old parents through component_matches to new-SBOM ID space.
465            // Parents not in the match map or matched to None (removed) are excluded.
466            let old_parents_mapped: HashSet<CanonicalId> = old_parents
467                .iter()
468                .filter_map(|old_parent| matches.get(old_parent).and_then(|opt| opt.clone()))
469                .collect();
470            let new_parents_set: HashSet<CanonicalId> = new_parents.into_iter().collect();
471
472            // Check if parents differ
473            if old_parents_mapped == new_parents_set {
474                continue;
475            }
476
477            // Determine which parents were removed and added
478            let removed_parents: Vec<_> = old_parents_mapped.difference(&new_parents_set).collect();
479            let added_parents: Vec<_> = new_parents_set.difference(&old_parents_mapped).collect();
480
481            // Need at least one removed AND one added parent for a proper reparenting.
482            // Pure parent-add or parent-remove without the other side is just a
483            // dependency add/remove, which is already captured in the main diff loop.
484            if removed_parents.is_empty() || added_parents.is_empty() {
485                continue;
486            }
487
488            let old_parent = removed_parents[0];
489            let new_parent = added_parents[0];
490
491            let component_name = new_graph.get_component_name(new_id);
492            let old_parent_name = new_graph.get_component_name(old_parent);
493            let new_parent_name = new_graph.get_component_name(new_parent);
494
495            // Only suppress the specific add/remove entries for the primary
496            // reparenting pair (old_parent→child removed, new_parent→child added).
497            // Other add/remove entries for the same child but different parents
498            // are preserved.
499            suppress_added
500                .entry(new_id.clone())
501                .or_default()
502                .insert(new_parent.clone());
503            suppress_removed
504                .entry(new_id.clone())
505                .or_default()
506                .insert(old_parent.clone());
507
508            changes.push(DependencyGraphChange {
509                component_id: new_id.clone(),
510                component_name,
511                change: DependencyChangeType::Reparented {
512                    dependency_id: new_id.clone(),
513                    dependency_name: new_graph.get_component_name(new_id),
514                    old_parent_id: old_parent.clone(),
515                    old_parent_name,
516                    new_parent_id: new_parent.clone(),
517                    new_parent_name,
518                },
519                impact: GraphChangeImpact::Medium,
520            });
521        }
522    }
523
524    if !suppress_added.is_empty() || !suppress_removed.is_empty() {
525        changes.retain(|c| match &c.change {
526            DependencyChangeType::DependencyAdded { dependency_id, .. } => !suppress_added
527                .get(dependency_id)
528                .is_some_and(|parents| parents.contains(&c.component_id)),
529            DependencyChangeType::DependencyRemoved { dependency_id, .. } => !suppress_removed
530                .get(dependency_id)
531                .is_some_and(|parents| parents.contains(&c.component_id)),
532            _ => true,
533        });
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::model::{
541        Component, DependencyEdge, DependencyType, NormalizedSbom, VulnerabilityRef,
542        VulnerabilitySource,
543    };
544
545    /// Helper to create a test component with a given name
546    fn make_component(name: &str) -> Component {
547        Component::new(name.to_string(), name.to_string())
548    }
549
550    /// Helper to create a component with version
551    fn make_component_v(name: &str, version: &str) -> Component {
552        Component::new(name.to_string(), format!("{name}@{version}"))
553            .with_version(version.to_string())
554    }
555
556    /// Helper to build a simple SBOM with given components and edges
557    fn make_sbom(
558        components: Vec<Component>,
559        edges: Vec<(CanonicalId, CanonicalId)>,
560    ) -> NormalizedSbom {
561        let mut sbom = NormalizedSbom::default();
562        for comp in components {
563            sbom.add_component(comp);
564        }
565        for (from, to) in edges {
566            sbom.add_edge(DependencyEdge::new(from, to, DependencyType::DependsOn));
567        }
568        sbom
569    }
570
571    /// Helper to build an SBOM with explicit relationship types on edges
572    fn make_sbom_with_rel(
573        components: Vec<Component>,
574        edges: Vec<(CanonicalId, CanonicalId, DependencyType)>,
575    ) -> NormalizedSbom {
576        let mut sbom = NormalizedSbom::default();
577        for comp in components {
578            sbom.add_component(comp);
579        }
580        for (from, to, rel) in edges {
581            sbom.add_edge(DependencyEdge::new(from, to, rel));
582        }
583        sbom
584    }
585
586    #[test]
587    fn test_graph_diff_config_default() {
588        let config = GraphDiffConfig::default();
589        assert!(config.detect_reparenting);
590        assert!(config.detect_depth_changes);
591        assert_eq!(config.max_depth, 0);
592    }
593
594    #[test]
595    fn test_graph_change_impact_display() {
596        assert_eq!(GraphChangeImpact::Critical.as_str(), "critical");
597        assert_eq!(GraphChangeImpact::High.as_str(), "high");
598        assert_eq!(GraphChangeImpact::Medium.as_str(), "medium");
599        assert_eq!(GraphChangeImpact::Low.as_str(), "low");
600    }
601
602    #[test]
603    fn test_children_mapped_through_component_matches() {
604        // Old SBOM: A -> B (old IDs)
605        let a_old = make_component("a-old");
606        let b_old = make_component("b-old");
607        let a_old_id = a_old.canonical_id.clone();
608        let b_old_id = b_old.canonical_id.clone();
609
610        let old_sbom = make_sbom(
611            vec![a_old, b_old],
612            vec![(a_old_id.clone(), b_old_id.clone())],
613        );
614
615        // New SBOM: A -> B (new IDs, same logical components)
616        let a_new = make_component("a-new");
617        let b_new = make_component("b-new");
618        let a_new_id = a_new.canonical_id.clone();
619        let b_new_id = b_new.canonical_id.clone();
620
621        let new_sbom = make_sbom(
622            vec![a_new, b_new],
623            vec![(a_new_id.clone(), b_new_id.clone())],
624        );
625
626        // Map: a-old -> a-new, b-old -> b-new
627        let mut matches = HashMap::new();
628        matches.insert(a_old_id, Some(a_new_id));
629        matches.insert(b_old_id, Some(b_new_id));
630
631        let config = GraphDiffConfig::default();
632        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
633
634        // No changes expected: same logical graph structure
635        assert_eq!(summary.dependencies_added, 0, "No false add: {changes:?}");
636        assert_eq!(
637            summary.dependencies_removed, 0,
638            "No false remove: {changes:?}"
639        );
640    }
641
642    #[test]
643    fn test_depth_linear_chain() {
644        // A -> B -> C -> D
645        let a = make_component("a");
646        let b = make_component("b");
647        let c = make_component("c");
648        let d = make_component("d");
649
650        let ids: Vec<_> = [&a, &b, &c, &d]
651            .iter()
652            .map(|c| c.canonical_id.clone())
653            .collect();
654        let sbom = make_sbom(
655            vec![a, b, c, d],
656            vec![
657                (ids[0].clone(), ids[1].clone()),
658                (ids[1].clone(), ids[2].clone()),
659                (ids[2].clone(), ids[3].clone()),
660            ],
661        );
662
663        let config = GraphDiffConfig::default();
664        let graph = DependencyGraph::from_sbom(&sbom, &config);
665
666        assert_eq!(graph.get_depth(&ids[0]), Some(1)); // root
667        assert_eq!(graph.get_depth(&ids[1]), Some(2));
668        assert_eq!(graph.get_depth(&ids[2]), Some(3));
669        assert_eq!(graph.get_depth(&ids[3]), Some(4));
670    }
671
672    #[test]
673    fn test_depth_diamond_dependency() {
674        // A -> B, A -> C, B -> D, C -> D
675        // D should have min depth 3 (via A->B->D or A->C->D)
676        let a = make_component("a");
677        let b = make_component("b");
678        let c = make_component("c");
679        let d = make_component("d");
680
681        let ids: Vec<_> = [&a, &b, &c, &d]
682            .iter()
683            .map(|c| c.canonical_id.clone())
684            .collect();
685        let sbom = make_sbom(
686            vec![a, b, c, d],
687            vec![
688                (ids[0].clone(), ids[1].clone()),
689                (ids[0].clone(), ids[2].clone()),
690                (ids[1].clone(), ids[3].clone()),
691                (ids[2].clone(), ids[3].clone()),
692            ],
693        );
694
695        let config = GraphDiffConfig::default();
696        let graph = DependencyGraph::from_sbom(&sbom, &config);
697
698        assert_eq!(graph.get_depth(&ids[0]), Some(1));
699        assert_eq!(graph.get_depth(&ids[1]), Some(2));
700        assert_eq!(graph.get_depth(&ids[2]), Some(2));
701        assert_eq!(graph.get_depth(&ids[3]), Some(3)); // min of both paths
702    }
703
704    #[test]
705    fn test_depth_rootless_cycle() {
706        // A -> B -> C -> A (pure cycle, no roots)
707        let a = make_component("a");
708        let b = make_component("b");
709        let c = make_component("c");
710
711        let ids: Vec<_> = [&a, &b, &c]
712            .iter()
713            .map(|c| c.canonical_id.clone())
714            .collect();
715        let sbom = make_sbom(
716            vec![a, b, c],
717            vec![
718                (ids[0].clone(), ids[1].clone()),
719                (ids[1].clone(), ids[2].clone()),
720                (ids[2].clone(), ids[0].clone()),
721            ],
722        );
723
724        let config = GraphDiffConfig::default();
725        let graph = DependencyGraph::from_sbom(&sbom, &config);
726
727        // All nodes should get sentinel depth (unreachable from root)
728        for (i, id) in ids.iter().enumerate() {
729            let depth = graph.get_depth(id);
730            assert!(depth.is_some(), "Node {i} should have depth");
731            assert_eq!(
732                depth.unwrap(),
733                CYCLIC_SENTINEL_DEPTH,
734                "Cyclic node {i} should get sentinel depth, not 0"
735            );
736        }
737    }
738
739    #[test]
740    fn test_depth_cycle_reachable_from_root() {
741        // Root -> A -> B -> C -> B (cycle B→C→B reachable from root)
742        let root = make_component("root");
743        let a = make_component("a");
744        let b = make_component("b");
745        let c = make_component("c");
746
747        let ids: Vec<_> = [&root, &a, &b, &c]
748            .iter()
749            .map(|comp| comp.canonical_id.clone())
750            .collect();
751        let sbom = make_sbom(
752            vec![root, a, b, c],
753            vec![
754                (ids[0].clone(), ids[1].clone()), // root → A
755                (ids[1].clone(), ids[2].clone()), // A → B
756                (ids[2].clone(), ids[3].clone()), // B → C
757                (ids[3].clone(), ids[2].clone()), // C → B (cycle)
758            ],
759        );
760
761        let config = GraphDiffConfig::default();
762        let graph = DependencyGraph::from_sbom(&sbom, &config);
763
764        assert_eq!(graph.get_depth(&ids[0]), Some(1)); // root
765        assert_eq!(graph.get_depth(&ids[1]), Some(2)); // A (direct)
766        assert_eq!(graph.get_depth(&ids[2]), Some(3)); // B (transitive, reachable)
767        assert_eq!(graph.get_depth(&ids[3]), Some(4)); // C (transitive, reachable)
768        // Despite being in a cycle, B and C have real depths because
769        // they are reachable from root via BFS
770    }
771
772    #[test]
773    fn test_depth_disconnected_subgraphs() {
774        // Subgraph 1: R1 -> A
775        // Subgraph 2: R2 -> B -> C
776        // Independent depth computation for each
777        let r1 = make_component("r1");
778        let a = make_component("a");
779        let r2 = make_component("r2");
780        let b = make_component("b");
781        let c = make_component("c");
782
783        let ids: Vec<_> = [&r1, &a, &r2, &b, &c]
784            .iter()
785            .map(|comp| comp.canonical_id.clone())
786            .collect();
787        let sbom = make_sbom(
788            vec![r1, a, r2, b, c],
789            vec![
790                (ids[0].clone(), ids[1].clone()), // R1 → A
791                (ids[2].clone(), ids[3].clone()), // R2 → B
792                (ids[3].clone(), ids[4].clone()), // B → C
793            ],
794        );
795
796        let config = GraphDiffConfig::default();
797        let graph = DependencyGraph::from_sbom(&sbom, &config);
798
799        assert_eq!(graph.get_depth(&ids[0]), Some(1)); // R1
800        assert_eq!(graph.get_depth(&ids[1]), Some(2)); // A
801        assert_eq!(graph.get_depth(&ids[2]), Some(1)); // R2
802        assert_eq!(graph.get_depth(&ids[3]), Some(2)); // B
803        assert_eq!(graph.get_depth(&ids[4]), Some(3)); // C
804    }
805
806    #[test]
807    fn test_self_referencing_edge_no_infinite_loop() {
808        // A -> A (self-loop)
809        let a = make_component("a");
810        let a_id = a.canonical_id.clone();
811
812        let sbom = make_sbom(vec![a], vec![(a_id.clone(), a_id.clone())]);
813
814        let config = GraphDiffConfig::default();
815        let graph = DependencyGraph::from_sbom(&sbom, &config);
816
817        // A is its own parent, but it's also a root (no OTHER incoming edges
818        // that would remove it from the root set... actually it HAS an incoming
819        // edge from itself). With the self-loop, A has an incoming edge so it's
820        // NOT a root → gets sentinel depth.
821        let depth = graph.get_depth(&a_id);
822        assert!(depth.is_some(), "A should have a depth");
823        // Self-loop means A has incoming edges, so not a root → sentinel
824        assert_eq!(
825            depth.unwrap(),
826            CYCLIC_SENTINEL_DEPTH,
827            "Self-referencing node should get sentinel depth"
828        );
829    }
830
831    #[test]
832    fn test_depth_max_depth_limit() {
833        // A -> B -> C -> D with max_depth 2
834        let a = make_component("a");
835        let b = make_component("b");
836        let c = make_component("c");
837        let d = make_component("d");
838
839        let ids: Vec<_> = [&a, &b, &c, &d]
840            .iter()
841            .map(|c| c.canonical_id.clone())
842            .collect();
843        let sbom = make_sbom(
844            vec![a, b, c, d],
845            vec![
846                (ids[0].clone(), ids[1].clone()),
847                (ids[1].clone(), ids[2].clone()),
848                (ids[2].clone(), ids[3].clone()),
849            ],
850        );
851
852        let config = GraphDiffConfig {
853            max_depth: 2,
854            ..Default::default()
855        };
856        let graph = DependencyGraph::from_sbom(&sbom, &config);
857
858        assert_eq!(graph.get_depth(&ids[0]), Some(1));
859        assert_eq!(graph.get_depth(&ids[1]), Some(2));
860        // C and D get sentinel depth since BFS stops at depth 2
861        // and they're unreachable from root BFS at that limit
862        assert_eq!(graph.get_depth(&ids[2]), Some(CYCLIC_SENTINEL_DEPTH));
863        assert_eq!(graph.get_depth(&ids[3]), Some(CYCLIC_SENTINEL_DEPTH));
864    }
865
866    #[test]
867    fn test_reparenting_single_parent() {
868        // Old: P1 -> C (P2 exists but not parent of C)
869        // New: P2 -> C (P1 exists but not parent of C)
870        // P1 and P2 are distinct components present in both SBOMs.
871        let p1 = make_component("p1");
872        let p2 = make_component("p2");
873        let child = make_component("child");
874
875        let p1_id = p1.canonical_id.clone();
876        let p2_id = p2.canonical_id.clone();
877        let child_id = child.canonical_id.clone();
878
879        let old_sbom = make_sbom(
880            vec![p1.clone(), p2.clone(), child.clone()],
881            vec![(p1_id.clone(), child_id.clone())],
882        );
883        let new_sbom = make_sbom(
884            vec![p1.clone(), p2.clone(), child.clone()],
885            vec![(p2_id.clone(), child_id.clone())],
886        );
887
888        // Both parents map to themselves — they are distinct logical components
889        let mut matches = HashMap::new();
890        matches.insert(p1_id.clone(), Some(p1_id));
891        matches.insert(p2_id.clone(), Some(p2_id));
892        matches.insert(child_id.clone(), Some(child_id));
893
894        let config = GraphDiffConfig::default();
895        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
896
897        assert!(
898            summary.reparented > 0,
899            "Should detect reparenting: {changes:?}"
900        );
901    }
902
903    #[test]
904    fn test_renamed_parent_is_not_reparenting() {
905        // Old: P1 -> C. New: P2 -> C. P1 matched to P2 (same logical component).
906        // This is a rename, not reparenting — no structural change.
907        let p1 = make_component("p1");
908        let p2 = make_component("p2");
909        let child = make_component("child");
910
911        let p1_id = p1.canonical_id.clone();
912        let p2_id = p2.canonical_id.clone();
913        let child_id = child.canonical_id.clone();
914
915        let old_sbom = make_sbom(
916            vec![p1, child.clone()],
917            vec![(p1_id.clone(), child_id.clone())],
918        );
919        let new_sbom = make_sbom(
920            vec![p2, child.clone()],
921            vec![(p2_id.clone(), child_id.clone())],
922        );
923
924        let mut matches = HashMap::new();
925        matches.insert(p1_id, Some(p2_id));
926        matches.insert(child_id.clone(), Some(child_id));
927
928        let config = GraphDiffConfig::default();
929        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
930
931        assert_eq!(
932            summary.reparented, 0,
933            "Renamed parent should not be reparenting: {changes:?}"
934        );
935    }
936
937    #[test]
938    fn test_reparenting_multi_parent() {
939        // Old: P1 -> C, P2 -> C
940        // New: P1 -> C, P3 -> C
941        // P2 and P3 are distinct components (P2 removed, P3 added).
942        // All components exist in both SBOMs to enable proper matching.
943        let p1 = make_component("p1");
944        let p2 = make_component("p2");
945        let p3 = make_component("p3");
946        let child = make_component("child");
947
948        let p1_id = p1.canonical_id.clone();
949        let p2_id = p2.canonical_id.clone();
950        let p3_id = p3.canonical_id.clone();
951        let child_id = child.canonical_id.clone();
952
953        let old_sbom = make_sbom(
954            vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
955            vec![
956                (p1_id.clone(), child_id.clone()),
957                (p2_id.clone(), child_id.clone()),
958            ],
959        );
960        let new_sbom = make_sbom(
961            vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
962            vec![
963                (p1_id.clone(), child_id.clone()),
964                (p3_id.clone(), child_id.clone()),
965            ],
966        );
967
968        // All map to themselves — they are distinct logical components
969        let mut matches = HashMap::new();
970        matches.insert(p1_id.clone(), Some(p1_id));
971        matches.insert(p2_id.clone(), Some(p2_id));
972        matches.insert(p3_id.clone(), Some(p3_id));
973        matches.insert(child_id.clone(), Some(child_id));
974
975        let config = GraphDiffConfig::default();
976        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
977
978        assert!(
979            summary.reparented > 0,
980            "Should detect multi-parent reparenting: {changes:?}"
981        );
982    }
983
984    #[test]
985    fn test_vulnerable_direct_dep_is_critical() {
986        // A -> V where V has vulnerabilities and depth=1 (direct)
987        let a = make_component("root");
988        let mut vuln_comp = make_component("vuln-lib");
989        vuln_comp.vulnerabilities.push(VulnerabilityRef {
990            id: "CVE-2024-0001".to_string(),
991            source: VulnerabilitySource::Osv,
992            severity: None,
993            cvss: vec![],
994            affected_versions: vec![],
995            remediation: None,
996            description: None,
997            cwes: vec![],
998            published: None,
999            modified: None,
1000            is_kev: false,
1001            kev_info: None,
1002            epss_score: None,
1003            epss_percentile: None,
1004            vex_status: None,
1005        });
1006
1007        let a_id = a.canonical_id.clone();
1008        let v_id = vuln_comp.canonical_id.clone();
1009
1010        let old_sbom = make_sbom(vec![a.clone()], vec![]);
1011        let new_sbom = make_sbom(
1012            vec![a.clone(), vuln_comp],
1013            vec![(a_id.clone(), v_id.clone())],
1014        );
1015
1016        let mut matches = HashMap::new();
1017        matches.insert(a_id.clone(), Some(a_id));
1018
1019        let config = GraphDiffConfig::default();
1020        let (changes, _) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1021
1022        let critical = changes
1023            .iter()
1024            .any(|c| c.impact == GraphChangeImpact::Critical);
1025        assert!(
1026            critical,
1027            "Vulnerable direct dep should be critical impact: {changes:?}"
1028        );
1029    }
1030
1031    #[test]
1032    fn test_empty_sboms_no_changes() {
1033        let old_sbom = NormalizedSbom::default();
1034        let new_sbom = NormalizedSbom::default();
1035        let matches = HashMap::new();
1036        let config = GraphDiffConfig::default();
1037
1038        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1039        assert!(changes.is_empty());
1040        assert_eq!(summary.total_changes, 0);
1041    }
1042
1043    #[test]
1044    fn test_identical_graphs_no_changes() {
1045        let a = make_component("a");
1046        let b = make_component("b");
1047        let a_id = a.canonical_id.clone();
1048        let b_id = b.canonical_id.clone();
1049
1050        let sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
1051
1052        let mut matches = HashMap::new();
1053        matches.insert(a_id.clone(), Some(a_id));
1054        matches.insert(b_id.clone(), Some(b_id));
1055
1056        let config = GraphDiffConfig::default();
1057        let (changes, summary) = diff_dependency_graph(&sbom, &sbom, &matches, &config);
1058
1059        // No added/removed (depth might diff trivially due to same-SBOM comparison)
1060        assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1061        assert_eq!(
1062            summary.dependencies_removed, 0,
1063            "No false removes: {changes:?}"
1064        );
1065    }
1066
1067    #[test]
1068    fn test_removed_child_not_false_positive() {
1069        // Old: A -> B_v1, New: A -> B_v2 (different canonical IDs for B)
1070        // B_v1 is matched to B_v2 in the mapping.
1071        // Should detect no structural change (same logical edge, just version bump).
1072        let a = make_component("a");
1073        let b_v1 = make_component_v("b", "1.0");
1074        let b_v2 = make_component_v("b", "2.0");
1075
1076        let a_id = a.canonical_id.clone();
1077        let b_v1_id = b_v1.canonical_id.clone();
1078        let b_v2_id = b_v2.canonical_id.clone();
1079
1080        let old_sbom = make_sbom(vec![a.clone(), b_v1], vec![(a_id.clone(), b_v1_id.clone())]);
1081        let new_sbom = make_sbom(vec![a.clone(), b_v2], vec![(a_id.clone(), b_v2_id.clone())]);
1082
1083        let mut matches = HashMap::new();
1084        matches.insert(a_id.clone(), Some(a_id));
1085        matches.insert(b_v1_id, Some(b_v2_id));
1086
1087        let config = GraphDiffConfig::default();
1088        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1089
1090        assert_eq!(
1091            summary.dependencies_added, 0,
1092            "Version bump should not be false add: {changes:?}"
1093        );
1094        assert_eq!(
1095            summary.dependencies_removed, 0,
1096            "Version bump should not be false remove: {changes:?}"
1097        );
1098    }
1099
1100    #[test]
1101    fn test_unmatched_old_child_excluded_from_comparison() {
1102        // Old: A -> B, A -> C. New: A -> B. C is removed (matched to None).
1103        // Should detect: C removed as dependency of A. Not: false add of B.
1104        let a = make_component("a");
1105        let b = make_component("b");
1106        let c = make_component("c");
1107
1108        let a_id = a.canonical_id.clone();
1109        let b_id = b.canonical_id.clone();
1110        let c_id = c.canonical_id.clone();
1111
1112        let old_sbom = make_sbom(
1113            vec![a.clone(), b.clone(), c],
1114            vec![(a_id.clone(), b_id.clone()), (a_id.clone(), c_id.clone())],
1115        );
1116        let new_sbom = make_sbom(
1117            vec![a.clone(), b.clone()],
1118            vec![(a_id.clone(), b_id.clone())],
1119        );
1120
1121        let mut matches = HashMap::new();
1122        matches.insert(a_id.clone(), Some(a_id));
1123        matches.insert(b_id.clone(), Some(b_id));
1124        matches.insert(c_id, None); // C is removed
1125
1126        let config = GraphDiffConfig::default();
1127        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1128
1129        // C was removed, so it's excluded from old_children_mapped.
1130        // B is in both → no change for B.
1131        // The graph diff tracks children per matched parent. Since C is excluded from
1132        // the mapped set, it won't appear in the difference. This is correct because
1133        // the component-level diff already reports C as removed.
1134        assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1135    }
1136
1137    #[test]
1138    fn test_reparenting_with_removed_parent() {
1139        // Old: P1 -> C, P2 -> C. New: P1 -> C. P2 removed (matched to None).
1140        // Should NOT report reparenting — parent set simply lost a removed node.
1141        let p1 = make_component("p1");
1142        let p2 = make_component("p2");
1143        let child = make_component("child");
1144
1145        let p1_id = p1.canonical_id.clone();
1146        let p2_id = p2.canonical_id.clone();
1147        let child_id = child.canonical_id.clone();
1148
1149        let old_sbom = make_sbom(
1150            vec![p1.clone(), p2, child.clone()],
1151            vec![
1152                (p1_id.clone(), child_id.clone()),
1153                (p2_id.clone(), child_id.clone()),
1154            ],
1155        );
1156        let new_sbom = make_sbom(
1157            vec![p1.clone(), child.clone()],
1158            vec![(p1_id.clone(), child_id.clone())],
1159        );
1160
1161        let mut matches = HashMap::new();
1162        matches.insert(p1_id.clone(), Some(p1_id));
1163        matches.insert(p2_id, None); // P2 removed
1164        matches.insert(child_id.clone(), Some(child_id));
1165
1166        let config = GraphDiffConfig::default();
1167        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1168
1169        assert_eq!(
1170            summary.reparented, 0,
1171            "Removed parent should not trigger reparenting: {changes:?}"
1172        );
1173    }
1174
1175    #[test]
1176    fn test_relationship_change_detected() {
1177        // Old: A -[DependsOn]-> B. New: A -[DevDependsOn]-> B.
1178        // Same endpoints, different relationship → RelationshipChanged.
1179        let a = make_component("a");
1180        let b = make_component("b");
1181        let a_id = a.canonical_id.clone();
1182        let b_id = b.canonical_id.clone();
1183
1184        let old_sbom = make_sbom_with_rel(
1185            vec![a.clone(), b.clone()],
1186            vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1187        );
1188        let new_sbom = make_sbom_with_rel(
1189            vec![a, b],
1190            vec![(a_id.clone(), b_id.clone(), DependencyType::DevDependsOn)],
1191        );
1192
1193        let mut matches = HashMap::new();
1194        matches.insert(a_id.clone(), Some(a_id));
1195        matches.insert(b_id.clone(), Some(b_id));
1196
1197        let config = GraphDiffConfig::default();
1198        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1199
1200        assert!(
1201            summary.relationship_changed > 0,
1202            "Should detect relationship change: {changes:?}"
1203        );
1204        // Should NOT report add+remove for same endpoints
1205        assert_eq!(
1206            summary.dependencies_added, 0,
1207            "Relationship change is not an add: {changes:?}"
1208        );
1209        assert_eq!(
1210            summary.dependencies_removed, 0,
1211            "Relationship change is not a remove: {changes:?}"
1212        );
1213    }
1214
1215    #[test]
1216    fn test_scope_change_detected() {
1217        // Old: A -[DependsOn, Required]-> B. New: A -[DependsOn, Optional]-> B.
1218        // Same endpoints and relationship, different scope → RelationshipChanged.
1219        use crate::model::DependencyScope;
1220
1221        let a = make_component("a");
1222        let b = make_component("b");
1223        let a_id = a.canonical_id.clone();
1224        let b_id = b.canonical_id.clone();
1225
1226        let mut old_sbom = NormalizedSbom::default();
1227        old_sbom.add_component(a.clone());
1228        old_sbom.add_component(b.clone());
1229        old_sbom.add_edge(
1230            DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
1231                .with_scope(DependencyScope::Required),
1232        );
1233
1234        let mut new_sbom = NormalizedSbom::default();
1235        new_sbom.add_component(a);
1236        new_sbom.add_component(b);
1237        new_sbom.add_edge(
1238            DependencyEdge::new(a_id.clone(), b_id.clone(), DependencyType::DependsOn)
1239                .with_scope(DependencyScope::Optional),
1240        );
1241
1242        let mut matches = HashMap::new();
1243        matches.insert(a_id.clone(), Some(a_id));
1244        matches.insert(b_id.clone(), Some(b_id));
1245
1246        let config = GraphDiffConfig::default();
1247        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1248
1249        assert!(
1250            summary.relationship_changed > 0,
1251            "Should detect scope change: {changes:?}"
1252        );
1253    }
1254
1255    #[test]
1256    fn test_reparenting_does_not_suppress_unrelated_add() {
1257        // Reparenting C from P1→P2 should NOT suppress "C added to P3".
1258        // Old: P1 -> C, P2 exists, P3 exists
1259        // New: P2 -> C, P3 -> C
1260        // P1→C removed, P2→C added (reparenting), P3→C added (unrelated, must survive)
1261        let p1 = make_component("p1");
1262        let p2 = make_component("p2");
1263        let p3 = make_component("p3");
1264        let child = make_component("child");
1265
1266        let p1_id = p1.canonical_id.clone();
1267        let p2_id = p2.canonical_id.clone();
1268        let p3_id = p3.canonical_id.clone();
1269        let child_id = child.canonical_id.clone();
1270
1271        let old_sbom = make_sbom(
1272            vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
1273            vec![(p1_id.clone(), child_id.clone())],
1274        );
1275        let new_sbom = make_sbom(
1276            vec![p1.clone(), p2.clone(), p3.clone(), child.clone()],
1277            vec![
1278                (p2_id.clone(), child_id.clone()),
1279                (p3_id.clone(), child_id.clone()),
1280            ],
1281        );
1282
1283        let mut matches = HashMap::new();
1284        matches.insert(p1_id.clone(), Some(p1_id));
1285        matches.insert(p2_id.clone(), Some(p2_id.clone()));
1286        matches.insert(p3_id.clone(), Some(p3_id.clone()));
1287        matches.insert(child_id.clone(), Some(child_id.clone()));
1288
1289        let config = GraphDiffConfig::default();
1290        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1291
1292        // Should have reparenting (P1→P2)
1293        assert!(
1294            summary.reparented > 0,
1295            "Should detect reparenting: {changes:?}"
1296        );
1297
1298        // The reparenting picks one of {P2, P3} as the new parent. The OTHER one's
1299        // DependencyAdded entry must survive — it's unrelated to the reparenting.
1300        let reparent = changes
1301            .iter()
1302            .find(|c| matches!(&c.change, DependencyChangeType::Reparented { .. }))
1303            .expect("Should have a reparent entry");
1304        let reparent_new_parent = match &reparent.change {
1305            DependencyChangeType::Reparented { new_parent_id, .. } => new_parent_id.clone(),
1306            _ => unreachable!(),
1307        };
1308        let other_parent = if reparent_new_parent == p2_id {
1309            &p3_id
1310        } else {
1311            &p2_id
1312        };
1313
1314        let other_added = changes.iter().any(|c| {
1315            c.component_id == *other_parent
1316                && matches!(
1317                    &c.change,
1318                    DependencyChangeType::DependencyAdded { dependency_id, .. }
1319                    if *dependency_id == child_id
1320                )
1321        });
1322        assert!(
1323            other_added,
1324            "The non-reparented parent's add should not be suppressed: {changes:?}"
1325        );
1326    }
1327
1328    #[test]
1329    fn test_root_promotion_not_skipped() {
1330        // Old: P1 -> C (C has a parent)
1331        // New: C is a root (no parents)
1332        // This is NOT reparenting (no added parent), but the code should
1333        // not skip it entirely — it should still detect the parent removal.
1334        let p1 = make_component("p1");
1335        let child = make_component("child");
1336
1337        let p1_id = p1.canonical_id.clone();
1338        let child_id = child.canonical_id.clone();
1339
1340        let old_sbom = make_sbom(
1341            vec![p1.clone(), child.clone()],
1342            vec![(p1_id.clone(), child_id.clone())],
1343        );
1344        let new_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
1345
1346        let mut matches = HashMap::new();
1347        matches.insert(p1_id.clone(), Some(p1_id.clone()));
1348        matches.insert(child_id.clone(), Some(child_id));
1349
1350        let config = GraphDiffConfig::default();
1351        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1352
1353        // Should detect the removed dependency (P1→C removed)
1354        assert!(
1355            summary.dependencies_removed > 0,
1356            "Root promotion: dependency removal should be detected: {changes:?}"
1357        );
1358        // Should NOT report reparenting (no new parent added)
1359        assert_eq!(
1360            summary.reparented, 0,
1361            "Root promotion is not reparenting: {changes:?}"
1362        );
1363    }
1364
1365    #[test]
1366    fn test_root_demotion_not_skipped() {
1367        // Old: C is a root (no parents)
1368        // New: P1 -> C (C now has a parent)
1369        // This is NOT reparenting, just a dependency addition.
1370        let p1 = make_component("p1");
1371        let child = make_component("child");
1372
1373        let p1_id = p1.canonical_id.clone();
1374        let child_id = child.canonical_id.clone();
1375
1376        let old_sbom = make_sbom(vec![p1.clone(), child.clone()], vec![]);
1377        let new_sbom = make_sbom(
1378            vec![p1.clone(), child.clone()],
1379            vec![(p1_id.clone(), child_id.clone())],
1380        );
1381
1382        let mut matches = HashMap::new();
1383        matches.insert(p1_id.clone(), Some(p1_id.clone()));
1384        matches.insert(child_id.clone(), Some(child_id));
1385
1386        let config = GraphDiffConfig::default();
1387        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1388
1389        // Should detect the added dependency
1390        assert!(
1391            summary.dependencies_added > 0,
1392            "Root demotion: dependency addition should be detected: {changes:?}"
1393        );
1394        // Should NOT report reparenting (no old parent removed)
1395        assert_eq!(
1396            summary.reparented, 0,
1397            "Root demotion is not reparenting: {changes:?}"
1398        );
1399    }
1400
1401    #[test]
1402    fn test_parent_added_multi_parent_not_reparenting() {
1403        // Old: P1 -> C. New: P1 -> C, P2 -> C.
1404        // C gains a parent but keeps the old one — this is NOT reparenting.
1405        let p1 = make_component("p1");
1406        let p2 = make_component("p2");
1407        let child = make_component("child");
1408
1409        let p1_id = p1.canonical_id.clone();
1410        let p2_id = p2.canonical_id.clone();
1411        let child_id = child.canonical_id.clone();
1412
1413        let old_sbom = make_sbom(
1414            vec![p1.clone(), p2.clone(), child.clone()],
1415            vec![(p1_id.clone(), child_id.clone())],
1416        );
1417        let new_sbom = make_sbom(
1418            vec![p1.clone(), p2.clone(), child.clone()],
1419            vec![
1420                (p1_id.clone(), child_id.clone()),
1421                (p2_id.clone(), child_id.clone()),
1422            ],
1423        );
1424
1425        let mut matches = HashMap::new();
1426        matches.insert(p1_id.clone(), Some(p1_id));
1427        matches.insert(p2_id.clone(), Some(p2_id));
1428        matches.insert(child_id.clone(), Some(child_id));
1429
1430        let config = GraphDiffConfig::default();
1431        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1432
1433        assert_eq!(
1434            summary.reparented, 0,
1435            "Adding a new parent while keeping old is not reparenting: {changes:?}"
1436        );
1437        // But the P2→C addition should still be detected
1438        assert!(
1439            summary.dependencies_added > 0,
1440            "P2→C should be detected as added: {changes:?}"
1441        );
1442    }
1443
1444    #[test]
1445    fn test_same_relationship_no_change() {
1446        // Old: A -[DependsOn]-> B. New: A -[DependsOn]-> B.
1447        // Same everything → no change.
1448        let a = make_component("a");
1449        let b = make_component("b");
1450        let a_id = a.canonical_id.clone();
1451        let b_id = b.canonical_id.clone();
1452
1453        let old_sbom = make_sbom_with_rel(
1454            vec![a.clone(), b.clone()],
1455            vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1456        );
1457        let new_sbom = make_sbom_with_rel(
1458            vec![a, b],
1459            vec![(a_id.clone(), b_id.clone(), DependencyType::DependsOn)],
1460        );
1461
1462        let mut matches = HashMap::new();
1463        matches.insert(a_id.clone(), Some(a_id));
1464        matches.insert(b_id.clone(), Some(b_id));
1465
1466        let config = GraphDiffConfig::default();
1467        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1468
1469        assert_eq!(
1470            summary.relationship_changed, 0,
1471            "Same relationship should not be a change: {changes:?}"
1472        );
1473    }
1474
1475    #[test]
1476    fn test_duplicate_edges_different_types() {
1477        // A -[DependsOn]-> B and A -[DevDependsOn]-> B in same SBOM.
1478        // The last edge wins in the edge_attrs map (HashMap insert semantics).
1479        let a = make_component("a");
1480        let b = make_component("b");
1481        let a_id = a.canonical_id.clone();
1482        let b_id = b.canonical_id.clone();
1483
1484        let mut sbom = NormalizedSbom::default();
1485        sbom.add_component(a);
1486        sbom.add_component(b);
1487        sbom.add_edge(DependencyEdge::new(
1488            a_id.clone(),
1489            b_id.clone(),
1490            DependencyType::DependsOn,
1491        ));
1492        sbom.add_edge(DependencyEdge::new(
1493            a_id.clone(),
1494            b_id.clone(),
1495            DependencyType::DevDependsOn,
1496        ));
1497
1498        let config = GraphDiffConfig::default();
1499        let graph = DependencyGraph::from_sbom(&sbom, &config);
1500
1501        // B should appear as child of A (possibly duplicated in children list)
1502        let children = graph.get_children(&a_id);
1503        assert!(children.contains(&b_id), "B should be a child of A");
1504
1505        // Edge attrs should have one entry (last-write-wins for same pair)
1506        let attrs = graph.get_edge_attrs(&a_id, &b_id);
1507        assert!(attrs.is_some(), "Should have edge attrs for A→B");
1508    }
1509
1510    #[test]
1511    fn test_large_graph_completes() {
1512        // 500 nodes in a chain: root → n1 → n2 → ... → n499
1513        let mut components = Vec::new();
1514        let mut edges = Vec::new();
1515        let mut ids = Vec::new();
1516
1517        for i in 0..500 {
1518            let comp = make_component(&format!("node-{i}"));
1519            ids.push(comp.canonical_id.clone());
1520            components.push(comp);
1521        }
1522        for i in 0..499 {
1523            edges.push((ids[i].clone(), ids[i + 1].clone()));
1524        }
1525
1526        let sbom = make_sbom(components, edges);
1527        let config = GraphDiffConfig::default();
1528        let graph = DependencyGraph::from_sbom(&sbom, &config);
1529
1530        // Root should have depth 1, last node should have depth 500
1531        assert_eq!(graph.get_depth(&ids[0]), Some(1));
1532        assert_eq!(graph.get_depth(&ids[499]), Some(500));
1533    }
1534
1535    #[test]
1536    fn test_empty_vs_nonempty_graph() {
1537        // Old: no edges. New: A → B. All deps should be "added".
1538        let a = make_component("a");
1539        let b = make_component("b");
1540        let a_id = a.canonical_id.clone();
1541        let b_id = b.canonical_id.clone();
1542
1543        let old_sbom = make_sbom(vec![a.clone(), b.clone()], vec![]);
1544        let new_sbom = make_sbom(vec![a, b], vec![(a_id.clone(), b_id.clone())]);
1545
1546        let mut matches = HashMap::new();
1547        matches.insert(a_id.clone(), Some(a_id));
1548        matches.insert(b_id.clone(), Some(b_id));
1549
1550        let config = GraphDiffConfig::default();
1551        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1552
1553        assert!(
1554            summary.dependencies_added > 0,
1555            "Should detect added dependency: {changes:?}"
1556        );
1557        assert_eq!(
1558            summary.dependencies_removed, 0,
1559            "No false removes: {changes:?}"
1560        );
1561    }
1562
1563    #[test]
1564    fn test_nonempty_vs_empty_graph() {
1565        // Old: A → B. New: no edges. All deps should be "removed".
1566        let a = make_component("a");
1567        let b = make_component("b");
1568        let a_id = a.canonical_id.clone();
1569        let b_id = b.canonical_id.clone();
1570
1571        let old_sbom = make_sbom(
1572            vec![a.clone(), b.clone()],
1573            vec![(a_id.clone(), b_id.clone())],
1574        );
1575        let new_sbom = make_sbom(vec![a, b], vec![]);
1576
1577        let mut matches = HashMap::new();
1578        matches.insert(a_id.clone(), Some(a_id));
1579        matches.insert(b_id.clone(), Some(b_id));
1580
1581        let config = GraphDiffConfig::default();
1582        let (changes, summary) = diff_dependency_graph(&old_sbom, &new_sbom, &matches, &config);
1583
1584        assert!(
1585            summary.dependencies_removed > 0,
1586            "Should detect removed dependency: {changes:?}"
1587        );
1588        assert_eq!(summary.dependencies_added, 0, "No false adds: {changes:?}");
1589    }
1590
1591    #[test]
1592    fn test_relation_filter() {
1593        // Graph with DependsOn and DevDependsOn edges.
1594        // Filtering to only DependsOn should exclude DevDependsOn edges.
1595        let a = make_component("a");
1596        let b = make_component("b");
1597        let c = make_component("c");
1598        let a_id = a.canonical_id.clone();
1599        let b_id = b.canonical_id.clone();
1600        let c_id = c.canonical_id.clone();
1601
1602        let mut sbom = NormalizedSbom::default();
1603        sbom.add_component(a);
1604        sbom.add_component(b);
1605        sbom.add_component(c);
1606        sbom.add_edge(DependencyEdge::new(
1607            a_id.clone(),
1608            b_id.clone(),
1609            DependencyType::DependsOn,
1610        ));
1611        sbom.add_edge(DependencyEdge::new(
1612            a_id.clone(),
1613            c_id.clone(),
1614            DependencyType::DevDependsOn,
1615        ));
1616
1617        let config = GraphDiffConfig {
1618            relation_filter: vec!["depends-on".to_string()],
1619            ..Default::default()
1620        };
1621        let graph = DependencyGraph::from_sbom(&sbom, &config);
1622
1623        let children = graph.get_children(&a_id);
1624        assert!(
1625            children.contains(&b_id),
1626            "DependsOn edge should be included"
1627        );
1628        assert!(
1629            !children.contains(&c_id),
1630            "DevDependsOn edge should be excluded by filter"
1631        );
1632    }
1633}