repopilot 0.9.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 std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};

mod model;
pub use model::*;

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";

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 {
    let properties = findings
        .iter()
        .map(|f| SarifResultProperties {
            baseline_status: None,
            baseline_key: None,
            workspace_package: f.workspace_package.clone(),
            category: f.category.label().to_string(),
            confidence: f.confidence.label().to_string(),
            recommendation: f.recommendation_or_default().to_string(),
        })
        .collect();
    findings_to_sarif_with_properties(findings, root, properties)
}

fn findings_to_sarif_with_baseline(report: &BaselineScanReport) -> SarifLog {
    let properties = report
        .summary
        .findings
        .iter()
        .enumerate()
        .map(|(index, finding)| SarifResultProperties {
            baseline_status: Some(report.finding_status(index).lowercase_label().to_string()),
            baseline_key: Some(
                report
                    .findings
                    .get(index)
                    .map(|f| f.key.clone())
                    .unwrap_or_default(),
            ),
            workspace_package: finding.workspace_package.clone(),
            category: finding.category.label().to_string(),
            confidence: finding.confidence.label().to_string(),
            recommendation: finding.recommendation_or_default().to_string(),
        })
        .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, &Finding> = BTreeMap::new();
    for finding in findings {
        rule_map.entry(finding.rule_id.as_str()).or_insert(finding);
    }

    rule_map
        .into_iter()
        .map(|(rule_id, finding)| {
            let meta = crate::rules::lookup_rule_metadata(rule_id);
            let help_uri = meta
                .and_then(|m| m.docs_url)
                .map(str::to_owned)
                .or_else(|| finding.docs_url.clone());
            let help = Some(SarifMessage {
                text: finding.recommendation_or_default().to_string(),
            });
            SarifRule {
                id: rule_id.to_string(),
                name: rule_id.to_string(),
                short_description: SarifMessage {
                    text: finding.title.clone(),
                },
                full_description: Some(SarifMessage {
                    text: finding.description.clone(),
                }),
                help_uri,
                help,
            }
        })
        .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('\\', "/")
}