dk_runner/steps/agent_review/
parse.rs1use 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}