deflake-rs 0.1.0

cargo-deflake is a command that detects flaky tests based on what tests fail and what code has changed
use std::{collections::HashMap, time::Instant};

use syn::visit::Visit;

use crate::{
    ast::{AddedVisitor, ModificationVisitor, NestedItem, SpanHelpers, AST},
    cli::{Cli, Resolution},
    cov::Cov,
    git::{Edit, EditType, FileChange, Git},
    test_interface::{TestCase, TestId, TestResult, Track, TrackMeta, TrackReason},
};

pub struct Deflake;

impl Deflake {
    pub fn run(args: &Cli, failed: Vec<(&TestId, &(&TestCase, TestResult))>) -> Vec<TestId> {
        // Load the git repo
        let git = Git::new(&args.git_dir).unwrap();

        // Get all changes from a commit to the current one
        let changes = git.changes(&args.commit, args);

        // Collect lines to track for each file
        let mut file_tracks: HashMap<String, Vec<Track>> = HashMap::new();

        let now = Instant::now();
        for (file_change, edits) in changes {
            match file_change {
                FileChange::Addition(name, file) => {
                    // Parse added file, and process AST
                    let file = AST::parse_file(git.get_file(file).as_str());
                    let mut v = AddedVisitor::new();
                    v.visit_file(&file);

                    // Track each function added. Line level isn't necessary as whole file added
                    file_tracks.insert(name, Deflake::addition_tracking(&v));
                }
                FileChange::Modification(name, old_file, new_file) => {
                    let old_file = AST::parse_file(git.get_file(old_file).as_str());
                    let new_file = AST::parse_file(git.get_file(new_file).as_str());

                    let mut oldv = ModificationVisitor::new(false);
                    oldv.visit_file(&old_file);

                    let mut newv = ModificationVisitor::new(true);
                    newv.visit_file(&new_file);

                    let track =
                        Deflake::modification_tracking(&oldv, &newv, &edits, &args.resolution);

                    file_tracks.insert(name, track);
                }
            }
        }
        let elapsed = now.elapsed();
        //println!("Time getting AST's: {:.2?}", elapsed);

        // Get the absolute location of the git repo in use
        let mut code_dir = std::env::current_dir().unwrap();
        code_dir.push(&args.git_dir);
        let file_prefix = code_dir
            .canonicalize()
            .unwrap()
            .to_string_lossy()
            .to_string()
            + "/";

        let mut flaky = vec![];

        for (id, (test_case, test_result)) in failed {
            //println!("Test: {}", id);
            // Load the coverage data for the test
            Cov::generate_profdata(test_case);
            let cov = Cov::parse_cov(test_case);
            let file_regions = Cov::get_regions(cov, &file_prefix);

            // Find if any area with modifications was executed
            let mut any_hit = false;
            for (file, track) in (&file_tracks).into_iter() {
                //println!("File: {}", file);
                // Get coverage for edited file
                let regions = file_regions.get(file);
                if let Some(regions) = regions {
                    // See if coverage has hits at the specified locations
                    let hit = Cov::has_hits(regions, track);
                    if hit {
                        any_hit = true;
                    }
                    break;
                } else {
                    println!("WARN: no coverage found for file {}", file);
                }
            }

            // If no modifications executed, then test is likely to be flaky
            if !any_hit {
                flaky.push(id.to_string());
            }
        }

        flaky
    }

    fn modification_tracking(
        old: &ModificationVisitor,
        new: &ModificationVisitor,
        edits: &Vec<Edit>,
        resolution: &Resolution,
    ) -> Vec<Track> {
        let mut track = vec![];

        let additions = new.difference(old);
        let removals = old.difference(new);

        let functions_added = NestedItem::functions(&additions);
        let functions_removed = NestedItem::functions(&removals);

        for func in &functions_added {
            let l = Track::Line(
                func.ident_line,
                Some(TrackMeta {
                    func: func.clone(),
                    reason: TrackReason::FuncAdded,
                    edit: None, //Todo: would be nice?
                }),
            );
            track.push(l);
        }

        for edit in edits {
            if match &edit.edit_type {
                EditType::Add => &functions_added,
                EditType::Delete => &functions_removed,
            }
            .into_iter()
            .any(|func| func.span.has_line(edit.line as usize))
            {
                dbg!(
                    "Line covered by function tracking, ignoring. Line {}",
                    edit.line
                );
                continue;
            }

            // Find if edit related to a function
            // WARN: very inefficient.
            let edit_in_function = (&new.functions)
                .into_iter()
                .find(|func| func.span.has_line(edit.line as usize));

            if let Some(func) = edit_in_function {
                match resolution {
                    Resolution::Line => {
                        match edit.edit_type {
                            EditType::Add => {
                                let edit_line = &(edit.line as usize);
                                if func.trackable_lines.contains(edit_line) {
                                    // If edit to attributes of function, have to track its signature
                                    // instead
                                    let track_line = match func.attr_lines.contains(edit_line) {
                                        true => func.ident_line,
                                        false => *edit_line,
                                    };

                                    track.push(Track::Line(
                                        track_line,
                                        Some(TrackMeta {
                                            func: func.clone(),
                                            reason: TrackReason::LineAdded,
                                            edit: Some(edit.clone()),
                                        }),
                                    ));
                                } else {
                                    println!("Line {} is in function, but can't be tracked as no statements cover it", edit.line);
                                }
                            }
                            EditType::Delete => {
                                // NOTE: for now this uses function level coverage
                                // TODO: attempt line level tracking
                                // - find the closest statement above the deletion and track that
                                println!("Deletion, inserting track at {}", func.ident_line);
                                track.push(Track::Line(
                                    func.ident_line,
                                    Some(TrackMeta {
                                        func: func.clone(),
                                        reason: TrackReason::LineDeleted,
                                        edit: Some(edit.clone()),
                                    }),
                                ));
                            }
                        }
                    }
                    Resolution::Function => {
                        track.push(Track::Line(
                            func.ident_line,
                            Some(TrackMeta {
                                func: func.clone(),
                                reason: TrackReason::FuncModified,
                                edit: Some(edit.clone()),
                            }),
                        ));
                    }
                }
            } else {
                println!(
                    "Edit wasn't in a function, so can't be tracked. Line: {}",
                    edit.line
                );
            }
        }

        track
    }

    fn addition_tracking(v: &AddedVisitor) -> Vec<Track> {
        let mut track = vec![];

        for func in &v.functions {
            let line = func.ident_line;
            track.push(Track::Line(line, None));
        }

        track
    }
}

#[cfg(test)]
mod test {
    use std::fs;

    use super::*;
    #[test]
    fn file_added() {
        // The lines we expect to be tracked
        let lines = [3, 9, 29, 39, 47, 52, 62, 68, 74];

        let f = fs::read_to_string("diffs/new_file").unwrap();
        let file = AST::parse_file(&f);
        let mut v = AddedVisitor::new();
        v.visit_file(&file);

        // Track each function added. Line level isn't necessary as whole file added
        let track = Deflake::addition_tracking(&v);
        dbg!(&track);

        for l in lines {
            assert_eq!(true, (&track).into_iter().any(|t| t.covers(l)));
        }
    }
}