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 =
320 run_cargo_command(&self.workspace_root, &["check", "--message-format=json"]).await?;
321 Ok(self.parse_diagnostics(&output))
322 }
323}
324
325pub struct ClippyFixer {
327 workspace_root: PathBuf,
328}
329
330impl ClippyFixer {
331 pub fn new(workspace_root: PathBuf) -> Self {
332 Self { workspace_root }
333 }
334
335 pub async fn check(&self) -> Result<Vec<Diagnostic>> {
337 let output = run_cargo_command(
338 &self.workspace_root,
339 &["clippy", "--message-format=json", "--", "-W", "clippy::all"],
340 )
341 .await?;
342 let fixer = CompilerFixer::new(self.workspace_root.clone());
343 let mut diagnostics = fixer.parse_diagnostics(&output);
344 diagnostics.retain(|d| d.kind == DiagnosticKind::Warning);
345 Ok(diagnostics)
346 }
347}
348
349pub struct AuditFixer {
362 workspace_root: PathBuf,
363}
364
365impl AuditFixer {
366 pub fn new(workspace_root: PathBuf) -> Self {
368 Self { workspace_root }
369 }
370
371 pub async fn check(&self) -> Result<Vec<Diagnostic>> {
373 let child = Command::new("cargo")
374 .args(["audit", "--json"])
375 .current_dir(&self.workspace_root)
376 .stdout(Stdio::piped())
377 .stderr(Stdio::piped())
378 .stdin(Stdio::null())
379 .spawn();
380
381 let child = match child {
383 Ok(c) => c,
384 Err(_) => return Ok(Vec::new()),
385 };
386
387 let output = match tokio::time::timeout(
388 std::time::Duration::from_secs(120),
389 child.wait_with_output(),
390 )
391 .await
392 {
393 Ok(Ok(o)) => o,
394 _ => return Ok(Vec::new()),
395 };
396
397 let stdout = String::from_utf8_lossy(&output.stdout);
401 Ok(Self::parse_audit_json(&stdout))
402 }
403
404 pub fn parse_audit_json(json_text: &str) -> Vec<Diagnostic> {
408 let mut diagnostics = Vec::new();
409
410 let parsed: serde_json::Value = match serde_json::from_str(json_text) {
411 Ok(v) => v,
412 Err(_) => return diagnostics,
413 };
414
415 if let Some(vulns) = parsed
417 .get("vulnerabilities")
418 .and_then(|v| v.get("list"))
419 .and_then(|v| v.as_array())
420 {
421 for vuln in vulns {
422 let advisory = vuln.get("advisory");
423 let id = advisory
424 .and_then(|a| a.get("id"))
425 .and_then(|v| v.as_str())
426 .unwrap_or("unknown");
427 let title = advisory
428 .and_then(|a| a.get("title"))
429 .and_then(|v| v.as_str())
430 .unwrap_or("");
431 let crate_name = vuln
432 .get("package")
433 .and_then(|p| p.get("name"))
434 .and_then(|v| v.as_str())
435 .unwrap_or("unknown");
436 let crate_version = vuln
437 .get("package")
438 .and_then(|p| p.get("version"))
439 .and_then(|v| v.as_str())
440 .unwrap_or("");
441
442 diagnostics.push(Diagnostic {
443 kind: DiagnosticKind::Error,
444 message: format!("[security] {crate_name} {crate_version}: {title}"),
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!("[{kind_name}] {crate_name}: {title}"),
478 file: None,
479 line: None,
480 column: None,
481 code: Some(id.to_string()),
482 suggestion: None,
483 raw: entry.to_string(),
484 });
485 }
486 }
487 }
488 }
489
490 diagnostics
491 }
492}
493
494pub struct TestFixer {
496 workspace_root: PathBuf,
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
501pub struct FailedTest {
502 pub name: String,
504 pub module: String,
506 pub failure: String,
508 pub file: Option<PathBuf>,
510 pub line: Option<usize>,
512}
513
514impl TestFixer {
515 pub fn new(workspace_root: PathBuf) -> Self {
517 Self { workspace_root }
518 }
519
520 pub async fn check(&self) -> Result<Vec<FailedTest>> {
522 let output = run_cargo_command(
523 &self.workspace_root,
524 &["test", "--no-fail-fast", "--", "--nocapture"],
525 )
526 .await?;
527 Ok(self.parse_test_output(&output))
528 }
529
530 fn parse_test_output(&self, output: &str) -> Vec<FailedTest> {
532 let mut failures = Vec::new();
533 let mut in_failures_section = false;
534 let mut current_test: Option<String> = None;
535 let mut current_output = String::new();
536
537 for line in output.lines() {
538 if line.contains("failures:") && !line.contains("test result:") {
540 in_failures_section = true;
541 continue;
542 }
543
544 if in_failures_section && line.starts_with("test result:") {
546 if let Some(test_name) = current_test.take() {
548 failures.push(FailedTest {
549 name: test_name.clone(),
550 module: self.extract_module(&test_name),
551 failure: current_output.trim().to_string(),
552 file: None,
553 line: None,
554 });
555 }
556 break;
557 }
558
559 if line.starts_with("---- ") && line.ends_with(" stdout ----") {
561 if let Some(test_name) = current_test.take() {
563 failures.push(FailedTest {
564 name: test_name.clone(),
565 module: self.extract_module(&test_name),
566 failure: current_output.trim().to_string(),
567 file: None,
568 line: None,
569 });
570 }
571
572 let test_name = line
574 .trim_start_matches("---- ")
575 .trim_end_matches(" stdout ----")
576 .to_string();
577 current_test = Some(test_name);
578 current_output.clear();
579 } else if current_test.is_some() {
580 current_output.push_str(line);
581 current_output.push('\n');
582 }
583 }
584
585 for line in output.lines() {
587 if line.contains("FAILED")
588 && line.starts_with("test ")
589 && !line.starts_with("test result:")
590 {
591 let parts: Vec<&str> = line.split_whitespace().collect();
592 if parts.len() >= 2 {
593 let test_name = parts[1].trim_end_matches(" ...");
594
595 if !failures.iter().any(|f| f.name == test_name) {
597 failures.push(FailedTest {
598 name: test_name.to_string(),
599 module: self.extract_module(test_name),
600 failure: line.to_string(),
601 file: None,
602 line: None,
603 });
604 }
605 }
606 }
607 }
608
609 failures
610 }
611
612 fn extract_module(&self, test_name: &str) -> String {
614 if let Some(pos) = test_name.rfind("::") {
615 test_name[..pos].to_string()
616 } else {
617 String::new()
618 }
619 }
620}
621
622pub struct Healer {
624 #[allow(dead_code)]
625 workspace_root: PathBuf,
626 config: HealingConfig,
627 compiler_fixer: CompilerFixer,
628 clippy_fixer: ClippyFixer,
629 test_fixer: TestFixer,
630 audit_fixer: AuditFixer,
631}
632
633impl Healer {
634 pub fn new(workspace_root: PathBuf, config: HealingConfig) -> Self {
636 Self {
637 compiler_fixer: CompilerFixer::new(workspace_root.clone()),
638 clippy_fixer: ClippyFixer::new(workspace_root.clone()),
639 test_fixer: TestFixer::new(workspace_root.clone()),
640 audit_fixer: AuditFixer::new(workspace_root.clone()),
641 workspace_root,
642 config,
643 }
644 }
645
646 pub async fn get_diagnostics(&self) -> Result<Vec<Diagnostic>> {
656 let mut all = Vec::new();
657
658 if self.config.fix_errors {
659 all.extend(self.compiler_fixer.check().await?);
660 }
661
662 if self.config.fix_warnings {
663 all.extend(self.clippy_fixer.check().await?);
664 }
665
666 if self.config.fix_security {
667 all.extend(self.audit_fixer.check().await?);
668 }
669
670 Ok(all)
671 }
672
673 pub async fn get_failed_tests(&self) -> Result<Vec<FailedTest>> {
680 if self.config.fix_tests {
681 self.test_fixer.check().await
682 } else {
683 Ok(Vec::new())
684 }
685 }
686
687 pub async fn count_issues(&self) -> Result<(usize, usize, usize)> {
689 let (diagnostics, tests) = tokio::join!(self.get_diagnostics(), self.get_failed_tests(),);
690 let diagnostics = diagnostics?;
691 let tests = tests?;
692
693 let errors = diagnostics
694 .iter()
695 .filter(|d| d.kind == DiagnosticKind::Error)
696 .count();
697 let warnings = diagnostics
698 .iter()
699 .filter(|d| d.kind == DiagnosticKind::Warning)
700 .count();
701 let failed_tests = tests.len();
702
703 Ok((errors, warnings, failed_tests))
704 }
705
706 pub fn format_diagnostics_for_prompt(&self, diagnostics: &[Diagnostic]) -> String {
717 let mut output = String::new();
718
719 for (i, d) in diagnostics.iter().enumerate() {
720 output.push_str(&format!("\n### Issue {}\n", i + 1));
721 output.push_str(&format!("Type: {:?}\n", d.kind));
722
723 if let Some(ref code) = d.code {
724 output.push_str(&format!("Code: {}\n", code));
725 }
726
727 output.push_str(&format!("Message: {}\n", d.message));
728
729 if let Some(ref file) = d.file {
730 output.push_str(&format!(
731 "Location: {}:{}:{}\n",
732 file.display(),
733 d.line.unwrap_or(0),
734 d.column.unwrap_or(0)
735 ));
736 }
737
738 if let Some(ref suggestion) = d.suggestion {
739 output.push_str(&format!("Suggestion: {}\n", suggestion));
740 }
741 }
742
743 output
744 }
745
746 pub fn format_tests_for_prompt(&self, tests: &[FailedTest]) -> String {
748 let mut output = String::new();
749
750 for (i, test) in tests.iter().enumerate() {
751 output.push_str(&format!("\n### Failed Test {}\n", i + 1));
752 output.push_str(&format!("Name: {}\n", test.name));
753 output.push_str(&format!("Module: {}\n", test.module));
754 output.push_str(&format!("Failure:\n```\n{}\n```\n", test.failure));
755 }
756
757 output
758 }
759}
760
761pub async fn run_verify_cmd(workspace_root: &Path, cmd: &str) -> Result<Option<Diagnostic>> {
769 let output = Command::new("sh")
770 .arg("-c")
771 .arg(cmd)
772 .current_dir(workspace_root)
773 .stdout(Stdio::piped())
774 .stderr(Stdio::piped())
775 .output()
776 .await
777 .map_err(PawanError::Io)?;
778
779 if output.status.success() {
780 return Ok(None);
781 }
782
783 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
784 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
785 let combined = match (stdout.trim().is_empty(), stderr.trim().is_empty()) {
786 (false, false) => format!("{stdout}\n{stderr}"),
787 (true, _) => stderr,
788 (_, true) => stdout,
789 };
790
791 Ok(Some(Diagnostic {
792 kind: DiagnosticKind::Error,
793 message: format!("verify_cmd `{cmd}` exited with {}", output.status),
794 file: None,
795 line: None,
796 column: None,
797 code: None,
798 suggestion: None,
799 raw: combined,
800 }))
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806
807 #[test]
808 fn test_parse_text_diagnostic() {
809 let output = r#"error[E0425]: cannot find value `x` in this scope
810 --> src/main.rs:10:5
811 |
81210 | x
813 | ^ not found in this scope
814"#;
815
816 let fixer = CompilerFixer::new(PathBuf::from("."));
817 let diagnostics = fixer.parse_text_diagnostics(output);
818
819 assert_eq!(diagnostics.len(), 1);
820 assert_eq!(diagnostics[0].kind, DiagnosticKind::Error);
821 assert_eq!(diagnostics[0].code, Some("E0425".to_string()));
822 assert!(diagnostics[0].message.contains("cannot find value"));
823 }
824
825 #[test]
826 fn test_extract_module() {
827 let fixer = TestFixer::new(PathBuf::from("."));
828
829 assert_eq!(
830 fixer.extract_module("crate::module::tests::test_foo"),
831 "crate::module::tests"
832 );
833 assert_eq!(fixer.extract_module("test_foo"), "");
834 }
835
836 #[test]
837 fn test_parse_text_diagnostic_with_location() {
838 let output = "error[E0308]: mismatched types\n --> src/lib.rs:42:10\n";
839 let fixer = CompilerFixer::new(PathBuf::from("."));
840 let diagnostics = fixer.parse_text_diagnostics(output);
841 assert_eq!(diagnostics.len(), 1);
842 assert_eq!(diagnostics[0].file, Some(PathBuf::from("src/lib.rs")));
843 assert_eq!(diagnostics[0].line, Some(42));
844 assert_eq!(diagnostics[0].column, Some(10));
845 }
846
847 #[test]
848 fn test_parse_text_diagnostic_file_line_only() {
849 let output = "warning: unused variable\n --> src/main.rs:5\n";
851 let fixer = CompilerFixer::new(PathBuf::from("."));
852 let diagnostics = fixer.parse_text_diagnostics(output);
853 assert_eq!(diagnostics.len(), 1);
854 assert_eq!(diagnostics[0].file, Some(PathBuf::from("src/main.rs")));
855 assert_eq!(diagnostics[0].line, Some(5));
856 assert_eq!(diagnostics[0].column, None);
857 }
858
859 #[test]
860 fn test_parse_text_diagnostic_warning() {
861 let output = "warning: unused variable `x`\n --> src/lib.rs:3:5\n";
862 let fixer = CompilerFixer::new(PathBuf::from("."));
863 let diagnostics = fixer.parse_text_diagnostics(output);
864 assert_eq!(diagnostics.len(), 1);
865 assert_eq!(diagnostics[0].kind, DiagnosticKind::Warning);
866 assert!(diagnostics[0].message.contains("unused variable"));
867 }
868
869 #[test]
870 fn test_parse_text_diagnostic_with_help() {
871 let output = "error[E0425]: cannot find value `x`\n --> src/main.rs:10:5\nhelp: consider importing this\n";
872 let fixer = CompilerFixer::new(PathBuf::from("."));
873 let diagnostics = fixer.parse_text_diagnostics(output);
874 assert_eq!(diagnostics.len(), 1);
875 assert_eq!(
876 diagnostics[0].suggestion,
877 Some("consider importing this".to_string())
878 );
879 }
880
881 #[test]
882 fn test_parse_text_multiple_diagnostics() {
883 let output = "error[E0425]: first error\n --> a.rs:1:1\nerror[E0308]: second error\n --> b.rs:2:2\n";
884 let fixer = CompilerFixer::new(PathBuf::from("."));
885 let diagnostics = fixer.parse_text_diagnostics(output);
886 assert_eq!(diagnostics.len(), 2);
887 assert_eq!(diagnostics[0].code, Some("E0425".to_string()));
888 assert_eq!(diagnostics[1].code, Some("E0308".to_string()));
889 assert_eq!(diagnostics[0].file, Some(PathBuf::from("a.rs")));
890 assert_eq!(diagnostics[1].file, Some(PathBuf::from("b.rs")));
891 }
892
893 #[test]
894 fn test_parse_text_empty_output() {
895 let fixer = CompilerFixer::new(PathBuf::from("."));
896 let diagnostics = fixer.parse_text_diagnostics("");
897 assert!(diagnostics.is_empty());
898 }
899
900 #[test]
901 fn test_parse_json_diagnostic() {
902 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":[]}}"#;
903 let fixer = CompilerFixer::new(PathBuf::from("."));
904 let diagnostics = fixer.parse_diagnostics(json_line);
905 assert_eq!(diagnostics.len(), 1);
906 assert_eq!(diagnostics[0].kind, DiagnosticKind::Error);
907 assert_eq!(diagnostics[0].file, Some(PathBuf::from("src/lib.rs")));
908 assert_eq!(diagnostics[0].line, Some(5));
909 assert_eq!(diagnostics[0].column, Some(3));
910 }
911
912 #[test]
913 fn test_parse_json_skips_ice() {
914 let json_line = r#"{"reason":"compiler-message","message":{"level":"error","message":"internal compiler error: something broke","spans":[],"children":[]}}"#;
915 let fixer = CompilerFixer::new(PathBuf::from("."));
916 let diagnostics = fixer.parse_diagnostics(json_line);
917 assert!(diagnostics.is_empty());
918 }
919
920 #[test]
921 fn test_parse_test_output_failures() {
922 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";
923 let fixer = TestFixer::new(PathBuf::from("."));
924 let failures = fixer.parse_test_output(output);
925 assert_eq!(failures.len(), 1);
926 assert_eq!(failures[0].name, "tests::test_add");
927 assert_eq!(failures[0].module, "tests");
928 assert!(failures[0].failure.contains("assertion failed"));
929 }
930
931 #[test]
932 fn test_parse_test_output_no_failures() {
933 let output = "running 5 tests\ntest result: ok. 5 passed; 0 failed;\n";
934 let fixer = TestFixer::new(PathBuf::from("."));
935 let failures = fixer.parse_test_output(output);
936 assert!(failures.is_empty());
937 }
938
939 #[test]
940 fn test_parse_test_output_simple_failed_line() {
941 let output = "test my_module::test_thing ... FAILED\n";
943 let fixer = TestFixer::new(PathBuf::from("."));
944 let failures = fixer.parse_test_output(output);
945 assert_eq!(failures.len(), 1);
946 assert_eq!(failures[0].name, "my_module::test_thing");
947 assert_eq!(failures[0].module, "my_module");
948 }
949
950 #[test]
951 fn test_format_diagnostics_for_prompt() {
952 let healer = Healer::new(PathBuf::from("."), HealingConfig::default());
953 let diagnostics = vec![Diagnostic {
954 kind: DiagnosticKind::Error,
955 message: "unused variable".into(),
956 file: Some(PathBuf::from("src/lib.rs")),
957 line: Some(10),
958 column: Some(5),
959 code: Some("E0001".into()),
960 suggestion: Some("remove it".into()),
961 raw: String::new(),
962 }];
963 let output = healer.format_diagnostics_for_prompt(&diagnostics);
964 assert!(output.contains("Issue 1"));
965 assert!(output.contains("E0001"));
966 assert!(output.contains("unused variable"));
967 assert!(output.contains("src/lib.rs:10:5"));
968 assert!(output.contains("remove it"));
969 }
970
971 #[test]
972 fn test_format_tests_for_prompt() {
973 let healer = Healer::new(PathBuf::from("."), HealingConfig::default());
974 let tests = vec![FailedTest {
975 name: "tests::test_foo".into(),
976 module: "tests".into(),
977 failure: "assertion failed".into(),
978 file: None,
979 line: None,
980 }];
981 let output = healer.format_tests_for_prompt(&tests);
982 assert!(output.contains("Failed Test 1"));
983 assert!(output.contains("tests::test_foo"));
984 assert!(output.contains("assertion failed"));
985 }
986
987 #[test]
988 fn test_parse_json_note_and_help_levels() {
989 let note_line = r#"{"reason":"compiler-message","message":{"level":"note","message":"for more info, see E0001","spans":[],"children":[]}}"#;
991 let help_line = r#"{"reason":"compiler-message","message":{"level":"help","message":"consider borrowing","spans":[],"children":[]}}"#;
992 let fixer = CompilerFixer::new(PathBuf::from("."));
993 let combined = format!("{}\n{}", note_line, help_line);
994 let diagnostics = fixer.parse_diagnostics(&combined);
995 assert_eq!(diagnostics.len(), 2);
996 assert_eq!(diagnostics[0].kind, DiagnosticKind::Note);
997 assert_eq!(diagnostics[1].kind, DiagnosticKind::Help);
998 assert_eq!(diagnostics[0].file, None);
999 assert_eq!(diagnostics[0].line, None);
1000 }
1001
1002 #[test]
1003 fn test_parse_json_unknown_level_is_filtered() {
1004 let line = r#"{"reason":"compiler-message","message":{"level":"trace","message":"verbose info","spans":[],"children":[]}}"#;
1006 let fixer = CompilerFixer::new(PathBuf::from("."));
1007 let diagnostics = fixer.parse_diagnostics(line);
1008 assert!(
1009 diagnostics.is_empty(),
1010 "unknown level should be filtered, got {} diagnostics",
1011 diagnostics.len()
1012 );
1013 }
1014
1015 #[test]
1016 fn test_parse_json_suggestion_with_replacement() {
1017 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":";"}]}]}}"#;
1020 let fixer = CompilerFixer::new(PathBuf::from("."));
1021 let diagnostics = fixer.parse_diagnostics(json);
1022 assert_eq!(diagnostics.len(), 1);
1023 let d = &diagnostics[0];
1024 assert!(d.suggestion.is_some(), "suggestion should be populated");
1025 let suggestion = d.suggestion.as_ref().unwrap();
1026 assert!(
1027 suggestion.contains("add semicolon"),
1028 "suggestion missing help text: {}",
1029 suggestion
1030 );
1031 assert!(
1032 suggestion.contains(";"),
1033 "suggestion missing replacement: {}",
1034 suggestion
1035 );
1036 }
1037
1038 #[test]
1039 fn test_parse_json_no_primary_span() {
1040 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":[]}}"#;
1042 let fixer = CompilerFixer::new(PathBuf::from("."));
1043 let diagnostics = fixer.parse_diagnostics(json);
1044 assert_eq!(diagnostics.len(), 1);
1045 assert_eq!(diagnostics[0].file, None);
1046 assert_eq!(diagnostics[0].line, None);
1047 assert_eq!(diagnostics[0].column, None);
1048 }
1049
1050 #[test]
1051 fn test_parse_mixed_json_and_text_prefers_json() {
1052 let mixed = format!(
1056 "{}\nerror[E0999]: should not be double-parsed\n",
1057 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":[]}}"#
1058 );
1059 let fixer = CompilerFixer::new(PathBuf::from("."));
1060 let diagnostics = fixer.parse_diagnostics(&mixed);
1061 assert_eq!(
1062 diagnostics.len(),
1063 1,
1064 "text fallback must be suppressed when JSON parsing yielded any diagnostics"
1065 );
1066 assert_eq!(diagnostics[0].message, "real error");
1067 }
1068
1069 #[test]
1072 fn test_parse_text_error_without_code_bracket() {
1073 let output = "error: cannot find crate `missing`\n --> src/lib.rs:1:5\n";
1077 let fixer = CompilerFixer::new(PathBuf::from("."));
1078 let diagnostics = fixer.parse_text_diagnostics(output);
1079 assert_eq!(diagnostics.len(), 1);
1080 assert_eq!(diagnostics[0].kind, DiagnosticKind::Error);
1081 assert_eq!(diagnostics[0].code, None, "no [Exxxx] → code is None");
1082 assert!(
1083 diagnostics[0].message.contains("cannot find crate"),
1084 "message must be extracted after 'error: '"
1085 );
1086 }
1087
1088 #[test]
1089 fn test_parse_text_help_before_any_error_is_dropped() {
1090 let output = "help: this is orphaned\nhelp: also orphaned\n";
1093 let fixer = CompilerFixer::new(PathBuf::from("."));
1094 let diagnostics = fixer.parse_text_diagnostics(output);
1095 assert!(
1096 diagnostics.is_empty(),
1097 "orphan help: must not create a diagnostic"
1098 );
1099 }
1100
1101 #[test]
1102 fn test_parse_text_arrow_with_only_filename_no_colons() {
1103 let output = "error[E0999]: malformed error\n --> weird_filename\n";
1107 let fixer = CompilerFixer::new(PathBuf::from("."));
1108 let diagnostics = fixer.parse_text_diagnostics(output);
1109 assert_eq!(diagnostics.len(), 1);
1110 assert_eq!(diagnostics[0].file, None);
1112 assert_eq!(diagnostics[0].line, None);
1113 assert_eq!(diagnostics[0].column, None);
1114 }
1115
1116 #[test]
1117 fn test_format_diagnostics_empty_vec_produces_empty_output() {
1118 let healer = Healer::new(PathBuf::from("."), HealingConfig::default());
1121 let out = healer.format_diagnostics_for_prompt(&[]);
1122 assert!(
1124 !out.contains("Issue 1"),
1125 "empty diagnostics should not render any Issue lines, got: {out}"
1126 );
1127 }
1128
1129 #[test]
1130 fn test_extract_module_with_deeply_nested_path() {
1131 let fixer = TestFixer::new(PathBuf::from("."));
1133 assert_eq!(
1134 fixer.extract_module("a::b::c::d::test_foo"),
1135 "a::b::c::d",
1136 "deeply nested paths strip only the last segment"
1137 );
1138 assert_eq!(
1139 fixer.extract_module("single::test"),
1140 "single",
1141 "single-level path strips to top module"
1142 );
1143 assert_eq!(
1144 fixer.extract_module(""),
1145 "",
1146 "empty string stays empty (no panic)"
1147 );
1148 }
1149
1150 #[test]
1151 fn test_parse_text_diagnostic_with_no_content_at_all() {
1152 let output = " Compiling pawan-core v0.3.1\n Building [====> ] 42/100\n Finished dev [unoptimized] in 2.3s\n";
1155 let fixer = CompilerFixer::new(PathBuf::from("."));
1156 let diagnostics = fixer.parse_text_diagnostics(output);
1157 assert!(
1158 diagnostics.is_empty(),
1159 "build progress lines should not produce diagnostics, got {}",
1160 diagnostics.len()
1161 );
1162 }
1163
1164 #[test]
1167 fn test_audit_parse_empty_output_returns_empty() {
1168 let diagnostics = AuditFixer::parse_audit_json("");
1169 assert!(diagnostics.is_empty());
1170 }
1171
1172 #[test]
1173 fn test_audit_parse_invalid_json_returns_empty() {
1174 let diagnostics = AuditFixer::parse_audit_json("not json at all { ] [");
1177 assert!(diagnostics.is_empty());
1178 }
1179
1180 #[test]
1181 fn test_audit_parse_no_findings_returns_empty() {
1182 let json = r#"{"vulnerabilities":{"list":[]},"warnings":{}}"#;
1183 let diagnostics = AuditFixer::parse_audit_json(json);
1184 assert!(diagnostics.is_empty());
1185 }
1186
1187 #[test]
1188 fn test_audit_parse_vulnerability_becomes_error_diagnostic() {
1189 let json = r#"{
1190 "vulnerabilities": {
1191 "list": [{
1192 "advisory": {
1193 "id": "RUSTSEC-2023-0071",
1194 "title": "Marvin Attack: timing sidechannel in RSA"
1195 },
1196 "package": {
1197 "name": "rsa",
1198 "version": "0.9.10"
1199 }
1200 }]
1201 },
1202 "warnings": {}
1203 }"#;
1204 let diagnostics = AuditFixer::parse_audit_json(json);
1205 assert_eq!(diagnostics.len(), 1);
1206 let d = &diagnostics[0];
1207 assert_eq!(d.kind, DiagnosticKind::Error);
1208 assert_eq!(d.code.as_deref(), Some("RUSTSEC-2023-0071"));
1209 assert!(d.message.contains("rsa"));
1210 assert!(d.message.contains("0.9.10"));
1211 assert!(d.message.contains("Marvin Attack"));
1212 assert!(d.message.starts_with("[security]"));
1213 }
1214
1215 #[test]
1216 fn test_audit_parse_unmaintained_warning_becomes_warning_diagnostic() {
1217 let json = r#"{
1218 "vulnerabilities": {"list": []},
1219 "warnings": {
1220 "unmaintained": [{
1221 "advisory": {
1222 "id": "RUSTSEC-2025-0012",
1223 "title": "backoff is unmaintained"
1224 },
1225 "package": {"name": "backoff", "version": "0.4.0"}
1226 }]
1227 }
1228 }"#;
1229 let diagnostics = AuditFixer::parse_audit_json(json);
1230 assert_eq!(diagnostics.len(), 1);
1231 let d = &diagnostics[0];
1232 assert_eq!(d.kind, DiagnosticKind::Warning);
1233 assert_eq!(d.code.as_deref(), Some("RUSTSEC-2025-0012"));
1234 assert!(d.message.contains("[unmaintained]"));
1235 assert!(d.message.contains("backoff"));
1236 }
1237
1238 #[test]
1239 fn test_audit_parse_mixed_vuln_and_warning_separates_kinds() {
1240 let json = r#"{
1241 "vulnerabilities": {
1242 "list": [{
1243 "advisory": {"id": "RUSTSEC-2023-0071", "title": "marvin"},
1244 "package": {"name": "rsa", "version": "0.9.10"}
1245 }]
1246 },
1247 "warnings": {
1248 "unsound": [{
1249 "advisory": {"id": "RUSTSEC-2026-0097", "title": "rand thread_rng"},
1250 "package": {"name": "rand", "version": "0.8.5"}
1251 }]
1252 }
1253 }"#;
1254 let diagnostics = AuditFixer::parse_audit_json(json);
1255 assert_eq!(diagnostics.len(), 2);
1256 let errors: Vec<_> = diagnostics
1257 .iter()
1258 .filter(|d| d.kind == DiagnosticKind::Error)
1259 .collect();
1260 let warnings: Vec<_> = diagnostics
1261 .iter()
1262 .filter(|d| d.kind == DiagnosticKind::Warning)
1263 .collect();
1264 assert_eq!(errors.len(), 1, "vulnerability must be Error kind");
1265 assert_eq!(warnings.len(), 1, "unsound entry must be Warning kind");
1266 assert!(warnings[0].message.contains("[unsound]"));
1267 }
1268
1269 #[test]
1270 fn test_audit_parse_handles_missing_fields_gracefully() {
1271 let json = r#"{
1274 "vulnerabilities": {
1275 "list": [{
1276 "package": {}
1277 }]
1278 },
1279 "warnings": {}
1280 }"#;
1281 let diagnostics = AuditFixer::parse_audit_json(json);
1282 assert_eq!(diagnostics.len(), 1);
1283 let d = &diagnostics[0];
1284 assert_eq!(d.code.as_deref(), Some("unknown"));
1285 assert!(d.message.contains("unknown"));
1286 }
1287
1288 fn make_diag(kind: DiagnosticKind, code: Option<&str>, message: &str) -> Diagnostic {
1291 Diagnostic {
1292 kind,
1293 message: message.to_string(),
1294 file: None,
1295 line: None,
1296 column: None,
1297 code: code.map(str::to_string),
1298 suggestion: None,
1299 raw: String::new(),
1300 }
1301 }
1302
1303 #[test]
1304 fn test_fingerprint_same_diag_is_stable() {
1305 let d = make_diag(
1306 DiagnosticKind::Error,
1307 Some("E0425"),
1308 "cannot find value `x`",
1309 );
1310 assert_eq!(
1311 d.fingerprint(),
1312 d.fingerprint(),
1313 "fingerprint must be deterministic"
1314 );
1315 }
1316
1317 #[test]
1318 fn test_fingerprint_different_code_differs() {
1319 let d1 = make_diag(DiagnosticKind::Error, Some("E0425"), "msg");
1320 let d2 = make_diag(DiagnosticKind::Error, Some("E0308"), "msg");
1321 assert_ne!(
1322 d1.fingerprint(),
1323 d2.fingerprint(),
1324 "different codes must differ"
1325 );
1326 }
1327
1328 #[test]
1329 fn test_fingerprint_different_kind_differs() {
1330 let d1 = make_diag(DiagnosticKind::Error, Some("E0001"), "msg");
1331 let d2 = make_diag(DiagnosticKind::Warning, Some("E0001"), "msg");
1332 assert_ne!(
1333 d1.fingerprint(),
1334 d2.fingerprint(),
1335 "different kinds must differ"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_fingerprint_ignores_raw_field() {
1341 let mut d1 = make_diag(DiagnosticKind::Error, Some("E0001"), "msg");
1345 let mut d2 = d1.clone();
1346 d1.raw = "first run output".to_string();
1347 d2.raw = "second run output (different)".to_string();
1348 assert_eq!(
1349 d1.fingerprint(),
1350 d2.fingerprint(),
1351 "raw must not affect fingerprint"
1352 );
1353 }
1354
1355 #[test]
1356 fn test_fingerprint_long_message_truncated_to_120_chars() {
1357 let prefix = "x".repeat(120);
1360 let d1 = make_diag(DiagnosticKind::Error, None, &format!("{prefix}suffix_A"));
1361 let d2 = make_diag(DiagnosticKind::Error, None, &format!("{prefix}suffix_B"));
1362 assert_eq!(
1363 d1.fingerprint(),
1364 d2.fingerprint(),
1365 "messages differing only beyond 120 chars must share a fingerprint"
1366 );
1367 }
1368
1369 #[test]
1372 fn test_healing_config_default_has_no_verify_cmd() {
1373 let cfg = HealingConfig::default();
1374 assert!(cfg.verify_cmd.is_none(), "verify_cmd must default to None");
1375 }
1376
1377 #[tokio::test]
1378 async fn test_run_verify_cmd_success_returns_none() {
1379 let result = run_verify_cmd(Path::new("."), "true").await;
1381 assert!(result.is_ok());
1382 assert!(result.unwrap().is_none(), "exit 0 should return Ok(None)");
1383 }
1384
1385 #[tokio::test]
1386 async fn test_run_verify_cmd_failure_returns_some_diagnostic() {
1387 let result = run_verify_cmd(Path::new("."), "false").await;
1389 assert!(result.is_ok());
1390 let diag = result.unwrap();
1391 assert!(diag.is_some(), "exit non-zero should return Ok(Some(_))");
1392 let d = diag.unwrap();
1393 assert_eq!(d.kind, DiagnosticKind::Error);
1394 assert!(
1395 d.message.contains("false"),
1396 "message should name the command"
1397 );
1398 }
1399
1400 #[tokio::test]
1401 async fn test_run_verify_cmd_failure_captures_stderr() {
1402 let result =
1404 run_verify_cmd(Path::new("."), "echo 'stage2-failure-marker' >&2; exit 1").await;
1405 assert!(result.is_ok());
1406 let d = result.unwrap().expect("should be Some on failure");
1407 assert!(
1408 d.raw.contains("stage2-failure-marker"),
1409 "stderr output must appear in raw field, got: {:?}",
1410 d.raw
1411 );
1412 }
1413}