repopilot 0.5.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::baseline::diff::BaselineScanReport;
use crate::findings::types::{Finding, Severity};
use crate::scan::types::ScanSummary;
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};

const SARIF_VERSION: &str = "2.1.0";
const SARIF_SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json";
const TOOL_NAME: &str = "RepoPilot";
const TOOL_INFORMATION_URI: &str = "https://github.com/MykytaStel/repopilot";

#[derive(Debug, Clone, Serialize)]
pub struct SarifLog {
    pub version: String,
    #[serde(rename = "$schema")]
    pub schema: String,
    pub runs: Vec<SarifRun>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SarifRun {
    pub tool: SarifTool,
    pub results: Vec<SarifResult>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SarifTool {
    pub driver: SarifDriver,
}

#[derive(Debug, Clone, Serialize)]
pub struct SarifDriver {
    pub name: String,
    pub version: String,
    #[serde(rename = "informationUri")]
    pub information_uri: String,
    pub rules: Vec<SarifRule>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SarifRule {
    pub id: String,
    pub name: String,
    #[serde(rename = "shortDescription")]
    pub short_description: SarifMessage,
    #[serde(rename = "fullDescription", skip_serializing_if = "Option::is_none")]
    pub full_description: Option<SarifMessage>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SarifResult {
    #[serde(rename = "ruleId")]
    pub rule_id: String,
    pub level: String,
    pub message: SarifMessage,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub locations: Vec<SarifLocation>,
    #[serde(
        rename = "partialFingerprints",
        skip_serializing_if = "BTreeMap::is_empty"
    )]
    pub partial_fingerprints: BTreeMap<String, String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub properties: Option<SarifResultProperties>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SarifResultProperties {
    #[serde(rename = "baselineStatus")]
    pub baseline_status: String,
    #[serde(rename = "baselineKey")]
    pub baseline_key: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct SarifMessage {
    pub text: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct SarifLocation {
    #[serde(rename = "physicalLocation")]
    pub physical_location: SarifPhysicalLocation,
}

#[derive(Debug, Clone, Serialize)]
pub struct SarifPhysicalLocation {
    #[serde(rename = "artifactLocation")]
    pub artifact_location: SarifArtifactLocation,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub region: Option<SarifRegion>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SarifArtifactLocation {
    pub uri: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct SarifRegion {
    #[serde(rename = "startLine")]
    pub start_line: usize,
}

pub fn scan_summary_to_sarif(summary: &ScanSummary, root: &Path) -> SarifLog {
    findings_to_sarif(&summary.findings, root)
}

pub fn render(summary: &ScanSummary) -> Result<String, serde_json::Error> {
    let sarif = scan_summary_to_sarif(summary, &summary.root_path);
    serde_json::to_string_pretty(&sarif)
}

pub fn render_with_baseline(report: &BaselineScanReport) -> Result<String, serde_json::Error> {
    let sarif = findings_to_sarif_with_baseline(report);
    serde_json::to_string_pretty(&sarif)
}

pub fn findings_to_sarif(findings: &[Finding], root: &Path) -> SarifLog {
    findings_to_sarif_with_properties(findings, root, Vec::new())
}

fn findings_to_sarif_with_baseline(report: &BaselineScanReport) -> SarifLog {
    let properties = report
        .summary
        .findings
        .iter()
        .enumerate()
        .map(|(index, _)| SarifResultProperties {
            baseline_status: report.finding_status(index).lowercase_label().to_string(),
            baseline_key: report
                .findings
                .get(index)
                .map(|finding| finding.key.clone())
                .unwrap_or_default(),
        })
        .collect();

    findings_to_sarif_with_properties(
        &report.summary.findings,
        &report.summary.root_path,
        properties,
    )
}

fn findings_to_sarif_with_properties(
    findings: &[Finding],
    root: &Path,
    properties: Vec<SarifResultProperties>,
) -> SarifLog {
    SarifLog {
        version: SARIF_VERSION.to_string(),
        schema: SARIF_SCHEMA.to_string(),
        runs: vec![SarifRun {
            tool: SarifTool {
                driver: SarifDriver {
                    name: TOOL_NAME.to_string(),
                    version: env!("CARGO_PKG_VERSION").to_string(),
                    information_uri: TOOL_INFORMATION_URI.to_string(),
                    rules: sarif_rules(findings),
                },
            },
            results: findings
                .iter()
                .enumerate()
                .map(|(index, finding)| sarif_result(finding, root, properties.get(index).cloned()))
                .collect(),
        }],
    }
}

fn sarif_result(
    finding: &Finding,
    root: &Path,
    properties: Option<SarifResultProperties>,
) -> SarifResult {
    let locations: Vec<SarifLocation> = finding
        .evidence
        .iter()
        .filter_map(|evidence| {
            let uri = sarif_uri(&evidence.path, root)?;
            Some(SarifLocation {
                physical_location: SarifPhysicalLocation {
                    artifact_location: SarifArtifactLocation { uri },
                    region: (evidence.line_start > 0).then_some(SarifRegion {
                        start_line: evidence.line_start,
                    }),
                },
            })
        })
        .collect();

    let partial_fingerprints = if let Some(first) = finding.evidence.first() {
        let key = format!(
            "{}:{}:{}",
            finding.rule_id,
            first.path.display(),
            first.line_start
        );
        BTreeMap::from([("primaryLocationLineHash/v1".to_string(), key)])
    } else {
        BTreeMap::new()
    };

    SarifResult {
        rule_id: finding.rule_id.clone(),
        level: sarif_level(finding.severity).to_string(),
        message: SarifMessage {
            text: finding_message(finding),
        },
        locations,
        partial_fingerprints,
        properties,
    }
}

fn sarif_rules(findings: &[Finding]) -> Vec<SarifRule> {
    let mut rule_map: BTreeMap<&str, &str> = BTreeMap::new();
    for finding in findings {
        rule_map
            .entry(finding.rule_id.as_str())
            .or_insert(finding.description.as_str());
    }

    rule_map
        .into_iter()
        .map(|(rule_id, description)| SarifRule {
            id: rule_id.to_string(),
            name: rule_id.to_string(),
            short_description: SarifMessage {
                text: description.to_string(),
            },
            full_description: None,
        })
        .collect()
}

fn sarif_level(severity: Severity) -> &'static str {
    match severity {
        Severity::Critical | Severity::High => "error",
        Severity::Medium => "warning",
        Severity::Low | Severity::Info => "note",
    }
}

fn finding_message(finding: &Finding) -> String {
    if finding.title.is_empty() {
        finding.description.clone()
    } else {
        finding.title.clone()
    }
}

fn sarif_uri(path: &Path, root: &Path) -> Option<String> {
    if path.as_os_str().is_empty() {
        return None;
    }

    let relative = root_relative_path(path, root);
    Some(path_to_forward_slash_uri(&relative))
}

fn root_relative_path(path: &Path, root: &Path) -> PathBuf {
    path.strip_prefix(root).unwrap_or(path).to_path_buf()
}

fn path_to_forward_slash_uri(path: &Path) -> String {
    path.components()
        .map(component_to_string)
        .filter(|component| !component.is_empty())
        .collect::<Vec<_>>()
        .join("/")
}

fn component_to_string(component: Component<'_>) -> String {
    component.as_os_str().to_string_lossy().replace('\\', "/")
}