1use anyhow::Result;
7use fix_engine_core::{FixConfidence, FixSource, LlmFixRequest, PlannedFix, TextEdit};
8use serde::{Deserialize, Serialize};
9
10use crate::context::FixContext;
11
12#[derive(Serialize)]
14struct ChatRequest {
15 model: String,
16 messages: Vec<ChatMessage>,
17 temperature: f32,
18}
19
20#[derive(Serialize)]
21struct ChatMessage {
22 role: String,
23 content: String,
24}
25
26#[derive(Deserialize)]
28struct ChatResponse {
29 choices: Vec<ChatChoice>,
30}
31
32#[derive(Deserialize)]
33struct ChatChoice {
34 message: ChatChoiceMessage,
35}
36
37#[derive(Deserialize)]
38struct ChatChoiceMessage {
39 content: String,
40}
41
42pub async fn request_llm_fix(
44 endpoint: &str,
45 request: &LlmFixRequest,
46 ctx: &dyn FixContext,
47) -> Result<Vec<PlannedFix>> {
48 let source = std::fs::read_to_string(&request.file_path)?;
50
51 let system_prompt = ctx.llm_system_prompt();
52
53 let user_prompt = format!(
54 "File: {}\nLine: {}\n\nMigration rule: {}\n\nMessage: {}\n\nFull file source:\n```\n{}\n```",
55 request.file_path.display(),
56 request.line,
57 request.rule_id,
58 request.message,
59 source,
60 );
61
62 let chat_request = ChatRequest {
63 model: "gpt-4".to_string(),
64 messages: vec![
65 ChatMessage {
66 role: "system".to_string(),
67 content: system_prompt,
68 },
69 ChatMessage {
70 role: "user".to_string(),
71 content: user_prompt,
72 },
73 ],
74 temperature: 0.0,
75 };
76
77 let client = reqwest::Client::new();
78 let response = client
79 .post(endpoint)
80 .json(&chat_request)
81 .send()
82 .await?
83 .json::<ChatResponse>()
84 .await?;
85
86 let content = response
87 .choices
88 .first()
89 .map(|c| c.message.content.as_str())
90 .unwrap_or("");
91
92 let edits = parse_llm_fix_response(content, &request.rule_id);
93
94 if edits.is_empty() {
95 return Ok(Vec::new());
96 }
97
98 Ok(vec![PlannedFix {
99 edits,
100 confidence: FixConfidence::Medium,
101 source: FixSource::Llm,
102 rule_id: request.rule_id.clone(),
103 file_uri: request.file_uri.clone(),
104 line: request.line,
105 description: format!("LLM-generated fix for {}", request.rule_id),
106 }])
107}
108
109fn parse_llm_fix_response(content: &str, rule_id: &str) -> Vec<TextEdit> {
111 let mut edits = Vec::new();
112 let mut in_fix_block = false;
113 let mut current_line: Option<u32> = None;
114 let mut current_old: Option<String> = None;
115
116 for line in content.lines() {
117 let trimmed = line.trim();
118
119 if trimmed == "```fix" {
120 in_fix_block = true;
121 continue;
122 }
123 if trimmed == "```" && in_fix_block {
124 in_fix_block = false;
125 continue;
126 }
127
128 if !in_fix_block {
129 continue;
130 }
131
132 if let Some(rest) = trimmed.strip_prefix("LINE:") {
133 current_line = rest.trim().parse().ok();
134 } else if let Some(rest) = trimmed.strip_prefix("OLD:") {
135 current_old = Some(rest.to_string());
136 } else if let Some(rest) = trimmed.strip_prefix("NEW:") {
137 if let (Some(line_num), Some(old_text)) = (current_line, current_old.take()) {
138 edits.push(TextEdit {
139 line: line_num,
140 old_text,
141 new_text: rest.to_string(),
142 rule_id: rule_id.to_string(),
143 description: "LLM-generated fix".to_string(),
144 replace_all: false,
145 });
146 }
147 current_line = None;
148 }
149 }
150
151 edits
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn test_parse_llm_response() {
160 let response = r#"
161```fix
162LINE:56
163OLD:<BarsIcon />
164NEW:<PageToggleButton isHamburgerButton />
165```
166
167```fix
168LINE:10
169OLD:import { Button, BarsIcon } from '@patternfly/react-core';
170NEW:import { PageToggleButton } from '@patternfly/react-core';
171```
172"#;
173 let edits = parse_llm_fix_response(response, "test-rule");
174 assert_eq!(edits.len(), 2);
175 assert_eq!(edits[0].line, 56);
176 assert_eq!(edits[0].old_text, "<BarsIcon />");
177 assert_eq!(edits[0].new_text, "<PageToggleButton isHamburgerButton />");
178 assert_eq!(edits[1].line, 10);
179 }
180
181 #[test]
182 fn test_parse_llm_response_empty_input() {
183 let edits = parse_llm_fix_response("", "rule-1");
184 assert!(edits.is_empty());
185 }
186
187 #[test]
188 fn test_parse_llm_response_no_fix_blocks() {
189 let response = "Here is some explanation text without any fix blocks.";
190 let edits = parse_llm_fix_response(response, "rule-1");
191 assert!(edits.is_empty());
192 }
193
194 #[test]
195 fn test_parse_llm_response_non_fix_code_blocks_ignored() {
196 let response = r#"
197```typescript
198const x = 1;
199```
200
201```javascript
202console.log("hello");
203```
204"#;
205 let edits = parse_llm_fix_response(response, "rule-1");
206 assert!(edits.is_empty());
207 }
208
209 #[test]
210 fn test_parse_llm_response_single_fix() {
211 let response = r#"
212```fix
213LINE:1
214OLD:import { Chip } from '@patternfly/react-core';
215NEW:import { Label } from '@patternfly/react-core';
216```
217"#;
218 let edits = parse_llm_fix_response(response, "rename-rule");
219 assert_eq!(edits.len(), 1);
220 assert_eq!(edits[0].line, 1);
221 assert_eq!(
222 edits[0].old_text,
223 "import { Chip } from '@patternfly/react-core';"
224 );
225 assert_eq!(
226 edits[0].new_text,
227 "import { Label } from '@patternfly/react-core';"
228 );
229 assert_eq!(edits[0].rule_id, "rename-rule");
230 }
231
232 #[test]
233 fn test_parse_llm_response_incomplete_fix_block_skipped() {
234 let response = r#"
236```fix
237LINE:5
238OLD:something
239```
240"#;
241 let edits = parse_llm_fix_response(response, "rule-1");
242 assert!(edits.is_empty());
243 }
244
245 #[test]
246 fn test_parse_llm_response_missing_line_skipped() {
247 let response = r#"
249```fix
250OLD:old text
251NEW:new text
252```
253"#;
254 let edits = parse_llm_fix_response(response, "rule-1");
255 assert!(edits.is_empty());
256 }
257
258 #[test]
259 fn test_parse_llm_response_whitespace_tolerance() {
260 let response = r#"
261```fix
262LINE: 42
263OLD: <Chip />
264NEW: <Label />
265```
266"#;
267 let edits = parse_llm_fix_response(response, "rule-1");
268 assert_eq!(edits.len(), 1);
269 assert_eq!(edits[0].line, 42);
270 assert_eq!(edits[0].old_text, " <Chip />");
272 assert_eq!(edits[0].new_text, " <Label />");
273 }
274
275 #[test]
276 fn test_parse_llm_response_new_can_be_empty() {
277 let response = r#"
279```fix
280LINE:10
281OLD: isHidden={true}
282NEW:
283```
284"#;
285 let edits = parse_llm_fix_response(response, "rule-1");
286 assert_eq!(edits.len(), 1);
287 assert_eq!(edits[0].old_text, " isHidden={true}");
288 assert_eq!(edits[0].new_text, "");
289 }
290}