Skip to main content

pawan/healing/
mod.rs

1//! Self-healing module for Pawan
2//!
3//! This module provides automated fixing capabilities for:
4//! - Compilation errors (`rustc` errors)
5//! - Clippy warnings
6//! - Test failures
7//! - Missing documentation
8
9use 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
16/// Shared cargo command runner with concurrent stdout/stderr reads and 5-minute timeout.
17async 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/// A compilation diagnostic (error or warning)
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Diagnostic {
43    /// The type of diagnostic
44    pub kind: DiagnosticKind,
45    /// The diagnostic message
46    pub message: String,
47    /// File path where the issue is
48    pub file: Option<PathBuf>,
49    /// Line number (1-indexed)
50    pub line: Option<usize>,
51    /// Column number (1-indexed)  
52    pub column: Option<usize>,
53    /// The error/warning code (e.g., E0425)
54    pub code: Option<String>,
55    /// Suggested fix from the compiler
56    pub suggestion: Option<String>,
57    /// Full raw output for context
58    pub raw: String,
59}
60
61/// Type of diagnostic
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63pub enum DiagnosticKind {
64    Error,
65    Warning,
66    Note,
67    Help,
68}
69
70/// Result from a healing operation containing remaining issues and a summary.
71#[derive(Debug)]
72pub struct HealingResult {
73    /// Remaining unfixed issues
74    pub remaining: Vec<Diagnostic>,
75    /// Description of what was done
76    pub summary: String,
77}
78
79/// Compiler error fixer — parses cargo check output (JSON + text fallback) into Diagnostics.
80pub struct CompilerFixer {
81    workspace_root: PathBuf,
82}
83
84impl CompilerFixer {
85    /// Create a new CompilerFixer
86    pub fn new(workspace_root: PathBuf) -> Self {
87        Self { workspace_root }
88    }
89
90    /// Parse cargo check output into diagnostics
91    ///
92    /// This method supports both JSON output (from `cargo check --message-format=json`)
93    /// and text output formats.
94    ///
95    /// # Arguments
96    /// * `output` - The output from cargo check
97    ///
98    /// # Returns
99    /// A vector of Diagnostic objects representing compilation errors and warnings
100    pub fn parse_diagnostics(&self, output: &str) -> Vec<Diagnostic> {
101        let mut diagnostics = Vec::new();
102
103        // Parse the JSON output from cargo check --message-format=json
104        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 no JSON output, try parsing text format
116        if diagnostics.is_empty() {
117            diagnostics = self.parse_text_diagnostics(output);
118        }
119
120        diagnostics
121    }
122
123    /// Parse a JSON diagnostic message
124    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        // Skip ICE messages and internal errors
137        if message.contains("internal compiler error") {
138            return None;
139        }
140
141        // Extract code
142        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        // Extract primary span
149        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        // Extract suggestion
175        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                        // Look for suggested replacement
184                        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    /// Parse text format diagnostics (fallback)
212    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            // Match pattern: error[E0425]: cannot find value `x` in this scope
218            if line.starts_with("error") || line.starts_with("warning") {
219                // Save previous diagnostic
220                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                // Extract code like E0425
231                let code = line
232                    .find('[')
233                    .and_then(|start| line.find(']').map(|end| line[start + 1..end].to_string()));
234
235                // Extract message
236                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            // Match pattern: --> src/main.rs:10:5
256            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                    // Parse file:line:column (column may be absent)
260                    let parts: Vec<&str> = path_part.rsplitn(3, ':').collect();
261                    match parts.len() {
262                        3 => {
263                            // file:line:column
264                            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                            // file:line (no column)
270                            d.line = parts[0].parse().ok();
271                            d.file = Some(PathBuf::from(parts[1]));
272                        }
273                        _ => {}
274                    }
275                }
276            }
277            // Match help suggestions
278            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        // Don't forget the last one
287        if let Some(d) = current_diagnostic {
288            diagnostics.push(d);
289        }
290
291        diagnostics
292    }
293
294    /// Run cargo check and get diagnostics
295    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
301/// Clippy warning fixer — runs clippy and filters to warnings only.
302pub 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    /// Run clippy and get warnings
312    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
324/// Test failure fixer — parses cargo test output to identify and locate failed tests.
325pub struct TestFixer {
326    workspace_root: PathBuf,
327}
328
329/// A failed test with name, module, failure output, and optional source location.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct FailedTest {
332    /// Test name (full path)
333    pub name: String,
334    /// Test module path
335    pub module: String,
336    /// Failure message/output
337    pub failure: String,
338    /// Location of the test
339    pub file: Option<PathBuf>,
340    /// Line number
341    pub line: Option<usize>,
342}
343
344impl TestFixer {
345    /// Create a new TestFixer
346    pub fn new(workspace_root: PathBuf) -> Self {
347        Self { workspace_root }
348    }
349
350    /// Run tests and get failures
351    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    /// Parse test output for failures
360    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            // Detect failures section
368            if line.contains("failures:") && !line.contains("test result:") {
369                in_failures_section = true;
370                continue;
371            }
372
373            // End of failures section
374            if in_failures_section && line.starts_with("test result:") {
375                // Save last failure
376                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            // Detect individual test failure
389            if line.starts_with("---- ") && line.ends_with(" stdout ----") {
390                // Save previous failure
391                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                // Start new failure
402                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        // Also look for simple FAILED lines (but skip "test result:" summary lines)
415        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                    // Check if we already have this failure
422                    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    /// Extract module path from test name
439    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
448/// Healer — coordinates CompilerFixer, ClippyFixer, and TestFixer for self-healing.
449pub 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    /// Create a new Healer
460    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    /// Get all diagnostics (errors and warnings) from the workspace
471    ///
472    /// This method runs cargo check and clippy (if configured) to collect
473    /// compilation errors and warnings.
474    ///
475    /// # Returns
476    /// A vector of Diagnostic objects, or an error if the checks fail to run
477    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    /// Get all failed tests from the workspace
492    ///
493    /// This method runs cargo test and collects information about failed tests.
494    ///
495    /// # Returns
496    /// A vector of FailedTest objects, or an error if the tests fail to run
497    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    /// Count total issues concurrently: (errors, warnings, failed_tests).
506    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    /// Format diagnostics for LLM prompt
528    ///
529    /// This method formats compilation diagnostics into a structured format
530    /// suitable for inclusion in an LLM prompt.
531    ///
532    /// # Arguments
533    /// * `diagnostics` - A slice of Diagnostic objects to format
534    ///
535    /// # Returns
536    /// A formatted string ready for use in an LLM prompt
537    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    /// Format failed tests for LLM prompt
568    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        // Some diagnostics omit the column
629        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        // Use only the "test X ... FAILED" line without "test result: FAILED"
718        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}