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
70impl Diagnostic {
71 pub fn fingerprint(&self) -> u64 {
82 use std::collections::hash_map::DefaultHasher;
83 use std::hash::{Hash, Hasher};
84 let mut hasher = DefaultHasher::new();
85 (self.kind as u8).hash(&mut hasher);
86 self.code.as_deref().unwrap_or("").hash(&mut hasher);
87 let msg_prefix: String = self.message.chars().take(120).collect();
88 msg_prefix.hash(&mut hasher);
89 hasher.finish()
90 }
91}
92
93#[derive(Debug)]
95pub struct HealingResult {
96 pub remaining: Vec<Diagnostic>,
98 pub summary: String,
100}
101
102pub struct CompilerFixer {
104 workspace_root: PathBuf,
105}
106
107impl CompilerFixer {
108 pub fn new(workspace_root: PathBuf) -> Self {
110 Self { workspace_root }
111 }
112
113 pub fn parse_diagnostics(&self, output: &str) -> Vec<Diagnostic> {
124 let mut diagnostics = Vec::new();
125
126 for line in output.lines() {
128 if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
129 if let Some(msg) = json.get("message") {
130 let diagnostic = self.parse_diagnostic_message(msg, line);
131 if let Some(d) = diagnostic {
132 diagnostics.push(d);
133 }
134 }
135 }
136 }
137
138 if diagnostics.is_empty() {
140 diagnostics = self.parse_text_diagnostics(output);
141 }
142
143 diagnostics
144 }
145
146 fn parse_diagnostic_message(&self, msg: &serde_json::Value, raw: &str) -> Option<Diagnostic> {
148 let level = msg.get("level")?.as_str()?;
149 let message = msg.get("message")?.as_str()?.to_string();
150
151 let kind = match level {
152 "error" => DiagnosticKind::Error,
153 "warning" => DiagnosticKind::Warning,
154 "note" => DiagnosticKind::Note,
155 "help" => DiagnosticKind::Help,
156 _ => return None,
157 };
158
159 if message.contains("internal compiler error") {
161 return None;
162 }
163
164 let code = msg
166 .get("code")
167 .and_then(|c| c.get("code"))
168 .and_then(|c| c.as_str())
169 .map(|s| s.to_string());
170
171 let spans = msg.get("spans")?.as_array()?;
173 let primary_span = spans.iter().find(|s| {
174 s.get("is_primary")
175 .and_then(|v| v.as_bool())
176 .unwrap_or(false)
177 });
178
179 let (file, line, column) = if let Some(span) = primary_span {
180 let file = span
181 .get("file_name")
182 .and_then(|v| v.as_str())
183 .map(PathBuf::from);
184 let line = span
185 .get("line_start")
186 .and_then(|v| v.as_u64())
187 .map(|v| v as usize);
188 let column = span
189 .get("column_start")
190 .and_then(|v| v.as_u64())
191 .map(|v| v as usize);
192 (file, line, column)
193 } else {
194 (None, None, None)
195 };
196
197 let suggestion = msg
199 .get("children")
200 .and_then(|c| c.as_array())
201 .and_then(|children| {
202 children.iter().find_map(|child| {
203 let level = child.get("level")?.as_str()?;
204 if level == "help" {
205 let help_msg = child.get("message")?.as_str()?;
206 if let Some(spans) = child.get("spans").and_then(|s| s.as_array()) {
208 for span in spans {
209 if let Some(replacement) =
210 span.get("suggested_replacement").and_then(|v| v.as_str())
211 {
212 return Some(format!("{}: {}", help_msg, replacement));
213 }
214 }
215 }
216 return Some(help_msg.to_string());
217 }
218 None
219 })
220 });
221
222 Some(Diagnostic {
223 kind,
224 message,
225 file,
226 line,
227 column,
228 code,
229 suggestion,
230 raw: raw.to_string(),
231 })
232 }
233
234 fn parse_text_diagnostics(&self, output: &str) -> Vec<Diagnostic> {
236 let mut diagnostics = Vec::new();
237 let mut current_diagnostic: Option<Diagnostic> = None;
238
239 for line in output.lines() {
240 if line.starts_with("error") || line.starts_with("warning") {
242 if let Some(d) = current_diagnostic.take() {
244 diagnostics.push(d);
245 }
246
247 let kind = if line.starts_with("error") {
248 DiagnosticKind::Error
249 } else {
250 DiagnosticKind::Warning
251 };
252
253 let code = line
255 .find('[')
256 .and_then(|start| line.find(']').map(|end| line[start + 1..end].to_string()));
257
258 let message = if let Some(colon_pos) = line.find("]: ") {
260 line[colon_pos + 3..].to_string()
261 } else if let Some(colon_pos) = line.find(": ") {
262 line[colon_pos + 2..].to_string()
263 } else {
264 line.to_string()
265 };
266
267 current_diagnostic = Some(Diagnostic {
268 kind,
269 message,
270 file: None,
271 line: None,
272 column: None,
273 code,
274 suggestion: None,
275 raw: line.to_string(),
276 });
277 }
278 else if line.trim().starts_with("-->") {
280 if let Some(ref mut d) = current_diagnostic {
281 let path_part = line.trim().trim_start_matches("-->").trim();
282 let parts: Vec<&str> = path_part.rsplitn(3, ':').collect();
284 match parts.len() {
285 3 => {
286 d.column = parts[0].parse().ok();
288 d.line = parts[1].parse().ok();
289 d.file = Some(PathBuf::from(parts[2]));
290 }
291 2 => {
292 d.line = parts[0].parse().ok();
294 d.file = Some(PathBuf::from(parts[1]));
295 }
296 _ => {}
297 }
298 }
299 }
300 else if line.trim().starts_with("help:") {
302 if let Some(ref mut d) = current_diagnostic {
303 let suggestion = line.trim().trim_start_matches("help:").trim();
304 d.suggestion = Some(suggestion.to_string());
305 }
306 }
307 }
308
309 if let Some(d) = current_diagnostic {
311 diagnostics.push(d);
312 }
313
314 diagnostics
315 }
316
317 pub async fn check(&self) -> Result<Vec<Diagnostic>> {
319 let output = run_cargo_command(&self.workspace_root, &["check", "--message-format=json"]).await?;
320 Ok(self.parse_diagnostics(&output))
321 }
322}
323
324pub struct ClippyFixer {
326 workspace_root: PathBuf,
327}
328
329impl ClippyFixer {
330 pub fn new(workspace_root: PathBuf) -> Self {
331 Self { workspace_root }
332 }
333
334 pub async fn check(&self) -> Result<Vec<Diagnostic>> {
336 let output = run_cargo_command(
337 &self.workspace_root,
338 &["clippy", "--message-format=json", "--", "-W", "clippy::all"],
339 ).await?;
340 let fixer = CompilerFixer::new(self.workspace_root.clone());
341 let mut diagnostics = fixer.parse_diagnostics(&output);
342 diagnostics.retain(|d| d.kind == DiagnosticKind::Warning);
343 Ok(diagnostics)
344 }
345}
346
347pub struct AuditFixer {
360 workspace_root: PathBuf,
361}
362
363impl AuditFixer {
364 pub fn new(workspace_root: PathBuf) -> Self {
366 Self { workspace_root }
367 }
368
369 pub async fn check(&self) -> Result<Vec<Diagnostic>> {
371 let child = Command::new("cargo")
372 .args(["audit", "--json"])
373 .current_dir(&self.workspace_root)
374 .stdout(Stdio::piped())
375 .stderr(Stdio::piped())
376 .stdin(Stdio::null())
377 .spawn();
378
379 let child = match child {
381 Ok(c) => c,
382 Err(_) => return Ok(Vec::new()),
383 };
384
385 let output = match tokio::time::timeout(
386 std::time::Duration::from_secs(120),
387 child.wait_with_output(),
388 )
389 .await
390 {
391 Ok(Ok(o)) => o,
392 _ => return Ok(Vec::new()),
393 };
394
395 let stdout = String::from_utf8_lossy(&output.stdout);
399 Ok(Self::parse_audit_json(&stdout))
400 }
401
402 pub fn parse_audit_json(json_text: &str) -> Vec<Diagnostic> {
406 let mut diagnostics = Vec::new();
407
408 let parsed: serde_json::Value = match serde_json::from_str(json_text) {
409 Ok(v) => v,
410 Err(_) => return diagnostics,
411 };
412
413 if let Some(vulns) = parsed
415 .get("vulnerabilities")
416 .and_then(|v| v.get("list"))
417 .and_then(|v| v.as_array())
418 {
419 for vuln in vulns {
420 let advisory = vuln.get("advisory");
421 let id = advisory
422 .and_then(|a| a.get("id"))
423 .and_then(|v| v.as_str())
424 .unwrap_or("unknown");
425 let title = advisory
426 .and_then(|a| a.get("title"))
427 .and_then(|v| v.as_str())
428 .unwrap_or("");
429 let crate_name = vuln
430 .get("package")
431 .and_then(|p| p.get("name"))
432 .and_then(|v| v.as_str())
433 .unwrap_or("unknown");
434 let crate_version = vuln
435 .get("package")
436 .and_then(|p| p.get("version"))
437 .and_then(|v| v.as_str())
438 .unwrap_or("");
439
440 diagnostics.push(Diagnostic {
441 kind: DiagnosticKind::Error,
442 message: format!(
443 "[security] {crate_name} {crate_version}: {title}"
444 ),
445 file: None,
446 line: None,
447 column: None,
448 code: Some(id.to_string()),
449 suggestion: None,
450 raw: vuln.to_string(),
451 });
452 }
453 }
454
455 if let Some(warnings) = parsed.get("warnings").and_then(|v| v.as_object()) {
457 for (kind_name, list) in warnings {
458 if let Some(arr) = list.as_array() {
459 for entry in arr {
460 let advisory = entry.get("advisory");
461 let id = advisory
462 .and_then(|a| a.get("id"))
463 .and_then(|v| v.as_str())
464 .unwrap_or("unknown");
465 let title = advisory
466 .and_then(|a| a.get("title"))
467 .and_then(|v| v.as_str())
468 .unwrap_or("");
469 let crate_name = entry
470 .get("package")
471 .and_then(|p| p.get("name"))
472 .and_then(|v| v.as_str())
473 .unwrap_or("unknown");
474
475 diagnostics.push(Diagnostic {
476 kind: DiagnosticKind::Warning,
477 message: format!(
478 "[{kind_name}] {crate_name}: {title}"
479 ),
480 file: None,
481 line: None,
482 column: None,
483 code: Some(id.to_string()),
484 suggestion: None,
485 raw: entry.to_string(),
486 });
487 }
488 }
489 }
490 }
491
492 diagnostics
493 }
494}
495
496pub struct TestFixer {
498 workspace_root: PathBuf,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct FailedTest {
504 pub name: String,
506 pub module: String,
508 pub failure: String,
510 pub file: Option<PathBuf>,
512 pub line: Option<usize>,
514}
515
516impl TestFixer {
517 pub fn new(workspace_root: PathBuf) -> Self {
519 Self { workspace_root }
520 }
521
522 pub async fn check(&self) -> Result<Vec<FailedTest>> {
524 let output = run_cargo_command(
525 &self.workspace_root,
526 &["test", "--no-fail-fast", "--", "--nocapture"],
527 ).await?;
528 Ok(self.parse_test_output(&output))
529 }
530
531 fn parse_test_output(&self, output: &str) -> Vec<FailedTest> {
533 let mut failures = Vec::new();
534 let mut in_failures_section = false;
535 let mut current_test: Option<String> = None;
536 let mut current_output = String::new();
537
538 for line in output.lines() {
539 if line.contains("failures:") && !line.contains("test result:") {
541 in_failures_section = true;
542 continue;
543 }
544
545 if in_failures_section && line.starts_with("test result:") {
547 if let Some(test_name) = current_test.take() {
549 failures.push(FailedTest {
550 name: test_name.clone(),
551 module: self.extract_module(&test_name),
552 failure: current_output.trim().to_string(),
553 file: None,
554 line: None,
555 });
556 }
557 break;
558 }
559
560 if line.starts_with("---- ") && line.ends_with(" stdout ----") {
562 if let Some(test_name) = current_test.take() {
564 failures.push(FailedTest {
565 name: test_name.clone(),
566 module: self.extract_module(&test_name),
567 failure: current_output.trim().to_string(),
568 file: None,
569 line: None,
570 });
571 }
572
573 let test_name = line
575 .trim_start_matches("---- ")
576 .trim_end_matches(" stdout ----")
577 .to_string();
578 current_test = Some(test_name);
579 current_output.clear();
580 } else if current_test.is_some() {
581 current_output.push_str(line);
582 current_output.push('\n');
583 }
584 }
585
586 for line in output.lines() {
588 if line.contains("FAILED") && line.starts_with("test ") && !line.starts_with("test result:") {
589 let parts: Vec<&str> = line.split_whitespace().collect();
590 if parts.len() >= 2 {
591 let test_name = parts[1].trim_end_matches(" ...");
592
593 if !failures.iter().any(|f| f.name == test_name) {
595 failures.push(FailedTest {
596 name: test_name.to_string(),
597 module: self.extract_module(test_name),
598 failure: line.to_string(),
599 file: None,
600 line: None,
601 });
602 }
603 }
604 }
605 }
606
607 failures
608 }
609
610 fn extract_module(&self, test_name: &str) -> String {
612 if let Some(pos) = test_name.rfind("::") {
613 test_name[..pos].to_string()
614 } else {
615 String::new()
616 }
617 }
618}
619
620pub struct Healer {
622 #[allow(dead_code)]
623 workspace_root: PathBuf,
624 config: HealingConfig,
625 compiler_fixer: CompilerFixer,
626 clippy_fixer: ClippyFixer,
627 test_fixer: TestFixer,
628 audit_fixer: AuditFixer,
629}
630
631impl Healer {
632 pub fn new(workspace_root: PathBuf, config: HealingConfig) -> Self {
634 Self {
635 compiler_fixer: CompilerFixer::new(workspace_root.clone()),
636 clippy_fixer: ClippyFixer::new(workspace_root.clone()),
637 test_fixer: TestFixer::new(workspace_root.clone()),
638 audit_fixer: AuditFixer::new(workspace_root.clone()),
639 workspace_root,
640 config,
641 }
642 }
643
644 pub async fn get_diagnostics(&self) -> Result<Vec<Diagnostic>> {
654 let mut all = Vec::new();
655
656 if self.config.fix_errors {
657 all.extend(self.compiler_fixer.check().await?);
658 }
659
660 if self.config.fix_warnings {
661 all.extend(self.clippy_fixer.check().await?);
662 }
663
664 if self.config.fix_security {
665 all.extend(self.audit_fixer.check().await?);
666 }
667
668 Ok(all)
669 }
670
671 pub async fn get_failed_tests(&self) -> Result<Vec<FailedTest>> {
678 if self.config.fix_tests {
679 self.test_fixer.check().await
680 } else {
681 Ok(Vec::new())
682 }
683 }
684
685 pub async fn count_issues(&self) -> Result<(usize, usize, usize)> {
687 let (diagnostics, tests) = tokio::join!(
688 self.get_diagnostics(),
689 self.get_failed_tests(),
690 );
691 let diagnostics = diagnostics?;
692 let tests = tests?;
693
694 let errors = diagnostics
695 .iter()
696 .filter(|d| d.kind == DiagnosticKind::Error)
697 .count();
698 let warnings = diagnostics
699 .iter()
700 .filter(|d| d.kind == DiagnosticKind::Warning)
701 .count();
702 let failed_tests = tests.len();
703
704 Ok((errors, warnings, failed_tests))
705 }
706
707 pub fn format_diagnostics_for_prompt(&self, diagnostics: &[Diagnostic]) -> String {
718 let mut output = String::new();
719
720 for (i, d) in diagnostics.iter().enumerate() {
721 output.push_str(&format!("\n### Issue {}\n", i + 1));
722 output.push_str(&format!("Type: {:?}\n", d.kind));
723
724 if let Some(ref code) = d.code {
725 output.push_str(&format!("Code: {}\n", code));
726 }
727
728 output.push_str(&format!("Message: {}\n", d.message));
729
730 if let Some(ref file) = d.file {
731 output.push_str(&format!(
732 "Location: {}:{}:{}\n",
733 file.display(),
734 d.line.unwrap_or(0),
735 d.column.unwrap_or(0)
736 ));
737 }
738
739 if let Some(ref suggestion) = d.suggestion {
740 output.push_str(&format!("Suggestion: {}\n", suggestion));
741 }
742 }
743
744 output
745 }
746
747 pub fn format_tests_for_prompt(&self, tests: &[FailedTest]) -> String {
749 let mut output = String::new();
750
751 for (i, test) in tests.iter().enumerate() {
752 output.push_str(&format!("\n### Failed Test {}\n", i + 1));
753 output.push_str(&format!("Name: {}\n", test.name));
754 output.push_str(&format!("Module: {}\n", test.module));
755 output.push_str(&format!("Failure:\n```\n{}\n```\n", test.failure));
756 }
757
758 output
759 }
760}
761
762pub async fn run_verify_cmd(workspace_root: &Path, cmd: &str) -> Result<Option<Diagnostic>> {
770 let output = Command::new("sh")
771 .arg("-c")
772 .arg(cmd)
773 .current_dir(workspace_root)
774 .stdout(Stdio::piped())
775 .stderr(Stdio::piped())
776 .output()
777 .await
778 .map_err(|e| PawanError::Io(e))?;
779
780 if output.status.success() {
781 return Ok(None);
782 }
783
784 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
785 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
786 let combined = match (stdout.trim().is_empty(), stderr.trim().is_empty()) {
787 (false, false) => format!("{stdout}\n{stderr}"),
788 (true, _) => stderr,
789 (_, true) => stdout,
790 };
791
792 Ok(Some(Diagnostic {
793 kind: DiagnosticKind::Error,
794 message: format!("verify_cmd `{cmd}` exited with {}", output.status),
795 file: None,
796 line: None,
797 column: None,
798 code: None,
799 suggestion: None,
800 raw: combined,
801 }))
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807
808 #[test]
809 fn test_parse_text_diagnostic() {
810 let output = r#"error[E0425]: cannot find value `x` in this scope
811 --> src/main.rs:10:5
812 |
81310 | x
814 | ^ not found in this scope
815"#;
816
817 let fixer = CompilerFixer::new(PathBuf::from("."));
818 let diagnostics = fixer.parse_text_diagnostics(output);
819
820 assert_eq!(diagnostics.len(), 1);
821 assert_eq!(diagnostics[0].kind, DiagnosticKind::Error);
822 assert_eq!(diagnostics[0].code, Some("E0425".to_string()));
823 assert!(diagnostics[0].message.contains("cannot find value"));
824 }
825
826 #[test]
827 fn test_extract_module() {
828 let fixer = TestFixer::new(PathBuf::from("."));
829
830 assert_eq!(
831 fixer.extract_module("crate::module::tests::test_foo"),
832 "crate::module::tests"
833 );
834 assert_eq!(fixer.extract_module("test_foo"), "");
835 }
836
837 #[test]
838 fn test_parse_text_diagnostic_with_location() {
839 let output = "error[E0308]: mismatched types\n --> src/lib.rs:42:10\n";
840 let fixer = CompilerFixer::new(PathBuf::from("."));
841 let diagnostics = fixer.parse_text_diagnostics(output);
842 assert_eq!(diagnostics.len(), 1);
843 assert_eq!(diagnostics[0].file, Some(PathBuf::from("src/lib.rs")));
844 assert_eq!(diagnostics[0].line, Some(42));
845 assert_eq!(diagnostics[0].column, Some(10));
846 }
847
848 #[test]
849 fn test_parse_text_diagnostic_file_line_only() {
850 let output = "warning: unused variable\n --> src/main.rs:5\n";
852 let fixer = CompilerFixer::new(PathBuf::from("."));
853 let diagnostics = fixer.parse_text_diagnostics(output);
854 assert_eq!(diagnostics.len(), 1);
855 assert_eq!(diagnostics[0].file, Some(PathBuf::from("src/main.rs")));
856 assert_eq!(diagnostics[0].line, Some(5));
857 assert_eq!(diagnostics[0].column, None);
858 }
859
860 #[test]
861 fn test_parse_text_diagnostic_warning() {
862 let output = "warning: unused variable `x`\n --> src/lib.rs:3:5\n";
863 let fixer = CompilerFixer::new(PathBuf::from("."));
864 let diagnostics = fixer.parse_text_diagnostics(output);
865 assert_eq!(diagnostics.len(), 1);
866 assert_eq!(diagnostics[0].kind, DiagnosticKind::Warning);
867 assert!(diagnostics[0].message.contains("unused variable"));
868 }
869
870 #[test]
871 fn test_parse_text_diagnostic_with_help() {
872 let output = "error[E0425]: cannot find value `x`\n --> src/main.rs:10:5\nhelp: consider importing this\n";
873 let fixer = CompilerFixer::new(PathBuf::from("."));
874 let diagnostics = fixer.parse_text_diagnostics(output);
875 assert_eq!(diagnostics.len(), 1);
876 assert_eq!(diagnostics[0].suggestion, Some("consider importing this".to_string()));
877 }
878
879 #[test]
880 fn test_parse_text_multiple_diagnostics() {
881 let output = "error[E0425]: first error\n --> a.rs:1:1\nerror[E0308]: second error\n --> b.rs:2:2\n";
882 let fixer = CompilerFixer::new(PathBuf::from("."));
883 let diagnostics = fixer.parse_text_diagnostics(output);
884 assert_eq!(diagnostics.len(), 2);
885 assert_eq!(diagnostics[0].code, Some("E0425".to_string()));
886 assert_eq!(diagnostics[1].code, Some("E0308".to_string()));
887 assert_eq!(diagnostics[0].file, Some(PathBuf::from("a.rs")));
888 assert_eq!(diagnostics[1].file, Some(PathBuf::from("b.rs")));
889 }
890
891 #[test]
892 fn test_parse_text_empty_output() {
893 let fixer = CompilerFixer::new(PathBuf::from("."));
894 let diagnostics = fixer.parse_text_diagnostics("");
895 assert!(diagnostics.is_empty());
896 }
897
898 #[test]
899 fn test_parse_json_diagnostic() {
900 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":[]}}"#;
901 let fixer = CompilerFixer::new(PathBuf::from("."));
902 let diagnostics = fixer.parse_diagnostics(json_line);
903 assert_eq!(diagnostics.len(), 1);
904 assert_eq!(diagnostics[0].kind, DiagnosticKind::Error);
905 assert_eq!(diagnostics[0].file, Some(PathBuf::from("src/lib.rs")));
906 assert_eq!(diagnostics[0].line, Some(5));
907 assert_eq!(diagnostics[0].column, Some(3));
908 }
909
910 #[test]
911 fn test_parse_json_skips_ice() {
912 let json_line = r#"{"reason":"compiler-message","message":{"level":"error","message":"internal compiler error: something broke","spans":[],"children":[]}}"#;
913 let fixer = CompilerFixer::new(PathBuf::from("."));
914 let diagnostics = fixer.parse_diagnostics(json_line);
915 assert!(diagnostics.is_empty());
916 }
917
918 #[test]
919 fn test_parse_test_output_failures() {
920 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";
921 let fixer = TestFixer::new(PathBuf::from("."));
922 let failures = fixer.parse_test_output(output);
923 assert_eq!(failures.len(), 1);
924 assert_eq!(failures[0].name, "tests::test_add");
925 assert_eq!(failures[0].module, "tests");
926 assert!(failures[0].failure.contains("assertion failed"));
927 }
928
929 #[test]
930 fn test_parse_test_output_no_failures() {
931 let output = "running 5 tests\ntest result: ok. 5 passed; 0 failed;\n";
932 let fixer = TestFixer::new(PathBuf::from("."));
933 let failures = fixer.parse_test_output(output);
934 assert!(failures.is_empty());
935 }
936
937 #[test]
938 fn test_parse_test_output_simple_failed_line() {
939 let output = "test my_module::test_thing ... FAILED\n";
941 let fixer = TestFixer::new(PathBuf::from("."));
942 let failures = fixer.parse_test_output(output);
943 assert_eq!(failures.len(), 1);
944 assert_eq!(failures[0].name, "my_module::test_thing");
945 assert_eq!(failures[0].module, "my_module");
946 }
947
948 #[test]
949 fn test_format_diagnostics_for_prompt() {
950 let healer = Healer::new(PathBuf::from("."), HealingConfig::default());
951 let diagnostics = vec![Diagnostic {
952 kind: DiagnosticKind::Error,
953 message: "unused variable".into(),
954 file: Some(PathBuf::from("src/lib.rs")),
955 line: Some(10),
956 column: Some(5),
957 code: Some("E0001".into()),
958 suggestion: Some("remove it".into()),
959 raw: String::new(),
960 }];
961 let output = healer.format_diagnostics_for_prompt(&diagnostics);
962 assert!(output.contains("Issue 1"));
963 assert!(output.contains("E0001"));
964 assert!(output.contains("unused variable"));
965 assert!(output.contains("src/lib.rs:10:5"));
966 assert!(output.contains("remove it"));
967 }
968
969 #[test]
970 fn test_format_tests_for_prompt() {
971 let healer = Healer::new(PathBuf::from("."), HealingConfig::default());
972 let tests = vec![FailedTest {
973 name: "tests::test_foo".into(),
974 module: "tests".into(),
975 failure: "assertion failed".into(),
976 file: None,
977 line: None,
978 }];
979 let output = healer.format_tests_for_prompt(&tests);
980 assert!(output.contains("Failed Test 1"));
981 assert!(output.contains("tests::test_foo"));
982 assert!(output.contains("assertion failed"));
983 }
984
985 #[test]
986 fn test_parse_json_note_and_help_levels() {
987 let note_line = r#"{"reason":"compiler-message","message":{"level":"note","message":"for more info, see E0001","spans":[],"children":[]}}"#;
989 let help_line = r#"{"reason":"compiler-message","message":{"level":"help","message":"consider borrowing","spans":[],"children":[]}}"#;
990 let fixer = CompilerFixer::new(PathBuf::from("."));
991 let combined = format!("{}\n{}", note_line, help_line);
992 let diagnostics = fixer.parse_diagnostics(&combined);
993 assert_eq!(diagnostics.len(), 2);
994 assert_eq!(diagnostics[0].kind, DiagnosticKind::Note);
995 assert_eq!(diagnostics[1].kind, DiagnosticKind::Help);
996 assert_eq!(diagnostics[0].file, None);
997 assert_eq!(diagnostics[0].line, None);
998 }
999
1000 #[test]
1001 fn test_parse_json_unknown_level_is_filtered() {
1002 let line = r#"{"reason":"compiler-message","message":{"level":"trace","message":"verbose info","spans":[],"children":[]}}"#;
1004 let fixer = CompilerFixer::new(PathBuf::from("."));
1005 let diagnostics = fixer.parse_diagnostics(line);
1006 assert!(
1007 diagnostics.is_empty(),
1008 "unknown level should be filtered, got {} diagnostics",
1009 diagnostics.len()
1010 );
1011 }
1012
1013 #[test]
1014 fn test_parse_json_suggestion_with_replacement() {
1015 let json = r#"{"reason":"compiler-message","message":{"level":"error","message":"missing semicolon","code":{"code":"E0001"},"spans":[{"file_name":"src/foo.rs","line_start":3,"column_start":10,"is_primary":true}],"children":[{"level":"help","message":"add semicolon","spans":[{"suggested_replacement":";"}]}]}}"#;
1018 let fixer = CompilerFixer::new(PathBuf::from("."));
1019 let diagnostics = fixer.parse_diagnostics(json);
1020 assert_eq!(diagnostics.len(), 1);
1021 let d = &diagnostics[0];
1022 assert!(d.suggestion.is_some(), "suggestion should be populated");
1023 let suggestion = d.suggestion.as_ref().unwrap();
1024 assert!(
1025 suggestion.contains("add semicolon"),
1026 "suggestion missing help text: {}",
1027 suggestion
1028 );
1029 assert!(
1030 suggestion.contains(";"),
1031 "suggestion missing replacement: {}",
1032 suggestion
1033 );
1034 }
1035
1036 #[test]
1037 fn test_parse_json_no_primary_span() {
1038 let json = r#"{"reason":"compiler-message","message":{"level":"error","message":"no primary span","code":null,"spans":[{"file_name":"src/x.rs","line_start":1,"is_primary":false}],"children":[]}}"#;
1040 let fixer = CompilerFixer::new(PathBuf::from("."));
1041 let diagnostics = fixer.parse_diagnostics(json);
1042 assert_eq!(diagnostics.len(), 1);
1043 assert_eq!(diagnostics[0].file, None);
1044 assert_eq!(diagnostics[0].line, None);
1045 assert_eq!(diagnostics[0].column, None);
1046 }
1047
1048 #[test]
1049 fn test_parse_mixed_json_and_text_prefers_json() {
1050 let mixed = format!(
1054 "{}\nerror[E0999]: should not be double-parsed\n",
1055 r#"{"reason":"compiler-message","message":{"level":"error","message":"real error","code":{"code":"E0001"},"spans":[{"file_name":"src/a.rs","line_start":1,"column_start":1,"is_primary":true}],"children":[]}}"#
1056 );
1057 let fixer = CompilerFixer::new(PathBuf::from("."));
1058 let diagnostics = fixer.parse_diagnostics(&mixed);
1059 assert_eq!(
1060 diagnostics.len(),
1061 1,
1062 "text fallback must be suppressed when JSON parsing yielded any diagnostics"
1063 );
1064 assert_eq!(diagnostics[0].message, "real error");
1065 }
1066
1067 #[test]
1070 fn test_parse_text_error_without_code_bracket() {
1071 let output = "error: cannot find crate `missing`\n --> src/lib.rs:1:5\n";
1075 let fixer = CompilerFixer::new(PathBuf::from("."));
1076 let diagnostics = fixer.parse_text_diagnostics(output);
1077 assert_eq!(diagnostics.len(), 1);
1078 assert_eq!(diagnostics[0].kind, DiagnosticKind::Error);
1079 assert_eq!(diagnostics[0].code, None, "no [Exxxx] → code is None");
1080 assert!(
1081 diagnostics[0].message.contains("cannot find crate"),
1082 "message must be extracted after 'error: '"
1083 );
1084 }
1085
1086 #[test]
1087 fn test_parse_text_help_before_any_error_is_dropped() {
1088 let output = "help: this is orphaned\nhelp: also orphaned\n";
1091 let fixer = CompilerFixer::new(PathBuf::from("."));
1092 let diagnostics = fixer.parse_text_diagnostics(output);
1093 assert!(
1094 diagnostics.is_empty(),
1095 "orphan help: must not create a diagnostic"
1096 );
1097 }
1098
1099 #[test]
1100 fn test_parse_text_arrow_with_only_filename_no_colons() {
1101 let output = "error[E0999]: malformed error\n --> weird_filename\n";
1105 let fixer = CompilerFixer::new(PathBuf::from("."));
1106 let diagnostics = fixer.parse_text_diagnostics(output);
1107 assert_eq!(diagnostics.len(), 1);
1108 assert_eq!(diagnostics[0].file, None);
1110 assert_eq!(diagnostics[0].line, None);
1111 assert_eq!(diagnostics[0].column, None);
1112 }
1113
1114 #[test]
1115 fn test_format_diagnostics_empty_vec_produces_empty_output() {
1116 let healer = Healer::new(PathBuf::from("."), HealingConfig::default());
1119 let out = healer.format_diagnostics_for_prompt(&[]);
1120 assert!(
1122 !out.contains("Issue 1"),
1123 "empty diagnostics should not render any Issue lines, got: {out}"
1124 );
1125 }
1126
1127 #[test]
1128 fn test_extract_module_with_deeply_nested_path() {
1129 let fixer = TestFixer::new(PathBuf::from("."));
1131 assert_eq!(
1132 fixer.extract_module("a::b::c::d::test_foo"),
1133 "a::b::c::d",
1134 "deeply nested paths strip only the last segment"
1135 );
1136 assert_eq!(
1137 fixer.extract_module("single::test"),
1138 "single",
1139 "single-level path strips to top module"
1140 );
1141 assert_eq!(
1142 fixer.extract_module(""),
1143 "",
1144 "empty string stays empty (no panic)"
1145 );
1146 }
1147
1148 #[test]
1149 fn test_parse_text_diagnostic_with_no_content_at_all() {
1150 let output = " Compiling pawan-core v0.3.1\n Building [====> ] 42/100\n Finished dev [unoptimized] in 2.3s\n";
1153 let fixer = CompilerFixer::new(PathBuf::from("."));
1154 let diagnostics = fixer.parse_text_diagnostics(output);
1155 assert!(
1156 diagnostics.is_empty(),
1157 "build progress lines should not produce diagnostics, got {}",
1158 diagnostics.len()
1159 );
1160 }
1161
1162 #[test]
1165 fn test_audit_parse_empty_output_returns_empty() {
1166 let diagnostics = AuditFixer::parse_audit_json("");
1167 assert!(diagnostics.is_empty());
1168 }
1169
1170 #[test]
1171 fn test_audit_parse_invalid_json_returns_empty() {
1172 let diagnostics = AuditFixer::parse_audit_json("not json at all { ] [");
1175 assert!(diagnostics.is_empty());
1176 }
1177
1178 #[test]
1179 fn test_audit_parse_no_findings_returns_empty() {
1180 let json = r#"{"vulnerabilities":{"list":[]},"warnings":{}}"#;
1181 let diagnostics = AuditFixer::parse_audit_json(json);
1182 assert!(diagnostics.is_empty());
1183 }
1184
1185 #[test]
1186 fn test_audit_parse_vulnerability_becomes_error_diagnostic() {
1187 let json = r#"{
1188 "vulnerabilities": {
1189 "list": [{
1190 "advisory": {
1191 "id": "RUSTSEC-2023-0071",
1192 "title": "Marvin Attack: timing sidechannel in RSA"
1193 },
1194 "package": {
1195 "name": "rsa",
1196 "version": "0.9.10"
1197 }
1198 }]
1199 },
1200 "warnings": {}
1201 }"#;
1202 let diagnostics = AuditFixer::parse_audit_json(json);
1203 assert_eq!(diagnostics.len(), 1);
1204 let d = &diagnostics[0];
1205 assert_eq!(d.kind, DiagnosticKind::Error);
1206 assert_eq!(d.code.as_deref(), Some("RUSTSEC-2023-0071"));
1207 assert!(d.message.contains("rsa"));
1208 assert!(d.message.contains("0.9.10"));
1209 assert!(d.message.contains("Marvin Attack"));
1210 assert!(d.message.starts_with("[security]"));
1211 }
1212
1213 #[test]
1214 fn test_audit_parse_unmaintained_warning_becomes_warning_diagnostic() {
1215 let json = r#"{
1216 "vulnerabilities": {"list": []},
1217 "warnings": {
1218 "unmaintained": [{
1219 "advisory": {
1220 "id": "RUSTSEC-2025-0012",
1221 "title": "backoff is unmaintained"
1222 },
1223 "package": {"name": "backoff", "version": "0.4.0"}
1224 }]
1225 }
1226 }"#;
1227 let diagnostics = AuditFixer::parse_audit_json(json);
1228 assert_eq!(diagnostics.len(), 1);
1229 let d = &diagnostics[0];
1230 assert_eq!(d.kind, DiagnosticKind::Warning);
1231 assert_eq!(d.code.as_deref(), Some("RUSTSEC-2025-0012"));
1232 assert!(d.message.contains("[unmaintained]"));
1233 assert!(d.message.contains("backoff"));
1234 }
1235
1236 #[test]
1237 fn test_audit_parse_mixed_vuln_and_warning_separates_kinds() {
1238 let json = r#"{
1239 "vulnerabilities": {
1240 "list": [{
1241 "advisory": {"id": "RUSTSEC-2023-0071", "title": "marvin"},
1242 "package": {"name": "rsa", "version": "0.9.10"}
1243 }]
1244 },
1245 "warnings": {
1246 "unsound": [{
1247 "advisory": {"id": "RUSTSEC-2026-0097", "title": "rand thread_rng"},
1248 "package": {"name": "rand", "version": "0.8.5"}
1249 }]
1250 }
1251 }"#;
1252 let diagnostics = AuditFixer::parse_audit_json(json);
1253 assert_eq!(diagnostics.len(), 2);
1254 let errors: Vec<_> = diagnostics
1255 .iter()
1256 .filter(|d| d.kind == DiagnosticKind::Error)
1257 .collect();
1258 let warnings: Vec<_> = diagnostics
1259 .iter()
1260 .filter(|d| d.kind == DiagnosticKind::Warning)
1261 .collect();
1262 assert_eq!(errors.len(), 1, "vulnerability must be Error kind");
1263 assert_eq!(warnings.len(), 1, "unsound entry must be Warning kind");
1264 assert!(warnings[0].message.contains("[unsound]"));
1265 }
1266
1267 #[test]
1268 fn test_audit_parse_handles_missing_fields_gracefully() {
1269 let json = r#"{
1272 "vulnerabilities": {
1273 "list": [{
1274 "package": {}
1275 }]
1276 },
1277 "warnings": {}
1278 }"#;
1279 let diagnostics = AuditFixer::parse_audit_json(json);
1280 assert_eq!(diagnostics.len(), 1);
1281 let d = &diagnostics[0];
1282 assert_eq!(d.code.as_deref(), Some("unknown"));
1283 assert!(d.message.contains("unknown"));
1284 }
1285
1286 fn make_diag(kind: DiagnosticKind, code: Option<&str>, message: &str) -> Diagnostic {
1289 Diagnostic {
1290 kind,
1291 message: message.to_string(),
1292 file: None,
1293 line: None,
1294 column: None,
1295 code: code.map(str::to_string),
1296 suggestion: None,
1297 raw: String::new(),
1298 }
1299 }
1300
1301 #[test]
1302 fn test_fingerprint_same_diag_is_stable() {
1303 let d = make_diag(DiagnosticKind::Error, Some("E0425"), "cannot find value `x`");
1304 assert_eq!(d.fingerprint(), d.fingerprint(), "fingerprint must be deterministic");
1305 }
1306
1307 #[test]
1308 fn test_fingerprint_different_code_differs() {
1309 let d1 = make_diag(DiagnosticKind::Error, Some("E0425"), "msg");
1310 let d2 = make_diag(DiagnosticKind::Error, Some("E0308"), "msg");
1311 assert_ne!(d1.fingerprint(), d2.fingerprint(), "different codes must differ");
1312 }
1313
1314 #[test]
1315 fn test_fingerprint_different_kind_differs() {
1316 let d1 = make_diag(DiagnosticKind::Error, Some("E0001"), "msg");
1317 let d2 = make_diag(DiagnosticKind::Warning, Some("E0001"), "msg");
1318 assert_ne!(d1.fingerprint(), d2.fingerprint(), "different kinds must differ");
1319 }
1320
1321 #[test]
1322 fn test_fingerprint_ignores_raw_field() {
1323 let mut d1 = make_diag(DiagnosticKind::Error, Some("E0001"), "msg");
1327 let mut d2 = d1.clone();
1328 d1.raw = "first run output".to_string();
1329 d2.raw = "second run output (different)".to_string();
1330 assert_eq!(d1.fingerprint(), d2.fingerprint(), "raw must not affect fingerprint");
1331 }
1332
1333 #[test]
1334 fn test_fingerprint_long_message_truncated_to_120_chars() {
1335 let prefix = "x".repeat(120);
1338 let d1 = make_diag(DiagnosticKind::Error, None, &format!("{prefix}suffix_A"));
1339 let d2 = make_diag(DiagnosticKind::Error, None, &format!("{prefix}suffix_B"));
1340 assert_eq!(
1341 d1.fingerprint(),
1342 d2.fingerprint(),
1343 "messages differing only beyond 120 chars must share a fingerprint"
1344 );
1345 }
1346
1347 #[test]
1350 fn test_healing_config_default_has_no_verify_cmd() {
1351 let cfg = HealingConfig::default();
1352 assert!(cfg.verify_cmd.is_none(), "verify_cmd must default to None");
1353 }
1354
1355 #[tokio::test]
1356 async fn test_run_verify_cmd_success_returns_none() {
1357 let result = run_verify_cmd(Path::new("."), "true").await;
1359 assert!(result.is_ok());
1360 assert!(result.unwrap().is_none(), "exit 0 should return Ok(None)");
1361 }
1362
1363 #[tokio::test]
1364 async fn test_run_verify_cmd_failure_returns_some_diagnostic() {
1365 let result = run_verify_cmd(Path::new("."), "false").await;
1367 assert!(result.is_ok());
1368 let diag = result.unwrap();
1369 assert!(diag.is_some(), "exit non-zero should return Ok(Some(_))");
1370 let d = diag.unwrap();
1371 assert_eq!(d.kind, DiagnosticKind::Error);
1372 assert!(d.message.contains("false"), "message should name the command");
1373 }
1374
1375 #[tokio::test]
1376 async fn test_run_verify_cmd_failure_captures_stderr() {
1377 let result = run_verify_cmd(Path::new("."), "echo 'stage2-failure-marker' >&2; exit 1").await;
1379 assert!(result.is_ok());
1380 let d = result.unwrap().expect("should be Some on failure");
1381 assert!(
1382 d.raw.contains("stage2-failure-marker"),
1383 "stderr output must appear in raw field, got: {:?}",
1384 d.raw
1385 );
1386 }
1387}