oy-cli 0.8.3

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
use anyhow::Result;
use serde_json::{Value, json};
use std::path::Path;

use super::report;

pub(super) fn render_sarif(report: &str) -> Result<String> {
    let findings = report::extract_findings(&report.lines().collect::<Vec<_>>());
    let mut rules = std::collections::BTreeMap::<String, Value>::new();
    let mut results = Vec::new();

    for finding in findings {
        let location = sarif_location(&finding.code_ref);
        let rule_id = sarif_rule_id(&finding);
        let level = sarif_level(&finding.severity);
        rules.entry(rule_id.clone()).or_insert_with(|| {
            json!({
                "id": rule_id,
                "name": finding.title,
                "shortDescription": { "text": finding.title },
                "defaultConfiguration": { "level": level },
                "properties": {
                    "severity": finding.severity,
                    "security-severity": sarif_security_severity(&finding.severity)
                }
            })
        });
        let mut result = json!({
            "ruleId": rule_id,
            "level": level,
            "message": { "text": format!("{}: {}", finding.severity, finding.title) },
            "properties": {
                "severity": finding.severity,
                "codeRef": finding.code_ref
            }
        });
        if let Some(location) = location {
            result["locations"] = json!([location]);
        }
        results.push(result);
    }

    let sarif = json!({
        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
        "version": "2.1.0",
        "runs": [{
            "tool": {
                "driver": {
                    "name": "oy-cli",
                    "semanticVersion": env!("CARGO_PKG_VERSION"),
                    "informationUri": "https://github.com/wagov-dtt/oy-cli",
                    "rules": rules.into_values().collect::<Vec<_>>()
                }
            },
            "results": results,
            "columnKind": "utf16CodeUnits"
        }]
    });
    let mut out = serde_json::to_string_pretty(&sarif)?;
    out.push('\n');
    Ok(out)
}

fn sarif_rule_id(finding: &report::FindingSummary) -> String {
    let mut slug = String::new();
    for ch in finding.title.chars().flat_map(char::to_lowercase) {
        if ch.is_ascii_alphanumeric() {
            slug.push(ch);
        } else if !slug.ends_with('-') {
            slug.push('-');
        }
    }
    let slug = slug.trim_matches('-');
    let slug = if slug.is_empty() { "finding" } else { slug };
    format!("oy/{}/{}", finding.severity.to_ascii_lowercase(), slug)
}

fn sarif_level(severity: &str) -> &'static str {
    match severity.to_ascii_lowercase().as_str() {
        "critical" | "high" => "error",
        "medium" => "warning",
        _ => "note",
    }
}

fn sarif_security_severity(severity: &str) -> &'static str {
    match severity.to_ascii_lowercase().as_str() {
        "critical" => "9.0",
        "high" => "7.0",
        "medium" => "5.0",
        "low" => "2.0",
        _ => "0.0",
    }
}

fn sarif_location(code_ref: &str) -> Option<Value> {
    let (path, line) = split_code_ref(code_ref);
    let path = normalize_safe_relative_path(path)?;
    let mut region = serde_json::Map::new();
    if let Some(line) = line {
        region.insert("startLine".to_string(), json!(line));
    }
    let mut physical = serde_json::Map::new();
    physical.insert(
        "artifactLocation".to_string(),
        json!({ "uri": path, "uriBaseId": "%SRCROOT%" }),
    );
    if !region.is_empty() {
        physical.insert("region".to_string(), Value::Object(region));
    }
    Some(json!({ "physicalLocation": Value::Object(physical) }))
}

fn split_code_ref(code_ref: &str) -> (&str, Option<u32>) {
    if let Some((path, tail)) = code_ref.rsplit_once(':')
        && !tail.contains(':')
        && let Ok(line) = tail.parse::<u32>()
    {
        return (path, Some(line));
    }
    (
        code_ref
            .split_once("::")
            .map(|(path, _)| path)
            .unwrap_or(code_ref),
        None,
    )
}

fn normalize_safe_relative_path(path: &str) -> Option<String> {
    let path = Path::new(path);
    if path.is_absolute() {
        return None;
    }
    let mut out = Vec::new();
    for component in path.components() {
        match component {
            std::path::Component::Normal(value) => out.push(value.to_string_lossy()),
            std::path::Component::CurDir => {}
            _ => return None,
        }
    }
    if out.is_empty() {
        return None;
    }
    Some(out.join("/"))
}