ripr 0.3.0

Static RIPR mutation-exposure analysis for Rust workspaces
Documentation
mod classifier;
mod diff;
mod probes;
mod rust_index;
mod workspace;

use crate::domain::{Finding, Summary};
use std::path::PathBuf;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AnalysisMode {
    Instant,
    Draft,
    Fast,
    Deep,
    Ready,
}

#[derive(Clone, Debug)]
pub struct AnalysisOptions {
    pub root: PathBuf,
    pub base: Option<String>,
    pub diff_file: Option<PathBuf>,
    pub mode: AnalysisMode,
    pub include_unchanged_tests: bool,
}

#[derive(Clone, Debug)]
pub struct AnalysisResult {
    pub summary: Summary,
    pub findings: Vec<Finding>,
}

pub fn run_analysis(options: &AnalysisOptions) -> Result<AnalysisResult, String> {
    let diff_text = diff::load_diff(
        &options.root,
        options.base.as_deref(),
        options.diff_file.as_ref(),
    )?;
    let changed_files = diff::parse_unified_diff(&diff_text);
    let changed_rust_paths = changed_files
        .iter()
        .filter(|file| file.path.extension().and_then(|e| e.to_str()) == Some("rs"))
        .map(|file| file.path.clone())
        .collect::<Vec<_>>();
    let rust_files = workspace::discover_rust_files(&options.root)?;
    let index_files = workspace::select_rust_files_for_mode(
        &rust_files,
        &changed_rust_paths,
        options.mode,
        options.include_unchanged_tests,
    );
    let index = rust_index::build_index(&options.root, &index_files)?;

    let mut findings = Vec::new();
    let mut changed_rust_files = 0usize;

    for changed in changed_files
        .iter()
        .filter(|file| file.path.extension().and_then(|e| e.to_str()) == Some("rs"))
    {
        changed_rust_files += 1;
        let probes = probes::probes_for_file(&options.root, changed, &index);
        for probe in probes {
            findings.push(classifier::classify_probe(&probe, &index));
        }
    }

    findings.sort_by(|a, b| {
        a.probe
            .location
            .file
            .cmp(&b.probe.location.file)
            .then(a.probe.location.line.cmp(&b.probe.location.line))
            .then(a.probe.family.as_str().cmp(b.probe.family.as_str()))
    });

    let mut summary = Summary {
        changed_rust_files,
        probes: findings.len(),
        findings: findings.len(),
        ..Summary::default()
    };
    for finding in &findings {
        match finding.class {
            crate::domain::ExposureClass::Exposed => summary.exposed += 1,
            crate::domain::ExposureClass::WeaklyExposed => summary.weakly_exposed += 1,
            crate::domain::ExposureClass::ReachableUnrevealed => summary.reachable_unrevealed += 1,
            crate::domain::ExposureClass::NoStaticPath => summary.no_static_path += 1,
            crate::domain::ExposureClass::InfectionUnknown => summary.infection_unknown += 1,
            crate::domain::ExposureClass::PropagationUnknown => summary.propagation_unknown += 1,
            crate::domain::ExposureClass::StaticUnknown => summary.static_unknown += 1,
        }
    }

    Ok(AnalysisResult { summary, findings })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_dir(name: &str) -> PathBuf {
        let stamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let dir = std::env::temp_dir().join(format!("ripr-{name}-{stamp}"));
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn analyzes_simple_predicate_gap() {
        let root = temp_dir("simple");
        fs::create_dir_all(root.join("src")).unwrap();
        fs::create_dir_all(root.join("tests")).unwrap();
        fs::write(
            root.join("Cargo.toml"),
            "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
        )
        .unwrap();
        fs::write(
            root.join("src/lib.rs"),
            r#"
pub fn price(amount: i32, threshold: i32) -> i32 {
    if amount >= threshold { amount - 10 } else { amount }
}
"#,
        )
        .unwrap();
        fs::write(
            root.join("tests/pricing.rs"),
            r#"
#[test]
fn premium_customer_gets_discount() {
    let total = x::price(10000, 100);
    assert!(total > 0);
}
"#,
        )
        .unwrap();
        fs::write(
            root.join("diff.patch"),
            r#"diff --git a/src/lib.rs b/src/lib.rs
index 0000000..1111111 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,3 @@
 pub fn price(amount: i32, threshold: i32) -> i32 {
+    if amount >= threshold { amount - 10 } else { amount }
 }
"#,
        )
        .unwrap();
        let out = run_analysis(&AnalysisOptions {
            root: root.clone(),
            base: None,
            diff_file: Some(root.join("diff.patch")),
            mode: AnalysisMode::Draft,
            include_unchanged_tests: true,
        })
        .unwrap();
        assert!(!out.findings.is_empty());
        assert!(
            out.findings
                .iter()
                .any(|f| f.class == crate::domain::ExposureClass::WeaklyExposed
                    || f.class == crate::domain::ExposureClass::InfectionUnknown)
        );

        let instant = run_analysis(&AnalysisOptions {
            root: root.clone(),
            base: None,
            diff_file: Some(root.join("diff.patch")),
            mode: AnalysisMode::Instant,
            include_unchanged_tests: true,
        })
        .unwrap();
        assert!(instant.findings.iter().any(|finding| {
            finding.class == crate::domain::ExposureClass::NoStaticPath
                && finding.related_tests.is_empty()
        }));
    }
}