cargo-gamma-lib 0.1.0

Internal library for cargo-gamma
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

//! Which test functions the workspace's sources declare right now.
//!
//! A kill is a claim about the suite, not about the code: the mutant's identity hashes the file, the
//! item path, the mutator and the replacement, and none of those change when somebody deletes the
//! test that did the killing. Without a check the next run hashes the mutant identically, carries
//! the kill, and reports coverage that no longer exists — silently, permanently, and in the one
//! direction a tool like this must never err in.
//!
//! The names are read out of the sources rather than out of a harness. Asking a harness would mean
//! building the suite, and the decision has to be made before anything is instrumented — which is
//! the whole point of not running these mutants. Parsing is what discovery pays for anyway.
//!
//! That makes the index approximate in exactly one direction. A test generated by a macro this
//! cannot see does not appear here, so the mutant it killed is re-run. Re-running costs time and
//! reaches the same verdict; carrying a kill that is no longer true costs the credibility of every
//! score the tool prints, so the approximation is spent that way deliberately.

use camino::{Utf8Path, Utf8PathBuf};
use syn::{Attribute, Item, ItemMod};

use crate::discover::record::digest;
use crate::parse::SourceFile;
use crate::{HashMap, HashSet};

/// The test functions the current sources declare.
#[derive(Debug, Default)]
pub struct Killers {
    /// Whether every requested file was read and parsed.
    ///
    /// This is deliberately separate from `paths`: a successful scan that finds zero tests proves
    /// that recorded tests are gone, while an incomplete scan proves nothing about them.
    complete: bool,

    /// Full item paths, such as `tests::rejects_empty`.
    paths: HashSet<String>,

    /// Bare function names, such as `rejects_empty`.
    ///
    /// Harnesses disagree about how much of the path they print, and nextest prefixes the binary,
    /// so a name that came back from one of them cannot be matched against a path alone. Deleting
    /// a test removes it from here too, which is the case this whole module exists for.
    leaves: HashSet<String>,

    /// Mapping from full path to the declaring file.
    paths_to_file: HashMap<String, Utf8PathBuf>,

    /// Content digest for each scanned file.
    file_digests: HashMap<Utf8PathBuf, String>,
}

impl Killers {
    /// Reads every `.rs` file under the directories given and indexes the tests they declare.
    ///
    /// Unparseable and unreadable files are skipped rather than reported. A file that cannot be
    /// parsed contributes no names, so every kill attributed to it is re-run — the safe direction —
    /// and discovery proper will report the same file with a diagnostic worth reading.
    #[cfg(test)]
    #[must_use]
    pub fn scan(files: &[Utf8PathBuf]) -> Self {
        Self::scan_complete(files, true)
    }

    /// Reads files whose enclosing walk completed or failed.
    ///
    /// `Survey` owns the directory walk, so only it can say whether the list is exhaustive. A
    /// failed walk makes every recorded kill unconfirmable even when the files it did return parse
    /// successfully.
    #[must_use]
    pub(super) fn scan_complete(files: &[Utf8PathBuf], complete: bool) -> Self {
        let mut found = Self {
            complete,
            ..Self::default()
        };

        for file in files {
            let Ok(bytes) = std::fs::read(file.as_std_path()) else {
                found.complete = false;
                continue;
            };

            let file_digest = digest(&bytes);
            let _ = found.file_digests.insert(file.clone(), file_digest);

            let Ok(text) = core::str::from_utf8(&bytes) else {
                found.complete = false;
                continue;
            };

            let Ok(source) = SourceFile::parse(file.as_str(), text.to_owned()) else {
                found.complete = false;
                continue;
            };

            found.absorb(&source.ast().items, "", file);
        }

        found
    }

    /// Whether this index completely scanned the files it was asked to scan.
    #[cfg(test)]
    #[must_use]
    pub const fn is_complete(&self) -> bool {
        self.complete
    }

    /// Finds the file where the killing test is declared.
    #[cfg(test)]
    #[must_use]
    pub fn file_for(&self, killer: &str) -> Option<&Utf8Path> {
        let path = killer.rsplit('$').next().unwrap_or(killer).trim();

        if path.is_empty() {
            return None;
        }

        if let Some(file) = self.paths_to_file.get(path) {
            return Some(file.as_path());
        }

        let leaf = path.rsplit("::").next().unwrap_or(path);
        let mut files = self
            .paths
            .iter()
            .filter(|candidate| candidate.rsplit("::").next() == Some(leaf))
            .filter_map(|candidate| self.paths_to_file.get(candidate));
        let file = files.next()?;

        files.next().is_none().then_some(file.as_path())
    }

    /// Returns the digest of a scanned test file.
    #[must_use]
    pub fn file_digest(&self, file: &Utf8Path) -> Option<&str> {
        self.file_digests.get(file).map(String::as_str)
    }

    /// Whether a name an earlier report recorded as the killer still names a test.
    ///
    /// Nextest spells a test as `package::binary$module::name` and libtest as `module::name`, so
    /// the binary is trimmed before the path is compared, and a bare name is accepted on its own.
    /// Accepting the bare name means two tests with the same leaf in different modules are not told
    /// apart — but the case this guards against is a test that no longer exists anywhere, and a
    /// name nothing declares fails both comparisons.
    #[cfg(test)]
    #[must_use]
    pub fn still_there(&self, killer: &str) -> bool {
        let path = killer.rsplit('$').next().unwrap_or(killer).trim();

        if path.is_empty() {
            return false;
        }

        if self.paths.contains(path) {
            return true;
        }

        self.leaves.contains(path.rsplit("::").next().unwrap_or(path))
    }

    /// Finds an unambiguous declaring file for a verdict that is about to be carried.
    ///
    /// The reported name has to match a parsed item path exactly. A root-level test's full item
    /// path is itself bare, so this accepts it without treating a nested test's leaf as an
    /// identity. The permissive [`Self::still_there`] remains for probes, which are rerun before
    /// they can affect a verdict.
    #[must_use]
    pub fn verdict_file_for(&self, killer: &str) -> Option<&Utf8Path> {
        if !self.complete {
            return None;
        }

        let path = killer.rsplit('$').next().unwrap_or(killer).trim();

        if path.is_empty() {
            return None;
        }

        self.paths_to_file.get(path).map(Utf8PathBuf::as_path)
    }

    /// Walks one module's items, recording the tests and descending into inline modules.
    fn absorb(&mut self, items: &[Item], prefix: &str, file: &Utf8Path) {
        for item in items {
            match item {
                Item::Fn(function) if is_test(&function.attrs) => {
                    let name = function.sig.ident.to_string();
                    let full = if prefix.is_empty() {
                        name.clone()
                    } else {
                        format!("{prefix}::{name}")
                    };

                    let _added = self.paths.insert(full.clone());
                    let _ = self.paths_to_file.insert(full, file.to_path_buf());
                    let _also = self.leaves.insert(name.clone());
                }

                Item::Mod(module) => self.descend(module, prefix, file),

                _other => {}
            }
        }
    }

    /// Descends into an inline module, extending the path a test would be named by.
    ///
    /// A `mod x;` with no body is a file of its own, and that file is walked in its own right by
    /// the caller. It contributes nothing here — which loses the module prefix for the tests in it,
    /// and costs nothing, because they are still indexed under their own names.
    fn descend(&mut self, module: &ItemMod, prefix: &str, file: &Utf8Path) {
        let Some((_brace, items)) = module.content.as_ref() else {
            return;
        };

        let name = module.ident.to_string();
        let inner = if prefix.is_empty() { name } else { format!("{prefix}::{name}") };

        self.absorb(items, &inner, file);
    }
}

/// Whether a function's attributes mark it as a test.
///
/// The last segment is what is compared, so `#[test]`, `#[tokio::test]` and `#[test_log::test]` all
/// count. An attribute macro that expands *into* a test — `#[rstest]`, `#[test_case]` — does not,
/// and the kills those tests made are re-run rather than carried.
fn is_test(attrs: &[Attribute]) -> bool {
    attrs
        .iter()
        .filter_map(|attr| attr.path().segments.last())
        .any(|segment| segment.ident == "test")
}

#[cfg(test)]
#[cfg(not(miri))]
mod tests {
    use super::*;

    fn indexed(text: &str) -> Killers {
        let directory = crate::testing::workdir("killers-");
        let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("utf8");
        let file = root.join("lib.rs");

        std::fs::write(file.as_std_path(), text).expect("fixture");

        Killers::scan(&[file])
    }

    /// The shape the check is for: a test that was there is deleted, and its kill must not survive.
    #[test]
    fn a_test_that_no_longer_exists_is_not_found() {
        let killers = indexed("#[cfg(test)]\nmod tests {\n    #[test]\n    fn rejects_empty() {}\n}\n");

        assert!(killers.still_there("tests::rejects_empty"));
        assert!(!killers.still_there("tests::rejects_whitespace"));
    }

    /// Harnesses disagree about how much of the name they print, and neither spelling may be
    /// refused: a kill declined because the tool could not parse its own report is a rerun nobody
    /// needed.
    #[test]
    fn every_spelling_a_harness_uses_finds_the_same_test() {
        let killers = indexed("#[cfg(test)]\nmod tests {\n    #[tokio::test]\n    async fn reads_it() {}\n}\n");

        assert!(killers.still_there("tests::reads_it"), "libtest");
        assert!(killers.still_there("my-crate::lib$tests::reads_it"), "nextest");
        assert!(killers.still_there("reads_it"), "a bare name");
        assert!(!killers.still_there(""), "and nothing is not a name");
    }

    /// Nested modules are walked, so a deep test is found by its whole path.
    ///
    /// A path nobody declares still matches when its leaf does, which is the leniency
    /// [`Killers::still_there`] documents: what this check is for is a test that exists nowhere,
    /// and moving one between modules must not cost a rerun of everything it killed.
    #[test]
    fn a_test_nested_in_modules_is_indexed_under_its_whole_path() {
        let killers = indexed("mod outer {\n    mod inner {\n        #[test]\n        fn deep() {}\n    }\n}\n");

        assert!(killers.still_there("outer::inner::deep"));
        assert!(killers.still_there("somewhere::else::deep"), "the leaf is what a moved test keeps");
        assert!(
            !killers.still_there("outer::inner::shallow"),
            "a name nothing declares is not a match"
        );
    }

    /// A function that is not a test never becomes one, or every helper beside a test would keep
    /// a deleted test's kill alive.
    #[test]
    fn a_plain_function_is_not_a_test() {
        let killers = indexed("#[cfg(test)]\nmod tests {\n    fn helper() {}\n\n    #[test]\n    fn real() {}\n}\n");

        assert!(killers.still_there("tests::real"));
        assert!(!killers.still_there("tests::helper"));
    }

    /// A completed zero-test scan proves that a recorded test is gone.
    #[test]
    fn scan_completeness_is_separate_from_test_count() {
        assert!(!Killers::default().is_complete());
        assert!(indexed("pub fn f() {}\n").is_complete());
        assert!(indexed("#[test]\nfn t() {}\n").is_complete());
    }

    #[test]
    fn a_qualified_name_never_falls_back_to_its_leaf_for_a_verdict() {
        let killers = indexed("mod left { #[test] fn same() {} }\nmod right { #[test] fn same() {} }\n");

        assert!(killers.still_there("missing::same"), "hints may still use a leaf");
        assert_eq!(killers.verdict_file_for("missing::same"), None);
        assert_eq!(killers.verdict_file_for("same"), None, "duplicate bare names are ambiguous");
        assert!(killers.verdict_file_for("left::same").is_some());
    }

    #[test]
    fn a_nested_test_leaf_is_not_an_identity_for_a_verdict() {
        let killers = indexed("mod nested { #[test] fn only() {} }\n");

        assert!(killers.still_there("only"), "a probe may still try the unique leaf");
        assert_eq!(killers.verdict_file_for("only"), None);
        assert!(killers.verdict_file_for("nested::only").is_some());
    }
}