1use std::ops::Range;
12
13use bstr::{BStr, ByteSlice};
14use gix_object::tree::{EntryKind, EntryMode};
15
16use crate::{
17 Rewrites,
18 blob::{DiffLineStats, ResourceKind, platform::prepare_diff::Operation},
19 rewrites::{CopySource, Outcome, Tracker, tracker::visit::SourceKind},
20 tree::visit::{Action, ChangeId, Relation},
21};
22
23#[derive(Debug, Copy, Clone, Ord, PartialOrd, PartialEq, Eq)]
25pub enum ChangeKind {
26 Deletion,
28 Modification,
30 Addition,
32}
33
34pub trait Change: Clone {
36 fn id(&self) -> &gix_hash::oid;
41 fn relation(&self) -> Option<Relation>;
52 fn kind(&self) -> ChangeKind;
54 fn entry_mode(&self) -> EntryMode;
56 fn id_and_entry_mode(&self) -> (&gix_hash::oid, EntryMode);
58}
59
60pub(crate) struct Item<T> {
62 change: T,
64 path: Range<usize>,
66 emitted: bool,
68}
69
70impl<T: Change> Item<T> {
71 fn location<'a>(&self, backing: &'a [u8]) -> &'a BStr {
72 backing[self.path.clone()].as_ref()
73 }
74 fn entry_mode_compatible(&self, other: EntryMode) -> bool {
75 use EntryKind::*;
76 matches!(
77 (other.kind(), self.change.entry_mode().kind()),
78 (Blob | BlobExecutable, Blob | BlobExecutable) | (Link, Link) | (Tree, Tree) | (Commit, Commit)
79 )
80 }
81
82 fn is_source_for_destination_of(&self, kind: visit::SourceKind, dest_item_mode: EntryMode) -> bool {
83 self.entry_mode_compatible(dest_item_mode)
84 && match kind {
85 visit::SourceKind::Rename => !self.emitted && matches!(self.change.kind(), ChangeKind::Deletion),
86 visit::SourceKind::Copy => {
87 matches!(self.change.kind(), ChangeKind::Modification)
88 }
89 }
90 }
91}
92
93pub mod visit {
95 use bstr::BStr;
96 use gix_object::tree::EntryMode;
97
98 use crate::blob::DiffLineStats;
99
100 #[derive(Debug, Clone, PartialEq, PartialOrd)]
102 pub struct Source<'a, T> {
103 pub entry_mode: EntryMode,
105 pub id: gix_hash::ObjectId,
107 pub kind: SourceKind,
109 pub location: &'a BStr,
111 pub change: &'a T,
113 pub diff: Option<DiffLineStats>,
115 }
116
117 #[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
119 pub enum SourceKind {
120 Rename,
122 Copy,
124 }
125
126 #[derive(Debug, Clone)]
128 pub struct Destination<'a, T: Clone> {
129 pub change: T,
131 pub location: &'a BStr,
133 }
134}
135
136pub mod emit {
138 #[derive(Debug, thiserror::Error)]
140 #[expect(missing_docs)]
141 pub enum Error {
142 #[error("Could not find blob for similarity checking")]
143 FindExistingBlob(#[from] gix_object::find::existing_object::Error),
144 #[error("Could not obtain exhaustive item set to use as possible sources for copy detection")]
145 GetItemsForExhaustiveCopyDetection(#[source] Box<dyn std::error::Error + Send + Sync>),
146 #[error(transparent)]
147 SetResource(#[from] crate::blob::platform::set_resource::Error),
148 #[error(transparent)]
149 PrepareDiff(#[from] crate::blob::platform::prepare_diff::Error),
150 }
151}
152
153impl<T: Change> Tracker<T> {
155 pub fn new(rewrites: Rewrites) -> Self {
157 Tracker {
158 items: vec![],
159 path_backing: vec![],
160 rewrites,
161 child_renames: Default::default(),
162 }
163 }
164}
165
166impl<T: Change> Tracker<T> {
168 pub fn try_push_change(&mut self, change: T, location: &BStr) -> Option<T> {
170 let change_kind = change.kind();
171 if let (None, ChangeKind::Modification) = (self.rewrites.copies, change_kind) {
172 return Some(change);
173 }
174
175 let entry_kind = change.entry_mode().kind();
176 let relation = change
177 .relation()
178 .filter(|_| matches!(change_kind, ChangeKind::Addition | ChangeKind::Deletion));
179 if let (None, EntryKind::Tree) = (relation, entry_kind) {
180 return Some(change);
181 }
182
183 let start = self.path_backing.len();
184 self.path_backing.extend_from_slice(location);
185 let path = start..self.path_backing.len();
186
187 self.items.push(Item {
188 path,
189 change,
190 emitted: false,
191 });
192 None
193 }
194
195 pub fn emit<PushSourceTreeFn, E>(
218 &mut self,
219 mut cb: impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
220 diff_cache: &mut crate::blob::Platform,
221 objects: &impl gix_object::FindObjectOrHeader,
222 mut push_source_tree: PushSourceTreeFn,
223 ) -> Result<Outcome, emit::Error>
224 where
225 PushSourceTreeFn: FnMut(&mut dyn FnMut(T, &BStr)) -> Result<(), E>,
226 E: std::error::Error + Send + Sync + 'static,
227 {
228 fn is_parent(change: &impl Change) -> bool {
229 matches!(change.relation(), Some(Relation::Parent(_)))
230 }
231 diff_cache.options.skip_internal_diff_if_external_is_configured = false;
232
233 let has_work = {
235 let (mut num_deletions, mut num_additions, mut num_modifications) = (0, 0, 0);
236 let mut has_work = false;
237 for change in &self.items {
238 match change.change.kind() {
239 ChangeKind::Deletion => {
240 num_deletions += 1;
241 }
242 ChangeKind::Modification => {
243 num_modifications += 1;
245 }
246 ChangeKind::Addition => num_additions += 1,
247 }
248 if (num_deletions != 0 && num_additions != 0)
249 || (self.rewrites.copies.is_some() && num_modifications + num_additions > 1)
250 {
251 has_work = true;
252 break;
253 }
254 }
255 has_work
256 };
257
258 let mut out = Outcome {
259 options: self.rewrites,
260 ..Default::default()
261 };
262 if has_work {
263 self.sort_items_by_id_and_location();
264
265 self.match_pairs_of_kind(
269 visit::SourceKind::Rename,
270 &mut cb,
271 None, &mut out,
273 diff_cache,
274 objects,
275 Some(is_parent),
276 )?;
277
278 self.match_pairs_of_kind(
279 visit::SourceKind::Rename,
280 &mut cb,
281 self.rewrites.percentage,
282 &mut out,
283 diff_cache,
284 objects,
285 None,
286 )?;
287
288 self.match_renamed_directories(&mut cb)?;
289
290 if let Some(copies) = self.rewrites.copies {
291 self.match_pairs_of_kind(
292 visit::SourceKind::Copy,
293 &mut cb,
294 copies.percentage,
295 &mut out,
296 diff_cache,
297 objects,
298 None,
299 )?;
300
301 match copies.source {
302 CopySource::FromSetOfModifiedFiles => {}
303 CopySource::FromSetOfModifiedFilesAndAllSources => {
304 push_source_tree(&mut |change, location| {
305 if self.try_push_change(change, location).is_none() {
306 self.items.last_mut().expect("just pushed").emitted = true;
308 }
309 })
310 .map_err(|err| emit::Error::GetItemsForExhaustiveCopyDetection(Box::new(err)))?;
311 self.sort_items_by_id_and_location();
312
313 self.match_pairs_of_kind(
314 visit::SourceKind::Copy,
315 &mut cb,
316 copies.percentage,
317 &mut out,
318 diff_cache,
319 objects,
320 None,
321 )?;
322 }
323 }
324 }
325 }
326
327 self.items
328 .sort_by(|a, b| a.location(&self.path_backing).cmp(b.location(&self.path_backing)));
329 for item in self.items.drain(..).filter(|item| !item.emitted) {
330 if cb(
331 visit::Destination {
332 location: item.location(&self.path_backing),
333 change: item.change,
334 },
335 None,
336 )
337 .is_break()
338 {
339 break;
340 }
341 }
342 Ok(out)
343 }
344}
345
346impl<T: Change> Tracker<T> {
347 fn sort_items_by_id_and_location(&mut self) {
355 self.items.sort_by(|a, b| {
356 a.change
357 .id()
358 .cmp(b.change.id())
359 .then_with(|| a.location(&self.path_backing).cmp(b.location(&self.path_backing)))
360 .then_with(|| a.change.kind().cmp(&b.change.kind()))
361 .then_with(|| a.change.relation().cmp(&b.change.relation()))
362 .then_with(|| a.change.entry_mode().cmp(&b.change.entry_mode()))
363 });
364 }
365
366 #[expect(clippy::too_many_arguments)]
367 fn match_pairs_of_kind(
368 &mut self,
369 kind: visit::SourceKind,
370 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
371 percentage: Option<f32>,
372 out: &mut Outcome,
373 diff_cache: &mut crate::blob::Platform,
374 objects: &impl gix_object::FindObjectOrHeader,
375 filter: Option<fn(&T) -> bool>,
376 ) -> Result<(), emit::Error> {
377 let needs_second_pass = !needs_exact_match(percentage);
379
380 if self
384 .match_pairs(cb, None , kind, out, diff_cache, objects, filter)?
385 .is_break()
386 {
387 return Ok(());
388 }
389 if needs_second_pass {
390 let is_limited = if self.rewrites.limit == 0 {
391 false
392 } else {
393 let (num_src, num_dst) =
394 estimate_involved_items(self.items.iter().map(|item| (item.emitted, item.change.kind())), kind);
395 let permutations = num_src * num_dst;
396 if permutations > self.rewrites.limit {
397 match kind {
398 visit::SourceKind::Rename => {
399 out.num_similarity_checks_skipped_for_rename_tracking_due_to_limit = permutations;
400 }
401 visit::SourceKind::Copy => {
402 out.num_similarity_checks_skipped_for_copy_tracking_due_to_limit = permutations;
403 }
404 }
405 true
406 } else {
407 false
408 }
409 };
410 if !is_limited {
411 let _ = self.match_pairs(cb, percentage, kind, out, diff_cache, objects, None)?;
412 }
413 }
414 Ok(())
415 }
416
417 #[expect(clippy::too_many_arguments)]
418 fn match_pairs(
419 &mut self,
420 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
421 percentage: Option<f32>,
422 kind: visit::SourceKind,
423 stats: &mut Outcome,
424 diff_cache: &mut crate::blob::Platform,
425 objects: &impl gix_object::FindObjectOrHeader,
426 filter: Option<fn(&T) -> bool>,
427 ) -> Result<Action, emit::Error> {
428 let mut dest_ofs = 0;
429 let mut num_checks = 0;
430 let max_checks = {
431 let limit = self.rewrites.limit.saturating_pow(2);
432 if self.items.len() < 100_000 { 0 } else { limit }
436 };
437
438 while let Some((mut dest_idx, dest)) = self.items[dest_ofs..].iter().enumerate().find_map(|(idx, item)| {
439 (!item.emitted
440 && matches!(item.change.kind(), ChangeKind::Addition)
441 && filter.map_or_else(
442 || {
443 self.rewrites.track_empty
444 || matches!(item.change.relation(), Some(Relation::ChildOfParent(_)))
447 || {
448 let id = item.change.id();
449 id != gix_hash::ObjectId::empty_blob(id.kind())
450 }
451 },
452 |f| f(&item.change),
453 ))
454 .then_some((idx, item))
455 }) {
456 dest_idx += dest_ofs;
457 dest_ofs = dest_idx + 1;
458 self.items[dest_idx].location(&self.path_backing);
459 let src = find_match(
460 &self.items,
461 dest,
462 dest_idx,
463 percentage,
464 kind,
465 stats,
466 objects,
467 diff_cache,
468 &self.path_backing,
469 &mut num_checks,
470 )?
471 .map(|(src_idx, src, diff)| {
472 let (id, entry_mode) = src.change.id_and_entry_mode();
473 let id = id.to_owned();
474 let location = src.location(&self.path_backing);
475 (
476 visit::Source {
477 entry_mode,
478 id,
479 kind,
480 location,
481 change: &src.change,
482 diff,
483 },
484 src_idx,
485 )
486 });
487 if max_checks != 0 && num_checks > max_checks {
488 gix_trace::warn!(
489 "Cancelled rename matching as there were too many iterations ({num_checks} > {max_checks})"
490 );
491 return Ok(std::ops::ControlFlow::Break(()));
492 }
493 let Some((src, src_idx)) = src else {
494 continue;
495 };
496 let location = dest.location(&self.path_backing);
497 let change = dest.change.clone();
498 let dest = visit::Destination { change, location };
499 let relations = if percentage.is_none() {
500 src.change.relation().zip(dest.change.relation())
501 } else {
502 None
503 };
504 let res = cb(dest, Some(src));
505
506 self.items[dest_idx].emitted = true;
507 self.items[src_idx].emitted = true;
508
509 if res.is_break() {
510 return Ok(std::ops::ControlFlow::Break(()));
511 }
512
513 match relations {
514 Some((Relation::Parent(src), Relation::Parent(dst))) => {
515 let res = self.emit_child_renames_matching_identity(cb, kind, src, dst)?;
516 if res.is_break() {
517 return Ok(std::ops::ControlFlow::Break(()));
518 }
519 }
520 Some((Relation::ChildOfParent(src), Relation::ChildOfParent(dst))) => {
521 self.child_renames.insert((src, dst));
522 }
523 _ => {}
524 }
525 }
526 Ok(std::ops::ControlFlow::Continue(()))
527 }
528
529 fn emit_child_renames_matching_identity(
533 &mut self,
534 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
535 kind: visit::SourceKind,
536 src_parent_id: ChangeId,
537 dst_parent_id: ChangeId,
538 ) -> Result<Action, emit::Error> {
539 debug_assert_ne!(
540 src_parent_id, dst_parent_id,
541 "src and destination directories must be distinct"
542 );
543 let (mut src_items, mut dst_items) = (Vec::with_capacity(1), Vec::with_capacity(1));
544 for item in self.items.iter_mut().filter(|item| !item.emitted) {
545 match item.change.relation() {
546 Some(Relation::ChildOfParent(id)) if id == src_parent_id => {
547 src_items.push((item.change.id().to_owned(), item));
548 }
549 Some(Relation::ChildOfParent(id)) if id == dst_parent_id => {
550 dst_items.push((item.change.id().to_owned(), item));
551 }
552 _ => continue,
553 }
554 }
555
556 for ((src_id, src_item), (dst_id, dst_item)) in src_items.into_iter().zip(dst_items) {
557 if src_id == dst_id
560 && filename(src_item.location(&self.path_backing)) == filename(dst_item.location(&self.path_backing))
561 {
562 let entry_mode = src_item.change.entry_mode();
563 let location = src_item.location(&self.path_backing);
564 let src = visit::Source {
565 entry_mode,
566 id: src_id,
567 kind,
568 location,
569 change: &src_item.change,
570 diff: None,
571 };
572 let location = dst_item.location(&self.path_backing);
573 let change = dst_item.change.clone();
574 let dst = visit::Destination { change, location };
575 let res = cb(dst, Some(src));
576
577 src_item.emitted = true;
578 dst_item.emitted = true;
579
580 if res.is_break() {
581 return Ok(res);
582 }
583 } else {
584 gix_trace::warn!(
585 "Children of parents with change-id {src_parent_id} and {dst_parent_id} were not equal, even though their parents claimed to be"
586 );
587 break;
588 }
589 }
590 Ok(std::ops::ControlFlow::Continue(()))
591 }
592
593 fn match_renamed_directories(
599 &mut self,
600 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
601 ) -> Result<(), emit::Error> {
602 fn unemitted_directory_matching_relation_id<T: Change>(items: &[Item<T>], child_id: ChangeId) -> Option<usize> {
603 items.iter().position(|i| {
604 !i.emitted && matches!(i.change.relation(), Some(Relation::Parent(pid)) if pid == child_id)
605 })
606 }
607 for (deleted_child_id, added_child_id) in &self.child_renames {
608 let Some(src_idx) = unemitted_directory_matching_relation_id(&self.items, *deleted_child_id) else {
609 continue;
610 };
611 let Some(dst_idx) = unemitted_directory_matching_relation_id(&self.items, *added_child_id) else {
612 continue;
615 };
616
617 let (src_item, dst_item) = (&self.items[src_idx], &self.items[dst_idx]);
618 let entry_mode = src_item.change.entry_mode();
619 let location = src_item.location(&self.path_backing);
620 let src = visit::Source {
621 entry_mode,
622 id: src_item.change.id().to_owned(),
623 kind: SourceKind::Rename,
624 location,
625 change: &src_item.change,
626 diff: None,
627 };
628 let location = dst_item.location(&self.path_backing);
629 let change = dst_item.change.clone();
630 let dst = visit::Destination { change, location };
631 let res = cb(dst, Some(src));
632
633 self.items[src_idx].emitted = true;
634 self.items[dst_idx].emitted = true;
635
636 if res.is_break() {
637 return Ok(());
638 }
639 }
640 Ok(())
641 }
642}
643
644fn filename(path: &BStr) -> &BStr {
645 path.rfind_byte(b'/').map_or(path, |idx| path[idx + 1..].as_bstr())
646}
647
648fn estimate_involved_items(
650 items: impl IntoIterator<Item = (bool, ChangeKind)>,
651 kind: visit::SourceKind,
652) -> (usize, usize) {
653 items
654 .into_iter()
655 .filter(|(emitted, _)| match kind {
656 visit::SourceKind::Rename => !*emitted,
657 visit::SourceKind::Copy => true,
658 })
659 .fold((0, 0), |(mut src, mut dest), (emitted, change_kind)| {
660 match change_kind {
661 ChangeKind::Addition => {
662 if kind == visit::SourceKind::Rename || !emitted {
663 dest += 1;
664 }
665 }
666 ChangeKind::Deletion => {
667 if kind == visit::SourceKind::Rename {
668 src += 1;
669 }
670 }
671 ChangeKind::Modification => {
672 if kind == visit::SourceKind::Copy {
673 src += 1;
674 }
675 }
676 }
677 (src, dest)
678 })
679}
680
681fn needs_exact_match(percentage: Option<f32>) -> bool {
682 percentage.is_none_or(|p| p >= 1.0)
683}
684
685type SourceTuple<'a, T> = (usize, &'a Item<T>, Option<DiffLineStats>);
687
688#[expect(clippy::too_many_arguments)]
696fn find_match<'a, T: Change>(
697 items: &'a [Item<T>],
698 item: &Item<T>,
699 item_idx: usize,
700 percentage: Option<f32>,
701 kind: visit::SourceKind,
702 stats: &mut Outcome,
703 objects: &impl gix_object::FindObjectOrHeader,
704 diff_cache: &mut crate::blob::Platform,
705 path_backing: &[u8],
706 num_checks: &mut usize,
707) -> Result<Option<SourceTuple<'a, T>>, emit::Error> {
708 let (item_id, item_mode) = item.change.id_and_entry_mode();
709 if needs_exact_match(percentage) || item_mode.is_link() || item_mode.is_commit() {
711 let first_idx = items.partition_point(|a| a.change.id() < item_id);
712 let range = items.get(first_idx..).map(|slice| {
713 let end = slice
714 .iter()
715 .position(|a| a.change.id() != item_id)
716 .map_or(items.len(), |idx| first_idx + idx);
717 first_idx..end
718 });
719 let range = match range {
720 Some(range) => range,
721 None => return Ok(None),
722 };
723 if range.is_empty() {
724 return Ok(None);
725 }
726 let item_name = filename(item.location(path_backing));
727 let mut fallback = None;
728 for (mut src_idx, src) in items[range.clone()].iter().enumerate() {
729 src_idx += range.start;
730 *num_checks += 1;
731 if src_idx == item_idx || !src.is_source_for_destination_of(kind, item_mode) {
732 continue;
733 }
734 if filename(src.location(path_backing)) == item_name {
737 return Ok(Some((src_idx, src, None)));
738 }
739 fallback.get_or_insert((src_idx, src, None));
740 }
741 if fallback.is_some() {
742 return Ok(fallback);
743 }
744 } else if item_mode.is_blob() {
745 let mut has_new = false;
746 let percentage = percentage.expect("it's set to something below 1.0 and we assured this");
747 let item_name = filename(item.location(path_backing));
748
749 let mut best: Option<(usize, &Item<T>, DiffLineStats, bool)> = None;
752 for (can_idx, src) in items
753 .iter()
754 .enumerate()
755 .filter(|(src_idx, src)| *src_idx != item_idx && src.is_source_for_destination_of(kind, item_mode))
756 {
757 if !has_new {
758 diff_cache.set_resource(
759 item_id.to_owned(),
760 item_mode.kind(),
761 item.location(path_backing),
762 ResourceKind::NewOrDestination,
763 objects,
764 )?;
765 has_new = true;
766 }
767 let (src_id, src_mode) = src.change.id_and_entry_mode();
768 diff_cache.set_resource(
769 src_id.to_owned(),
770 src_mode.kind(),
771 src.location(path_backing),
772 ResourceKind::OldOrSource,
773 objects,
774 )?;
775 let prep = diff_cache.prepare_diff()?;
776 stats.num_similarity_checks += 1;
777 *num_checks += 1;
778 match prep.operation {
779 Operation::InternalDiff { algorithm } => {
780 let tokens = crate::blob::InternedInput::new(prep.old.intern_source(), prep.new.intern_source());
781 let diff = crate::blob::Diff::compute(algorithm, &tokens);
782 let removed_bytes = diff::removed_bytes(&diff, &tokens);
783 let old_data_len = prep.old.data.as_slice().unwrap_or_default().len();
784 let new_data_len = prep.new.data.as_slice().unwrap_or_default().len();
785 let similarity = (old_data_len - removed_bytes) as f32 / old_data_len.max(new_data_len) as f32;
786 if similarity >= percentage {
787 let candidate_diff = DiffLineStats {
788 removals: diff.count_removals(),
789 insertions: diff.count_additions(),
790 before: tokens.before.len(),
791 after: tokens.after.len(),
792 similarity,
793 };
794 let has_same_filename = filename(src.location(path_backing)) == item_name;
795 let is_better =
796 best.as_ref()
797 .is_none_or(|(_, _, best_diff, best_has_same_filename)| {
798 match candidate_diff.similarity.total_cmp(&best_diff.similarity) {
799 std::cmp::Ordering::Greater => true,
800 std::cmp::Ordering::Equal => has_same_filename && !best_has_same_filename,
801 std::cmp::Ordering::Less => false,
802 }
803 });
804 if is_better {
805 best = Some((can_idx, src, candidate_diff, has_same_filename));
806 }
807 }
808 }
809 Operation::ExternalCommand { .. } => {
810 unreachable!("we have disabled this possibility with an option")
811 }
812 Operation::SourceOrDestinationIsBinary => {
813 }
815 }
816 }
817 return Ok(best.map(|(candidate_idx, src, diff, _)| (candidate_idx, src, Some(diff))));
818 }
819 Ok(None)
820}
821
822mod diff {
823 pub fn removed_bytes(diff: &crate::blob::Diff, input: &crate::blob::InternedInput<&[u8]>) -> usize {
824 diff.hunks()
825 .map(|hunk| {
826 input.before[hunk.before.start as usize..hunk.before.end as usize]
827 .iter()
828 .map(|token| input.interner[*token].len())
829 .sum::<usize>()
830 })
831 .sum()
832 }
833}
834
835#[cfg(test)]
836mod estimate_involved_items {
837 use super::estimate_involved_items;
838 use crate::rewrites::tracker::{ChangeKind, visit::SourceKind};
839
840 #[test]
841 fn renames_count_unemitted_as_sources_and_destinations() {
842 let items = [
843 (false, ChangeKind::Addition),
844 (true, ChangeKind::Deletion),
845 (true, ChangeKind::Deletion),
846 ];
847 assert_eq!(
848 estimate_involved_items(items, SourceKind::Rename),
849 (0, 1),
850 "here we only have one eligible source, hence nothing to do"
851 );
852 assert_eq!(
853 estimate_involved_items(items.into_iter().map(|t| (false, t.1)), SourceKind::Rename),
854 (2, 1),
855 "now we have more possibilities as renames count un-emitted deletions as source"
856 );
857 }
858
859 #[test]
860 fn copies_do_not_count_additions_as_sources() {
861 let items = [
862 (false, ChangeKind::Addition),
863 (true, ChangeKind::Addition),
864 (true, ChangeKind::Deletion),
865 ];
866 assert_eq!(
867 estimate_involved_items(items, SourceKind::Copy),
868 (0, 1),
869 "one addition as source, the other isn't counted as it's emitted, nor is it considered a copy-source.\
870 deletions don't count"
871 );
872 }
873
874 #[test]
875 fn copies_count_modifications_as_sources() {
876 let items = [
877 (false, ChangeKind::Addition),
878 (true, ChangeKind::Modification),
879 (false, ChangeKind::Modification),
880 ];
881 assert_eq!(
882 estimate_involved_items(items, SourceKind::Copy),
883 (2, 1),
884 "any modifications is a valid source, emitted or not"
885 );
886 }
887}