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)
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 if entry_kind == EntryKind::Commit {
177 return Some(change);
178 }
179 let relation = change
180 .relation()
181 .filter(|_| matches!(change_kind, ChangeKind::Addition | ChangeKind::Deletion));
182 if let (None, EntryKind::Tree) = (relation, entry_kind) {
183 return Some(change);
184 }
185
186 let start = self.path_backing.len();
187 self.path_backing.extend_from_slice(location);
188 let path = start..self.path_backing.len();
189
190 self.items.push(Item {
191 path,
192 change,
193 emitted: false,
194 });
195 None
196 }
197
198 pub fn emit<PushSourceTreeFn, E>(
221 &mut self,
222 mut cb: impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
223 diff_cache: &mut crate::blob::Platform,
224 objects: &impl gix_object::FindObjectOrHeader,
225 mut push_source_tree: PushSourceTreeFn,
226 ) -> Result<Outcome, emit::Error>
227 where
228 PushSourceTreeFn: FnMut(&mut dyn FnMut(T, &BStr)) -> Result<(), E>,
229 E: std::error::Error + Send + Sync + 'static,
230 {
231 fn is_parent(change: &impl Change) -> bool {
232 matches!(change.relation(), Some(Relation::Parent(_)))
233 }
234 diff_cache.options.skip_internal_diff_if_external_is_configured = false;
235
236 let has_work = {
238 let (mut num_deletions, mut num_additions, mut num_modifications) = (0, 0, 0);
239 let mut has_work = false;
240 for change in &self.items {
241 match change.change.kind() {
242 ChangeKind::Deletion => {
243 num_deletions += 1;
244 }
245 ChangeKind::Modification => {
246 num_modifications += 1;
248 }
249 ChangeKind::Addition => num_additions += 1,
250 }
251 if (num_deletions != 0 && num_additions != 0)
252 || (self.rewrites.copies.is_some() && num_modifications + num_additions > 1)
253 {
254 has_work = true;
255 break;
256 }
257 }
258 has_work
259 };
260
261 let mut out = Outcome {
262 options: self.rewrites,
263 ..Default::default()
264 };
265 if has_work {
266 self.sort_items_by_id_and_location();
267
268 self.match_pairs_of_kind(
272 visit::SourceKind::Rename,
273 &mut cb,
274 None, &mut out,
276 diff_cache,
277 objects,
278 Some(is_parent),
279 )?;
280
281 self.match_pairs_of_kind(
282 visit::SourceKind::Rename,
283 &mut cb,
284 self.rewrites.percentage,
285 &mut out,
286 diff_cache,
287 objects,
288 None,
289 )?;
290
291 self.match_renamed_directories(&mut cb)?;
292
293 if let Some(copies) = self.rewrites.copies {
294 self.match_pairs_of_kind(
295 visit::SourceKind::Copy,
296 &mut cb,
297 copies.percentage,
298 &mut out,
299 diff_cache,
300 objects,
301 None,
302 )?;
303
304 match copies.source {
305 CopySource::FromSetOfModifiedFiles => {}
306 CopySource::FromSetOfModifiedFilesAndAllSources => {
307 push_source_tree(&mut |change, location| {
308 if self.try_push_change(change, location).is_none() {
309 self.items.last_mut().expect("just pushed").emitted = true;
311 }
312 })
313 .map_err(|err| emit::Error::GetItemsForExhaustiveCopyDetection(Box::new(err)))?;
314 self.sort_items_by_id_and_location();
315
316 self.match_pairs_of_kind(
317 visit::SourceKind::Copy,
318 &mut cb,
319 copies.percentage,
320 &mut out,
321 diff_cache,
322 objects,
323 None,
324 )?;
325 }
326 }
327 }
328 }
329
330 self.items
331 .sort_by(|a, b| a.location(&self.path_backing).cmp(b.location(&self.path_backing)));
332 for item in self.items.drain(..).filter(|item| !item.emitted) {
333 if cb(
334 visit::Destination {
335 location: item.location(&self.path_backing),
336 change: item.change,
337 },
338 None,
339 )
340 .is_break()
341 {
342 break;
343 }
344 }
345 Ok(out)
346 }
347}
348
349impl<T: Change> Tracker<T> {
350 fn sort_items_by_id_and_location(&mut self) {
358 self.items.sort_by(|a, b| {
359 a.change
360 .id()
361 .cmp(b.change.id())
362 .then_with(|| a.location(&self.path_backing).cmp(b.location(&self.path_backing)))
363 .then_with(|| a.change.kind().cmp(&b.change.kind()))
364 .then_with(|| a.change.relation().cmp(&b.change.relation()))
365 .then_with(|| a.change.entry_mode().cmp(&b.change.entry_mode()))
366 });
367 }
368
369 #[expect(clippy::too_many_arguments)]
370 fn match_pairs_of_kind(
371 &mut self,
372 kind: visit::SourceKind,
373 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
374 percentage: Option<f32>,
375 out: &mut Outcome,
376 diff_cache: &mut crate::blob::Platform,
377 objects: &impl gix_object::FindObjectOrHeader,
378 filter: Option<fn(&T) -> bool>,
379 ) -> Result<(), emit::Error> {
380 let needs_second_pass = !needs_exact_match(percentage);
382
383 if self
387 .match_pairs(cb, None , kind, out, diff_cache, objects, filter)?
388 .is_break()
389 {
390 return Ok(());
391 }
392 if needs_second_pass {
393 let is_limited = if self.rewrites.limit == 0 {
394 false
395 } else {
396 let (num_src, num_dst) =
397 estimate_involved_items(self.items.iter().map(|item| (item.emitted, item.change.kind())), kind);
398 let permutations = num_src * num_dst;
399 if permutations > self.rewrites.limit {
400 match kind {
401 visit::SourceKind::Rename => {
402 out.num_similarity_checks_skipped_for_rename_tracking_due_to_limit = permutations;
403 }
404 visit::SourceKind::Copy => {
405 out.num_similarity_checks_skipped_for_copy_tracking_due_to_limit = permutations;
406 }
407 }
408 true
409 } else {
410 false
411 }
412 };
413 if !is_limited {
414 let _ = self.match_pairs(cb, percentage, kind, out, diff_cache, objects, None)?;
415 }
416 }
417 Ok(())
418 }
419
420 #[expect(clippy::too_many_arguments)]
421 fn match_pairs(
422 &mut self,
423 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
424 percentage: Option<f32>,
425 kind: visit::SourceKind,
426 stats: &mut Outcome,
427 diff_cache: &mut crate::blob::Platform,
428 objects: &impl gix_object::FindObjectOrHeader,
429 filter: Option<fn(&T) -> bool>,
430 ) -> Result<Action, emit::Error> {
431 let mut dest_ofs = 0;
432 let mut num_checks = 0;
433 let max_checks = {
434 let limit = self.rewrites.limit.saturating_pow(2);
435 if self.items.len() < 100_000 { 0 } else { limit }
439 };
440
441 while let Some((mut dest_idx, dest)) = self.items[dest_ofs..].iter().enumerate().find_map(|(idx, item)| {
442 (!item.emitted
443 && matches!(item.change.kind(), ChangeKind::Addition)
444 && filter.map_or_else(
445 || {
446 self.rewrites.track_empty
447 || matches!(item.change.relation(), Some(Relation::ChildOfParent(_)))
450 || {
451 let id = item.change.id();
452 id != gix_hash::ObjectId::empty_blob(id.kind())
453 }
454 },
455 |f| f(&item.change),
456 ))
457 .then_some((idx, item))
458 }) {
459 dest_idx += dest_ofs;
460 dest_ofs = dest_idx + 1;
461 self.items[dest_idx].location(&self.path_backing);
462 let src = find_match(
463 &self.items,
464 dest,
465 dest_idx,
466 percentage,
467 kind,
468 stats,
469 objects,
470 diff_cache,
471 &self.path_backing,
472 &mut num_checks,
473 )?
474 .map(|(src_idx, src, diff)| {
475 let (id, entry_mode) = src.change.id_and_entry_mode();
476 let id = id.to_owned();
477 let location = src.location(&self.path_backing);
478 (
479 visit::Source {
480 entry_mode,
481 id,
482 kind,
483 location,
484 change: &src.change,
485 diff,
486 },
487 src_idx,
488 )
489 });
490 if max_checks != 0 && num_checks > max_checks {
491 gix_trace::warn!(
492 "Cancelled rename matching as there were too many iterations ({num_checks} > {max_checks})"
493 );
494 return Ok(std::ops::ControlFlow::Break(()));
495 }
496 let Some((src, src_idx)) = src else {
497 continue;
498 };
499 let location = dest.location(&self.path_backing);
500 let change = dest.change.clone();
501 let dest = visit::Destination { change, location };
502 let relations = if percentage.is_none() {
503 src.change.relation().zip(dest.change.relation())
504 } else {
505 None
506 };
507 let res = cb(dest, Some(src));
508
509 self.items[dest_idx].emitted = true;
510 self.items[src_idx].emitted = true;
511
512 if res.is_break() {
513 return Ok(std::ops::ControlFlow::Break(()));
514 }
515
516 match relations {
517 Some((Relation::Parent(src), Relation::Parent(dst))) => {
518 let res = self.emit_child_renames_matching_identity(cb, kind, src, dst)?;
519 if res.is_break() {
520 return Ok(std::ops::ControlFlow::Break(()));
521 }
522 }
523 Some((Relation::ChildOfParent(src), Relation::ChildOfParent(dst))) => {
524 self.child_renames.insert((src, dst));
525 }
526 _ => {}
527 }
528 }
529 Ok(std::ops::ControlFlow::Continue(()))
530 }
531
532 fn emit_child_renames_matching_identity(
536 &mut self,
537 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
538 kind: visit::SourceKind,
539 src_parent_id: ChangeId,
540 dst_parent_id: ChangeId,
541 ) -> Result<Action, emit::Error> {
542 debug_assert_ne!(
543 src_parent_id, dst_parent_id,
544 "src and destination directories must be distinct"
545 );
546 let (mut src_items, mut dst_items) = (Vec::with_capacity(1), Vec::with_capacity(1));
547 for item in self.items.iter_mut().filter(|item| !item.emitted) {
548 match item.change.relation() {
549 Some(Relation::ChildOfParent(id)) if id == src_parent_id => {
550 src_items.push((item.change.id().to_owned(), item));
551 }
552 Some(Relation::ChildOfParent(id)) if id == dst_parent_id => {
553 dst_items.push((item.change.id().to_owned(), item));
554 }
555 _ => continue,
556 }
557 }
558
559 for ((src_id, src_item), (dst_id, dst_item)) in src_items.into_iter().zip(dst_items) {
560 if src_id == dst_id
563 && filename(src_item.location(&self.path_backing)) == filename(dst_item.location(&self.path_backing))
564 {
565 let entry_mode = src_item.change.entry_mode();
566 let location = src_item.location(&self.path_backing);
567 let src = visit::Source {
568 entry_mode,
569 id: src_id,
570 kind,
571 location,
572 change: &src_item.change,
573 diff: None,
574 };
575 let location = dst_item.location(&self.path_backing);
576 let change = dst_item.change.clone();
577 let dst = visit::Destination { change, location };
578 let res = cb(dst, Some(src));
579
580 src_item.emitted = true;
581 dst_item.emitted = true;
582
583 if res.is_break() {
584 return Ok(res);
585 }
586 } else {
587 gix_trace::warn!(
588 "Children of parents with change-id {src_parent_id} and {dst_parent_id} were not equal, even though their parents claimed to be"
589 );
590 break;
591 }
592 }
593 Ok(std::ops::ControlFlow::Continue(()))
594 }
595
596 fn match_renamed_directories(
602 &mut self,
603 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
604 ) -> Result<(), emit::Error> {
605 fn unemitted_directory_matching_relation_id<T: Change>(items: &[Item<T>], child_id: ChangeId) -> Option<usize> {
606 items.iter().position(|i| {
607 !i.emitted && matches!(i.change.relation(), Some(Relation::Parent(pid)) if pid == child_id)
608 })
609 }
610 for (deleted_child_id, added_child_id) in &self.child_renames {
611 let Some(src_idx) = unemitted_directory_matching_relation_id(&self.items, *deleted_child_id) else {
612 continue;
613 };
614 let Some(dst_idx) = unemitted_directory_matching_relation_id(&self.items, *added_child_id) else {
615 continue;
618 };
619
620 let (src_item, dst_item) = (&self.items[src_idx], &self.items[dst_idx]);
621 let entry_mode = src_item.change.entry_mode();
622 let location = src_item.location(&self.path_backing);
623 let src = visit::Source {
624 entry_mode,
625 id: src_item.change.id().to_owned(),
626 kind: SourceKind::Rename,
627 location,
628 change: &src_item.change,
629 diff: None,
630 };
631 let location = dst_item.location(&self.path_backing);
632 let change = dst_item.change.clone();
633 let dst = visit::Destination { change, location };
634 let res = cb(dst, Some(src));
635
636 self.items[src_idx].emitted = true;
637 self.items[dst_idx].emitted = true;
638
639 if res.is_break() {
640 return Ok(());
641 }
642 }
643 Ok(())
644 }
645}
646
647fn filename(path: &BStr) -> &BStr {
648 path.rfind_byte(b'/').map_or(path, |idx| path[idx + 1..].as_bstr())
649}
650
651fn estimate_involved_items(
653 items: impl IntoIterator<Item = (bool, ChangeKind)>,
654 kind: visit::SourceKind,
655) -> (usize, usize) {
656 items
657 .into_iter()
658 .filter(|(emitted, _)| match kind {
659 visit::SourceKind::Rename => !*emitted,
660 visit::SourceKind::Copy => true,
661 })
662 .fold((0, 0), |(mut src, mut dest), (emitted, change_kind)| {
663 match change_kind {
664 ChangeKind::Addition => {
665 if kind == visit::SourceKind::Rename || !emitted {
666 dest += 1;
667 }
668 }
669 ChangeKind::Deletion => {
670 if kind == visit::SourceKind::Rename {
671 src += 1;
672 }
673 }
674 ChangeKind::Modification => {
675 if kind == visit::SourceKind::Copy {
676 src += 1;
677 }
678 }
679 }
680 (src, dest)
681 })
682}
683
684fn needs_exact_match(percentage: Option<f32>) -> bool {
685 percentage.is_none_or(|p| p >= 1.0)
686}
687
688type SourceTuple<'a, T> = (usize, &'a Item<T>, Option<DiffLineStats>);
690
691#[expect(clippy::too_many_arguments)]
699fn find_match<'a, T: Change>(
700 items: &'a [Item<T>],
701 item: &Item<T>,
702 item_idx: usize,
703 percentage: Option<f32>,
704 kind: visit::SourceKind,
705 stats: &mut Outcome,
706 objects: &impl gix_object::FindObjectOrHeader,
707 diff_cache: &mut crate::blob::Platform,
708 path_backing: &[u8],
709 num_checks: &mut usize,
710) -> Result<Option<SourceTuple<'a, T>>, emit::Error> {
711 let (item_id, item_mode) = item.change.id_and_entry_mode();
712 if needs_exact_match(percentage) || item_mode.is_link() {
713 let first_idx = items.partition_point(|a| a.change.id() < item_id);
714 let range = items.get(first_idx..).map(|slice| {
715 let end = slice
716 .iter()
717 .position(|a| a.change.id() != item_id)
718 .map_or(items.len(), |idx| first_idx + idx);
719 first_idx..end
720 });
721 let range = match range {
722 Some(range) => range,
723 None => return Ok(None),
724 };
725 if range.is_empty() {
726 return Ok(None);
727 }
728 let item_name = filename(item.location(path_backing));
729 let mut fallback = None;
730 for (mut src_idx, src) in items[range.clone()].iter().enumerate() {
731 src_idx += range.start;
732 *num_checks += 1;
733 if src_idx == item_idx || !src.is_source_for_destination_of(kind, item_mode) {
734 continue;
735 }
736 if filename(src.location(path_backing)) == item_name {
739 return Ok(Some((src_idx, src, None)));
740 }
741 fallback.get_or_insert((src_idx, src, None));
742 }
743 if fallback.is_some() {
744 return Ok(fallback);
745 }
746 } else if item_mode.is_blob() {
747 let mut has_new = false;
748 let percentage = percentage.expect("it's set to something below 1.0 and we assured this");
749 let item_name = filename(item.location(path_backing));
750
751 let mut best: Option<(usize, &Item<T>, DiffLineStats, bool)> = None;
754 for (can_idx, src) in items
755 .iter()
756 .enumerate()
757 .filter(|(src_idx, src)| *src_idx != item_idx && src.is_source_for_destination_of(kind, item_mode))
758 {
759 if !has_new {
760 diff_cache.set_resource(
761 item_id.to_owned(),
762 item_mode.kind(),
763 item.location(path_backing),
764 ResourceKind::NewOrDestination,
765 objects,
766 )?;
767 has_new = true;
768 }
769 let (src_id, src_mode) = src.change.id_and_entry_mode();
770 diff_cache.set_resource(
771 src_id.to_owned(),
772 src_mode.kind(),
773 src.location(path_backing),
774 ResourceKind::OldOrSource,
775 objects,
776 )?;
777 let prep = diff_cache.prepare_diff()?;
778 stats.num_similarity_checks += 1;
779 *num_checks += 1;
780 match prep.operation {
781 Operation::InternalDiff { algorithm } => {
782 let tokens = crate::blob::InternedInput::new(prep.old.intern_source(), prep.new.intern_source());
783 let diff = crate::blob::Diff::compute(algorithm, &tokens);
784 let removed_bytes = diff::removed_bytes(&diff, &tokens);
785 let old_data_len = prep.old.data.as_slice().unwrap_or_default().len();
786 let new_data_len = prep.new.data.as_slice().unwrap_or_default().len();
787 let similarity = (old_data_len - removed_bytes) as f32 / old_data_len.max(new_data_len) as f32;
788 if similarity >= percentage {
789 let candidate_diff = DiffLineStats {
790 removals: diff.count_removals(),
791 insertions: diff.count_additions(),
792 before: tokens.before.len(),
793 after: tokens.after.len(),
794 similarity,
795 };
796 let has_same_filename = filename(src.location(path_backing)) == item_name;
797 let is_better =
798 best.as_ref()
799 .is_none_or(|(_, _, best_diff, best_has_same_filename)| {
800 match candidate_diff.similarity.total_cmp(&best_diff.similarity) {
801 std::cmp::Ordering::Greater => true,
802 std::cmp::Ordering::Equal => has_same_filename && !best_has_same_filename,
803 std::cmp::Ordering::Less => false,
804 }
805 });
806 if is_better {
807 best = Some((can_idx, src, candidate_diff, has_same_filename));
808 }
809 }
810 }
811 Operation::ExternalCommand { .. } => {
812 unreachable!("we have disabled this possibility with an option")
813 }
814 Operation::SourceOrDestinationIsBinary => {
815 }
817 }
818 }
819 return Ok(best.map(|(candidate_idx, src, diff, _)| (candidate_idx, src, Some(diff))));
820 }
821 Ok(None)
822}
823
824mod diff {
825 pub fn removed_bytes(diff: &crate::blob::Diff, input: &crate::blob::InternedInput<&[u8]>) -> usize {
826 diff.hunks()
827 .map(|hunk| {
828 input.before[hunk.before.start as usize..hunk.before.end as usize]
829 .iter()
830 .map(|token| input.interner[*token].len())
831 .sum::<usize>()
832 })
833 .sum()
834 }
835}
836
837#[cfg(test)]
838mod estimate_involved_items {
839 use super::estimate_involved_items;
840 use crate::rewrites::tracker::{ChangeKind, visit::SourceKind};
841
842 #[test]
843 fn renames_count_unemitted_as_sources_and_destinations() {
844 let items = [
845 (false, ChangeKind::Addition),
846 (true, ChangeKind::Deletion),
847 (true, ChangeKind::Deletion),
848 ];
849 assert_eq!(
850 estimate_involved_items(items, SourceKind::Rename),
851 (0, 1),
852 "here we only have one eligible source, hence nothing to do"
853 );
854 assert_eq!(
855 estimate_involved_items(items.into_iter().map(|t| (false, t.1)), SourceKind::Rename),
856 (2, 1),
857 "now we have more possibilities as renames count un-emitted deletions as source"
858 );
859 }
860
861 #[test]
862 fn copies_do_not_count_additions_as_sources() {
863 let items = [
864 (false, ChangeKind::Addition),
865 (true, ChangeKind::Addition),
866 (true, ChangeKind::Deletion),
867 ];
868 assert_eq!(
869 estimate_involved_items(items, SourceKind::Copy),
870 (0, 1),
871 "one addition as source, the other isn't counted as it's emitted, nor is it considered a copy-source.\
872 deletions don't count"
873 );
874 }
875
876 #[test]
877 fn copies_count_modifications_as_sources() {
878 let items = [
879 (false, ChangeKind::Addition),
880 (true, ChangeKind::Modification),
881 (false, ChangeKind::Modification),
882 ];
883 assert_eq!(
884 estimate_involved_items(items, SourceKind::Copy),
885 (2, 1),
886 "any modifications is a valid source, emitted or not"
887 );
888 }
889}