ripr 0.8.0

Find static mutation-exposure gaps before expensive mutation testing
Documentation
use super::model::*;
use super::{display_path, read_json_value_with_display, resolve_root_path, string_field};
use crate::output::gap_decision_ledger::{self, GapRecord};
use serde_json::Value;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};

pub(super) fn read_labels_impl(
    input: &GateEvaluateInput,
    warnings: &mut Vec<String>,
) -> Vec<String> {
    let mut labels = input
        .labels
        .iter()
        .filter(|label| !label.trim().is_empty())
        .cloned()
        .collect::<BTreeSet<_>>();
    if let Some(path) = &input.labels_json {
        let resolved = resolve_root_path(&input.root, path);
        match read_json_value_with_display(&resolved, path) {
            Ok(value) => {
                for label in labels_from_value(&value) {
                    labels.insert(label);
                }
            }
            Err(error) => warnings.push(format!(
                "optional labels_json {} is unavailable: {error}",
                display_path(path)
            )),
        }
    }
    labels.into_iter().collect()
}

pub(super) fn warn_for_optional_json_impl(
    root: &Path,
    path: Option<&PathBuf>,
    name: &str,
    warnings: &mut Vec<String>,
) {
    let Some(path) = path else {
        return;
    };
    if let Err(error) = read_json_value_with_display(&resolve_root_path(root, path), path) {
        warnings.push(format!(
            "optional {name} {} is unavailable: {error}",
            display_path(path)
        ));
    }
}

pub(super) fn read_gap_ledger_impl(
    input: &GateEvaluateInput,
    config_errors: &mut Vec<String>,
) -> Option<Vec<GapRecord>> {
    let path = input.gap_ledger.as_ref()?;
    let resolved = resolve_root_path(&input.root, path);
    let text = match fs::read_to_string(&resolved) {
        Ok(text) => text,
        Err(error) => {
            config_errors.push(format!(
                "required gap decision ledger input {} is invalid: read failed: {error}",
                display_path(path)
            ));
            return Some(Vec::new());
        }
    };
    match gap_decision_ledger::parse_gap_records_json(&text) {
        Ok(records) => Some(records),
        Err(error) => {
            config_errors.push(format!(
                "required gap decision ledger input {} is invalid: {error}",
                display_path(path)
            ));
            Some(Vec::new())
        }
    }
}

pub(super) fn read_recommendation_calibration_impl(
    input: &GateEvaluateInput,
    warnings: &mut Vec<String>,
) -> CalibrationIndex {
    let mut index = CalibrationIndex::default();
    let Some(path) = &input.recommendation_calibration else {
        return index;
    };
    let resolved = resolve_root_path(&input.root, path);
    let value = match read_json_value_with_display(&resolved, path) {
        Ok(v) => v,
        Err(error) => {
            warnings.push(format!(
                "optional recommendation_calibration {} is unavailable: {error}",
                display_path(path)
            ));
            return index;
        }
    };
    for item in value
        .get("recommendations")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
    {
        let evidence = CalibrationEvidence {
            available: true,
            outcome: string_field(item.pointer("/calibration/outcome")),
            confidence_effect: recommendation_confidence_effect(
                item.pointer("/calibration/outcome").and_then(Value::as_str),
            )
            .to_string(),
        };
        if let Some(id) = item.get("id").and_then(Value::as_str) {
            index.by_source_id.insert(id.to_string(), evidence.clone());
        }
        if let Some(seam_id) = item.get("seam_id").and_then(Value::as_str) {
            index.by_seam_id.insert(seam_id.to_string(), evidence);
        }
    }
    index
}

pub(super) fn read_mutation_calibration_impl(
    input: &GateEvaluateInput,
    warnings: &mut Vec<String>,
) -> CalibrationIndex {
    let mut index = CalibrationIndex::default();
    let Some(path) = &input.mutation_calibration else {
        return index;
    };
    let resolved = resolve_root_path(&input.root, path);
    let value = match read_json_value_with_display(&resolved, path) {
        Ok(v) => v,
        Err(error) => {
            warnings.push(format!(
                "optional mutation_calibration {} is unavailable: {error}",
                display_path(path)
            ));
            return index;
        }
    };
    for item in value
        .get("matches")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
    {
        let seam_id = item
            .pointer("/static/seam_id")
            .and_then(Value::as_str)
            .or_else(|| item.pointer("/runtime/seam_id").and_then(Value::as_str));
        let Some(seam_id) = seam_id else {
            continue;
        };
        let outcome = item
            .pointer("/runtime/runtime_outcome")
            .and_then(Value::as_str)
            .or_else(|| item.pointer("/runtime/outcome").and_then(Value::as_str));
        index.by_seam_id.insert(
            seam_id.to_string(),
            CalibrationEvidence {
                available: true,
                outcome: outcome.map(ToOwned::to_owned),
                confidence_effect: mutation_confidence_effect(outcome).to_string(),
            },
        );
    }
    for item in value
        .get("static_only_findings")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
    {
        if let Some(seam_id) = item.pointer("/static/seam_id").and_then(Value::as_str) {
            index.by_seam_id.insert(
                seam_id.to_string(),
                CalibrationEvidence {
                    available: true,
                    outcome: Some("static_gap_without_runtime_signal".to_string()),
                    confidence_effect: "keeps_advisory".to_string(),
                },
            );
        }
    }
    if !value
        .get("ambiguous_file_line_matches")
        .and_then(Value::as_array)
        .map(|items| items.is_empty())
        .unwrap_or(true)
    {
        warnings.push(format!("mutation_calibration {} contains ambiguous file/line matches; those records do not raise gate confidence", display_path(path)));
    }
    index
}

pub(super) fn read_baseline_impl(
    input: &GateEvaluateInput,
    warnings: &mut Vec<String>,
    config_errors: &mut Vec<String>,
) -> BaselineIndex {
    if input.mode.requires_baseline() && input.baseline.is_none() {
        config_errors.push(format!(
            "{} mode requires an explicit --baseline artifact",
            input.mode.as_str()
        ));
        return BaselineIndex::default();
    }
    let Some(path) = &input.baseline else {
        return BaselineIndex::default();
    };
    let resolved = resolve_root_path(&input.root, path);
    match read_json_value_with_display(&resolved, path) {
        Ok(value) => baseline_index_from_value(&value),
        Err(error) if input.mode.requires_baseline() => {
            config_errors.push(format!(
                "required baseline {} is invalid: {error}",
                display_path(path)
            ));
            BaselineIndex::default()
        }
        Err(error) => {
            warnings.push(format!(
                "optional baseline {} is unavailable: {error}",
                display_path(path)
            ));
            BaselineIndex::default()
        }
    }
}

fn labels_from_value(value: &Value) -> Vec<String> {
    value
        .as_array()
        .map(|values| {
            values
                .iter()
                .filter_map(Value::as_str)
                .map(ToOwned::to_owned)
                .collect()
        })
        .unwrap_or_else(|| {
            value
                .get("labels")
                .and_then(Value::as_array)
                .map(|values| {
                    values
                        .iter()
                        .filter_map(Value::as_str)
                        .map(ToOwned::to_owned)
                        .collect()
                })
                .unwrap_or_default()
        })
}
fn recommendation_confidence_effect(outcome: Option<&str>) -> &'static str {
    match outcome {
        Some("useful" | "summary_only_correct" | "suppressed_correctly") => "supports_static_gap",
        Some("noisy" | "wrong_line" | "wrong_target" | "already_covered") => "keeps_advisory",
        Some(_) => "unknown",
        None => "not_used",
    }
}
fn mutation_confidence_effect(outcome: Option<&str>) -> &'static str {
    let Some(outcome) = outcome else {
        return "not_used";
    };
    if is_runtime_gap_outcome(outcome) {
        "supports_static_gap"
    } else if matches!(
        outcome,
        "caught" | "timeout" | "static_gap_without_runtime_signal"
    ) {
        "keeps_advisory"
    } else {
        "unknown"
    }
}
fn is_runtime_gap_outcome(outcome: &str) -> bool {
    outcome == "missed"
        || outcome == "not_caught"
        || outcome == "uncaught"
        || outcome == format!("{}{}", "sur", "vived")
}
pub(super) fn baseline_index_from_value(value: &Value) -> BaselineIndex {
    let mut index = BaselineIndex::default();
    for item in value
        .get("entries")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
    {
        collect_identity(&mut index.identities, item.get("canonical_gap_id"));
        collect_identity(
            &mut index.identities,
            item.pointer("/identity/canonical_gap_id"),
        );
        collect_identity(&mut index.identities, item.pointer("/identity/seam_id"));
        collect_identity(&mut index.identities, item.pointer("/identity/source_id"));
        collect_identity(&mut index.identities, item.pointer("/identity/id"));
        collect_identity(&mut index.identities, item.pointer("/identity/dedupe_key"));
        collect_identity(&mut index.identities, item.pointer("/identity/fallback"));
        collect_identity(
            &mut index.identities,
            item.pointer("/evidence_record/canonical_gap_id"),
        );
    }
    for item in value
        .get("decisions")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
    {
        collect_identity(&mut index.identities, item.get("canonical_gap_id"));
        collect_identity(
            &mut index.identities,
            item.pointer("/identity/canonical_gap_id"),
        );
        collect_identity(
            &mut index.identities,
            item.pointer("/evidence_record/canonical_gap_id"),
        );
        collect_identity(&mut index.identities, item.get("seam_id"));
        collect_identity(&mut index.identities, item.get("source_id"));
    }
    for collection in ["comments", "summary_only", "suppressed"] {
        for item in value
            .get(collection)
            .and_then(Value::as_array)
            .into_iter()
            .flatten()
        {
            collect_identity(&mut index.identities, item.get("canonical_gap_id"));
            collect_identity(
                &mut index.identities,
                item.pointer("/identity/canonical_gap_id"),
            );
            collect_identity(
                &mut index.identities,
                item.pointer("/evidence_record/canonical_gap_id"),
            );
            collect_identity(&mut index.identities, item.get("seam_id"));
            collect_identity(&mut index.identities, item.get("id"));
            collect_identity(&mut index.identities, item.get("dedupe_key"));
        }
    }
    index
}
fn collect_identity(identities: &mut BTreeSet<String>, value: Option<&Value>) {
    if let Some(text) = value.and_then(Value::as_str).filter(|t| !t.is_empty()) {
        identities.insert(text.to_string());
    }
}