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
70impl Diagnostic {
71    /// Return a stable 64-bit fingerprint for this diagnostic.
72    ///
73    /// Two diagnostics with the same fingerprint represent the same
74    /// underlying problem.  Used by the anti-thrash guard in
75    /// `heal_with_retries` to detect when the LLM is not making
76    /// progress.
77    ///
78    /// The fingerprint covers `kind`, `code`, and the first 120 chars
79    /// of `message` — enough to distinguish issues without being
80    /// sensitive to trailing whitespace or minor formatting differences.
81    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/// Result from a healing operation containing remaining issues and a summary.
94#[derive(Debug)]
95pub struct HealingResult {
96    /// Remaining unfixed issues
97    pub remaining: Vec<Diagnostic>,
98    /// Description of what was done
99    pub summary: String,
100}
101
102/// Compiler error fixer — parses cargo check output (JSON + text fallback) into Diagnostics.
103pub struct CompilerFixer {
104    workspace_root: PathBuf,
105}
106
107impl CompilerFixer {
108    /// Create a new CompilerFixer
109    pub fn new(workspace_root: PathBuf) -> Self {
110        Self { workspace_root }
111    }
112
113    /// Parse cargo check output into diagnostics
114    ///
115    /// This method supports both JSON output (from `cargo check --message-format=json`)
116    /// and text output formats.
117    ///
118    /// # Arguments
119    /// * `output` - The output from cargo check
120    ///
121    /// # Returns
122    /// A vector of Diagnostic objects representing compilation errors and warnings
123    pub fn parse_diagnostics(&self, output: &str) -> Vec<Diagnostic> {
124        let mut diagnostics = Vec::new();
125
126        // Parse the JSON output from cargo check --message-format=json
127        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 no JSON output, try parsing text format
139        if diagnostics.is_empty() {
140            diagnostics = self.parse_text_diagnostics(output);
141        }
142
143        diagnostics
144    }
145
146    /// Parse a JSON diagnostic message
147    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        // Skip ICE messages and internal errors
160        if message.contains("internal compiler error") {
161            return None;
162        }
163
164        // Extract code
165        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        // Extract primary span
172        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        // Extract suggestion
198        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                        // Look for suggested replacement
207                        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    /// Parse text format diagnostics (fallback)
235    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            // Match pattern: error[E0425]: cannot find value `x` in this scope
241            if line.starts_with("error") || line.starts_with("warning") {
242                // Save previous diagnostic
243                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                // Extract code like E0425
254                let code = line
255                    .find('[')
256                    .and_then(|start| line.find(']').map(|end| line[start + 1..end].to_string()));
257
258                // Extract message
259                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            // Match pattern: --> src/main.rs:10:5
279            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                    // Parse file:line:column (column may be absent)
283                    let parts: Vec<&str> = path_part.rsplitn(3, ':').collect();
284                    match parts.len() {
285                        3 => {
286                            // file:line:column
287                            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                            // file:line (no column)
293                            d.line = parts[0].parse().ok();
294                            d.file = Some(PathBuf::from(parts[1]));
295                        }
296                        _ => {}
297                    }
298                }
299            }
300            // Match help suggestions
301            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        // Don't forget the last one
310        if let Some(d) = current_diagnostic {
311            diagnostics.push(d);
312        }
313
314        diagnostics
315    }
316
317    /// Run cargo check and get diagnostics
318    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
324/// Clippy warning fixer — runs clippy and filters to warnings only.
325pub 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    /// Run clippy and get warnings
335    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
347/// Security advisory fixer — runs `cargo audit --json` and parses each
348/// advisory into a `Diagnostic` so the heal loop can treat security
349/// findings the same way it treats compile errors and clippy warnings.
350///
351/// This is part of the "compile-as-auditor" amplification (#38): we widen
352/// the heal loop's input from `cargo check + clippy + test` to also include
353/// dependency vulnerabilities, treating the toolchain as a unified code
354/// auditor rather than just a build pipeline.
355///
356/// `cargo audit` is a separate binary and not always installed. If it's
357/// missing or fails to run, this fixer returns an empty Vec rather than
358/// erroring — security checks are advisory, not blocking.
359pub struct AuditFixer {
360    workspace_root: PathBuf,
361}
362
363impl AuditFixer {
364    /// Create a new AuditFixer
365    pub fn new(workspace_root: PathBuf) -> Self {
366        Self { workspace_root }
367    }
368
369    /// Run `cargo audit --json` and convert each vulnerability into a Diagnostic.
370    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        // If cargo audit isn't installed, return empty rather than error.
380        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        // cargo audit prints JSON to stdout (vulns + warnings sections);
396        // exit code is non-zero when vulnerabilities are present, but stdout
397        // still contains the JSON we want to parse.
398        let stdout = String::from_utf8_lossy(&output.stdout);
399        Ok(Self::parse_audit_json(&stdout))
400    }
401
402    /// Parse `cargo audit --json` output into Diagnostic entries.
403    /// Pure function — extracted so it can be unit-tested without invoking
404    /// the binary.
405    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        // Hard vulnerabilities (CVEs) — error level
414        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        // Soft warnings (unmaintained, unsound, yanked) — warning level
456        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
496/// Test failure fixer — parses cargo test output to identify and locate failed tests.
497pub struct TestFixer {
498    workspace_root: PathBuf,
499}
500
501/// A failed test with name, module, failure output, and optional source location.
502#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct FailedTest {
504    /// Test name (full path)
505    pub name: String,
506    /// Test module path
507    pub module: String,
508    /// Failure message/output
509    pub failure: String,
510    /// Location of the test
511    pub file: Option<PathBuf>,
512    /// Line number
513    pub line: Option<usize>,
514}
515
516impl TestFixer {
517    /// Create a new TestFixer
518    pub fn new(workspace_root: PathBuf) -> Self {
519        Self { workspace_root }
520    }
521
522    /// Run tests and get failures
523    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    /// Parse test output for failures
532    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            // Detect failures section
540            if line.contains("failures:") && !line.contains("test result:") {
541                in_failures_section = true;
542                continue;
543            }
544
545            // End of failures section
546            if in_failures_section && line.starts_with("test result:") {
547                // Save last failure
548                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            // Detect individual test failure
561            if line.starts_with("---- ") && line.ends_with(" stdout ----") {
562                // Save previous failure
563                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                // Start new failure
574                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        // Also look for simple FAILED lines (but skip "test result:" summary lines)
587        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                    // Check if we already have this failure
594                    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    /// Extract module path from test name
611    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
620/// Healer — coordinates CompilerFixer, ClippyFixer, and TestFixer for self-healing.
621pub 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    /// Create a new Healer
633    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    /// Get all diagnostics (errors and warnings) from the workspace
645    ///
646    /// This method runs cargo check, clippy, and (if `fix_security` is on)
647    /// `cargo audit` to collect compilation errors, warnings, and security
648    /// advisories — the "compile-as-auditor" amplification (#38) treats the
649    /// toolchain as a unified code reviewer rather than just a build pipeline.
650    ///
651    /// # Returns
652    /// A vector of Diagnostic objects, or an error if the checks fail to run
653    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    /// Get all failed tests from the workspace
672    ///
673    /// This method runs cargo test and collects information about failed tests.
674    ///
675    /// # Returns
676    /// A vector of FailedTest objects, or an error if the tests fail to run
677    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    /// Count total issues concurrently: (errors, warnings, failed_tests).
686    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    /// Format diagnostics for LLM prompt
708    ///
709    /// This method formats compilation diagnostics into a structured format
710    /// suitable for inclusion in an LLM prompt.
711    ///
712    /// # Arguments
713    /// * `diagnostics` - A slice of Diagnostic objects to format
714    ///
715    /// # Returns
716    /// A formatted string ready for use in an LLM prompt
717    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    /// Format failed tests for LLM prompt
748    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
762/// Run a user-supplied shell command (stage-2 verify gate) from `workspace_root`.
763///
764/// Returns `Ok(None)` if the command exits 0 (passed).
765/// Returns `Ok(Some(diagnostic))` if it exits non-zero (failed) — the
766/// diagnostic message contains the captured stdout+stderr so the heal loop
767/// can pass it back to the LLM as context.
768/// Returns `Err` only if the command itself cannot be spawned.
769pub 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        // Some diagnostics omit the column
851        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        // Use only the "test X ... FAILED" line without "test result: FAILED"
940        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        // Note and Help are valid diagnostic kinds — should not be filtered out.
988        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        // An unrecognized level like "trace" or "debug" should be skipped entirely.
1003        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        // children[].spans[].suggested_replacement should be combined into the
1016        // suggestion field as "help_msg: replacement_text".
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":";"}]}]}}"#;
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        // When no span has is_primary=true, file/line/column should all be None.
1039        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        // When JSON parsing succeeds on at least one line, text fallback must NOT
1051        // fire — otherwise a single good JSON line would be augmented with
1052        // potentially wrong text-parsed versions of surrounding lines.
1053        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    // ─── Edge cases for text parser (task #23 extension) ─────────────────
1068
1069    #[test]
1070    fn test_parse_text_error_without_code_bracket() {
1071        // Some cargo errors don't have [E0xxx] codes — e.g. "error: cannot
1072        // find crate" or the io::Error reported by rustc. The parser must
1073        // still produce a diagnostic with code=None and the message intact.
1074        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        // A `help:` line appearing before any error/warning has no current
1089        // diagnostic to attach to — it must be silently dropped, not crash.
1090        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        // An unusual --> with just a filename (no :line:column) must not
1102        // panic — the rsplitn(3, ':') yields 1 part which falls through the
1103        // match with `_ => {}`, leaving file/line/column unset.
1104        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        // Parser leaves all three None because parts.len() is 1 (no colons)
1109        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        // Regression: format_diagnostics_for_prompt on an empty vec must
1117        // return an empty/minimal string, not panic or iterate a None.
1118        let healer = Healer::new(PathBuf::from("."), HealingConfig::default());
1119        let out = healer.format_diagnostics_for_prompt(&[]);
1120        // No diagnostic markers should appear
1121        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        // rfind("::") correctly extracts everything before the last `::`
1130        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        // Running parser on lines that don't look like errors at all (build
1151        // progress, linker notes) must return 0 diagnostics.
1152        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    // ─── AuditFixer parser tests (compile-as-auditor amplification) ──────
1163
1164    #[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        // Garbage in, no panic, empty out — `cargo audit` failures must
1173        // never crash the heal loop.
1174        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        // Real-world JSON has occasional missing optional fields. Parser
1270        // should default to "unknown" rather than panicking.
1271        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    // ─── Diagnostic::fingerprint tests ───────────────────────────────────
1287
1288    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        // raw captures full compiler output; it must not affect the fingerprint
1324        // so that the same logical error seen twice (with different raw context)
1325        // is still identified as the same fingerprint.
1326        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        // Two diagnostics that share the same first 120 chars but differ after
1336        // must produce the same fingerprint (truncation at 120).
1337        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    // ─── run_verify_cmd tests ──────────────────────────────────────────────
1348
1349    #[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        // `true` always exits 0
1358        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        // `false` always exits 1 with no output
1366        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        // echo to stderr then exit non-zero
1378        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}