dk-runner 0.2.71

dkod verification runner — CI/CD pipeline execution
Documentation
use anyhow::{Context, Result};
use serde::Deserialize;
use crate::findings::{Finding, Severity, Suggestion};
use super::provider::{ReviewResponse, ReviewVerdict};

#[derive(Deserialize)]
struct LlmResponse {
    summary: String,
    #[serde(default)]
    issues: Vec<LlmIssue>,
    verdict: String,
}

#[derive(Deserialize)]
struct LlmIssue {
    severity: String,
    check_name: String,
    message: String,
    file_path: Option<String>,
    line: Option<u32>,
    suggestion: Option<String>,
}

pub fn parse_review_response(raw: &str) -> Result<ReviewResponse> {
    let json_str = extract_json(raw);
    let parsed: LlmResponse = serde_json::from_str(json_str)
        .context("Failed to parse LLM review response as JSON")?;

    let mut findings = Vec::new();
    let mut suggestions = Vec::new();

    for (i, issue) in parsed.issues.iter().enumerate() {
        let severity = match issue.severity.as_str() {
            "error" => Severity::Error,
            "warning" => Severity::Warning,
            _ => Severity::Info,
        };
        findings.push(Finding {
            severity,
            check_name: issue.check_name.clone(),
            message: issue.message.clone(),
            file_path: issue.file_path.clone(),
            line: issue.line,
            symbol: None,
        });
        if let Some(text) = &issue.suggestion {
            suggestions.push(Suggestion {
                finding_index: i,
                description: text.clone(),
                file_path: issue.file_path.clone().unwrap_or_default(),
                replacement: None,
            });
        }
    }

    let verdict = match parsed.verdict.as_str() {
        "approve" => ReviewVerdict::Approve,
        "request_changes" => ReviewVerdict::RequestChanges,
        _ => ReviewVerdict::Comment,
    };

    Ok(ReviewResponse {
        summary: parsed.summary,
        findings,
        suggestions,
        verdict,
    })
}

fn extract_json(raw: &str) -> &str {
    if let Some(start) = raw.find("```json") {
        let after = &raw[start + 7..];
        if let Some(end) = after.find("```") {
            return after[..end].trim();
        }
    }
    if let Some(start) = raw.find("```") {
        let after = &raw[start + 3..];
        if let Some(end) = after.find("```") {
            return after[..end].trim();
        }
    }
    if let Some(start) = raw.find('{') {
        if let Some(end) = raw.rfind('}') {
            return &raw[start..=end];
        }
    }
    raw.trim()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_clean_json() {
        let raw = r#"{"summary":"LGTM","issues":[],"verdict":"approve"}"#;
        let resp = parse_review_response(raw).unwrap();
        assert_eq!(resp.summary, "LGTM");
        assert!(resp.findings.is_empty());
        assert!(matches!(resp.verdict, ReviewVerdict::Approve));
    }

    #[test]
    fn test_parse_json_in_code_block() {
        let raw = "```json\n{\"summary\":\"ok\",\"issues\":[],\"verdict\":\"approve\"}\n```";
        let resp = parse_review_response(raw).unwrap();
        assert_eq!(resp.summary, "ok");
    }

    #[test]
    fn test_parse_with_issues() {
        let raw = r#"{"summary":"Issues found","issues":[{"severity":"error","check_name":"null-check","message":"Missing null check","file_path":"src/lib.rs","line":42,"suggestion":"Add a null check"}],"verdict":"request_changes"}"#;
        let resp = parse_review_response(raw).unwrap();
        assert_eq!(resp.findings.len(), 1);
        assert_eq!(resp.suggestions.len(), 1);
        assert!(matches!(resp.verdict, ReviewVerdict::RequestChanges));
    }
}