repopilot 0.11.0

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

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum FailOn {
    New(Severity),
    Any(Severity),
    Priority(RiskPriority),
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CiGateResult {
    pub fail_on: FailOn,
    pub failed_findings: usize,
}

impl CiGateResult {
    pub fn passed(&self) -> bool {
        self.failed_findings == 0
    }

    pub fn label(&self) -> String {
        match self.fail_on {
            FailOn::New(severity) => format!("new-{}", severity.lowercase_label()),
            FailOn::Any(severity) => severity.lowercase_label().to_string(),
            FailOn::Priority(priority) => format!("priority-{}", priority.label().to_lowercase()),
        }
    }

    pub fn failure_message(&self) -> Option<String> {
        if self.passed() {
            return None;
        }

        match self.fail_on {
            FailOn::New(severity) | FailOn::Any(severity) => {
                let scope = match self.fail_on {
                    FailOn::New(_) => "new findings",
                    FailOn::Any(_) => "findings",
                    FailOn::Priority(_) => unreachable!("handled by outer match"),
                };
                Some(format!(
                    "RepoPilot CI Gate failed\n\nReason:\nFound {} {scope} at or above {} severity.\n\nUse `repopilot baseline create . --force` only if these findings are accepted technical debt.",
                    self.failed_findings,
                    severity.lowercase_label()
                ))
            }
            FailOn::Priority(priority) => Some(format!(
                "RepoPilot CI Gate failed\n\nReason:\nFound {} findings at or above {} risk priority.\n\nUse `--min-priority` to inspect the same prioritized scope locally, or lower the gate only if this risk is accepted.",
                self.failed_findings,
                priority.label()
            )),
        }
    }
}

pub fn evaluate_ci_gate(report: &BaselineScanReport, fail_on: FailOn) -> CiGateResult {
    let failed_findings = report
        .summary
        .findings
        .iter()
        .enumerate()
        .filter(|(index, finding)| finding_matches(report, *index, finding, fail_on))
        .count();

    CiGateResult {
        fail_on,
        failed_findings,
    }
}

fn finding_matches(
    report: &BaselineScanReport,
    index: usize,
    finding: &Finding,
    fail_on: FailOn,
) -> bool {
    match fail_on {
        FailOn::New(threshold) => {
            report.finding_status(index) == BaselineStatus::New
                && finding.severity.is_at_least(&threshold)
        }
        FailOn::Any(threshold) => finding.severity.is_at_least(&threshold),
        FailOn::Priority(threshold) => priority_is_at_least(finding.risk.priority, threshold),
    }
}

fn priority_is_at_least(priority: RiskPriority, threshold: RiskPriority) -> bool {
    priority_rank(priority) <= priority_rank(threshold)
}

fn priority_rank(priority: RiskPriority) -> u8 {
    match priority {
        RiskPriority::P0 => 0,
        RiskPriority::P1 => 1,
        RiskPriority::P2 => 2,
        RiskPriority::P3 => 3,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::baseline::diff::{BaselineScanReport, BaselineStatus, FindingBaselineStatus};
    use crate::findings::types::{Evidence, Finding, Severity};
    use crate::risk::RiskPriority;
    use crate::scan::types::ScanSummary;
    use std::path::PathBuf;

    fn make_finding(severity: Severity) -> Finding {
        Finding {
            id: "test-id".to_string(),
            rule_id: "test.rule".to_string(),
            title: "Test finding".to_string(),
            severity,
            evidence: vec![Evidence {
                path: PathBuf::from("src/main.rs"),
                line_start: 1,
                line_end: None,
                snippet: String::new(),
            }],
            ..Default::default()
        }
    }

    fn make_priority_finding(priority: RiskPriority, score: u8) -> Finding {
        let mut finding = make_finding(Severity::Low);
        finding.risk.priority = priority;
        finding.risk.score = score;
        finding
    }

    fn make_report(findings: Vec<Finding>, statuses: Vec<BaselineStatus>) -> BaselineScanReport {
        let finding_statuses = findings
            .iter()
            .zip(statuses)
            .map(|(f, status)| FindingBaselineStatus {
                key: f.rule_id.clone(),
                status,
            })
            .collect();

        BaselineScanReport {
            summary: ScanSummary {
                root_path: PathBuf::from("."),
                findings,
                ..Default::default()
            },
            baseline_path: Some(PathBuf::from(".repopilot/baseline.json")),
            findings: finding_statuses,
        }
    }

    #[test]
    fn gate_passes_when_no_findings_exceed_threshold() {
        let report = make_report(vec![make_finding(Severity::Low)], vec![BaselineStatus::New]);

        let result = evaluate_ci_gate(&report, FailOn::New(Severity::High));

        assert!(result.passed());
    }

    #[test]
    fn gate_fails_on_new_high_finding() {
        let report = make_report(
            vec![make_finding(Severity::High)],
            vec![BaselineStatus::New],
        );

        let result = evaluate_ci_gate(&report, FailOn::New(Severity::High));

        assert!(!result.passed());
        assert_eq!(result.failed_findings, 1);
    }

    #[test]
    fn gate_passes_when_high_finding_is_existing() {
        let report = make_report(
            vec![make_finding(Severity::High)],
            vec![BaselineStatus::Existing],
        );

        let result = evaluate_ci_gate(&report, FailOn::New(Severity::High));

        assert!(result.passed());
    }

    #[test]
    fn gate_any_mode_fails_on_existing_finding_above_threshold() {
        let report = make_report(
            vec![make_finding(Severity::Critical)],
            vec![BaselineStatus::Existing],
        );

        let result = evaluate_ci_gate(&report, FailOn::Any(Severity::High));

        assert!(!result.passed());
        assert_eq!(result.failed_findings, 1);
    }

    #[test]
    fn priority_gate_fails_on_p1_or_higher_priority() {
        let report = make_report(
            vec![make_priority_finding(RiskPriority::P1, 70)],
            vec![BaselineStatus::Existing],
        );

        let result = evaluate_ci_gate(&report, FailOn::Priority(RiskPriority::P1));

        assert!(!result.passed());
        assert_eq!(result.failed_findings, 1);
        assert_eq!(result.label(), "priority-p1");
    }

    #[test]
    fn priority_gate_passes_when_findings_are_below_threshold() {
        let report = make_report(
            vec![make_priority_finding(RiskPriority::P3, 20)],
            vec![BaselineStatus::New],
        );

        let result = evaluate_ci_gate(&report, FailOn::Priority(RiskPriority::P1));

        assert!(result.passed());
    }

    #[test]
    fn gate_passes_with_no_findings() {
        let report = make_report(vec![], vec![]);

        let result = evaluate_ci_gate(&report, FailOn::New(Severity::Low));

        assert!(result.passed());
    }
}