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::PathBuf;
13use std::process::Stdio;
14use tokio::io::AsyncReadExt;
15use tokio::process::Command;
16
17/// A compilation diagnostic (error or warning)
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Diagnostic {
20    /// The type of diagnostic
21    pub kind: DiagnosticKind,
22    /// The diagnostic message
23    pub message: String,
24    /// File path where the issue is
25    pub file: Option<PathBuf>,
26    /// Line number (1-indexed)
27    pub line: Option<usize>,
28    /// Column number (1-indexed)  
29    pub column: Option<usize>,
30    /// The error/warning code (e.g., E0425)
31    pub code: Option<String>,
32    /// Suggested fix from the compiler
33    pub suggestion: Option<String>,
34    /// Full raw output for context
35    pub raw: String,
36}
37
38/// Type of diagnostic
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum DiagnosticKind {
41    Error,
42    Warning,
43    Note,
44    Help,
45}
46
47/// Result from a healing operation
48#[derive(Debug)]
49/// Result from a healing operation
50///
51/// Result from a healing operation
52///
53/// Contains statistics about issues found and fixed, remaining unfixed issues,
54/// and a summary of the healing operation.
55pub struct HealingResult {
56    /// Remaining unfixed issues
57    pub remaining: Vec<Diagnostic>,
58    /// Description of what was done
59    pub summary: String,
60}
61
62/// Compiler error fixer
63/// Compiler error fixer
64///
65/// This struct handles parsing and fixing compilation errors from cargo check output.
66/// It can parse both JSON and text output formats and convert them into structured
67/// Diagnostic objects that can be used by the healing system.
68pub struct CompilerFixer {
69    workspace_root: PathBuf,
70}
71
72impl CompilerFixer {
73    /// Create a new CompilerFixer
74    pub fn new(workspace_root: PathBuf) -> Self {
75        Self { workspace_root }
76    }
77
78    /// Parse cargo check output into diagnostics
79    ///
80    /// This method supports both JSON output (from `cargo check --message-format=json`)
81    /// and text output formats.
82    ///
83    /// # Arguments
84    /// * `output` - The output from cargo check
85    ///
86    /// # Returns
87    /// A vector of Diagnostic objects representing compilation errors and warnings
88    pub fn parse_diagnostics(&self, output: &str) -> Vec<Diagnostic> {
89        let mut diagnostics = Vec::new();
90
91        // Parse the JSON output from cargo check --message-format=json
92        for line in output.lines() {
93            if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
94                if let Some(msg) = json.get("message") {
95                    let diagnostic = self.parse_diagnostic_message(msg, line);
96                    if let Some(d) = diagnostic {
97                        diagnostics.push(d);
98                    }
99                }
100            }
101        }
102
103        // If no JSON output, try parsing text format
104        if diagnostics.is_empty() {
105            diagnostics = self.parse_text_diagnostics(output);
106        }
107
108        diagnostics
109    }
110
111    /// Parse a JSON diagnostic message
112    fn parse_diagnostic_message(&self, msg: &serde_json::Value, raw: &str) -> Option<Diagnostic> {
113        let level = msg.get("level")?.as_str()?;
114        let message = msg.get("message")?.as_str()?.to_string();
115
116        let kind = match level {
117            "error" => DiagnosticKind::Error,
118            "warning" => DiagnosticKind::Warning,
119            "note" => DiagnosticKind::Note,
120            "help" => DiagnosticKind::Help,
121            _ => return None,
122        };
123
124        // Skip ICE messages and internal errors
125        if message.contains("internal compiler error") {
126            return None;
127        }
128
129        // Extract code
130        let code = msg
131            .get("code")
132            .and_then(|c| c.get("code"))
133            .and_then(|c| c.as_str())
134            .map(|s| s.to_string());
135
136        // Extract primary span
137        let spans = msg.get("spans")?.as_array()?;
138        let primary_span = spans.iter().find(|s| {
139            s.get("is_primary")
140                .and_then(|v| v.as_bool())
141                .unwrap_or(false)
142        });
143
144        let (file, line, column) = if let Some(span) = primary_span {
145            let file = span
146                .get("file_name")
147                .and_then(|v| v.as_str())
148                .map(PathBuf::from);
149            let line = span
150                .get("line_start")
151                .and_then(|v| v.as_u64())
152                .map(|v| v as usize);
153            let column = span
154                .get("column_start")
155                .and_then(|v| v.as_u64())
156                .map(|v| v as usize);
157            (file, line, column)
158        } else {
159            (None, None, None)
160        };
161
162        // Extract suggestion
163        let suggestion = msg
164            .get("children")
165            .and_then(|c| c.as_array())
166            .and_then(|children| {
167                children.iter().find_map(|child| {
168                    let level = child.get("level")?.as_str()?;
169                    if level == "help" {
170                        let help_msg = child.get("message")?.as_str()?;
171                        // Look for suggested replacement
172                        if let Some(spans) = child.get("spans").and_then(|s| s.as_array()) {
173                            for span in spans {
174                                if let Some(replacement) =
175                                    span.get("suggested_replacement").and_then(|v| v.as_str())
176                                {
177                                    return Some(format!("{}: {}", help_msg, replacement));
178                                }
179                            }
180                        }
181                        return Some(help_msg.to_string());
182                    }
183                    None
184                })
185            });
186
187        Some(Diagnostic {
188            kind,
189            message,
190            file,
191            line,
192            column,
193            code,
194            suggestion,
195            raw: raw.to_string(),
196        })
197    }
198
199    /// Parse text format diagnostics (fallback)
200    fn parse_text_diagnostics(&self, output: &str) -> Vec<Diagnostic> {
201        let mut diagnostics = Vec::new();
202        let mut current_diagnostic: Option<Diagnostic> = None;
203
204        for line in output.lines() {
205            // Match pattern: error[E0425]: cannot find value `x` in this scope
206            if line.starts_with("error") || line.starts_with("warning") {
207                // Save previous diagnostic
208                if let Some(d) = current_diagnostic.take() {
209                    diagnostics.push(d);
210                }
211
212                let kind = if line.starts_with("error") {
213                    DiagnosticKind::Error
214                } else {
215                    DiagnosticKind::Warning
216                };
217
218                // Extract code like E0425
219                let code = line
220                    .find('[')
221                    .and_then(|start| line.find(']').map(|end| line[start + 1..end].to_string()));
222
223                // Extract message
224                let message = if let Some(colon_pos) = line.find("]: ") {
225                    line[colon_pos + 3..].to_string()
226                } else if let Some(colon_pos) = line.find(": ") {
227                    line[colon_pos + 2..].to_string()
228                } else {
229                    line.to_string()
230                };
231
232                current_diagnostic = Some(Diagnostic {
233                    kind,
234                    message,
235                    file: None,
236                    line: None,
237                    column: None,
238                    code,
239                    suggestion: None,
240                    raw: line.to_string(),
241                });
242            }
243            // Match pattern: --> src/main.rs:10:5
244            else if line.trim().starts_with("-->") {
245                if let Some(ref mut d) = current_diagnostic {
246                    let path_part = line.trim().trim_start_matches("-->").trim();
247                    // Parse file:line:column
248                    let parts: Vec<&str> = path_part.rsplitn(3, ':').collect();
249                    if parts.len() >= 2 {
250                        d.column = parts[0].parse().ok();
251                        d.line = parts[1].parse().ok();
252                        if parts.len() >= 3 {
253                            d.file = Some(PathBuf::from(parts[2]));
254                        }
255                    }
256                }
257            }
258            // Match help suggestions
259            else if line.trim().starts_with("help:") {
260                if let Some(ref mut d) = current_diagnostic {
261                    let suggestion = line.trim().trim_start_matches("help:").trim();
262                    d.suggestion = Some(suggestion.to_string());
263                }
264            }
265        }
266
267        // Don't forget the last one
268        if let Some(d) = current_diagnostic {
269            diagnostics.push(d);
270        }
271
272        diagnostics
273    }
274
275    /// Run cargo check and get diagnostics
276    pub async fn check(&self) -> Result<Vec<Diagnostic>> {
277        let output = self.run_cargo(&["check", "--message-format=json"]).await?;
278        Ok(self.parse_diagnostics(&output))
279    }
280
281    /// Run a cargo command
282    async fn run_cargo(&self, args: &[&str]) -> Result<String> {
283        let mut cmd = Command::new("cargo");
284        cmd.args(args)
285            .current_dir(&self.workspace_root)
286            .stdout(Stdio::piped())
287            .stderr(Stdio::piped())
288            .stdin(Stdio::null());
289
290        let mut child = cmd.spawn().map_err(PawanError::Io)?;
291
292        let mut stdout = String::new();
293        let mut stderr = String::new();
294
295        if let Some(mut handle) = child.stdout.take() {
296            handle.read_to_string(&mut stdout).await.ok();
297        }
298
299        if let Some(mut handle) = child.stderr.take() {
300            handle.read_to_string(&mut stderr).await.ok();
301        }
302
303        child.wait().await.map_err(PawanError::Io)?;
304
305        // Combine stdout and stderr
306        Ok(format!("{}\n{}", stdout, stderr))
307    }
308}
309
310/// Clippy warning fixer
311///
312/// This struct handles parsing and fixing clippy warnings from cargo clippy output.
313/// It extends CompilerFixer to parse clippy-specific diagnostics and filter them
314/// to only include warnings that need attention.
315///
316/// # Features
317/// - Run `cargo clippy` with JSON output format
318/// - Parse clippy diagnostics into structured Diagnostic objects
319/// - Filter to show only warnings (excluding notes and helps)
320/// - Support for all clippy lint groups
321pub struct ClippyFixer {
322    workspace_root: PathBuf,
323}
324
325impl ClippyFixer {
326    /// Create a new ClippyFixer
327    pub fn new(workspace_root: PathBuf) -> Self {
328        Self { workspace_root }
329    }
330
331    /// Run clippy and get warnings
332    pub async fn check(&self) -> Result<Vec<Diagnostic>> {
333        let output = self.run_clippy().await?;
334        let fixer = CompilerFixer::new(self.workspace_root.clone());
335        let mut diagnostics = fixer.parse_diagnostics(&output);
336
337        // Filter to only warnings
338        diagnostics.retain(|d| d.kind == DiagnosticKind::Warning);
339
340        Ok(diagnostics)
341    }
342
343    /// Run clippy with JSON output
344    async fn run_clippy(&self) -> Result<String> {
345        let mut cmd = Command::new("cargo");
346        cmd.args(["clippy", "--message-format=json", "--", "-W", "clippy::all"])
347            .current_dir(&self.workspace_root)
348            .stdout(Stdio::piped())
349            .stderr(Stdio::piped())
350            .stdin(Stdio::null());
351
352        let mut child = cmd.spawn().map_err(PawanError::Io)?;
353
354        let mut stdout = String::new();
355        let mut stderr = String::new();
356
357        if let Some(mut handle) = child.stdout.take() {
358            handle.read_to_string(&mut stdout).await.ok();
359        }
360
361        if let Some(mut handle) = child.stderr.take() {
362            handle.read_to_string(&mut stderr).await.ok();
363        }
364
365        child.wait().await.map_err(PawanError::Io)?;
366
367        Ok(format!("{}\n{}", stdout, stderr))
368    }
369}
370
371/// Test failure fixer
372/// Test failure fixer
373///
374/// This struct handles parsing and fixing failing tests from cargo test output.
375/// It can identify failed tests, extract their output, and provide structured
376/// information about test failures for the healing system to address.
377pub struct TestFixer {
378    workspace_root: PathBuf,
379}
380
381/// A failed test
382#[derive(Debug, Clone, Serialize, Deserialize)]
383/// A failed test
384///
385/// This struct represents information about a test that failed during execution.
386/// It includes the test name, module path, failure message, file location,
387/// and line number where the test failed.
388pub struct FailedTest {
389    /// Test name (full path)
390    pub name: String,
391    /// Test module path
392    pub module: String,
393    /// Failure message/output
394    pub failure: String,
395    /// Location of the test
396    pub file: Option<PathBuf>,
397    /// Line number
398    pub line: Option<usize>,
399}
400
401impl TestFixer {
402    /// Create a new TestFixer
403    pub fn new(workspace_root: PathBuf) -> Self {
404        Self { workspace_root }
405    }
406
407    /// Run tests and get failures
408    pub async fn check(&self) -> Result<Vec<FailedTest>> {
409        let output = self.run_tests().await?;
410        Ok(self.parse_test_output(&output))
411    }
412
413    /// Run cargo test
414    async fn run_tests(&self) -> Result<String> {
415        let mut cmd = Command::new("cargo");
416        cmd.args(["test", "--no-fail-fast", "--", "--nocapture"])
417            .current_dir(&self.workspace_root)
418            .stdout(Stdio::piped())
419            .stderr(Stdio::piped())
420            .stdin(Stdio::null());
421
422        let mut child = cmd.spawn().map_err(PawanError::Io)?;
423
424        let mut stdout = String::new();
425        let mut stderr = String::new();
426
427        if let Some(mut handle) = child.stdout.take() {
428            handle.read_to_string(&mut stdout).await.ok();
429        }
430
431        if let Some(mut handle) = child.stderr.take() {
432            handle.read_to_string(&mut stderr).await.ok();
433        }
434
435        child.wait().await.map_err(PawanError::Io)?;
436
437        Ok(format!("{}\n{}", stdout, stderr))
438    }
439
440    /// Parse test output for failures
441    fn parse_test_output(&self, output: &str) -> Vec<FailedTest> {
442        let mut failures = Vec::new();
443        let mut in_failures_section = false;
444        let mut current_test: Option<String> = None;
445        let mut current_output = String::new();
446
447        for line in output.lines() {
448            // Detect failures section
449            if line.contains("failures:") && !line.contains("test result:") {
450                in_failures_section = true;
451                continue;
452            }
453
454            // End of failures section
455            if in_failures_section && line.starts_with("test result:") {
456                // Save last failure
457                if let Some(test_name) = current_test.take() {
458                    failures.push(FailedTest {
459                        name: test_name.clone(),
460                        module: self.extract_module(&test_name),
461                        failure: current_output.trim().to_string(),
462                        file: None,
463                        line: None,
464                    });
465                }
466                break;
467            }
468
469            // Detect individual test failure
470            if line.starts_with("---- ") && line.ends_with(" stdout ----") {
471                // Save previous failure
472                if let Some(test_name) = current_test.take() {
473                    failures.push(FailedTest {
474                        name: test_name.clone(),
475                        module: self.extract_module(&test_name),
476                        failure: current_output.trim().to_string(),
477                        file: None,
478                        line: None,
479                    });
480                }
481
482                // Start new failure
483                let test_name = line
484                    .trim_start_matches("---- ")
485                    .trim_end_matches(" stdout ----")
486                    .to_string();
487                current_test = Some(test_name);
488                current_output.clear();
489            } else if current_test.is_some() {
490                current_output.push_str(line);
491                current_output.push('\n');
492            }
493        }
494
495        // Also look for simple FAILED lines
496        for line in output.lines() {
497            if line.contains("FAILED") && line.starts_with("test ") {
498                let parts: Vec<&str> = line.split_whitespace().collect();
499                if parts.len() >= 2 {
500                    let test_name = parts[1].trim_end_matches(" ...");
501
502                    // Check if we already have this failure
503                    if !failures.iter().any(|f| f.name == test_name) {
504                        failures.push(FailedTest {
505                            name: test_name.to_string(),
506                            module: self.extract_module(test_name),
507                            failure: line.to_string(),
508                            file: None,
509                            line: None,
510                        });
511                    }
512                }
513            }
514        }
515
516        failures
517    }
518
519    /// Extract module path from test name
520    fn extract_module(&self, test_name: &str) -> String {
521        if let Some(pos) = test_name.rfind("::") {
522            test_name[..pos].to_string()
523        } else {
524            String::new()
525        }
526    }
527}
528
529/// Healer that coordinates all fixing activities
530/// Healer that coordinates all fixing activities
531///
532/// This struct orchestrates the self-healing process by coordinating multiple fixers:
533/// - CompilerFixer for compilation errors
534/// - ClippyFixer for clippy warnings
535/// - TestFixer for failing tests
536///
537/// It provides a unified interface for getting diagnostics, failed tests, and
538/// counting issues in the workspace.
539pub struct Healer {
540    #[allow(dead_code)]
541    workspace_root: PathBuf,
542    config: HealingConfig,
543    compiler_fixer: CompilerFixer,
544    clippy_fixer: ClippyFixer,
545    test_fixer: TestFixer,
546}
547
548impl Healer {
549    /// Create a new Healer
550    pub fn new(workspace_root: PathBuf, config: HealingConfig) -> Self {
551        Self {
552            compiler_fixer: CompilerFixer::new(workspace_root.clone()),
553            clippy_fixer: ClippyFixer::new(workspace_root.clone()),
554            test_fixer: TestFixer::new(workspace_root.clone()),
555            workspace_root,
556            config,
557        }
558    }
559
560    /// Get all diagnostics (errors and warnings) from the workspace
561    ///
562    /// This method runs cargo check and clippy (if configured) to collect
563    /// compilation errors and warnings.
564    ///
565    /// # Returns
566    /// A vector of Diagnostic objects, or an error if the checks fail to run
567    pub async fn get_diagnostics(&self) -> Result<Vec<Diagnostic>> {
568        let mut all = Vec::new();
569
570        if self.config.fix_errors {
571            all.extend(self.compiler_fixer.check().await?);
572        }
573
574        if self.config.fix_warnings {
575            all.extend(self.clippy_fixer.check().await?);
576        }
577
578        Ok(all)
579    }
580
581    /// Get all failed tests from the workspace
582    ///
583    /// This method runs cargo test and collects information about failed tests.
584    ///
585    /// # Returns
586    /// A vector of FailedTest objects, or an error if the tests fail to run
587    pub async fn get_failed_tests(&self) -> Result<Vec<FailedTest>> {
588        if self.config.fix_tests {
589            self.test_fixer.check().await
590        } else {
591            Ok(Vec::new())
592        }
593    }
594
595    /// Count total issues in the workspace
596    ///
597    /// This method counts errors, warnings, and failed tests.
598    ///
599    /// # Returns
600    /// A tuple of (errors_count, warnings_count, failed_tests_count)
601    pub async fn count_issues(&self) -> Result<(usize, usize, usize)> {
602        let diagnostics = self.get_diagnostics().await?;
603        let tests = self.get_failed_tests().await?;
604
605        let errors = diagnostics
606            .iter()
607            .filter(|d| d.kind == DiagnosticKind::Error)
608            .count();
609        let warnings = diagnostics
610            .iter()
611            .filter(|d| d.kind == DiagnosticKind::Warning)
612            .count();
613        let failed_tests = tests.len();
614
615        Ok((errors, warnings, failed_tests))
616    }
617
618    /// Format diagnostics for LLM prompt
619    ///
620    /// This method formats compilation diagnostics into a structured format
621    /// suitable for inclusion in an LLM prompt.
622    ///
623    /// # Arguments
624    /// * `diagnostics` - A slice of Diagnostic objects to format
625    ///
626    /// # Returns
627    /// A formatted string ready for use in an LLM prompt
628    pub fn format_diagnostics_for_prompt(&self, diagnostics: &[Diagnostic]) -> String {
629        let mut output = String::new();
630
631        for (i, d) in diagnostics.iter().enumerate() {
632            output.push_str(&format!("\n### Issue {}\n", i + 1));
633            output.push_str(&format!("Type: {:?}\n", d.kind));
634
635            if let Some(ref code) = d.code {
636                output.push_str(&format!("Code: {}\n", code));
637            }
638
639            output.push_str(&format!("Message: {}\n", d.message));
640
641            if let Some(ref file) = d.file {
642                output.push_str(&format!(
643                    "Location: {}:{}:{}\n",
644                    file.display(),
645                    d.line.unwrap_or(0),
646                    d.column.unwrap_or(0)
647                ));
648            }
649
650            if let Some(ref suggestion) = d.suggestion {
651                output.push_str(&format!("Suggestion: {}\n", suggestion));
652            }
653        }
654
655        output
656    }
657
658    /// Format failed tests for LLM prompt
659    pub fn format_tests_for_prompt(&self, tests: &[FailedTest]) -> String {
660        let mut output = String::new();
661
662        for (i, test) in tests.iter().enumerate() {
663            output.push_str(&format!("\n### Failed Test {}\n", i + 1));
664            output.push_str(&format!("Name: {}\n", test.name));
665            output.push_str(&format!("Module: {}\n", test.module));
666            output.push_str(&format!("Failure:\n```\n{}\n```\n", test.failure));
667        }
668
669        output
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    #[test]
678    fn test_parse_text_diagnostic() {
679        let output = r#"error[E0425]: cannot find value `x` in this scope
680   --> src/main.rs:10:5
681    |
68210  |     x
683    |     ^ not found in this scope
684"#;
685
686        let fixer = CompilerFixer::new(PathBuf::from("."));
687        let diagnostics = fixer.parse_text_diagnostics(output);
688
689        assert_eq!(diagnostics.len(), 1);
690        assert_eq!(diagnostics[0].kind, DiagnosticKind::Error);
691        assert_eq!(diagnostics[0].code, Some("E0425".to_string()));
692        assert!(diagnostics[0].message.contains("cannot find value"));
693    }
694
695    #[test]
696    fn test_extract_module() {
697        let fixer = TestFixer::new(PathBuf::from("."));
698
699        assert_eq!(
700            fixer.extract_module("crate::module::tests::test_foo"),
701            "crate::module::tests"
702        );
703        assert_eq!(fixer.extract_module("test_foo"), "");
704    }
705}