repopilot 0.11.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::baseline::key::stable_finding_key;
use crate::baseline::model::Baseline;
use crate::findings::types::Finding;
use crate::risk::apply_baseline_overlay;
use crate::scan::types::ScanSummary;
use serde::Serialize;
use std::collections::HashSet;
use std::path::PathBuf;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum BaselineStatus {
    New,
    Existing,
}

impl BaselineStatus {
    pub fn lowercase_label(self) -> &'static str {
        match self {
            Self::New => "new",
            Self::Existing => "existing",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FindingBaselineStatus {
    pub key: String,
    pub status: BaselineStatus,
}

#[derive(Debug, PartialEq, Eq)]
pub struct BaselineScanReport {
    pub summary: ScanSummary,
    pub baseline_path: Option<PathBuf>,
    pub findings: Vec<FindingBaselineStatus>,
}

impl BaselineScanReport {
    pub fn new_count(&self) -> usize {
        self.findings
            .iter()
            .filter(|finding| finding.status == BaselineStatus::New)
            .count()
    }

    pub fn existing_count(&self) -> usize {
        self.findings
            .iter()
            .filter(|finding| finding.status == BaselineStatus::Existing)
            .count()
    }

    pub fn finding_status(&self, index: usize) -> BaselineStatus {
        self.findings
            .get(index)
            .map(|finding| finding.status)
            .unwrap_or(BaselineStatus::New)
    }

    pub fn findings_with_status(&self, status: BaselineStatus) -> Vec<&Finding> {
        self.summary
            .findings
            .iter()
            .enumerate()
            .filter_map(|(index, finding)| {
                (self.finding_status(index) == status).then_some(finding)
            })
            .collect()
    }
}

pub fn diff_summary_against_baseline(
    mut summary: ScanSummary,
    baseline: &Baseline,
    baseline_path: PathBuf,
) -> BaselineScanReport {
    let baseline_keys = baseline
        .findings
        .iter()
        .map(|finding| finding.key.clone())
        .collect::<HashSet<_>>();

    let mut findings = status_findings(&summary, &baseline_keys);
    apply_baseline_overlay(
        &mut summary.findings,
        &findings,
        summary.root_path.as_path(),
    );
    sort_findings_with_status(&mut summary.findings, &mut findings);

    BaselineScanReport {
        summary,
        baseline_path: Some(baseline_path),
        findings,
    }
}

pub fn all_findings_new(mut summary: ScanSummary) -> BaselineScanReport {
    let mut findings: Vec<FindingBaselineStatus> = summary
        .findings
        .iter()
        .map(|finding| FindingBaselineStatus {
            key: stable_finding_key(finding, &summary.root_path),
            status: BaselineStatus::New,
        })
        .collect();
    apply_baseline_overlay(
        &mut summary.findings,
        &findings,
        summary.root_path.as_path(),
    );
    sort_findings_with_status(&mut summary.findings, &mut findings);

    BaselineScanReport {
        summary,
        baseline_path: None,
        findings,
    }
}

fn status_findings(
    summary: &ScanSummary,
    baseline_keys: &HashSet<String>,
) -> Vec<FindingBaselineStatus> {
    let root = summary.root_path.as_path();

    summary
        .findings
        .iter()
        .map(|finding| {
            let key = stable_finding_key(finding, root);
            let status = if baseline_keys.contains(&key) {
                BaselineStatus::Existing
            } else {
                BaselineStatus::New
            };

            FindingBaselineStatus { key, status }
        })
        .collect()
}

fn sort_findings_with_status(
    findings: &mut Vec<Finding>,
    statuses: &mut Vec<FindingBaselineStatus>,
) {
    let mut paired = findings
        .drain(..)
        .zip(statuses.drain(..))
        .collect::<Vec<_>>();
    paired.sort_by(|(left, _), (right, _)| crate::risk::compare_findings(left, right));
    for (finding, status) in paired {
        findings.push(finding);
        statuses.push(status);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::baseline::model::{
        BASELINE_SCHEMA_VERSION, BASELINE_TOOL, Baseline, BaselineFinding,
    };
    use crate::findings::types::{Evidence, Finding, Severity};
    use crate::scan::types::ScanSummary;
    use std::path::PathBuf;

    fn make_finding(rule_id: &str, path: &str, line: usize) -> Finding {
        Finding {
            id: format!("{rule_id}-test"),
            rule_id: rule_id.to_string(),
            title: "Test".to_string(),
            severity: Severity::High,
            evidence: vec![Evidence {
                path: PathBuf::from(path),
                line_start: line,
                line_end: None,
                snippet: String::new(),
            }],
            ..Default::default()
        }
    }

    fn make_summary(findings: Vec<Finding>) -> ScanSummary {
        ScanSummary {
            root_path: PathBuf::from("/project"),
            findings,
            ..Default::default()
        }
    }

    fn baseline_with_keys(keys: Vec<String>) -> Baseline {
        Baseline {
            schema_version: BASELINE_SCHEMA_VERSION,
            tool: BASELINE_TOOL.to_string(),
            created_at: "2024-01-01T00:00:00Z".to_string(),
            root: "/project".to_string(),
            findings: keys
                .into_iter()
                .map(|key| BaselineFinding {
                    key: key.clone(),
                    rule_id: "test.rule".to_string(),
                    severity: "HIGH".to_string(),
                    path: "src/main.rs".to_string(),
                    message: "Test".to_string(),
                })
                .collect(),
        }
    }

    #[test]
    fn new_finding_not_in_baseline_is_marked_new() {
        let finding = make_finding("test.rule", "src/main.rs", 10);
        let summary = make_summary(vec![finding]);
        let baseline = baseline_with_keys(vec![]);
        let baseline_path = PathBuf::from(".repopilot/baseline.json");

        let report = diff_summary_against_baseline(summary, &baseline, baseline_path);

        assert_eq!(report.findings[0].status, BaselineStatus::New);
        assert_eq!(report.new_count(), 1);
        assert_eq!(report.existing_count(), 0);
    }

    #[test]
    fn finding_in_baseline_is_marked_existing() {
        let finding = make_finding("test.rule", "src/main.rs", 10);
        let root = PathBuf::from("/project");
        let key = stable_finding_key(&finding, &root);
        let summary = make_summary(vec![finding]);
        let baseline = baseline_with_keys(vec![key]);
        let baseline_path = PathBuf::from(".repopilot/baseline.json");

        let report = diff_summary_against_baseline(summary, &baseline, baseline_path);

        assert_eq!(report.findings[0].status, BaselineStatus::Existing);
        assert_eq!(report.new_count(), 0);
        assert_eq!(report.existing_count(), 1);
    }

    #[test]
    fn all_findings_new_marks_every_finding_as_new() {
        let summary = make_summary(vec![
            make_finding("rule.one", "src/a.rs", 1),
            make_finding("rule.two", "src/b.rs", 2),
        ]);

        let report = all_findings_new(summary);

        assert_eq!(report.new_count(), 2);
        assert_eq!(report.existing_count(), 0);
        assert!(report.baseline_path.is_none());
    }

    #[test]
    fn mixed_new_and_existing_findings() {
        let new_finding = make_finding("rule.new", "src/new.rs", 5);
        let existing_finding = make_finding("rule.existing", "src/existing.rs", 10);
        let root = PathBuf::from("/project");
        let existing_key = stable_finding_key(&existing_finding, &root);

        let summary = make_summary(vec![new_finding, existing_finding]);
        let baseline = baseline_with_keys(vec![existing_key]);

        let report = diff_summary_against_baseline(
            summary,
            &baseline,
            PathBuf::from(".repopilot/baseline.json"),
        );

        assert_eq!(report.new_count(), 1);
        assert_eq!(report.existing_count(), 1);
    }
}