cargo_difftests/analysis.rs
1/*
2 * Copyright (c) 2023-2024 Dinu Blanovschi
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! This module contains the [`AnalysisContext`] type, which is used to perform
18//! the analysis of a difftest, or a test index.
19//!
20//! The [`AnalysisContext`] can be obtained by calling any of the following:
21//! - [`Difftest::start_analysis`](super::Difftest::start_analysis)
22//! - [`AnalysisContext::from_index`]
23//! - [`AnalysisContext::with_index_from`]
24//! - [`AnalysisContext::with_index_from_difftest`]
25//!
26//! The [`AnalysisContext`] can then be used to perform the analysis, by calling
27//! [`AnalysisContext::run`].
28//!
29//! After [`AnalysisContext::run`] finished, the [`AnalysisContext::finish_analysis`]
30//! method can be called to finish the analysis and get the result, dropping the
31//! [`AnalysisContext`].
32//!
33//! # Examples
34//!
35//! ## Analyzing a difftest from coverage data
36//!
37//! ```no_run
38//! # use std::path::{PathBuf, Path};
39//! # use cargo_difftests::{
40//! # difftest::{Difftest, ExportProfdataConfig},
41//! # analysis::{AnalysisConfig, AnalysisResult, DirtyAlgorithm},
42//! # };
43//! let mut difftest = Difftest::discover_from(PathBuf::from("difftest"), None)?;
44//! difftest.merge_profraw_files_into_profdata(false)?;
45//!
46//! let mut analysis_context = difftest.start_analysis(ExportProfdataConfig {
47//! ignore_registry_files: true,
48//! other_binaries: vec![],
49//! })?;
50//! analysis_context.run(&AnalysisConfig {
51//! dirty_algorithm: DirtyAlgorithm::FileSystemMtimes,
52//! error_on_invalid_config: true,
53//! })?;
54//!
55//! let r = analysis_context.finish_analysis();
56//!
57//! match r {
58//! AnalysisResult::Dirty => {
59//! println!("difftest is dirty");
60//! }
61//! AnalysisResult::Clean => {
62//! println!("difftest is clean");
63//! }
64//! }
65//! # Ok::<_, cargo_difftests::DifftestsError>(())
66//! ```
67//!
68//! ## Analyzing a difftest from a test index
69//!
70//! ```no_run
71//! # use std::path::{PathBuf, Path};
72//! # use cargo_difftests::{
73//! # difftest::{Difftest, ExportProfdataConfig},
74//! # index_data::{IndexDataCompilerConfig, TestIndex, IndexSize},
75//! # analysis::{AnalysisConfig, AnalysisContext, AnalysisResult, DirtyAlgorithm},
76//! # };
77//! // compile the test index first
78//! let mut difftest = Difftest::discover_from(PathBuf::from("difftest"), None)?;
79//! difftest.merge_profraw_files_into_profdata(false)?;
80//!
81//! let test_index = difftest.compile_test_index_data(
82//! ExportProfdataConfig {
83//! ignore_registry_files: true,
84//! other_binaries: vec![],
85//! },
86//! IndexDataCompilerConfig {
87//! ignore_registry_files: true,
88//! remove_bin_path: true,
89//! accept_file: Box::new(|_| true),
90//! index_filename_converter: Box::new(|p| p.to_path_buf()),
91//! index_size: IndexSize::Tiny,
92//! }
93//! )?;
94//!
95//! // optionally save it
96//! test_index.write_to_file(Path::new("test_index.json"))?;
97//!
98//! // or if we already have a test index saved
99//! let test_index = TestIndex::read_from_file(Path::new("test_index.json"))?;
100//!
101//! let mut analysis_context = AnalysisContext::from_index(test_index);
102//! analysis_context.run(&AnalysisConfig {
103//! dirty_algorithm: DirtyAlgorithm::FileSystemMtimes,
104//! error_on_invalid_config: true,
105//! })?;
106//!
107//! let r = analysis_context.finish_analysis();
108//!
109//! match r {
110//! AnalysisResult::Dirty => {
111//! println!("difftest is dirty");
112//! }
113//! AnalysisResult::Clean => {
114//! println!("difftest is clean");
115//! }
116//! }
117//! # Ok::<_, cargo_difftests::DifftestsError>(())
118//! ```
119//!
120//! # Explanations of the different [`DirtyAlgorithm`]s
121//!
122//! ## [`DirtyAlgorithm::FileSystemMtimes`]
123//!
124//! This algorithm compares the mtime of the files that were "touched" by the test
125//! with the time when the test was last ran.
126//!
127//! A file is considered "touched" if it had any region with a non-zero coverage
128//! execution count.
129//!
130//! It uses the mtime of one of the files generated by the `cargo_difftests_testclient::init`
131//! function to determine when the test was last ran.
132//!
133//! ## [`DirtyAlgorithm::GitDiff`]
134//!
135//! For both [`GitDiffStrategy`]ies, this algorithm looks through the git diff
136//! between the working tree and the commit HEAD points to, or the commit
137//! commit in [`DirtyAlgorithm::GitDiff`] `commit` field if it is [`Some`].
138//!
139//! ### With [`GitDiffStrategy::FilesOnly`]
140//!
141//! This algorithm only looks at the files that were changed in the diff.
142//!
143//! If any of the files that were changed in the diff are files that were
144//! "touched" by the test, then the test is considered dirty, and that is
145//! the result of the analysis.
146//!
147//! ### With [`GitDiffStrategy::Hunks`]
148//!
149//! This algorithm looks at the hunks in the diff.
150//!
151//! A hunk is a continuous part of a file that was changed in the diff.
152//!
153//! It is identified by 4 numbers:
154//! - An old start index
155//! - An old line count
156//! - A new start index
157//! - A new line count
158//!
159//! This algorithm looks at the regions that were touched by the test,
160//! and tries to intersect them with the hunks that were changed in the diff.
161//!
162//! If any of the regions that were touched by the test intersect with any of
163//! the hunks that were changed in the diff, then the test is considered dirty,
164//! and that is the result of the analysis.
165//!
166//! The way it achieves this is by looking at the ranges given by:
167//! - `(hunk.old_start..hunk.old_start + hunk.old_line_count)` from the hunk
168//! - `(region.l1..=region.l2)` from the region.
169//!
170//! If the intersection of these two ranges is not empty, then the region
171//! intersects with the hunk.
172//!
173//! This is pretty error-prone, and in the [introductory blog post] there is
174//! an example of how this algorithm can fail, but if used properly, it has
175//! the potential to be the most accurate out of the three, as it can detect
176//! changes in specific parts of the code, and in the case of big files that
177//! can help a lot.
178//!
179//! [introductory blog post]: https://blog.dnbln.dev/posts/cargo-difftests/
180
181use std::cell::RefCell;
182use std::collections::BTreeSet;
183use std::fmt;
184use std::marker::PhantomData;
185use std::path::{Path, PathBuf};
186use std::rc::Rc;
187use std::time::SystemTime;
188
189use git2::{DiffDelta, DiffHunk};
190use log::{debug, info, warn};
191
192use crate::analysis_data::CoverageData;
193use crate::index_data::TestIndex;
194use crate::{Difftest, DifftestsError, DifftestsResult};
195
196enum AnalysisContextInternal<'r> {
197 DifftestWithCoverageData {
198 difftest: &'r mut Difftest,
199 profdata: CoverageData,
200 },
201 IndexData {
202 index: TestIndex,
203 },
204}
205
206/// An analysis context, which is used to perform analysis on a difftest, or
207/// a test index.
208///
209/// To get a context, you can use the
210/// [`HasExportedProfdata::start_analysis`](crate::difftest::HasExportedProfdata::start_analysis),
211/// [`AnalysisContext::from_index`], [`AnalysisContext::with_index_from`],
212/// or [`AnalysisContext::with_index_from_difftest`] associated functions.
213pub struct AnalysisContext<'r> {
214 internal: AnalysisContextInternal<'r>,
215 result: AnalysisResult,
216}
217
218impl AnalysisContext<'static> {
219 /// Create a new context from a test index.
220 pub fn from_index(index: TestIndex) -> Self {
221 Self {
222 internal: AnalysisContextInternal::IndexData { index },
223 result: AnalysisResult::Clean,
224 }
225 }
226
227 /// Create a new context from a test index, read from the file at the given path.
228 pub fn with_index_from(p: &Path) -> DifftestsResult<Self> {
229 let index = TestIndex::read_from_file(p)?;
230
231 Ok(Self::from_index(index))
232 }
233
234 /// Create a new context from a test index, read from the index from the [Difftest].
235 pub fn with_index_from_difftest(difftest: &Difftest) -> DifftestsResult<Self> {
236 let Some(index) = difftest.read_index_data()? else {
237 panic!("Difftest does not have index data")
238 };
239
240 Ok(Self::from_index(index))
241 }
242}
243
244impl<'r> AnalysisContext<'r> {
245 pub(crate) fn new(difftest: &'r mut Difftest, profdata: CoverageData) -> Self {
246 Self {
247 internal: AnalysisContextInternal::DifftestWithCoverageData { difftest, profdata },
248 result: AnalysisResult::Clean,
249 }
250 }
251
252 /// Get the optional [`CoverageData`] that is used for analysis.
253 pub fn get_profdata(&self) -> Option<&CoverageData> {
254 match &self.internal {
255 AnalysisContextInternal::DifftestWithCoverageData { profdata, .. } => Some(profdata),
256 AnalysisContextInternal::IndexData { .. } => None,
257 }
258 }
259
260 /// Get the optional [`Difftest`] that is used for analysis.
261 pub fn get_difftest(&self) -> Option<&Difftest> {
262 match &self.internal {
263 AnalysisContextInternal::DifftestWithCoverageData { difftest, .. } => Some(difftest),
264 AnalysisContextInternal::IndexData { .. } => None,
265 }
266 }
267
268 /// Get the optional [`TestIndex`] that is used for analysis.
269 pub fn get_index(&self) -> Option<&TestIndex> {
270 match &self.internal {
271 AnalysisContextInternal::DifftestWithCoverageData { .. } => None,
272 AnalysisContextInternal::IndexData { index } => Some(index),
273 }
274 }
275
276 /// Finish the analysis, and return the result.
277 ///
278 /// This function should be called after [`AnalysisContext::run`].
279 pub fn finish_analysis(self) -> AnalysisResult {
280 let r = self.result;
281
282 info!("Analysis finished with result: {r:?}");
283
284 r
285 }
286
287 /// Gets the time at which the test was run.
288 pub fn test_run_at(&self) -> DifftestsResult<SystemTime> {
289 match &self.internal {
290 AnalysisContextInternal::DifftestWithCoverageData { difftest, .. } => {
291 Ok(difftest.test_run_time())
292 }
293 AnalysisContextInternal::IndexData { index } => Ok(index.test_run.into()),
294 }
295 }
296
297 /// Gets an iterator over the regions that are covered by the test.
298 ///
299 /// This iterator does not filter the regions that were not touched, so it
300 /// may contain regions that were not covered by the test, but still were there
301 /// in the [`CoverageData`].
302 ///
303 /// If using a [`TestIndex`] to run the analysis, then this iterator will only
304 /// contain the regions that were touched by the test, as those are the only
305 /// regions present in the [`TestIndex`].
306 ///
307 /// To clarify, we call the regions that had a non-zero execution count "touched".
308 pub fn regions(&self) -> AnalysisRegions<'_> {
309 AnalysisRegions {
310 cx: self,
311 regions_iter_state: match self.internal {
312 AnalysisContextInternal::DifftestWithCoverageData { .. } => {
313 RegionsIterState::CoverageData {
314 mapping_idx: 0,
315 function_idx: 0,
316 region_idx: 0,
317 }
318 }
319 AnalysisContextInternal::IndexData { .. } => {
320 RegionsIterState::IndexData { region_idx: 0 }
321 }
322 },
323 }
324 }
325
326 pub fn files(&self, include_registry_files: bool) -> BTreeSet<PathBuf> {
327 match &self.internal {
328 AnalysisContextInternal::DifftestWithCoverageData { profdata, .. } => profdata
329 .data
330 .iter()
331 .flat_map(|it| {
332 it.functions
333 .iter()
334 .filter(|fun| fun.regions.iter().any(|it| it.execution_count > 0))
335 .flat_map(|fun| {
336 fun.filenames
337 .iter()
338 .filter(|file| {
339 include_registry_files || !file_is_from_cargo_registry(file)
340 })
341 .map(PathBuf::clone)
342 })
343 })
344 .collect(),
345 AnalysisContextInternal::IndexData { index } => index
346 .files
347 .iter()
348 .filter(|file| include_registry_files || !file_is_from_cargo_registry(file))
349 .map(PathBuf::clone)
350 .collect(),
351 }
352 }
353}
354
355/// An iterator over the regions that are present in the coverage data of the test.
356///
357/// See the documentation of [AnalysisContext::regions] for more information.
358pub struct AnalysisRegions<'r> {
359 cx: &'r AnalysisContext<'r>,
360 regions_iter_state: RegionsIterState,
361}
362
363enum RegionsIterState {
364 CoverageData {
365 mapping_idx: usize,
366 function_idx: usize,
367 region_idx: usize,
368 },
369 IndexData {
370 region_idx: usize,
371 },
372}
373
374impl<'r> Iterator for AnalysisRegions<'r> {
375 type Item = AnalysisRegion<'r>;
376
377 fn next(&mut self) -> Option<Self::Item> {
378 match &self.cx.internal {
379 AnalysisContextInternal::DifftestWithCoverageData { profdata, .. } => {
380 let RegionsIterState::CoverageData {
381 region_idx,
382 mapping_idx,
383 function_idx,
384 } = &mut self.regions_iter_state
385 else {
386 panic!("Invalid state");
387 };
388
389 if *mapping_idx >= profdata.data.len() {
390 return None;
391 }
392
393 let mapping = &profdata.data[*mapping_idx];
394
395 if *function_idx >= mapping.functions.len() {
396 *mapping_idx += 1;
397 *function_idx = 0;
398 *region_idx = 0;
399 return self.next();
400 }
401
402 let function = &mapping.functions[*function_idx];
403
404 if *region_idx >= function.regions.len() {
405 *function_idx += 1;
406 *region_idx = 0;
407 return self.next();
408 }
409
410 let region = &function.regions[*region_idx];
411
412 let r = AnalysisRegion {
413 l1: region.l1,
414 c1: region.c1,
415 l2: region.l2,
416 c2: region.c2,
417 execution_count: region.execution_count,
418 file_ref: &function.filenames[region.file_id],
419 };
420
421 *region_idx += 1;
422 Some(r)
423 }
424 AnalysisContextInternal::IndexData { index } => {
425 let RegionsIterState::IndexData { region_idx } = &mut self.regions_iter_state
426 else {
427 panic!("Invalid state");
428 };
429
430 if *region_idx >= index.regions.len() {
431 return None;
432 }
433
434 let region = &index.regions[*region_idx];
435
436 let r = AnalysisRegion {
437 l1: region.l1,
438 c1: region.c1,
439 l2: region.l2,
440 c2: region.c2,
441 execution_count: region.count,
442 file_ref: &index.files[region.file_id],
443 };
444
445 *region_idx += 1;
446 Some(r)
447 }
448 }
449 }
450}
451
452/// An analysis region.
453pub struct AnalysisRegion<'r> {
454 /// The first line of the region.
455 pub l1: usize,
456 /// The first column of the region.
457 pub c1: usize,
458 /// The last line of the region.
459 pub l2: usize,
460 /// The last column of the region.
461 pub c2: usize,
462 /// The execution count of the region.
463 pub execution_count: usize,
464 /// The file that the region is in.
465 pub file_ref: &'r Path,
466}
467
468/// The algorithm to use for the analysis.
469#[derive(Debug, Clone)]
470pub enum DirtyAlgorithm {
471 /// Use file system mtimes.
472 FileSystemMtimes,
473 /// Use git diff, with the given strategy,
474 /// and the base commit to diff with.
475 GitDiff {
476 /// The diff strategy to use.
477 ///
478 /// See [`GitDiffStrategy`] for more.
479 strategy: GitDiffStrategy,
480 /// The commit to diff with.
481 commit: Option<git2::Oid>,
482 },
483}
484
485/// The configuration for the analysis.
486#[derive(Debug, Clone)]
487pub struct AnalysisConfig {
488 /// The algorithm to use for the analysis.
489 pub dirty_algorithm: DirtyAlgorithm,
490
491 pub error_on_invalid_config: bool,
492}
493
494impl<'r> AnalysisContext<'r> {
495 /// Runs the analysis, with the given [`AnalysisConfig`].
496 ///
497 /// This should only be called once.
498 /// If called multiple times, the output of
499 /// the analysis will correspond to the last [`AnalysisContext::run`] call.
500 pub fn run(&mut self, config: &AnalysisConfig) -> DifftestsResult {
501 let AnalysisConfig {
502 dirty_algorithm,
503 error_on_invalid_config,
504 } = config;
505
506 let dirty_algorithm = if let AnalysisContextInternal::IndexData { index } = &self.internal
507 && index.regions.is_empty()
508 && let DirtyAlgorithm::GitDiff {
509 strategy: GitDiffStrategy::Hunks,
510 commit,
511 } = &config.dirty_algorithm
512 {
513 let lvl = if *error_on_invalid_config {
514 log::Level::Error
515 } else {
516 log::Level::Warn
517 };
518 log::log!(
519 lvl,
520 "cannot use GitDiff with strategy hunks on a test index with no regions"
521 );
522 log::log!(
523 lvl,
524 "hint: you might want to pass the --full-index flag to cargo-difftests"
525 );
526 log::log!(lvl, "when compiling the index");
527
528 if *error_on_invalid_config {
529 return Err(DifftestsError::InvalidConfig(
530 InvalidConfigError::GitDiffHunksOnTestIndexWithNoRegions,
531 ));
532 }
533
534 warn!("failling back to GitDiff with FilesOnly");
535
536 DirtyAlgorithm::GitDiff {
537 strategy: GitDiffStrategy::FilesOnly,
538 commit: *commit,
539 }
540 } else {
541 dirty_algorithm.clone()
542 };
543
544 let r = match dirty_algorithm {
545 DirtyAlgorithm::FileSystemMtimes => file_system_mtime_analysis(self)?,
546 DirtyAlgorithm::GitDiff {
547 strategy,
548 commit: Some(commit),
549 } => git_diff_analysis_from_commit(self, strategy, commit)?,
550 DirtyAlgorithm::GitDiff {
551 strategy,
552 commit: None,
553 } => git_diff_analysis(self, strategy)?,
554 };
555
556 self.result = r;
557
558 Ok(())
559 }
560}
561
562#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, thiserror::Error)]
563pub enum InvalidConfigError {
564 #[error("GitDiff with strategy hunks cannot be used on a test index with no regions")]
565 GitDiffHunksOnTestIndexWithNoRegions,
566}
567
568/// The result of an analysis of a single [`Difftest`] or [`TestIndex`].
569#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
570pub enum AnalysisResult {
571 /// The analysis found no modifications to the source files used by the test,
572 /// and it should probably not be rerun (unless something else changed, like
573 /// non-source code file system inputs).
574 Clean,
575 /// The analysis found some modifications to the source files used by the test,
576 /// and the it should be rerun.
577 Dirty,
578}
579
580/// Checks whether the file is under the cargo registry.
581///
582/// # Examples
583///
584/// ```
585/// # use std::path::Path;
586/// # use cargo_difftests::analysis::file_is_from_cargo_registry;
587/// # use home::cargo_home;
588///
589/// assert!(file_is_from_cargo_registry(&cargo_home()?.join("registry/src/github.com-1ecc6299db9ec823/serde-1.0.130/src/ser/impls.rs")));
590/// assert!(!file_is_from_cargo_registry(Path::new("src/main.rs")));
591///
592/// # Ok::<(), std::io::Error>(())
593/// ```
594pub fn file_is_from_cargo_registry(f: &Path) -> bool {
595 let Ok(p) = home::cargo_home() else {
596 return false;
597 };
598 let registry = p.join("registry");
599 f.starts_with(®istry)
600}
601
602/// Returns a [`BTreeSet`] of the files that were touched by the test (have an execution_count > 0),
603/// and ignoring files from the cargo registry if `include_registry_files` = false.
604pub fn test_touched_files(cx: &AnalysisContext, include_registry_files: bool) -> BTreeSet<PathBuf> {
605 cx.files(include_registry_files)
606}
607
608/// Performs an analysis of the [`Difftest`], using file system mtimes.
609///
610/// For a comparison of the different algorithms,
611/// see the [module-level documentation](crate::analysis).
612pub fn file_system_mtime_analysis(cx: &AnalysisContext) -> DifftestsResult<AnalysisResult> {
613 let test_run_time = cx.test_run_at()?;
614
615 let test_touched_files = test_touched_files(cx, false);
616
617 for f in &test_touched_files {
618 debug!("Touched file: {}", f.display());
619 let mtime = std::fs::metadata(f)?.modified()?;
620 if mtime > test_run_time {
621 debug!("File {} was modified after test run", f.display());
622 return Ok(AnalysisResult::Dirty);
623 }
624 }
625
626 Ok(AnalysisResult::Clean)
627}
628
629trait LineRangeConstraint {
630 fn validate(start: usize, end: usize) -> bool;
631}
632
633struct LineRange<C>
634where
635 C: LineRangeConstraint,
636{
637 start: usize,
638 end: usize,
639 _constraint: PhantomData<C>,
640}
641
642impl<C> Clone for LineRange<C>
643where
644 C: LineRangeConstraint,
645{
646 fn clone(&self) -> Self {
647 Self {
648 start: self.start,
649 end: self.end,
650 _constraint: PhantomData,
651 }
652 }
653}
654
655impl<C> Copy for LineRange<C> where C: LineRangeConstraint {}
656
657impl<C> fmt::Debug for LineRange<C>
658where
659 C: LineRangeConstraint,
660{
661 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
662 write!(f, "L{}..L{}", self.start, self.end)
663 }
664}
665
666impl<C> LineRange<C>
667where
668 C: LineRangeConstraint,
669{
670 #[track_caller]
671 fn new(start: usize, end: usize) -> Self {
672 Self::try_new(start, end).unwrap()
673 }
674
675 fn try_new(start: usize, end: usize) -> Option<Self> {
676 C::validate(start, end).then_some(Self {
677 start,
678 end,
679 _constraint: PhantomData,
680 })
681 }
682
683 fn map_constraint<C2: LineRangeConstraint>(self) -> Result<LineRange<C2>, Self> {
684 LineRange::try_new(self.start, self.end).ok_or(self)
685 }
686
687 fn map_constraint_assert<C2: LineRangeConstraint>(self) -> LineRange<C2> {
688 self.map_constraint::<C2>().unwrap()
689 }
690
691 #[track_caller]
692 fn new_u32(start: u32, end: u32) -> Self {
693 Self::new(start as usize, end as usize)
694 }
695
696 fn intersects<C2: LineRangeConstraint>(&self, other: &LineRange<C2>) -> bool {
697 self.start <= other.start && self.end > other.start
698 || self.start < other.end && self.end >= other.end
699 }
700}
701
702struct LineRangeEmptyConstraint;
703
704impl LineRangeConstraint for LineRangeEmptyConstraint {
705 fn validate(start: usize, end: usize) -> bool {
706 start == end
707 }
708}
709
710struct LineRangeNotEmptyConstraint;
711
712impl LineRangeConstraint for LineRangeNotEmptyConstraint {
713 fn validate(start: usize, end: usize) -> bool {
714 start < end
715 }
716}
717
718struct LineRangeValidConstraint;
719
720impl LineRangeConstraint for LineRangeValidConstraint {
721 fn validate(start: usize, end: usize) -> bool {
722 start <= end
723 }
724}
725
726#[derive(Debug, Clone, Copy)]
727enum Diff {
728 Added(
729 LineRange<LineRangeEmptyConstraint>,
730 LineRange<LineRangeNotEmptyConstraint>,
731 ),
732 Removed(
733 LineRange<LineRangeNotEmptyConstraint>,
734 LineRange<LineRangeEmptyConstraint>,
735 ),
736 Modified(
737 LineRange<LineRangeNotEmptyConstraint>,
738 LineRange<LineRangeNotEmptyConstraint>,
739 ),
740}
741
742impl Diff {
743 fn from_hunk(hunk: &DiffHunk) -> Self {
744 let start = hunk.new_start();
745 let end = start + hunk.new_lines();
746 let new_range = LineRange::<LineRangeValidConstraint>::new_u32(start, end);
747
748 let start = hunk.old_start();
749 let end = start + hunk.old_lines();
750 let old_range = LineRange::<LineRangeValidConstraint>::new_u32(start, end);
751
752 if hunk.old_lines() == 0 {
753 Self::Added(
754 old_range.map_constraint_assert(),
755 new_range.map_constraint_assert(),
756 )
757 } else if hunk.new_lines() == 0 {
758 Self::Removed(
759 old_range.map_constraint_assert(),
760 new_range.map_constraint_assert(),
761 )
762 } else {
763 Self::Modified(
764 old_range.map_constraint_assert(),
765 new_range.map_constraint_assert(),
766 )
767 }
768 }
769}
770
771/// The git-diff strategy to use for the analysis.
772///
773/// More information in the [module-level documentation](crate::analysis).
774#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
775pub enum GitDiffStrategy {
776 /// Use files only.
777 #[default]
778 FilesOnly,
779 /// Use hunks.
780 Hunks,
781}
782
783impl GitDiffStrategy {
784 fn callbacks<'a>(
785 &self,
786 cx: &'a AnalysisContext,
787 analysis_result: Rc<RefCell<AnalysisResult>>,
788 ) -> (
789 Box<dyn FnMut(DiffDelta, f32) -> bool + 'a>,
790 Box<dyn FnMut(DiffDelta, DiffHunk) -> bool + 'a>,
791 ) {
792 match self {
793 Self::FilesOnly => {
794 let file_cb = {
795 let analysis_result = Rc::clone(&analysis_result);
796
797 let test_touched_files = test_touched_files(cx, false);
798
799 move |delta: DiffDelta, _progress: f32| {
800 let Some(path) =
801 delta.new_file().path().or_else(|| delta.old_file().path())
802 else {
803 return true;
804 };
805
806 let test_modified = test_touched_files.iter().any(|it| it.ends_with(path));
807
808 if test_modified {
809 *analysis_result.borrow_mut() = AnalysisResult::Dirty;
810 return false;
811 }
812
813 true
814 }
815 };
816
817 let hunk_cb = { |_delta: DiffDelta, _hunk: DiffHunk| true };
818
819 (Box::new(file_cb), Box::new(hunk_cb))
820 }
821 Self::Hunks => {
822 let file_cb = { |_delta: DiffDelta, _progress: f32| true };
823
824 let hunk_cb = {
825 let analysis_result = Rc::clone(&analysis_result);
826
827 move |delta: DiffDelta, hunk: DiffHunk| {
828 let diff = Diff::from_hunk(&hunk);
829
830 let intersection_target = match diff {
831 Diff::Added(old, _new) => {
832 old.map_constraint_assert::<LineRangeValidConstraint>()
833 }
834 Diff::Removed(old, _new) => {
835 old.map_constraint_assert::<LineRangeValidConstraint>()
836 }
837 Diff::Modified(old, _new) => {
838 old.map_constraint_assert::<LineRangeValidConstraint>()
839 }
840 };
841
842 let Some(path) =
843 delta.old_file().path().or_else(|| delta.new_file().path())
844 else {
845 return true;
846 };
847
848 for region in
849 cx.regions()
850 .filter(|r| r.execution_count > 0)
851 .filter(|region| {
852 path.ends_with(region.file_ref)
853 || region.file_ref.ends_with(path)
854 })
855 {
856 let region_range = LineRange::<LineRangeValidConstraint>::new(
857 region.l1,
858 region.l2 + 1, // l2 is inclusive
859 );
860 if region_range.intersects(&intersection_target) {
861 *analysis_result.borrow_mut() = AnalysisResult::Dirty;
862 return false;
863 }
864 }
865
866 true
867 }
868 };
869
870 (Box::new(file_cb), Box::new(hunk_cb))
871 }
872 }
873 }
874}
875
876/// Performs a git diff analysis on the diff between the given tree
877/// and the working tree.
878///
879/// The analysis is performed using the given strategy.
880pub fn git_diff_analysis_from_tree(
881 cx: &AnalysisContext,
882 strategy: GitDiffStrategy,
883 repo: &git2::Repository,
884 tree: &git2::Tree,
885) -> DifftestsResult<AnalysisResult> {
886 let mut diff_options = git2::DiffOptions::new();
887
888 diff_options.context_lines(0);
889
890 let diff = repo.diff_tree_to_workdir(Some(&tree), Some(&mut diff_options))?;
891
892 let analysis_result = Rc::new(RefCell::new(AnalysisResult::Clean));
893
894 let (mut file_cb, mut hunk_cb) = strategy.callbacks(cx, Rc::clone(&analysis_result));
895
896 let git_r = diff.foreach(&mut *file_cb, None, Some(&mut *hunk_cb), None);
897
898 if let Err(e) = git_r {
899 if e.code() == git2::ErrorCode::User {
900 debug_assert_eq!(*analysis_result.borrow(), AnalysisResult::Dirty);
901 } else {
902 return Err(DifftestsError::Git(e));
903 }
904 }
905
906 let r = *analysis_result.borrow();
907
908 Ok(r)
909}
910
911/// Performs a git diff analysis on the diff between the current HEAD
912/// and the working tree.
913pub fn git_diff_analysis(
914 cx: &AnalysisContext,
915 strategy: GitDiffStrategy,
916) -> DifftestsResult<AnalysisResult> {
917 let repo = git2::Repository::open_from_env()?;
918 let head = repo.head()?.peel_to_tree()?;
919
920 git_diff_analysis_from_tree(cx, strategy, &repo, &head)
921}
922
923/// Performs a git diff analysis on the diff between tree of the
924/// given commit and the working tree.
925pub fn git_diff_analysis_from_commit(
926 cx: &AnalysisContext,
927 strategy: GitDiffStrategy,
928 commit: git2::Oid,
929) -> DifftestsResult<AnalysisResult> {
930 let repo = git2::Repository::open_from_env()?;
931 let tree = repo.find_commit(commit)?.tree()?;
932
933 git_diff_analysis_from_tree(cx, strategy, &repo, &tree)
934}