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(&registry)
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}