1use gix_error::{Result, message};
12use std::ops::Range;
13
14use bstr::{BStr, ByteSlice};
15use gix_error::ResultExt;
16use gix_object::tree::{EntryKind, EntryMode};
17
18use crate::{
19 Rewrites,
20 blob::{DiffLineStats, ResourceKind, platform::prepare_diff::Operation},
21 rewrites::{CopySource, Outcome, Tracker, tracker::visit::SourceKind},
22 tree::visit::{Action, ChangeId, Relation},
23};
24
25#[derive(Debug, Copy, Clone, Ord, PartialOrd, PartialEq, Eq)]
27pub enum ChangeKind {
28 Deletion,
30 Modification,
32 Addition,
34}
35
36pub trait Change: Clone {
38 fn id(&self) -> &gix_hash::oid;
43 fn relation(&self) -> Option<Relation>;
54 fn kind(&self) -> ChangeKind;
56 fn entry_mode(&self) -> EntryMode;
58 fn id_and_entry_mode(&self) -> (&gix_hash::oid, EntryMode);
60}
61
62pub(crate) struct Item<T> {
64 change: T,
66 path: Range<usize>,
68 emitted: bool,
70}
71
72impl<T: Change> Item<T> {
73 fn location<'a>(&self, backing: &'a [u8]) -> &'a BStr {
74 backing[self.path.clone()].as_ref()
75 }
76 fn entry_mode_compatible(&self, other: EntryMode) -> bool {
77 use EntryKind::*;
78 matches!(
79 (other.kind(), self.change.entry_mode().kind()),
80 (Blob | BlobExecutable, Blob | BlobExecutable) | (Link, Link) | (Tree, Tree) | (Commit, Commit)
81 )
82 }
83
84 fn is_source_candidate(&self, kind: visit::SourceKind) -> bool {
85 match kind {
86 visit::SourceKind::Rename => !self.emitted && matches!(self.change.kind(), ChangeKind::Deletion),
87 visit::SourceKind::Copy => matches!(self.change.kind(), ChangeKind::Modification),
88 }
89 }
90
91 fn is_source_for_destination_of(&self, kind: visit::SourceKind, dest_item_mode: EntryMode) -> bool {
92 self.entry_mode_compatible(dest_item_mode) && self.is_source_candidate(kind)
93 }
94}
95
96pub mod visit {
98 use bstr::BStr;
99 use gix_object::tree::EntryMode;
100
101 use crate::blob::DiffLineStats;
102
103 #[derive(Debug, Clone, PartialEq, PartialOrd)]
105 pub struct Source<'a, T> {
106 pub entry_mode: EntryMode,
108 pub id: gix_hash::ObjectId,
110 pub kind: SourceKind,
112 pub location: &'a BStr,
114 pub change: &'a T,
116 pub diff: Option<DiffLineStats>,
118 }
119
120 #[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
122 pub enum SourceKind {
123 Rename,
125 Copy,
127 }
128
129 #[derive(Debug, Clone)]
131 pub struct Destination<'a, T: Clone> {
132 pub change: T,
134 pub location: &'a BStr,
136 }
137}
138
139impl<T: Change> Tracker<T> {
141 pub fn new(rewrites: Rewrites) -> Self {
143 Tracker {
144 items: vec![],
145 path_backing: vec![],
146 rewrites,
147 child_renames: Default::default(),
148 }
149 }
150}
151
152impl<T: Change> Tracker<T> {
154 pub fn try_push_change(&mut self, change: T, location: &BStr) -> Option<T> {
156 let change_kind = change.kind();
157 if let (None, ChangeKind::Modification) = (self.rewrites.copies, change_kind) {
158 return Some(change);
159 }
160
161 let entry_kind = change.entry_mode().kind();
162 let relation = change
163 .relation()
164 .filter(|_| matches!(change_kind, ChangeKind::Addition | ChangeKind::Deletion));
165 if let (None, EntryKind::Tree) = (relation, entry_kind) {
166 return Some(change);
167 }
168
169 let start = self.path_backing.len();
170 self.path_backing.extend_from_slice(location);
171 let path = start..self.path_backing.len();
172
173 self.items.push(Item {
174 path,
175 change,
176 emitted: false,
177 });
178 None
179 }
180
181 pub fn emit<PushSourceTreeFn, E>(
204 &mut self,
205 mut cb: impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
206 diff_cache: &mut crate::blob::Platform,
207 objects: &impl gix_object::FindObjectOrHeader,
208 mut push_source_tree: PushSourceTreeFn,
209 ) -> Result<Outcome>
210 where
211 PushSourceTreeFn: FnMut(&mut dyn FnMut(T, &BStr)) -> std::result::Result<(), E>,
212 E: std::error::Error + Send + Sync + 'static,
213 {
214 fn is_parent(change: &impl Change) -> bool {
215 matches!(change.relation(), Some(Relation::Parent(_)))
216 }
217 diff_cache.options.skip_internal_diff_if_external_is_configured = false;
218
219 let has_work = {
221 let (mut num_deletions, mut num_additions, mut num_modifications) = (0, 0, 0);
222 let mut has_work = false;
223 for change in &self.items {
224 match change.change.kind() {
225 ChangeKind::Deletion => {
226 num_deletions += 1;
227 }
228 ChangeKind::Modification => {
229 num_modifications += 1;
231 }
232 ChangeKind::Addition => num_additions += 1,
233 }
234 if (num_deletions != 0 && num_additions != 0)
235 || (self.rewrites.copies.is_some() && num_modifications + num_additions > 1)
236 {
237 has_work = true;
238 break;
239 }
240 }
241 has_work
242 };
243
244 let mut out = Outcome {
245 options: self.rewrites,
246 ..Default::default()
247 };
248 if has_work {
249 self.sort_items_by_id_and_location();
250
251 self.match_pairs_of_kind(
255 visit::SourceKind::Rename,
256 &mut cb,
257 None, &mut out,
259 diff_cache,
260 objects,
261 Some(is_parent),
262 )?;
263
264 self.match_pairs_of_kind(
265 visit::SourceKind::Rename,
266 &mut cb,
267 self.rewrites.percentage,
268 &mut out,
269 diff_cache,
270 objects,
271 None,
272 )?;
273
274 self.match_renamed_directories(&mut cb)?;
275
276 if let Some(copies) = self.rewrites.copies {
277 self.match_pairs_of_kind(
278 visit::SourceKind::Copy,
279 &mut cb,
280 copies.percentage,
281 &mut out,
282 diff_cache,
283 objects,
284 None,
285 )?;
286
287 match copies.source {
288 CopySource::FromSetOfModifiedFiles => {}
289 CopySource::FromSetOfModifiedFilesAndAllSources => {
290 push_source_tree(&mut |change, location| {
291 if self.try_push_change(change, location).is_none() {
292 self.items.last_mut().expect("just pushed").emitted = true;
294 }
295 })
296 .or_raise(|| {
297 message(
298 "Could not obtain exhaustive item set to use as possible sources for copy detection",
299 )
300 })?;
301 self.sort_items_by_id_and_location();
302
303 self.match_pairs_of_kind(
304 visit::SourceKind::Copy,
305 &mut cb,
306 copies.percentage,
307 &mut out,
308 diff_cache,
309 objects,
310 None,
311 )?;
312 }
313 }
314 }
315 }
316
317 self.items
318 .sort_by(|a, b| a.location(&self.path_backing).cmp(b.location(&self.path_backing)));
319 for item in self.items.drain(..).filter(|item| !item.emitted) {
320 if cb(
321 visit::Destination {
322 location: item.location(&self.path_backing),
323 change: item.change,
324 },
325 None,
326 )
327 .is_break()
328 {
329 break;
330 }
331 }
332 Ok(out)
333 }
334}
335
336impl<T: Change> Tracker<T> {
337 fn sort_items_by_id_and_location(&mut self) {
345 self.items.sort_by(|a, b| {
346 a.change
347 .id()
348 .cmp(b.change.id())
349 .then_with(|| a.location(&self.path_backing).cmp(b.location(&self.path_backing)))
350 .then_with(|| a.change.kind().cmp(&b.change.kind()))
351 .then_with(|| a.change.relation().cmp(&b.change.relation()))
352 .then_with(|| a.change.entry_mode().cmp(&b.change.entry_mode()))
353 });
354 }
355
356 #[expect(clippy::too_many_arguments)]
357 fn match_pairs_of_kind(
358 &mut self,
359 kind: visit::SourceKind,
360 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
361 percentage: Option<f32>,
362 out: &mut Outcome,
363 diff_cache: &mut crate::blob::Platform,
364 objects: &impl gix_object::FindObjectOrHeader,
365 filter: Option<fn(&T) -> bool>,
366 ) -> Result {
367 let needs_second_pass = !needs_exact_match(percentage);
369
370 if self
374 .match_pairs(cb, None , kind, out, diff_cache, objects, filter)?
375 .is_break()
376 {
377 return Ok(());
378 }
379 if needs_second_pass {
380 let is_limited = if self.rewrites.limit == 0 {
381 false
382 } else {
383 let (num_src, num_dst) =
384 estimate_involved_items(self.items.iter().map(|item| (item.emitted, item.change.kind())), kind);
385 if limit_is_exceeded(num_src, num_dst, self.rewrites.limit) {
386 let permutations = num_src.saturating_mul(num_dst);
387 match kind {
388 visit::SourceKind::Rename => {
389 out.num_similarity_checks_skipped_for_rename_tracking_due_to_limit = permutations;
390 }
391 visit::SourceKind::Copy => {
392 out.num_similarity_checks_skipped_for_copy_tracking_due_to_limit = permutations;
393 }
394 }
395 true
396 } else {
397 false
398 }
399 };
400 if !is_limited {
401 let _ = self.match_pairs(cb, percentage, kind, out, diff_cache, objects, None)?;
402 }
403 }
404 Ok(())
405 }
406
407 #[expect(clippy::too_many_arguments)]
408 fn match_pairs(
409 &mut self,
410 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
411 percentage: Option<f32>,
412 kind: visit::SourceKind,
413 stats: &mut Outcome,
414 diff_cache: &mut crate::blob::Platform,
415 objects: &impl gix_object::FindObjectOrHeader,
416 filter: Option<fn(&T) -> bool>,
417 ) -> Result<Action> {
418 let mut dest_ofs = 0;
419 let mut num_checks = 0;
420 let max_checks = {
421 let limit = self.rewrites.limit.saturating_pow(2);
422 if self.items.len() < 100_000 { 0 } else { limit }
426 };
427 let source_candidates: Vec<usize> = self
431 .items
432 .iter()
433 .enumerate()
434 .filter_map(|(idx, item)| item.is_source_candidate(kind).then_some(idx))
435 .collect();
436 if source_candidates.is_empty() {
437 return Ok(std::ops::ControlFlow::Continue(()));
438 }
439
440 while let Some((mut dest_idx, dest)) = self.items[dest_ofs..].iter().enumerate().find_map(|(idx, item)| {
441 (!item.emitted
442 && matches!(item.change.kind(), ChangeKind::Addition)
443 && filter.map_or_else(
444 || {
445 self.rewrites.track_empty
446 || matches!(item.change.relation(), Some(Relation::ChildOfParent(_)))
449 || {
450 let id = item.change.id();
451 id != gix_hash::ObjectId::empty_blob(id.kind())
452 }
453 },
454 |f| f(&item.change),
455 ))
456 .then_some((idx, item))
457 }) {
458 dest_idx += dest_ofs;
459 dest_ofs = dest_idx + 1;
460 self.items[dest_idx].location(&self.path_backing);
461 let src = find_match(
462 &self.items,
463 &source_candidates,
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> {
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 {
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 limit_is_exceeded(num_src: usize, num_dst: usize, limit: usize) -> bool {
685 let limit = limit as u128;
687 (num_src as u128) * (num_dst as u128) > limit * limit
688}
689
690fn needs_exact_match(percentage: Option<f32>) -> bool {
691 percentage.is_none_or(|p| p >= 1.0)
692}
693
694type SourceTuple<'a, T> = (usize, &'a Item<T>, Option<DiffLineStats>);
696
697#[expect(clippy::too_many_arguments)]
705fn find_match<'a, T: Change>(
706 items: &'a [Item<T>],
707 source_candidates: &[usize],
708 item: &Item<T>,
709 item_idx: usize,
710 percentage: Option<f32>,
711 kind: visit::SourceKind,
712 stats: &mut Outcome,
713 objects: &impl gix_object::FindObjectOrHeader,
714 diff_cache: &mut crate::blob::Platform,
715 path_backing: &[u8],
716 num_checks: &mut usize,
717) -> Result<Option<SourceTuple<'a, T>>> {
718 let (item_id, item_mode) = item.change.id_and_entry_mode();
719 if needs_exact_match(percentage) || item_mode.is_link() || item_mode.is_commit() {
721 let start = source_candidates.partition_point(|&idx| items[idx].change.id() < item_id);
723 let end = source_candidates.partition_point(|&idx| items[idx].change.id() <= item_id);
724 if start == end {
725 return Ok(None);
726 }
727 let item_name = filename(item.location(path_backing));
728 let mut fallback = None;
729 for &src_idx in &source_candidates[start..end] {
730 let src = &items[src_idx];
731 *num_checks += 1;
732 if src_idx == item_idx || !src.is_source_for_destination_of(kind, item_mode) {
733 continue;
734 }
735 if filename(src.location(path_backing)) == item_name {
738 return Ok(Some((src_idx, src, None)));
739 }
740 fallback.get_or_insert((src_idx, src, None));
741 }
742 if fallback.is_some() {
743 return Ok(fallback);
744 }
745 } else if item_mode.is_blob() {
746 let mut has_new = false;
747 let percentage = percentage.expect("it's set to something below 1.0 and we assured this");
748 let item_name = filename(item.location(path_backing));
749
750 let mut best: Option<(usize, &Item<T>, DiffLineStats, bool)> = None;
753 for (can_idx, src) in source_candidates
754 .iter()
755 .map(|&src_idx| (src_idx, &items[src_idx]))
756 .filter(|(src_idx, src)| *src_idx != item_idx && src.is_source_for_destination_of(kind, item_mode))
757 {
758 if !has_new {
759 diff_cache
760 .set_resource(
761 item_id.to_owned(),
762 item_mode.kind(),
763 item.location(path_backing),
764 ResourceKind::NewOrDestination,
765 objects,
766 )
767 .or_raise(|| message("Could not set destination for similarity checking"))?;
768 has_new = true;
769 }
770 let (src_id, src_mode) = src.change.id_and_entry_mode();
771 diff_cache
772 .set_resource(
773 src_id.to_owned(),
774 src_mode.kind(),
775 src.location(path_backing),
776 ResourceKind::OldOrSource,
777 objects,
778 )
779 .or_raise(|| message("Could not set source for similarity checking"))?;
780 let prep = diff_cache
781 .prepare_diff()
782 .or_raise(|| message("Could not prepare resources for similarity checking"))?;
783 stats.num_similarity_checks += 1;
784 *num_checks += 1;
785 match prep.operation {
786 Operation::InternalDiff { algorithm } => {
787 let tokens = crate::blob::InternedInput::new(prep.old.intern_source(), prep.new.intern_source());
788 let diff = crate::blob::Diff::compute(algorithm, &tokens);
789 let removed_bytes = diff::removed_bytes(&diff, &tokens);
790 let old_data_len = prep.old.data.as_slice().unwrap_or_default().len();
791 let new_data_len = prep.new.data.as_slice().unwrap_or_default().len();
792 let similarity = (old_data_len - removed_bytes) as f32 / old_data_len.max(new_data_len) as f32;
793 if similarity >= percentage {
794 let candidate_diff = DiffLineStats {
795 removals: diff.count_removals(),
796 insertions: diff.count_additions(),
797 before: tokens.before.len(),
798 after: tokens.after.len(),
799 similarity,
800 };
801 let has_same_filename = filename(src.location(path_backing)) == item_name;
802 let is_better =
803 best.as_ref()
804 .is_none_or(|(_, _, best_diff, best_has_same_filename)| {
805 match candidate_diff.similarity.total_cmp(&best_diff.similarity) {
806 std::cmp::Ordering::Greater => true,
807 std::cmp::Ordering::Equal => has_same_filename && !best_has_same_filename,
808 std::cmp::Ordering::Less => false,
809 }
810 });
811 if is_better {
812 best = Some((can_idx, src, candidate_diff, has_same_filename));
813 }
814 }
815 }
816 Operation::ExternalCommand { .. } => {
817 unreachable!("we have disabled this possibility with an option")
818 }
819 Operation::SourceOrDestinationIsBinary => {
820 }
822 }
823 }
824 return Ok(best.map(|(candidate_idx, src, diff, _)| (candidate_idx, src, Some(diff))));
825 }
826 Ok(None)
827}
828
829mod diff {
830 pub fn removed_bytes(diff: &crate::blob::Diff, input: &crate::blob::InternedInput<&[u8]>) -> usize {
831 diff.hunks()
832 .map(|hunk| {
833 input.before[hunk.before.start as usize..hunk.before.end as usize]
834 .iter()
835 .map(|token| input.interner[*token].len())
836 .sum::<usize>()
837 })
838 .sum()
839 }
840}
841
842#[cfg(test)]
843mod limit_is_exceeded {
844 use super::limit_is_exceeded;
845
846 #[test]
847 fn overflowing_products_are_compared_without_saturation() {
848 for limit in [70_000, usize::MAX / 2] {
849 assert!(
850 limit_is_exceeded(limit + 1, limit + 1, limit),
851 "candidate counts just above the limit must be rejected even when both products overflow usize"
852 );
853 assert!(
854 !limit_is_exceeded(limit, limit, limit),
855 "a product equal to the squared limit is allowed"
856 );
857 assert!(
858 !limit_is_exceeded(limit - 1, limit, limit),
859 "a product below the squared limit is allowed even when both products overflow usize"
860 );
861 }
862 }
863
864 #[test]
865 fn maximum_counts_and_unbalanced_candidates() {
866 assert!(
867 !limit_is_exceeded(usize::MAX, usize::MAX, usize::MAX),
868 "squaring the largest supported count must not overflow the comparison"
869 );
870 assert!(
871 !limit_is_exceeded(0, usize::MAX, 1),
872 "without sources there are no comparisons to limit"
873 );
874 assert!(
875 limit_is_exceeded(usize::MAX, 1, 1),
876 "the product, not the smaller candidate count, determines whether the limit is exceeded"
877 );
878 }
879}
880
881#[cfg(test)]
882mod estimate_involved_items {
883 use super::estimate_involved_items;
884 use crate::rewrites::tracker::{ChangeKind, visit::SourceKind};
885
886 #[test]
887 fn renames_count_unemitted_as_sources_and_destinations() {
888 let items = [
889 (false, ChangeKind::Addition),
890 (true, ChangeKind::Deletion),
891 (true, ChangeKind::Deletion),
892 ];
893 assert_eq!(
894 estimate_involved_items(items, SourceKind::Rename),
895 (0, 1),
896 "here we only have one eligible source, hence nothing to do"
897 );
898 assert_eq!(
899 estimate_involved_items(items.into_iter().map(|t| (false, t.1)), SourceKind::Rename),
900 (2, 1),
901 "now we have more possibilities as renames count un-emitted deletions as source"
902 );
903 }
904
905 #[test]
906 fn copies_do_not_count_additions_as_sources() {
907 let items = [
908 (false, ChangeKind::Addition),
909 (true, ChangeKind::Addition),
910 (true, ChangeKind::Deletion),
911 ];
912 assert_eq!(
913 estimate_involved_items(items, SourceKind::Copy),
914 (0, 1),
915 "one addition as source, the other isn't counted as it's emitted, nor is it considered a copy-source.\
916 deletions don't count"
917 );
918 }
919
920 #[test]
921 fn copies_count_modifications_as_sources() {
922 let items = [
923 (false, ChangeKind::Addition),
924 (true, ChangeKind::Modification),
925 (false, ChangeKind::Modification),
926 ];
927 assert_eq!(
928 estimate_involved_items(items, SourceKind::Copy),
929 (2, 1),
930 "any modifications is a valid source, emitted or not"
931 );
932 }
933}