ripr 0.9.0

Find static mutation-exposure gaps before expensive mutation testing
Documentation
use serde::Serialize;

use crate::app::CheckOutput;

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct DiffChangedFile {
    pub(crate) path: String,
    pub(crate) added_lines: Vec<usize>,
    pub(crate) removed_lines: Vec<usize>,
    pub(crate) added_count: usize,
    pub(crate) removed_count: usize,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct DiffChangedSeam {
    pub(crate) seam_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) canonical_gap_id: Option<String>,
    pub(crate) file: String,
    pub(crate) line: usize,
    pub(crate) classification: String,
    pub(crate) evidence: Vec<String>,
    pub(crate) missing_discriminators: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) recommended_next_step: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct DiffPhaseStatus {
    pub(crate) state: String,
    pub(crate) phase: String,
    pub(crate) changed_files: usize,
    pub(crate) changed_seams: usize,
    pub(crate) downstream_consumable: bool,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct FullRepoContextStatus {
    pub(crate) state: String,
    pub(crate) phase: String,
    pub(crate) limitation_category: String,
    pub(crate) downstream_consumable: bool,
    pub(crate) repair_route: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct DiffRuntimeStatus {
    pub(crate) state: String,
    pub(crate) diff: DiffPhaseStatus,
    pub(crate) full_repo_context: FullRepoContextStatus,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct DiffReceiptStatus {
    pub(crate) state: String,
    pub(crate) path: String,
    pub(crate) outcome_hint: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct DiffSummary {
    pub(crate) changed_files: usize,
    pub(crate) changed_seams: usize,
    pub(crate) probes: usize,
    pub(crate) exposed: usize,
    pub(crate) weakly_exposed: usize,
    pub(crate) reachable_unrevealed: usize,
    pub(crate) no_static_path: usize,
    pub(crate) unknown: usize,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct DiffReport {
    pub(crate) schema_version: String,
    pub(crate) kind: String,
    pub(crate) tool: String,
    pub(crate) run_status: String,
    pub(crate) root: String,
    pub(crate) base: String,
    pub(crate) head: String,
    pub(crate) mode: String,
    pub(crate) runtime_status: DiffRuntimeStatus,
    pub(crate) receipt: DiffReceiptStatus,
    pub(crate) summary: DiffSummary,
    pub(crate) changed_files: Vec<DiffChangedFile>,
    pub(crate) changed_seams: Vec<DiffChangedSeam>,
}

pub(crate) fn build_diff_report(
    output: &CheckOutput,
    base: &str,
    head: &str,
    changed_files: Vec<DiffChangedFile>,
    receipt_path: String,
) -> DiffReport {
    let changed_seams = output
        .findings
        .iter()
        .map(|finding| DiffChangedSeam {
            seam_id: finding.id.clone(),
            canonical_gap_id: finding
                .canonical_gap
                .as_ref()
                .map(|canonical| canonical.id.clone()),
            file: finding.probe.location.file.display().to_string(),
            line: finding.probe.location.line,
            classification: finding.class.as_str().to_string(),
            evidence: finding.evidence.clone(),
            missing_discriminators: finding.missing.clone(),
            recommended_next_step: finding.recommended_next_step.clone(),
        })
        .collect::<Vec<_>>();
    let changed_file_count = changed_files.len();
    let changed_seam_count = changed_seams.len();
    let unknown = output.summary.static_unknown
        + output.summary.infection_unknown
        + output.summary.propagation_unknown;

    DiffReport {
        schema_version: "0.1".to_string(),
        kind: "ripr_diff".to_string(),
        tool: output.tool.clone(),
        run_status: "diff_complete_full_repo_limited".to_string(),
        root: output.root.display().to_string(),
        base: base.to_string(),
        head: head.to_string(),
        mode: output.mode.as_str().to_string(),
        runtime_status: DiffRuntimeStatus {
            state: "diff_complete_full_repo_limited".to_string(),
            diff: DiffPhaseStatus {
                state: "diff_complete".to_string(),
                phase: "changed_surface_diff".to_string(),
                changed_files: changed_file_count,
                changed_seams: changed_seam_count,
                downstream_consumable: true,
            },
            full_repo_context: FullRepoContextStatus {
                state: "full_repo_limited".to_string(),
                phase: "full_repo_context".to_string(),
                limitation_category: "full_repo_context_not_run".to_string(),
                downstream_consumable: false,
                repair_route: "ripr check --format repo-exposure-summary-json".to_string(),
            },
        },
        receipt: DiffReceiptStatus {
            state: "not_written".to_string(),
            path: receipt_path,
            outcome_hint: "diff_complete/full_repo_limited".to_string(),
        },
        summary: DiffSummary {
            changed_files: changed_file_count,
            changed_seams: changed_seam_count,
            probes: output.summary.probes,
            exposed: output.summary.exposed,
            weakly_exposed: output.summary.weakly_exposed,
            reachable_unrevealed: output.summary.reachable_unrevealed,
            no_static_path: output.summary.no_static_path,
            unknown,
        },
        changed_files,
        changed_seams,
    }
}

pub(crate) fn render_diff_report_json(report: &DiffReport) -> Result<String, String> {
    super::json::render_pretty_with_newline(report, "ripr diff report")
}

pub(crate) fn render_diff_report_human(report: &DiffReport) -> String {
    let mut out = String::new();
    out.push_str("RIPR diff status: ");
    out.push_str(&report.run_status);
    out.push('\n');
    out.push_str(&format!("root: {}\n", report.root));
    out.push_str(&format!("range: {}...{}\n", report.base, report.head));
    out.push_str(&format!("mode: {}\n", report.mode));
    out.push_str(&format!(
        "changed files: {}\nchanged seams: {}\n",
        report.summary.changed_files, report.summary.changed_seams
    ));
    out.push_str(&format!(
        "full repo context: {} ({})\n",
        report.runtime_status.full_repo_context.state,
        report.runtime_status.full_repo_context.limitation_category
    ));
    out.push_str(&format!("receipt path: {}\n\n", report.receipt.path));

    out.push_str("Changed files\n");
    if report.changed_files.is_empty() {
        out.push_str("  none\n");
    } else {
        for file in &report.changed_files {
            out.push_str(&format!(
                "  - {} (+{} -{})\n",
                file.path, file.added_count, file.removed_count
            ));
        }
    }

    out.push_str("\nChanged seams\n");
    if report.changed_seams.is_empty() {
        out.push_str("  none\n");
    } else {
        for seam in &report.changed_seams {
            out.push_str(&format!(
                "  - {} {}:{} {}\n",
                seam.seam_id, seam.file, seam.line, seam.classification
            ));
            if let Some(canonical_gap_id) = &seam.canonical_gap_id {
                out.push_str(&format!("    canonical_gap_id: {canonical_gap_id}\n"));
            }
            if let Some(next_step) = &seam.recommended_next_step {
                out.push_str(&format!("    next action: {next_step}\n"));
            }
            if let Some(evidence) = seam.evidence.first() {
                out.push_str(&format!("    evidence: {evidence}\n"));
            }
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::{CheckOutput, Mode};
    use crate::domain::{
        ActivationEvidence, Confidence, DeltaKind, ExposureClass, Probe, ProbeFamily, ProbeId,
        RevealEvidence, RiprEvidence, SourceLocation, StageEvidence, StageState, Summary, SymbolId,
    };
    use std::path::PathBuf;

    #[test]
    fn diff_report_preserves_diff_complete_full_repo_limited_status() -> Result<(), String> {
        let report = build_diff_report(
            &CheckOutput {
                schema_version: "0.1".to_string(),
                tool: "ripr".to_string(),
                mode: Mode::Draft,
                root: PathBuf::from("repo"),
                base: None,
                summary: Summary {
                    probes: 1,
                    weakly_exposed: 1,
                    ..Summary::default()
                },
                findings: vec![sample_finding()],
            },
            "origin/main",
            "HEAD",
            vec![DiffChangedFile {
                path: "src/lib.rs".to_string(),
                added_lines: vec![2],
                removed_lines: vec![2],
                added_count: 1,
                removed_count: 1,
            }],
            "target/ripr/receipts/diff-first-origin-main-HEAD.json".to_string(),
        );

        let json = render_diff_report_json(&report)?;
        assert!(json.contains(r#""run_status": "diff_complete_full_repo_limited""#));
        assert!(json.contains(r#""state": "diff_complete""#));
        assert!(json.contains(r#""state": "full_repo_limited""#));
        assert!(json.contains(r#""downstream_consumable": true"#));
        assert!(json.contains(r#""outcome_hint": "diff_complete/full_repo_limited""#));

        let human = render_diff_report_human(&report);
        assert!(human.contains("RIPR diff status: diff_complete_full_repo_limited"));
        assert!(human.contains("full repo context: full_repo_limited"));
        assert!(human.contains("receipt path: target/ripr/receipts/"));
        Ok(())
    }

    fn sample_finding() -> crate::domain::Finding {
        crate::domain::Finding {
            id: "probe:src_lib_rs:2:predicate".to_string(),
            canonical_gap: Some(crate::domain::FindingCanonicalGap {
                id: "gap:src-lib-rs:predicate".to_string(),
                language: "rust".to_string(),
                file: "src/lib.rs".to_string(),
                owner: "over_threshold".to_string(),
                behavior_kind: "predicate_boundary".to_string(),
                probe_kind: "predicate".to_string(),
                normalized_discriminator: "amount == threshold".to_string(),
            }),
            probe: Probe {
                id: ProbeId("probe:src_lib_rs:2:predicate".to_string()),
                location: SourceLocation::new("src/lib.rs", 2, 5),
                owner: Some(SymbolId("src::over_threshold".to_string())),
                family: ProbeFamily::Predicate,
                delta: DeltaKind::Control,
                before: Some("amount >= threshold".to_string()),
                after: Some("amount > threshold".to_string()),
                expression: "amount >= threshold".to_string(),
                expected_sinks: vec!["return_value".to_string()],
                required_oracles: vec!["equality_boundary".to_string()],
            },
            class: ExposureClass::WeaklyExposed,
            ripr: RiprEvidence {
                reach: StageEvidence::new(
                    StageState::Yes,
                    Confidence::Medium,
                    "related test reaches owner",
                ),
                infect: StageEvidence::new(
                    StageState::Weak,
                    Confidence::Medium,
                    "changed value is not exact",
                ),
                propagate: StageEvidence::new(
                    StageState::Yes,
                    Confidence::Medium,
                    "return value is visible",
                ),
                reveal: RevealEvidence {
                    observe: StageEvidence::new(
                        StageState::Weak,
                        Confidence::Medium,
                        "broad assertion observes value",
                    ),
                    discriminate: StageEvidence::new(
                        StageState::No,
                        Confidence::Medium,
                        "missing equality boundary",
                    ),
                },
            },
            confidence: 0.70,
            evidence: vec!["related test reaches owner".to_string()],
            missing: vec!["missing discriminator amount == threshold".to_string()],
            flow_sinks: Vec::new(),
            activation: ActivationEvidence::default(),
            stop_reasons: Vec::new(),
            related_tests: Vec::new(),
            recommended_next_step: Some("Add equality-boundary assertion.".to_string()),
            language: None,
            language_status: None,
            owner_kind: None,
            static_limit_kind: None,
        }
    }
}