repopilot 0.13.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::findings::types::{Finding, Severity};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FindingContractViolation {
    pub rule_id: String,
    pub finding_id: String,
    pub violation: FindingContractViolationKind,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum FindingContractViolationKind {
    EmptyId,
    EmptyRuleId,
    EmptyTitle,
    EmptyDescription,
    EmptyRecommendation,
    MissingEvidence,
    InvalidEvidencePath,
    InvalidEvidenceLineRange,
    MissingRiskFormulaVersion,
    MissingRiskSignals,
    MissingDocsForHighSeverity,
}

#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FindingContractReport {
    pub violations: Vec<FindingContractViolation>,
    pub findings_checked: usize,
    pub valid_findings: usize,
    pub invalid_findings: usize,
}

pub fn validate_finding_contract(finding: &Finding) -> Vec<FindingContractViolation> {
    let mut violations = Vec::new();

    push_if(
        &mut violations,
        finding,
        finding.id.trim().is_empty(),
        FindingContractViolationKind::EmptyId,
    );
    push_if(
        &mut violations,
        finding,
        finding.rule_id.trim().is_empty(),
        FindingContractViolationKind::EmptyRuleId,
    );
    push_if(
        &mut violations,
        finding,
        finding.title.trim().is_empty(),
        FindingContractViolationKind::EmptyTitle,
    );
    push_if(
        &mut violations,
        finding,
        finding.description.trim().is_empty(),
        FindingContractViolationKind::EmptyDescription,
    );
    push_if(
        &mut violations,
        finding,
        finding.recommendation.trim().is_empty(),
        FindingContractViolationKind::EmptyRecommendation,
    );

    if finding.evidence.is_empty() {
        push_violation(
            &mut violations,
            finding,
            FindingContractViolationKind::MissingEvidence,
        );
    } else {
        for evidence in &finding.evidence {
            if evidence.path.as_os_str().is_empty() {
                push_violation(
                    &mut violations,
                    finding,
                    FindingContractViolationKind::InvalidEvidencePath,
                );
            }
            if evidence.line_start == 0
                || evidence
                    .line_end
                    .is_some_and(|line_end| line_end < evidence.line_start)
            {
                push_violation(
                    &mut violations,
                    finding,
                    FindingContractViolationKind::InvalidEvidenceLineRange,
                );
            }
        }
    }

    push_if(
        &mut violations,
        finding,
        finding.risk.formula_version.trim().is_empty(),
        FindingContractViolationKind::MissingRiskFormulaVersion,
    );
    push_if(
        &mut violations,
        finding,
        finding.risk.signals.is_empty(),
        FindingContractViolationKind::MissingRiskSignals,
    );

    if matches!(finding.severity, Severity::High | Severity::Critical)
        && finding
            .docs_url
            .as_deref()
            .is_none_or(|docs_url| docs_url.trim().is_empty())
    {
        push_violation(
            &mut violations,
            finding,
            FindingContractViolationKind::MissingDocsForHighSeverity,
        );
    }

    violations
}

pub fn validate_findings_contract(findings: &[Finding]) -> FindingContractReport {
    let mut violations = Vec::new();
    let mut invalid_findings = 0;

    for finding in findings {
        let finding_violations = validate_finding_contract(finding);
        if !finding_violations.is_empty() {
            invalid_findings += 1;
        }
        violations.extend(finding_violations);
    }

    FindingContractReport {
        violations,
        findings_checked: findings.len(),
        valid_findings: findings.len().saturating_sub(invalid_findings),
        invalid_findings,
    }
}

fn push_if(
    violations: &mut Vec<FindingContractViolation>,
    finding: &Finding,
    condition: bool,
    kind: FindingContractViolationKind,
) {
    if condition {
        push_violation(violations, finding, kind);
    }
}

fn push_violation(
    violations: &mut Vec<FindingContractViolation>,
    finding: &Finding,
    kind: FindingContractViolationKind,
) {
    violations.push(FindingContractViolation {
        rule_id: finding.rule_id.clone(),
        finding_id: finding.id.clone(),
        violation: kind,
    });
}