Skip to main content

dk_runner/steps/agent_review/
parse.rs

1use anyhow::{Context, Result};
2use serde::Deserialize;
3use crate::findings::{Finding, Severity, Suggestion};
4use super::provider::{ReviewResponse, ReviewVerdict};
5
6#[derive(Deserialize)]
7struct LlmResponse {
8    summary: String,
9    #[serde(default)]
10    issues: Vec<LlmIssue>,
11    verdict: String,
12}
13
14#[derive(Deserialize)]
15struct LlmIssue {
16    severity: String,
17    check_name: String,
18    message: String,
19    file_path: Option<String>,
20    line: Option<u32>,
21    suggestion: Option<String>,
22}
23
24pub fn parse_review_response(raw: &str) -> Result<ReviewResponse> {
25    let json_str = extract_json(raw);
26    let parsed: LlmResponse = serde_json::from_str(json_str)
27        .context("Failed to parse LLM review response as JSON")?;
28
29    let mut findings = Vec::new();
30    let mut suggestions = Vec::new();
31
32    for (i, issue) in parsed.issues.iter().enumerate() {
33        let severity = match issue.severity.as_str() {
34            "error" => Severity::Error,
35            "warning" => Severity::Warning,
36            _ => Severity::Info,
37        };
38        findings.push(Finding {
39            severity,
40            check_name: issue.check_name.clone(),
41            message: issue.message.clone(),
42            file_path: issue.file_path.clone(),
43            line: issue.line,
44            symbol: None,
45        });
46        if let Some(text) = &issue.suggestion {
47            suggestions.push(Suggestion {
48                finding_index: i,
49                description: text.clone(),
50                file_path: issue.file_path.clone().unwrap_or_default(),
51                replacement: None,
52            });
53        }
54    }
55
56    let verdict = match parsed.verdict.as_str() {
57        "approve" => ReviewVerdict::Approve,
58        "request_changes" => ReviewVerdict::RequestChanges,
59        _ => ReviewVerdict::Comment,
60    };
61
62    Ok(ReviewResponse {
63        summary: parsed.summary,
64        findings,
65        suggestions,
66        verdict,
67    })
68}
69
70fn extract_json(raw: &str) -> &str {
71    if let Some(start) = raw.find("```json") {
72        let after = &raw[start + 7..];
73        if let Some(end) = after.find("```") {
74            return after[..end].trim();
75        }
76    }
77    if let Some(start) = raw.find("```") {
78        let after = &raw[start + 3..];
79        if let Some(end) = after.find("```") {
80            return after[..end].trim();
81        }
82    }
83    if let Some(start) = raw.find('{') {
84        if let Some(end) = raw.rfind('}') {
85            return &raw[start..=end];
86        }
87    }
88    raw.trim()
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn test_parse_clean_json() {
97        let raw = r#"{"summary":"LGTM","issues":[],"verdict":"approve"}"#;
98        let resp = parse_review_response(raw).unwrap();
99        assert_eq!(resp.summary, "LGTM");
100        assert!(resp.findings.is_empty());
101        assert!(matches!(resp.verdict, ReviewVerdict::Approve));
102    }
103
104    #[test]
105    fn test_parse_json_in_code_block() {
106        let raw = "```json\n{\"summary\":\"ok\",\"issues\":[],\"verdict\":\"approve\"}\n```";
107        let resp = parse_review_response(raw).unwrap();
108        assert_eq!(resp.summary, "ok");
109    }
110
111    #[test]
112    fn test_parse_with_issues() {
113        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"}"#;
114        let resp = parse_review_response(raw).unwrap();
115        assert_eq!(resp.findings.len(), 1);
116        assert_eq!(resp.suggestions.len(), 1);
117        assert!(matches!(resp.verdict, ReviewVerdict::RequestChanges));
118    }
119}