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::baseline::gate::CiGateResult;
use crate::findings::types::Finding;
use crate::risk::RiskSummary;
use crate::scan::types::LanguageSummary;
use crate::scan::types::ScanSummary;
use serde::Serialize;

pub const SCAN_REPORT_SCHEMA_VERSION: &str = "0.10";
pub const REPOPILOT_VERSION: &str = env!("CARGO_PKG_VERSION");

pub fn render(summary: &ScanSummary) -> Result<String, serde_json::Error> {
    let output = JsonScanReport {
        schema_version: SCAN_REPORT_SCHEMA_VERSION,
        repopilot_version: REPOPILOT_VERSION,
        risk_summary: RiskSummary::from_findings(&summary.findings),
        summary,
    };

    serde_json::to_string_pretty(&output)
}

#[derive(Serialize)]
struct JsonScanReport<'a> {
    schema_version: &'static str,
    repopilot_version: &'static str,
    risk_summary: RiskSummary,
    #[serde(flatten)]
    summary: &'a ScanSummary,
}

pub fn render_with_baseline(
    report: &BaselineScanReport,
    ci_gate: Option<&CiGateResult>,
) -> Result<String, serde_json::Error> {
    let findings = report
        .summary
        .findings
        .iter()
        .enumerate()
        .map(|(index, finding)| FindingWithBaselineStatus {
            finding,
            baseline_status: report.finding_status(index),
        })
        .collect::<Vec<_>>();

    let output = BaselineJsonReport {
        schema_version: SCAN_REPORT_SCHEMA_VERSION,
        repopilot_version: REPOPILOT_VERSION,
        root_path: report.summary.root_path.to_string_lossy().to_string(),
        files_count: report.summary.files_count,
        directories_count: report.summary.directories_count,
        lines_of_code: report.summary.lines_of_code,
        skipped_files_count: report.summary.skipped_files_count,
        skipped_bytes: report.summary.skipped_bytes,
        languages: &report.summary.languages,
        risk_summary: RiskSummary::from_findings(&report.summary.findings),
        baseline: BaselineJsonMetadata {
            path: report
                .baseline_path
                .as_ref()
                .map(|path| path.to_string_lossy().to_string()),
            new_findings: report.new_count(),
            existing_findings: report.existing_count(),
        },
        ci_gate: ci_gate.map(CiGateJsonMetadata::from),
        findings,
    };

    serde_json::to_string_pretty(&output)
}

#[derive(Serialize)]
struct BaselineJsonReport<'a> {
    schema_version: &'static str,
    repopilot_version: &'static str,
    root_path: String,
    files_count: usize,
    directories_count: usize,
    lines_of_code: usize,
    skipped_files_count: usize,
    skipped_bytes: u64,
    languages: &'a [LanguageSummary],
    risk_summary: RiskSummary,
    baseline: BaselineJsonMetadata,
    #[serde(skip_serializing_if = "Option::is_none")]
    ci_gate: Option<CiGateJsonMetadata>,
    findings: Vec<FindingWithBaselineStatus<'a>>,
}

#[derive(Serialize)]
struct BaselineJsonMetadata {
    path: Option<String>,
    new_findings: usize,
    existing_findings: usize,
}

#[derive(Serialize)]
struct CiGateJsonMetadata {
    fail_on: String,
    status: &'static str,
    failed_findings: usize,
}

impl From<&CiGateResult> for CiGateJsonMetadata {
    fn from(result: &CiGateResult) -> Self {
        Self {
            fail_on: result.label(),
            status: if result.passed() { "passed" } else { "failed" },
            failed_findings: result.failed_findings,
        }
    }
}

#[derive(Serialize)]
struct FindingWithBaselineStatus<'a> {
    #[serde(flatten)]
    finding: &'a Finding,
    baseline_status: BaselineStatus,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::baseline::diff::{BaselineScanReport, FindingBaselineStatus};
    use serde_json::Value;
    use std::path::PathBuf;

    #[test]
    fn json_scan_report_includes_schema_and_tool_versions() {
        let summary = ScanSummary {
            root_path: PathBuf::from("."),
            files_count: 1,
            ..ScanSummary::default()
        };

        let rendered = render(&summary).expect("json render should succeed");
        let value: Value = serde_json::from_str(&rendered).expect("json should parse");

        assert_eq!(value["schema_version"], SCAN_REPORT_SCHEMA_VERSION);
        assert_eq!(value["repopilot_version"], REPOPILOT_VERSION);
        assert_eq!(value["files_count"], 1);
        assert!(value.get("findings").is_some());
    }

    #[test]
    fn baseline_json_report_includes_schema_and_tool_versions() {
        let summary = ScanSummary {
            root_path: PathBuf::from("."),
            files_count: 2,
            ..ScanSummary::default()
        };
        let report = BaselineScanReport {
            summary,
            baseline_path: Some(PathBuf::from(".repopilot/baseline.json")),
            findings: Vec::<FindingBaselineStatus>::new(),
        };

        let rendered =
            render_with_baseline(&report, None).expect("baseline json render should succeed");
        let value: Value = serde_json::from_str(&rendered).expect("json should parse");

        assert_eq!(value["schema_version"], SCAN_REPORT_SCHEMA_VERSION);
        assert_eq!(value["repopilot_version"], REPOPILOT_VERSION);
        assert_eq!(value["files_count"], 2);
        assert!(value.get("baseline").is_some());
    }
}