repopilot 0.15.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::findings::quality::summarize_signal_quality;
use crate::findings::types::{Confidence, Finding, Severity};
use crate::risk::RiskPriority;
use crate::scan::types::ScanSummary;

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FindingFilter {
    pub min_severity: Option<Severity>,
    pub min_confidence: Option<Confidence>,
    pub min_priority: Option<RiskPriority>,
    pub rule_ids: Vec<String>,
}

impl FindingFilter {
    pub fn is_empty(&self) -> bool {
        self.min_severity.is_none()
            && self.min_confidence.is_none()
            && self.min_priority.is_none()
            && self.rule_ids.is_empty()
    }

    pub fn matches(&self, finding: &Finding) -> bool {
        if let Some(min) = self.min_severity
            && !finding.severity.is_at_least(&min)
        {
            return false;
        }

        if let Some(min) = self.min_confidence
            && finding.confidence < min
        {
            return false;
        }

        if let Some(min) = self.min_priority
            && !finding.risk.priority.is_at_least(min)
        {
            return false;
        }

        if !self.rule_ids.is_empty()
            && !self
                .rule_ids
                .iter()
                .any(|rule_id| rule_id == &finding.rule_id)
        {
            return false;
        }

        true
    }

    pub fn apply_to_summary(&self, summary: &mut ScanSummary) {
        if !self.is_empty() {
            summary
                .artifacts
                .findings
                .retain(|finding| self.matches(finding));
        }
        recompute_summary_metrics(summary);
    }
}

pub fn recompute_summary_metrics(summary: &mut ScanSummary) {
    summary.metrics.visible_findings_count = summary.artifacts.findings.len();
    summary.metrics.health_score = ScanSummary::compute_health_score(
        &summary.artifacts.findings,
        summary.metrics.non_empty_lines,
    );
    let visible_signal_quality = summarize_signal_quality(&summary.artifacts.findings);
    if summary.metrics.raw_findings_count == 0
        && summary.metrics.hidden_suggestions_count == 0
        && !summary.artifacts.findings.is_empty()
    {
        summary.metrics.raw_findings_count = summary.artifacts.findings.len();
        summary.artifacts.raw_signal_quality = visible_signal_quality.clone();
    }
    summary.artifacts.visible_signal_quality = visible_signal_quality.clone();
    summary.artifacts.signal_quality = visible_signal_quality;
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::risk::RiskAssessment;
    use crate::scan::types::{ScanArtifacts, ScanMetadata, ScanMetrics};
    use std::path::PathBuf;

    fn finding(
        rule_id: &str,
        severity: Severity,
        confidence: Confidence,
        priority: RiskPriority,
    ) -> Finding {
        Finding {
            rule_id: rule_id.to_string(),
            severity,
            confidence,
            provenance: Default::default(),
            risk: RiskAssessment {
                priority,
                ..RiskAssessment::default()
            },
            ..Finding::default()
        }
    }

    #[test]
    fn severity_threshold_matches_at_or_above_minimum() {
        let filter = FindingFilter {
            min_severity: Some(Severity::High),
            ..FindingFilter::default()
        };

        assert!(filter.matches(&finding(
            "rule.high",
            Severity::High,
            Confidence::Medium,
            RiskPriority::P3
        )));
        assert!(!filter.matches(&finding(
            "rule.medium",
            Severity::Medium,
            Confidence::Medium,
            RiskPriority::P3
        )));
    }

    #[test]
    fn confidence_threshold_matches_at_or_above_minimum() {
        let filter = FindingFilter {
            min_confidence: Some(Confidence::Medium),
            ..FindingFilter::default()
        };

        assert!(filter.matches(&finding(
            "rule.medium",
            Severity::Low,
            Confidence::Medium,
            RiskPriority::P3
        )));
        assert!(!filter.matches(&finding(
            "rule.low",
            Severity::Low,
            Confidence::Low,
            RiskPriority::P3
        )));
    }

    #[test]
    fn priority_threshold_matches_at_or_above_minimum() {
        let filter = FindingFilter {
            min_priority: Some(RiskPriority::P1),
            ..FindingFilter::default()
        };

        assert!(filter.matches(&finding(
            "rule.p1",
            Severity::Low,
            Confidence::Medium,
            RiskPriority::P1
        )));
        assert!(!filter.matches(&finding(
            "rule.p2",
            Severity::Low,
            Confidence::Medium,
            RiskPriority::P2
        )));
    }

    #[test]
    fn repeated_rule_filter_matches_any_rule_id() {
        let filter = FindingFilter {
            rule_ids: vec!["rule.one".to_string(), "rule.two".to_string()],
            ..FindingFilter::default()
        };

        assert!(filter.matches(&finding(
            "rule.two",
            Severity::Low,
            Confidence::Medium,
            RiskPriority::P3
        )));
        assert!(!filter.matches(&finding(
            "rule.three",
            Severity::Low,
            Confidence::Medium,
            RiskPriority::P3
        )));
    }

    #[test]
    fn combined_filter_requires_every_selected_threshold() {
        let filter = FindingFilter {
            min_severity: Some(Severity::Medium),
            min_confidence: Some(Confidence::High),
            min_priority: Some(RiskPriority::P2),
            rule_ids: vec!["rule.keep".to_string()],
        };

        assert!(filter.matches(&finding(
            "rule.keep",
            Severity::High,
            Confidence::High,
            RiskPriority::P2
        )));
        assert!(!filter.matches(&finding(
            "rule.keep",
            Severity::High,
            Confidence::Medium,
            RiskPriority::P2
        )));
        assert!(!filter.matches(&finding(
            "rule.drop",
            Severity::High,
            Confidence::High,
            RiskPriority::P2
        )));
    }

    #[test]
    fn apply_to_summary_recomputes_visible_count_and_health_score() {
        let filter = FindingFilter {
            min_severity: Some(Severity::High),
            ..FindingFilter::default()
        };
        let mut summary = ScanSummary {
            metadata: ScanMetadata {
                root_path: PathBuf::from("."),
                ..Default::default()
            },
            metrics: ScanMetrics {
                non_empty_lines: 1_000,
                ..Default::default()
            },
            artifacts: ScanArtifacts {
                findings: vec![
                    finding(
                        "rule.keep",
                        Severity::High,
                        Confidence::Medium,
                        RiskPriority::P3,
                    ),
                    finding(
                        "rule.drop",
                        Severity::Low,
                        Confidence::Medium,
                        RiskPriority::P3,
                    ),
                ],
                ..Default::default()
            },
        };

        filter.apply_to_summary(&mut summary);

        assert_eq!(summary.artifacts.findings.len(), 1);
        assert_eq!(summary.metrics.visible_findings_count, 1);
        assert_eq!(summary.metrics.health_score, 95);
    }
}