1use crate::config::HealingConfig;
10use crate::{PawanError, Result};
11use serde::{Deserialize, Serialize};
12use std::path::{Path, PathBuf};
13use std::process::Stdio;
14use tokio::process::Command;
15
16async fn run_cargo_command(workspace_root: &Path, args: &[&str]) -> Result<String> {
18 let child = Command::new("cargo")
19 .args(args)
20 .current_dir(workspace_root)
21 .stdout(Stdio::piped())
22 .stderr(Stdio::piped())
23 .stdin(Stdio::null())
24 .spawn()
25 .map_err(PawanError::Io)?;
26
27 let output = tokio::time::timeout(
28 std::time::Duration::from_secs(300),
29 child.wait_with_output(),
30 )
31 .await
32 .map_err(|_| PawanError::Timeout("cargo command timed out after 5 minutes".into()))?
33 .map_err(PawanError::Io)?;
34
35 let stdout = String::from_utf8_lossy(&output.stdout);
36 let stderr = String::from_utf8_lossy(&output.stderr);
37 Ok(format!("{}\n{}", stdout, stderr))
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Diagnostic {
43 pub kind: DiagnosticKind,
45 pub message: String,
47 pub file: Option<PathBuf>,
49 pub line: Option<usize>,
51 pub column: Option<usize>,
53 pub code: Option<String>,
55 pub suggestion: Option<String>,
57 pub raw: String,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63pub enum DiagnosticKind {
64 Error,
65 Warning,
66 Note,
67 Help,
68}
69
70#[derive(Debug)]
72pub struct HealingResult {
73 pub remaining: Vec<Diagnostic>,
75 pub summary: String,
77}
78
79pub struct CompilerFixer {
81 workspace_root: PathBuf,
82}
83
84impl CompilerFixer {
85 pub fn new(workspace_root: PathBuf) -> Self {
87 Self { workspace_root }
88 }
89
90 pub fn parse_diagnostics(&self, output: &str) -> Vec<Diagnostic> {
101 let mut diagnostics = Vec::new();
102
103 for line in output.lines() {
105 if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
106 if let Some(msg) = json.get("message") {
107 let diagnostic = self.parse_diagnostic_message(msg, line);
108 if let Some(d) = diagnostic {
109 diagnostics.push(d);
110 }
111 }
112 }
113 }
114
115 if diagnostics.is_empty() {
117 diagnostics = self.parse_text_diagnostics(output);
118 }
119
120 diagnostics
121 }
122
123 fn parse_diagnostic_message(&self, msg: &serde_json::Value, raw: &str) -> Option<Diagnostic> {
125 let level = msg.get("level")?.as_str()?;
126 let message = msg.get("message")?.as_str()?.to_string();
127
128 let kind = match level {
129 "error" => DiagnosticKind::Error,
130 "warning" => DiagnosticKind::Warning,
131 "note" => DiagnosticKind::Note,
132 "help" => DiagnosticKind::Help,
133 _ => return None,
134 };
135
136 if message.contains("internal compiler error") {
138 return None;
139 }
140
141 let code = msg
143 .get("code")
144 .and_then(|c| c.get("code"))
145 .and_then(|c| c.as_str())
146 .map(|s| s.to_string());
147
148 let spans = msg.get("spans")?.as_array()?;
150 let primary_span = spans.iter().find(|s| {
151 s.get("is_primary")
152 .and_then(|v| v.as_bool())
153 .unwrap_or(false)
154 });
155
156 let (file, line, column) = if let Some(span) = primary_span {
157 let file = span
158 .get("file_name")
159 .and_then(|v| v.as_str())
160 .map(PathBuf::from);
161 let line = span
162 .get("line_start")
163 .and_then(|v| v.as_u64())
164 .map(|v| v as usize);
165 let column = span
166 .get("column_start")
167 .and_then(|v| v.as_u64())
168 .map(|v| v as usize);
169 (file, line, column)
170 } else {
171 (None, None, None)
172 };
173
174 let suggestion = msg
176 .get("children")
177 .and_then(|c| c.as_array())
178 .and_then(|children| {
179 children.iter().find_map(|child| {
180 let level = child.get("level")?.as_str()?;
181 if level == "help" {
182 let help_msg = child.get("message")?.as_str()?;
183 if let Some(spans) = child.get("spans").and_then(|s| s.as_array()) {
185 for span in spans {
186 if let Some(replacement) =
187 span.get("suggested_replacement").and_then(|v| v.as_str())
188 {
189 return Some(format!("{}: {}", help_msg, replacement));
190 }
191 }
192 }
193 return Some(help_msg.to_string());
194 }
195 None
196 })
197 });
198
199 Some(Diagnostic {
200 kind,
201 message,
202 file,
203 line,
204 column,
205 code,
206 suggestion,
207 raw: raw.to_string(),
208 })
209 }
210
211 fn parse_text_diagnostics(&self, output: &str) -> Vec<Diagnostic> {
213 let mut diagnostics = Vec::new();
214 let mut current_diagnostic: Option<Diagnostic> = None;
215
216 for line in output.lines() {
217 if line.starts_with("error") || line.starts_with("warning") {
219 if let Some(d) = current_diagnostic.take() {
221 diagnostics.push(d);
222 }
223
224 let kind = if line.starts_with("error") {
225 DiagnosticKind::Error
226 } else {
227 DiagnosticKind::Warning
228 };
229
230 let code = line
232 .find('[')
233 .and_then(|start| line.find(']').map(|end| line[start + 1..end].to_string()));
234
235 let message = if let Some(colon_pos) = line.find("]: ") {
237 line[colon_pos + 3..].to_string()
238 } else if let Some(colon_pos) = line.find(": ") {
239 line[colon_pos + 2..].to_string()
240 } else {
241 line.to_string()
242 };
243
244 current_diagnostic = Some(Diagnostic {
245 kind,
246 message,
247 file: None,
248 line: None,
249 column: None,
250 code,
251 suggestion: None,
252 raw: line.to_string(),
253 });
254 }
255 else if line.trim().starts_with("-->") {
257 if let Some(ref mut d) = current_diagnostic {
258 let path_part = line.trim().trim_start_matches("-->").trim();
259 let parts: Vec<&str> = path_part.rsplitn(3, ':').collect();
261 match parts.len() {
262 3 => {
263 d.column = parts[0].parse().ok();
265 d.line = parts[1].parse().ok();
266 d.file = Some(PathBuf::from(parts[2]));
267 }
268 2 => {
269 d.line = parts[0].parse().ok();
271 d.file = Some(PathBuf::from(parts[1]));
272 }
273 _ => {}
274 }
275 }
276 }
277 else if line.trim().starts_with("help:") {
279 if let Some(ref mut d) = current_diagnostic {
280 let suggestion = line.trim().trim_start_matches("help:").trim();
281 d.suggestion = Some(suggestion.to_string());
282 }
283 }
284 }
285
286 if let Some(d) = current_diagnostic {
288 diagnostics.push(d);
289 }
290
291 diagnostics
292 }
293
294 pub async fn check(&self) -> Result<Vec<Diagnostic>> {
296 let output = run_cargo_command(&self.workspace_root, &["check", "--message-format=json"]).await?;
297 Ok(self.parse_diagnostics(&output))
298 }
299}
300
301pub struct ClippyFixer {
303 workspace_root: PathBuf,
304}
305
306impl ClippyFixer {
307 pub fn new(workspace_root: PathBuf) -> Self {
308 Self { workspace_root }
309 }
310
311 pub async fn check(&self) -> Result<Vec<Diagnostic>> {
313 let output = run_cargo_command(
314 &self.workspace_root,
315 &["clippy", "--message-format=json", "--", "-W", "clippy::all"],
316 ).await?;
317 let fixer = CompilerFixer::new(self.workspace_root.clone());
318 let mut diagnostics = fixer.parse_diagnostics(&output);
319 diagnostics.retain(|d| d.kind == DiagnosticKind::Warning);
320 Ok(diagnostics)
321 }
322}
323
324pub struct TestFixer {
326 workspace_root: PathBuf,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct FailedTest {
332 pub name: String,
334 pub module: String,
336 pub failure: String,
338 pub file: Option<PathBuf>,
340 pub line: Option<usize>,
342}
343
344impl TestFixer {
345 pub fn new(workspace_root: PathBuf) -> Self {
347 Self { workspace_root }
348 }
349
350 pub async fn check(&self) -> Result<Vec<FailedTest>> {
352 let output = run_cargo_command(
353 &self.workspace_root,
354 &["test", "--no-fail-fast", "--", "--nocapture"],
355 ).await?;
356 Ok(self.parse_test_output(&output))
357 }
358
359 fn parse_test_output(&self, output: &str) -> Vec<FailedTest> {
361 let mut failures = Vec::new();
362 let mut in_failures_section = false;
363 let mut current_test: Option<String> = None;
364 let mut current_output = String::new();
365
366 for line in output.lines() {
367 if line.contains("failures:") && !line.contains("test result:") {
369 in_failures_section = true;
370 continue;
371 }
372
373 if in_failures_section && line.starts_with("test result:") {
375 if let Some(test_name) = current_test.take() {
377 failures.push(FailedTest {
378 name: test_name.clone(),
379 module: self.extract_module(&test_name),
380 failure: current_output.trim().to_string(),
381 file: None,
382 line: None,
383 });
384 }
385 break;
386 }
387
388 if line.starts_with("---- ") && line.ends_with(" stdout ----") {
390 if let Some(test_name) = current_test.take() {
392 failures.push(FailedTest {
393 name: test_name.clone(),
394 module: self.extract_module(&test_name),
395 failure: current_output.trim().to_string(),
396 file: None,
397 line: None,
398 });
399 }
400
401 let test_name = line
403 .trim_start_matches("---- ")
404 .trim_end_matches(" stdout ----")
405 .to_string();
406 current_test = Some(test_name);
407 current_output.clear();
408 } else if current_test.is_some() {
409 current_output.push_str(line);
410 current_output.push('\n');
411 }
412 }
413
414 for line in output.lines() {
416 if line.contains("FAILED") && line.starts_with("test ") && !line.starts_with("test result:") {
417 let parts: Vec<&str> = line.split_whitespace().collect();
418 if parts.len() >= 2 {
419 let test_name = parts[1].trim_end_matches(" ...");
420
421 if !failures.iter().any(|f| f.name == test_name) {
423 failures.push(FailedTest {
424 name: test_name.to_string(),
425 module: self.extract_module(test_name),
426 failure: line.to_string(),
427 file: None,
428 line: None,
429 });
430 }
431 }
432 }
433 }
434
435 failures
436 }
437
438 fn extract_module(&self, test_name: &str) -> String {
440 if let Some(pos) = test_name.rfind("::") {
441 test_name[..pos].to_string()
442 } else {
443 String::new()
444 }
445 }
446}
447
448pub struct Healer {
450 #[allow(dead_code)]
451 workspace_root: PathBuf,
452 config: HealingConfig,
453 compiler_fixer: CompilerFixer,
454 clippy_fixer: ClippyFixer,
455 test_fixer: TestFixer,
456}
457
458impl Healer {
459 pub fn new(workspace_root: PathBuf, config: HealingConfig) -> Self {
461 Self {
462 compiler_fixer: CompilerFixer::new(workspace_root.clone()),
463 clippy_fixer: ClippyFixer::new(workspace_root.clone()),
464 test_fixer: TestFixer::new(workspace_root.clone()),
465 workspace_root,
466 config,
467 }
468 }
469
470 pub async fn get_diagnostics(&self) -> Result<Vec<Diagnostic>> {
478 let mut all = Vec::new();
479
480 if self.config.fix_errors {
481 all.extend(self.compiler_fixer.check().await?);
482 }
483
484 if self.config.fix_warnings {
485 all.extend(self.clippy_fixer.check().await?);
486 }
487
488 Ok(all)
489 }
490
491 pub async fn get_failed_tests(&self) -> Result<Vec<FailedTest>> {
498 if self.config.fix_tests {
499 self.test_fixer.check().await
500 } else {
501 Ok(Vec::new())
502 }
503 }
504
505 pub async fn count_issues(&self) -> Result<(usize, usize, usize)> {
507 let (diagnostics, tests) = tokio::join!(
508 self.get_diagnostics(),
509 self.get_failed_tests(),
510 );
511 let diagnostics = diagnostics?;
512 let tests = tests?;
513
514 let errors = diagnostics
515 .iter()
516 .filter(|d| d.kind == DiagnosticKind::Error)
517 .count();
518 let warnings = diagnostics
519 .iter()
520 .filter(|d| d.kind == DiagnosticKind::Warning)
521 .count();
522 let failed_tests = tests.len();
523
524 Ok((errors, warnings, failed_tests))
525 }
526
527 pub fn format_diagnostics_for_prompt(&self, diagnostics: &[Diagnostic]) -> String {
538 let mut output = String::new();
539
540 for (i, d) in diagnostics.iter().enumerate() {
541 output.push_str(&format!("\n### Issue {}\n", i + 1));
542 output.push_str(&format!("Type: {:?}\n", d.kind));
543
544 if let Some(ref code) = d.code {
545 output.push_str(&format!("Code: {}\n", code));
546 }
547
548 output.push_str(&format!("Message: {}\n", d.message));
549
550 if let Some(ref file) = d.file {
551 output.push_str(&format!(
552 "Location: {}:{}:{}\n",
553 file.display(),
554 d.line.unwrap_or(0),
555 d.column.unwrap_or(0)
556 ));
557 }
558
559 if let Some(ref suggestion) = d.suggestion {
560 output.push_str(&format!("Suggestion: {}\n", suggestion));
561 }
562 }
563
564 output
565 }
566
567 pub fn format_tests_for_prompt(&self, tests: &[FailedTest]) -> String {
569 let mut output = String::new();
570
571 for (i, test) in tests.iter().enumerate() {
572 output.push_str(&format!("\n### Failed Test {}\n", i + 1));
573 output.push_str(&format!("Name: {}\n", test.name));
574 output.push_str(&format!("Module: {}\n", test.module));
575 output.push_str(&format!("Failure:\n```\n{}\n```\n", test.failure));
576 }
577
578 output
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
587 fn test_parse_text_diagnostic() {
588 let output = r#"error[E0425]: cannot find value `x` in this scope
589 --> src/main.rs:10:5
590 |
59110 | x
592 | ^ not found in this scope
593"#;
594
595 let fixer = CompilerFixer::new(PathBuf::from("."));
596 let diagnostics = fixer.parse_text_diagnostics(output);
597
598 assert_eq!(diagnostics.len(), 1);
599 assert_eq!(diagnostics[0].kind, DiagnosticKind::Error);
600 assert_eq!(diagnostics[0].code, Some("E0425".to_string()));
601 assert!(diagnostics[0].message.contains("cannot find value"));
602 }
603
604 #[test]
605 fn test_extract_module() {
606 let fixer = TestFixer::new(PathBuf::from("."));
607
608 assert_eq!(
609 fixer.extract_module("crate::module::tests::test_foo"),
610 "crate::module::tests"
611 );
612 assert_eq!(fixer.extract_module("test_foo"), "");
613 }
614
615 #[test]
616 fn test_parse_text_diagnostic_with_location() {
617 let output = "error[E0308]: mismatched types\n --> src/lib.rs:42:10\n";
618 let fixer = CompilerFixer::new(PathBuf::from("."));
619 let diagnostics = fixer.parse_text_diagnostics(output);
620 assert_eq!(diagnostics.len(), 1);
621 assert_eq!(diagnostics[0].file, Some(PathBuf::from("src/lib.rs")));
622 assert_eq!(diagnostics[0].line, Some(42));
623 assert_eq!(diagnostics[0].column, Some(10));
624 }
625
626 #[test]
627 fn test_parse_text_diagnostic_file_line_only() {
628 let output = "warning: unused variable\n --> src/main.rs:5\n";
630 let fixer = CompilerFixer::new(PathBuf::from("."));
631 let diagnostics = fixer.parse_text_diagnostics(output);
632 assert_eq!(diagnostics.len(), 1);
633 assert_eq!(diagnostics[0].file, Some(PathBuf::from("src/main.rs")));
634 assert_eq!(diagnostics[0].line, Some(5));
635 assert_eq!(diagnostics[0].column, None);
636 }
637
638 #[test]
639 fn test_parse_text_diagnostic_warning() {
640 let output = "warning: unused variable `x`\n --> src/lib.rs:3:5\n";
641 let fixer = CompilerFixer::new(PathBuf::from("."));
642 let diagnostics = fixer.parse_text_diagnostics(output);
643 assert_eq!(diagnostics.len(), 1);
644 assert_eq!(diagnostics[0].kind, DiagnosticKind::Warning);
645 assert!(diagnostics[0].message.contains("unused variable"));
646 }
647
648 #[test]
649 fn test_parse_text_diagnostic_with_help() {
650 let output = "error[E0425]: cannot find value `x`\n --> src/main.rs:10:5\nhelp: consider importing this\n";
651 let fixer = CompilerFixer::new(PathBuf::from("."));
652 let diagnostics = fixer.parse_text_diagnostics(output);
653 assert_eq!(diagnostics.len(), 1);
654 assert_eq!(diagnostics[0].suggestion, Some("consider importing this".to_string()));
655 }
656
657 #[test]
658 fn test_parse_text_multiple_diagnostics() {
659 let output = "error[E0425]: first error\n --> a.rs:1:1\nerror[E0308]: second error\n --> b.rs:2:2\n";
660 let fixer = CompilerFixer::new(PathBuf::from("."));
661 let diagnostics = fixer.parse_text_diagnostics(output);
662 assert_eq!(diagnostics.len(), 2);
663 assert_eq!(diagnostics[0].code, Some("E0425".to_string()));
664 assert_eq!(diagnostics[1].code, Some("E0308".to_string()));
665 assert_eq!(diagnostics[0].file, Some(PathBuf::from("a.rs")));
666 assert_eq!(diagnostics[1].file, Some(PathBuf::from("b.rs")));
667 }
668
669 #[test]
670 fn test_parse_text_empty_output() {
671 let fixer = CompilerFixer::new(PathBuf::from("."));
672 let diagnostics = fixer.parse_text_diagnostics("");
673 assert!(diagnostics.is_empty());
674 }
675
676 #[test]
677 fn test_parse_json_diagnostic() {
678 let json_line = r#"{"reason":"compiler-message","message":{"level":"error","message":"unused","code":{"code":"E0001"},"spans":[{"file_name":"src/lib.rs","line_start":5,"column_start":3,"is_primary":true}],"children":[]}}"#;
679 let fixer = CompilerFixer::new(PathBuf::from("."));
680 let diagnostics = fixer.parse_diagnostics(json_line);
681 assert_eq!(diagnostics.len(), 1);
682 assert_eq!(diagnostics[0].kind, DiagnosticKind::Error);
683 assert_eq!(diagnostics[0].file, Some(PathBuf::from("src/lib.rs")));
684 assert_eq!(diagnostics[0].line, Some(5));
685 assert_eq!(diagnostics[0].column, Some(3));
686 }
687
688 #[test]
689 fn test_parse_json_skips_ice() {
690 let json_line = r#"{"reason":"compiler-message","message":{"level":"error","message":"internal compiler error: something broke","spans":[],"children":[]}}"#;
691 let fixer = CompilerFixer::new(PathBuf::from("."));
692 let diagnostics = fixer.parse_diagnostics(json_line);
693 assert!(diagnostics.is_empty());
694 }
695
696 #[test]
697 fn test_parse_test_output_failures() {
698 let output = "---- tests::test_add stdout ----\nthread panicked at 'assertion failed'\n\nfailures:\n tests::test_add\n\ntest result: FAILED. 1 passed; 1 failed;\n";
699 let fixer = TestFixer::new(PathBuf::from("."));
700 let failures = fixer.parse_test_output(output);
701 assert_eq!(failures.len(), 1);
702 assert_eq!(failures[0].name, "tests::test_add");
703 assert_eq!(failures[0].module, "tests");
704 assert!(failures[0].failure.contains("assertion failed"));
705 }
706
707 #[test]
708 fn test_parse_test_output_no_failures() {
709 let output = "running 5 tests\ntest result: ok. 5 passed; 0 failed;\n";
710 let fixer = TestFixer::new(PathBuf::from("."));
711 let failures = fixer.parse_test_output(output);
712 assert!(failures.is_empty());
713 }
714
715 #[test]
716 fn test_parse_test_output_simple_failed_line() {
717 let output = "test my_module::test_thing ... FAILED\n";
719 let fixer = TestFixer::new(PathBuf::from("."));
720 let failures = fixer.parse_test_output(output);
721 assert_eq!(failures.len(), 1);
722 assert_eq!(failures[0].name, "my_module::test_thing");
723 assert_eq!(failures[0].module, "my_module");
724 }
725
726 #[test]
727 fn test_format_diagnostics_for_prompt() {
728 let healer = Healer::new(PathBuf::from("."), HealingConfig::default());
729 let diagnostics = vec![Diagnostic {
730 kind: DiagnosticKind::Error,
731 message: "unused variable".into(),
732 file: Some(PathBuf::from("src/lib.rs")),
733 line: Some(10),
734 column: Some(5),
735 code: Some("E0001".into()),
736 suggestion: Some("remove it".into()),
737 raw: String::new(),
738 }];
739 let output = healer.format_diagnostics_for_prompt(&diagnostics);
740 assert!(output.contains("Issue 1"));
741 assert!(output.contains("E0001"));
742 assert!(output.contains("unused variable"));
743 assert!(output.contains("src/lib.rs:10:5"));
744 assert!(output.contains("remove it"));
745 }
746
747 #[test]
748 fn test_format_tests_for_prompt() {
749 let healer = Healer::new(PathBuf::from("."), HealingConfig::default());
750 let tests = vec![FailedTest {
751 name: "tests::test_foo".into(),
752 module: "tests".into(),
753 failure: "assertion failed".into(),
754 file: None,
755 line: None,
756 }];
757 let output = healer.format_tests_for_prompt(&tests);
758 assert!(output.contains("Failed Test 1"));
759 assert!(output.contains("tests::test_foo"));
760 assert!(output.contains("assertion failed"));
761 }
762}