ripr 0.8.0

Find static mutation-exposure gaps before expensive mutation testing
Documentation
use crate::agent::provenance;
use crate::analysis;
use crate::app::agent_brief::{
    AgentBriefChangedOwner, AgentBriefLine, AgentBriefResolvedWorkingSet,
};
use crate::config::{CONFIG_FILE_NAME, config_fingerprint};
use crate::output;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

pub(super) fn validate_agent_receipt_verify_path(
    root: &Path,
    path: &Path,
) -> Result<PathBuf, String> {
    let root = root.canonicalize().map_err(|err| {
        format!(
            "canonicalize agent receipt root {} failed: {err}",
            root.display()
        )
    })?;
    let candidate = if path.is_absolute() {
        path.to_path_buf()
    } else {
        root.join(path)
    };
    let candidate = candidate.canonicalize().map_err(|err| {
        format!(
            "canonicalize agent receipt --verify-json {} failed: {err}",
            path.display()
        )
    })?;

    if !candidate.starts_with(&root) {
        return Err(format!(
            "agent receipt --verify-json {} must stay under root {}",
            path.display(),
            root.display()
        ));
    }

    Ok(candidate)
}

pub(super) fn build_agent_receipt_provenance(
    root: &Path,
    verify_display_path: &Path,
    verify_path: &Path,
    input_paths: &output::agent_receipt::AgentReceiptInputPaths,
) -> Result<output::agent_receipt::AgentReceiptProvenance, String> {
    let before_artifact = agent_receipt_artifact_provenance(
        root,
        &input_paths.before,
        "before artifact",
        "before_artifact",
    )?;
    let after_artifact = agent_receipt_artifact_provenance(
        root,
        &input_paths.after,
        "after artifact",
        "after_artifact",
    )?;
    let verify_artifact = output::agent_receipt::AgentReceiptArtifactProvenance {
        path: output::outcome::display_path(verify_display_path),
        sha256: provenance::sha256_file(verify_path)?,
    };

    Ok(output::agent_receipt::AgentReceiptProvenance {
        ripr_version: env!("CARGO_PKG_VERSION").to_string(),
        repo_root: output::outcome::display_path(root),
        config_fingerprint: agent_receipt_config_fingerprint(root)?,
        command_template_version: crate::agent::loop_commands::AGENT_LOOP_COMMAND_TEMPLATE_VERSION
            .to_string(),
        generated_at: agent_receipt_generated_at()?,
        workflow_artifact: None,
        before_artifact,
        after_artifact,
        verify_artifact,
    })
}

fn agent_receipt_artifact_provenance(
    root: &Path,
    display_path: &str,
    role: &str,
    output_name: &str,
) -> Result<output::agent_receipt::AgentReceiptArtifactProvenance, String> {
    let resolved = validate_agent_receipt_artifact_path(root, Path::new(display_path), role)?;
    Ok(output::agent_receipt::AgentReceiptArtifactProvenance {
        path: display_path.replace('\\', "/"),
        sha256: provenance::sha256_file(&resolved).map_err(|err| {
            format!(
                "hash agent receipt {output_name} {} failed: {err}",
                display_path
            )
        })?,
    })
}

fn validate_agent_receipt_artifact_path(
    root: &Path,
    path: &Path,
    role: &str,
) -> Result<PathBuf, String> {
    let root = root.canonicalize().map_err(|err| {
        format!(
            "canonicalize agent receipt root {} failed: {err}",
            root.display()
        )
    })?;
    let candidate = if path.is_absolute() {
        path.to_path_buf()
    } else {
        root.join(path)
    };
    let candidate = candidate.canonicalize().map_err(|err| {
        format!(
            "canonicalize agent receipt {role} {} failed: {err}",
            path.display()
        )
    })?;

    if !candidate.starts_with(&root) {
        return Err(format!(
            "agent receipt {role} {} must stay under root {}",
            path.display(),
            root.display()
        ));
    }

    Ok(candidate)
}

fn agent_receipt_config_fingerprint(root: &Path) -> Result<Option<String>, String> {
    let path = root.join(CONFIG_FILE_NAME);
    match std::fs::read_to_string(&path) {
        Ok(text) => Ok(Some(config_fingerprint(&text))),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(err) => Err(format!("read {} failed: {err}", path.display())),
    }
}

fn agent_receipt_generated_at() -> Result<String, String> {
    let millis = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|err| format!("system clock before unix epoch: {err}"))?
        .as_millis();
    Ok(format!("unix_ms:{millis}"))
}

pub(super) fn validate_agent_verify_snapshot_path(
    root: &Path,
    path: &Path,
    flag: &str,
) -> Result<PathBuf, String> {
    let root = root.canonicalize().map_err(|err| {
        format!(
            "canonicalize agent verify root {} failed: {err}",
            root.display()
        )
    })?;
    let candidate = if path.is_absolute() {
        path.to_path_buf()
    } else {
        root.join(path)
    };
    let candidate = candidate.canonicalize().map_err(|err| {
        format!(
            "canonicalize agent verify {flag} {} failed: {err}",
            path.display()
        )
    })?;

    if !candidate.starts_with(&root) {
        return Err(format!(
            "agent verify {flag} {} must stay under root {}",
            path.display(),
            root.display()
        ));
    }

    Ok(candidate)
}

pub(super) fn read_agent_verify_snapshot(path: &Path, label: &str) -> Result<String, String> {
    std::fs::read_to_string(path).map_err(|err| {
        format!(
            "read agent verify {label} snapshot {} failed: {err}",
            output::outcome::display_path(path)
        )
    })
}

pub(super) fn resolve_agent_brief_working_set(
    root: &Path,
    working_set: &crate::cli::agent::AgentBriefWorkingSet,
) -> Result<AgentBriefResolvedWorkingSet, String> {
    match working_set {
        crate::cli::agent::AgentBriefWorkingSet::Diff(path) => {
            let diff_path = validate_agent_brief_diff_path(root, path)?;
            let diff_text = analysis::load_diff(root, None, Some(&diff_path))?;
            let changed_lines = agent_brief_lines_from_diff(root, &diff_text);
            let changed_owners = agent_brief_owners_for_lines(root, &changed_lines);
            Ok(AgentBriefResolvedWorkingSet::diff(
                path.clone(),
                changed_lines,
            ))
            .map(|working_set| working_set.with_changed_owners(changed_owners))
        }
        crate::cli::agent::AgentBriefWorkingSet::Base(base) => {
            let diff_text = analysis::load_diff(root, Some(base.as_str()), None)?;
            let changed_lines = agent_brief_lines_from_diff(root, &diff_text);
            let changed_owners = agent_brief_owners_for_lines(root, &changed_lines);
            Ok(AgentBriefResolvedWorkingSet::base(
                base.clone(),
                changed_lines,
            ))
            .map(|working_set| working_set.with_changed_owners(changed_owners))
        }
        crate::cli::agent::AgentBriefWorkingSet::Files(files) => {
            Ok(AgentBriefResolvedWorkingSet::files(
                files
                    .iter()
                    .map(|file| normalize_agent_brief_path(root, file))
                    .collect(),
            ))
        }
        crate::cli::agent::AgentBriefWorkingSet::SeamId(seam_id) => {
            Ok(AgentBriefResolvedWorkingSet::seam_id(seam_id.clone()))
        }
    }
}

fn validate_agent_brief_diff_path(root: &Path, path: &Path) -> Result<PathBuf, String> {
    let root = root.canonicalize().map_err(|err| {
        format!(
            "canonicalize agent brief root {} failed: {err}",
            root.display()
        )
    })?;
    let candidate = if path.is_absolute() || path.exists() {
        path.to_path_buf()
    } else {
        root.join(path)
    };
    let candidate = candidate.canonicalize().map_err(|err| {
        format!(
            "canonicalize agent brief diff {} failed: {err}",
            path.display()
        )
    })?;
    if !candidate.starts_with(&root) {
        return Err(format!(
            "agent brief --diff {} must stay under root {}",
            path.display(),
            root.display()
        ));
    }
    Ok(candidate)
}

pub(super) fn agent_brief_lines_from_diff(root: &Path, diff_text: &str) -> Vec<AgentBriefLine> {
    analysis::parse_unified_diff(diff_text)
        .into_iter()
        .flat_map(|file| {
            let path = normalize_agent_brief_path(root, &file.path);
            file.added_lines
                .into_iter()
                .map(move |line| AgentBriefLine::new(path.clone(), line.line))
        })
        .collect()
}

pub(super) fn agent_brief_owners_for_lines(
    root: &Path,
    lines: &[AgentBriefLine],
) -> Vec<AgentBriefChangedOwner> {
    let owner_inputs = lines
        .iter()
        .map(|line| (line.file.clone(), line.line))
        .collect::<Vec<_>>();
    let Ok(owners) = analysis::owner_symbols_for_lines(root, &owner_inputs) else {
        return Vec::new();
    };

    owners
        .into_iter()
        .map(|owner| AgentBriefChangedOwner::new(owner.file, owner.line, owner.owner))
        .collect()
}

pub(super) fn normalize_agent_brief_path(root: &Path, path: &Path) -> PathBuf {
    let path_text = normalized_path_text(path);
    for root_text in normalized_root_prefixes(root) {
        let prefix = format!("{root_text}/");
        if let Some(stripped) = path_text.strip_prefix(&prefix) {
            return PathBuf::from(stripped);
        }
    }
    PathBuf::from(path_text)
}

fn normalized_root_prefixes(root: &Path) -> Vec<String> {
    let mut prefixes = Vec::new();
    push_unique_normalized_path(&mut prefixes, root);
    if let Ok(root) = std::path::absolute(root) {
        push_unique_normalized_path(&mut prefixes, &root);
    }
    if let Ok(root) = root.canonicalize() {
        push_unique_normalized_path(&mut prefixes, &root);
    }
    prefixes
}

fn push_unique_normalized_path(prefixes: &mut Vec<String>, path: &Path) {
    let text = normalized_path_text(path);
    if !text.is_empty() && !prefixes.iter().any(|existing| existing == &text) {
        prefixes.push(text);
    }
}

fn normalized_path_text(path: &Path) -> String {
    let text = path.to_string_lossy().replace('\\', "/");
    text.strip_prefix("./").unwrap_or(&text).to_string()
}