1#![doc = include_str!("../readme.md")]
2
3use sbom_model::{Component, ComponentId, Sbom};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet, HashSet};
6
7pub mod renderer;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Diff {
15 pub added: Vec<Component>,
17 pub removed: Vec<Component>,
19 pub changed: Vec<ComponentChange>,
21 pub edge_diffs: Vec<EdgeDiff>,
23 pub metadata_changed: bool,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ComponentChange {
30 pub id: ComponentId,
32 pub old: Component,
34 pub new: Component,
36 pub changes: Vec<FieldChange>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct EdgeDiff {
43 pub parent: ComponentId,
45 pub added: BTreeSet<ComponentId>,
47 pub removed: BTreeSet<ComponentId>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub enum FieldChange {
54 Version(String, String),
56 License(BTreeSet<String>, BTreeSet<String>),
58 Supplier(Option<String>, Option<String>),
60 Purl(Option<String>, Option<String>),
62 Hashes,
64}
65
66#[derive(Debug, Copy, Clone, PartialEq, Eq)]
70pub enum Field {
71 Version,
73 License,
75 Supplier,
77 Purl,
79 Hashes,
81 Deps,
83}
84
85pub struct Differ;
90
91impl Differ {
92 pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
119 let mut old = old.clone();
120 let mut new = new.clone();
121
122 old.normalize();
123 new.normalize();
124
125 let mut added = Vec::new();
126 let mut removed = Vec::new();
127 let mut changed = Vec::new();
128
129 let mut processed_old = HashSet::new();
130 let mut processed_new = HashSet::new();
131
132 let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
134
135 for (id, new_comp) in &new.components {
137 if let Some(old_comp) = old.components.get(id) {
138 processed_old.insert(id.clone());
139 processed_new.insert(id.clone());
140 id_mapping.insert(id.clone(), id.clone());
141
142 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
143 changed.push(change);
144 }
145 }
146 }
147
148 let mut old_identity_map: BTreeMap<(Option<String>, String), Vec<ComponentId>> =
152 BTreeMap::new();
153 for (id, comp) in &old.components {
154 if !processed_old.contains(id) {
155 let identity = (comp.ecosystem.clone(), comp.name.clone());
156 old_identity_map
157 .entry(identity)
158 .or_default()
159 .push(id.clone());
160 }
161 }
162
163 for (id, new_comp) in &new.components {
164 if processed_new.contains(id) {
165 continue;
166 }
167
168 let identity = (new_comp.ecosystem.clone(), new_comp.name.clone());
169
170 let matched_old_id = old_identity_map
175 .get_mut(&identity)
176 .and_then(|ids| ids.pop())
177 .or_else(|| {
178 if new_comp.ecosystem.is_some() {
179 old_identity_map
181 .get_mut(&(None, new_comp.name.clone()))
182 .and_then(|ids| ids.pop())
183 } else {
184 old_identity_map
186 .iter_mut()
187 .find(|((_, name), ids)| name == &new_comp.name && !ids.is_empty())
188 .and_then(|(_, ids)| ids.pop())
189 }
190 });
191
192 if let Some(old_id) = matched_old_id {
193 if let Some(old_comp) = old.components.get(&old_id) {
194 processed_old.insert(old_id.clone());
195 processed_new.insert(id.clone());
196 id_mapping.insert(old_id.clone(), id.clone());
197
198 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
199 changed.push(change);
200 }
201 continue;
202 }
203 }
204
205 added.push(new_comp.clone());
206 processed_new.insert(id.clone());
207 }
208
209 for (id, old_comp) in &old.components {
210 if !processed_old.contains(id) {
211 removed.push(old_comp.clone());
212 }
213 }
214
215 let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
217 let edge_diffs = if should_include_deps {
218 Self::compute_edge_diffs(&old, &new, &id_mapping)
219 } else {
220 Vec::new()
221 };
222
223 Diff {
224 added,
225 removed,
226 changed,
227 edge_diffs,
228 metadata_changed: old.metadata != new.metadata,
229 }
230 }
231
232 fn compute_edge_diffs(
237 old: &Sbom,
238 new: &Sbom,
239 id_mapping: &BTreeMap<ComponentId, ComponentId>,
240 ) -> Vec<EdgeDiff> {
241 let mut edge_diffs = Vec::new();
242
243 let translate_id = |old_id: &ComponentId| -> ComponentId {
245 id_mapping
246 .get(old_id)
247 .cloned()
248 .unwrap_or_else(|| old_id.clone())
249 };
250
251 let mut all_parents: BTreeSet<ComponentId> = new.dependencies.keys().cloned().collect();
254
255 for old_parent in old.dependencies.keys() {
257 all_parents.insert(translate_id(old_parent));
258 }
259
260 for parent_id in all_parents {
261 let new_children: BTreeSet<ComponentId> = new
263 .dependencies
264 .get(&parent_id)
265 .cloned()
266 .unwrap_or_default();
267
268 let old_parent_id = id_mapping
271 .iter()
272 .find(|(_, new_id)| *new_id == &parent_id)
273 .map(|(old_id, _)| old_id.clone())
274 .unwrap_or_else(|| parent_id.clone());
275
276 let old_children: BTreeSet<ComponentId> = old
277 .dependencies
278 .get(&old_parent_id)
279 .map(|children| children.iter().map(&translate_id).collect())
280 .unwrap_or_default();
281
282 let added: BTreeSet<ComponentId> =
284 new_children.difference(&old_children).cloned().collect();
285 let removed: BTreeSet<ComponentId> =
286 old_children.difference(&new_children).cloned().collect();
287
288 if !added.is_empty() || !removed.is_empty() {
289 edge_diffs.push(EdgeDiff {
290 parent: parent_id,
291 added,
292 removed,
293 });
294 }
295 }
296
297 edge_diffs
298 }
299
300 fn compute_change(
301 old: &Component,
302 new: &Component,
303 only: Option<&[Field]>,
304 ) -> Option<ComponentChange> {
305 let mut changes = Vec::new();
306
307 let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
308
309 if should_include(Field::Version) && old.version != new.version {
310 changes.push(FieldChange::Version(
311 old.version.clone().unwrap_or_default(),
312 new.version.clone().unwrap_or_default(),
313 ));
314 }
315
316 if should_include(Field::License) && old.licenses != new.licenses {
317 changes.push(FieldChange::License(
318 old.licenses.clone(),
319 new.licenses.clone(),
320 ));
321 }
322
323 if should_include(Field::Supplier) && old.supplier != new.supplier {
324 changes.push(FieldChange::Supplier(
325 old.supplier.clone(),
326 new.supplier.clone(),
327 ));
328 }
329
330 if should_include(Field::Purl) && old.purl != new.purl {
331 changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
332 }
333
334 if should_include(Field::Hashes) && old.hashes != new.hashes {
335 changes.push(FieldChange::Hashes);
336 }
337
338 if changes.is_empty() {
339 None
340 } else {
341 Some(ComponentChange {
342 id: new.id.clone(),
343 old: old.clone(),
344 new: new.clone(),
345 changes,
346 })
347 }
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn test_diff_added_removed() {
357 let mut old = Sbom::default();
358 let mut new = Sbom::default();
359
360 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
361 let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
362
363 old.components.insert(c1.id.clone(), c1);
364 new.components.insert(c2.id.clone(), c2);
365
366 let diff = Differ::diff(&old, &new, None);
367 assert_eq!(diff.added.len(), 1);
368 assert_eq!(diff.removed.len(), 1);
369 assert_eq!(diff.changed.len(), 0);
370 }
371
372 #[test]
373 fn test_diff_changed() {
374 let mut old = Sbom::default();
375 let mut new = Sbom::default();
376
377 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
378 let mut c2 = c1.clone();
379 c2.version = Some("1.1".to_string());
380
381 old.components.insert(c1.id.clone(), c1);
382 new.components.insert(c2.id.clone(), c2);
383
384 let diff = Differ::diff(&old, &new, None);
385 assert_eq!(diff.added.len(), 0);
386 assert_eq!(diff.removed.len(), 0);
387 assert_eq!(diff.changed.len(), 1);
388 assert!(matches!(
389 diff.changed[0].changes[0],
390 FieldChange::Version(_, _)
391 ));
392 }
393
394 #[test]
395 fn test_diff_identity_reconciliation() {
396 let mut old = Sbom::default();
397 let mut new = Sbom::default();
398
399 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
400 let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
401
402 old.components.insert(c1.id.clone(), c1);
403 new.components.insert(c2.id.clone(), c2);
404
405 let diff = Differ::diff(&old, &new, None);
406 assert_eq!(diff.changed.len(), 1);
407 assert_eq!(diff.added.len(), 0);
408 }
409
410 #[test]
411 fn test_diff_license_change() {
412 let mut old = Sbom::default();
413 let mut new = Sbom::default();
414
415 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
416 c1.licenses.insert("MIT".into());
417 let mut c2 = c1.clone();
418 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
419
420 old.components.insert(c1.id.clone(), c1);
421 new.components.insert(c2.id.clone(), c2);
422
423 let diff = Differ::diff(&old, &new, None);
424 assert_eq!(diff.changed.len(), 1);
425 assert!(diff.changed[0]
426 .changes
427 .iter()
428 .any(|c| matches!(c, FieldChange::License(_, _))));
429 }
430
431 #[test]
432 fn test_diff_supplier_change() {
433 let mut old = Sbom::default();
434 let mut new = Sbom::default();
435
436 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
437 c1.supplier = Some("Acme Corp".into());
438 let mut c2 = c1.clone();
439 c2.supplier = Some("New Corp".into());
440
441 old.components.insert(c1.id.clone(), c1);
442 new.components.insert(c2.id.clone(), c2);
443
444 let diff = Differ::diff(&old, &new, None);
445 assert_eq!(diff.changed.len(), 1);
446 assert!(diff.changed[0]
447 .changes
448 .iter()
449 .any(|c| matches!(c, FieldChange::Supplier(_, _))));
450 }
451
452 #[test]
453 fn test_diff_hashes_change() {
454 let mut old = Sbom::default();
455 let mut new = Sbom::default();
456
457 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
458 c1.hashes.insert("sha256".into(), "aaa".into());
459 let mut c2 = c1.clone();
460 c2.hashes.insert("sha256".into(), "bbb".into());
461
462 old.components.insert(c1.id.clone(), c1);
463 new.components.insert(c2.id.clone(), c2);
464
465 let diff = Differ::diff(&old, &new, None);
466 assert_eq!(diff.changed.len(), 1);
467 assert!(diff.changed[0]
468 .changes
469 .iter()
470 .any(|c| matches!(c, FieldChange::Hashes)));
471 }
472
473 #[test]
474 fn test_diff_multiple_field_changes() {
475 let mut old = Sbom::default();
476 let mut new = Sbom::default();
477
478 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
479 c1.licenses.insert("MIT".into());
480 c1.supplier = Some("Old Corp".into());
481 c1.hashes.insert("sha256".into(), "aaa".into());
482
483 let mut c2 = c1.clone();
484 c2.version = Some("2.0".into());
485 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
486 c2.supplier = Some("New Corp".into());
487 c2.hashes.insert("sha256".into(), "bbb".into());
488
489 old.components.insert(c1.id.clone(), c1);
490 new.components.insert(c2.id.clone(), c2);
491
492 let diff = Differ::diff(&old, &new, None);
493 assert_eq!(diff.changed.len(), 1);
494 assert_eq!(diff.changed[0].changes.len(), 4);
495 }
496
497 #[test]
498 fn test_diff_no_changes() {
499 let mut old = Sbom::default();
500 let mut new = Sbom::default();
501
502 let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
503 old.components.insert(c.id.clone(), c.clone());
504 new.components.insert(c.id.clone(), c);
505
506 let diff = Differ::diff(&old, &new, None);
507 assert!(diff.added.is_empty());
508 assert!(diff.removed.is_empty());
509 assert!(diff.changed.is_empty());
510 assert!(diff.edge_diffs.is_empty());
511 }
512
513 #[test]
514 fn test_diff_metadata_changed() {
515 let mut old = Sbom::default();
516 let mut new = Sbom::default();
517
518 old.metadata.timestamp = Some("2024-01-01".into());
521 new.metadata.timestamp = Some("2024-01-02".into());
522
523 let diff = Differ::diff(&old, &new, None);
524 assert!(!diff.metadata_changed);
526 }
527
528 #[test]
529 fn test_diff_filtering() {
530 let mut old = Sbom::default();
531 let mut new = Sbom::default();
532
533 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
534 c1.licenses.insert("MIT".into());
535
536 let mut c2 = c1.clone();
537 c2.version = Some("1.1".to_string());
538 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
539
540 old.components.insert(c1.id.clone(), c1);
541 new.components.insert(c2.id.clone(), c2);
542
543 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
544 assert_eq!(diff.changed.len(), 1);
545 assert_eq!(diff.changed[0].changes.len(), 1);
546 assert!(matches!(
547 diff.changed[0].changes[0],
548 FieldChange::Version(_, _)
549 ));
550 }
551
552 #[test]
553 fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
554 let mut old = Sbom::default();
557 let mut new = Sbom::default();
558
559 let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
561 c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
562 c_old.ecosystem = Some("npm".to_string());
563 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
564
565 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
567 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
568 c_new.ecosystem = Some("npm".to_string());
569 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
570
571 old.components.insert(c_old.id.clone(), c_old);
572 new.components.insert(c_new.id.clone(), c_new);
573
574 let diff = Differ::diff(&old, &new, None);
575
576 assert_eq!(diff.added.len(), 0, "Should not have added components");
578 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
579
580 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
582
583 let changes = &diff.changed[0].changes;
585 assert!(changes
586 .iter()
587 .any(|c| matches!(c, FieldChange::Version(_, _))));
588 assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
589 }
590
591 #[test]
592 fn test_purl_removed_is_change() {
593 let mut old = Sbom::default();
596 let mut new = Sbom::default();
597
598 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
599 c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
600 c_old.ecosystem = Some("npm".to_string()); c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
602
603 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
605 c_new.purl = None;
606 c_new.ecosystem = None; c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
609
610 old.components.insert(c_old.id.clone(), c_old);
611 new.components.insert(c_new.id.clone(), c_new);
612
613 let diff = Differ::diff(&old, &new, None);
614
615 assert_eq!(diff.added.len(), 0, "Should not have added components");
616 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
617 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
618
619 assert!(diff.changed[0]
621 .changes
622 .iter()
623 .any(|c| matches!(c, FieldChange::Purl(_, _))));
624 }
625
626 #[test]
627 fn test_purl_added_is_change() {
628 let mut old = Sbom::default();
631 let mut new = Sbom::default();
632
633 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
634 c_old.purl = None;
635 c_old.ecosystem = None; c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
637
638 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
639 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
640 c_new.ecosystem = Some("npm".to_string()); c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
642
643 old.components.insert(c_old.id.clone(), c_old);
644 new.components.insert(c_new.id.clone(), c_new);
645
646 let diff = Differ::diff(&old, &new, None);
647
648 assert_eq!(diff.added.len(), 0, "Should not have added components");
649 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
650 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
651 }
652
653 #[test]
654 fn test_same_name_different_ecosystems_not_matched() {
655 let mut old = Sbom::default();
657 let mut new = Sbom::default();
658
659 let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
661 c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
662 c_old.ecosystem = Some("npm".to_string());
663 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
664
665 let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
667 c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
668 c_new.ecosystem = Some("pypi".to_string());
669 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
670
671 old.components.insert(c_old.id.clone(), c_old);
672 new.components.insert(c_new.id.clone(), c_new);
673
674 let diff = Differ::diff(&old, &new, None);
675
676 assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
678 assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
679 assert_eq!(
680 diff.changed.len(),
681 0,
682 "Should not match different ecosystems"
683 );
684 }
685
686 #[test]
687 fn test_same_name_both_no_ecosystem_matched() {
688 let mut old = Sbom::default();
691 let mut new = Sbom::default();
692
693 let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
694 c_old.ecosystem = None;
695
696 let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
697 c_new.ecosystem = None;
698
699 old.components.insert(c_old.id.clone(), c_old);
700 new.components.insert(c_new.id.clone(), c_new);
701
702 let diff = Differ::diff(&old, &new, None);
703
704 assert_eq!(diff.added.len(), 0);
705 assert_eq!(diff.removed.len(), 0);
706 assert_eq!(
707 diff.changed.len(),
708 1,
709 "Same name with None ecosystems should match"
710 );
711 }
712
713 #[test]
714 fn test_edge_diff_added_removed() {
715 let mut old = Sbom::default();
716 let mut new = Sbom::default();
717
718 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
719 let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
720 let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
721
722 let parent_id = c1.id.clone();
723 let child_a_id = c2.id.clone();
724 let child_b_id = c3.id.clone();
725
726 old.components.insert(c1.id.clone(), c1.clone());
728 old.components.insert(c2.id.clone(), c2.clone());
729 old.components.insert(c3.id.clone(), c3.clone());
730
731 new.components.insert(c1.id.clone(), c1);
732 new.components.insert(c2.id.clone(), c2);
733 new.components.insert(c3.id.clone(), c3);
734
735 old.dependencies
737 .entry(parent_id.clone())
738 .or_default()
739 .insert(child_a_id.clone());
740
741 new.dependencies
743 .entry(parent_id.clone())
744 .or_default()
745 .insert(child_b_id.clone());
746
747 let diff = Differ::diff(&old, &new, None);
748
749 assert_eq!(diff.edge_diffs.len(), 1);
750 assert_eq!(diff.edge_diffs[0].parent, parent_id);
751 assert!(diff.edge_diffs[0].added.contains(&child_b_id));
752 assert!(diff.edge_diffs[0].removed.contains(&child_a_id));
753 }
754
755 #[test]
756 fn test_edge_diff_with_identity_reconciliation() {
757 let mut old = Sbom::default();
760 let mut new = Sbom::default();
761
762 let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
764 parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
765 parent_old.ecosystem = Some("npm".to_string());
766 parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
767
768 let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
770 parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
771 parent_new.ecosystem = Some("npm".to_string());
772 parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
773
774 let child = Component::new("child".to_string(), Some("1.0".to_string()));
776
777 old.components
778 .insert(parent_old.id.clone(), parent_old.clone());
779 old.components.insert(child.id.clone(), child.clone());
780
781 new.components
782 .insert(parent_new.id.clone(), parent_new.clone());
783 new.components.insert(child.id.clone(), child.clone());
784
785 old.dependencies
787 .entry(parent_old.id.clone())
788 .or_default()
789 .insert(child.id.clone());
790
791 new.dependencies
793 .entry(parent_new.id.clone())
794 .or_default()
795 .insert(child.id.clone());
796
797 let diff = Differ::diff(&old, &new, None);
798
799 assert_eq!(
802 diff.edge_diffs.len(),
803 0,
804 "No edge changes expected when parent is reconciled by identity"
805 );
806 }
807
808 #[test]
809 fn test_edge_diff_filtering() {
810 let mut old = Sbom::default();
812 let mut new = Sbom::default();
813
814 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
815 let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
816
817 let parent_id = c1.id.clone();
818 let child_id = c2.id.clone();
819
820 old.components.insert(c1.id.clone(), c1.clone());
821 old.components.insert(c2.id.clone(), c2.clone());
822
823 new.components.insert(c1.id.clone(), c1);
824 new.components.insert(c2.id.clone(), c2);
825
826 new.dependencies
828 .entry(parent_id.clone())
829 .or_default()
830 .insert(child_id);
831
832 let diff = Differ::diff(&old, &new, None);
834 assert_eq!(diff.edge_diffs.len(), 1);
835
836 let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
838 assert_eq!(diff_filtered.edge_diffs.len(), 0);
839
840 let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
842 assert_eq!(diff_with_deps.edge_diffs.len(), 1);
843 }
844}