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 =
320            run_cargo_command(&self.workspace_root, &["check", "--message-format=json"]).await?;
321        Ok(self.parse_diagnostics(&output))
322    }
323}
324
325/// Clippy warning fixer — runs clippy and filters to warnings only.
326pub struct ClippyFixer {
327    workspace_root: PathBuf,
328}
329
330impl ClippyFixer {
331    pub fn new(workspace_root: PathBuf) -> Self {
332        Self { workspace_root }
333    }
334
335    /// Run clippy and get warnings
336    pub async fn check(&self) -> Result<Vec<Diagnostic>> {
337        let output = run_cargo_command(
338            &self.workspace_root,
339            &["clippy", "--message-format=json", "--", "-W", "clippy::all"],
340        )
341        .await?;
342        let fixer = CompilerFixer::new(self.workspace_root.clone());
343        let mut diagnostics = fixer.parse_diagnostics(&output);
344        diagnostics.retain(|d| d.kind == DiagnosticKind::Warning);
345        Ok(diagnostics)
346    }
347}
348
349/// Security advisory fixer — runs `cargo audit --json` and parses each
350/// advisory into a `Diagnostic` so the heal loop can treat security
351/// findings the same way it treats compile errors and clippy warnings.
352///
353/// This is part of the "compile-as-auditor" amplification (#38): we widen
354/// the heal loop's input from `cargo check + clippy + test` to also include
355/// dependency vulnerabilities, treating the toolchain as a unified code
356/// auditor rather than just a build pipeline.
357///
358/// `cargo audit` is a separate binary and not always installed. If it's
359/// missing or fails to run, this fixer returns an empty Vec rather than
360/// erroring — security checks are advisory, not blocking.
361pub struct AuditFixer {
362    workspace_root: PathBuf,
363}
364
365impl AuditFixer {
366    /// Create a new AuditFixer
367    pub fn new(workspace_root: PathBuf) -> Self {
368        Self { workspace_root }
369    }
370
371    /// Run `cargo audit --json` and convert each vulnerability into a Diagnostic.
372    pub async fn check(&self) -> Result<Vec<Diagnostic>> {
373        let child = Command::new("cargo")
374            .args(["audit", "--json"])
375            .current_dir(&self.workspace_root)
376            .stdout(Stdio::piped())
377            .stderr(Stdio::piped())
378            .stdin(Stdio::null())
379            .spawn();
380
381        // If cargo audit isn't installed, return empty rather than error.
382        let child = match child {
383            Ok(c) => c,
384            Err(_) => return Ok(Vec::new()),
385        };
386
387        let output = match tokio::time::timeout(
388            std::time::Duration::from_secs(120),
389            child.wait_with_output(),
390        )
391        .await
392        {
393            Ok(Ok(o)) => o,
394            _ => return Ok(Vec::new()),
395        };
396
397        // cargo audit prints JSON to stdout (vulns + warnings sections);
398        // exit code is non-zero when vulnerabilities are present, but stdout
399        // still contains the JSON we want to parse.
400        let stdout = String::from_utf8_lossy(&output.stdout);
401        Ok(Self::parse_audit_json(&stdout))
402    }
403
404    /// Parse `cargo audit --json` output into Diagnostic entries.
405    /// Pure function — extracted so it can be unit-tested without invoking
406    /// the binary.
407    pub fn parse_audit_json(json_text: &str) -> Vec<Diagnostic> {
408        let mut diagnostics = Vec::new();
409
410        let parsed: serde_json::Value = match serde_json::from_str(json_text) {
411            Ok(v) => v,
412            Err(_) => return diagnostics,
413        };
414
415        // Hard vulnerabilities (CVEs) — error level
416        if let Some(vulns) = parsed
417            .get("vulnerabilities")
418            .and_then(|v| v.get("list"))
419            .and_then(|v| v.as_array())
420        {
421            for vuln in vulns {
422                let advisory = vuln.get("advisory");
423                let id = advisory
424                    .and_then(|a| a.get("id"))
425                    .and_then(|v| v.as_str())
426                    .unwrap_or("unknown");
427                let title = advisory
428                    .and_then(|a| a.get("title"))
429                    .and_then(|v| v.as_str())
430                    .unwrap_or("");
431                let crate_name = vuln
432                    .get("package")
433                    .and_then(|p| p.get("name"))
434                    .and_then(|v| v.as_str())
435                    .unwrap_or("unknown");
436                let crate_version = vuln
437                    .get("package")
438                    .and_then(|p| p.get("version"))
439                    .and_then(|v| v.as_str())
440                    .unwrap_or("");
441
442                diagnostics.push(Diagnostic {
443                    kind: DiagnosticKind::Error,
444                    message: format!("[security] {crate_name} {crate_version}: {title}"),
445                    file: None,
446                    line: None,
447                    column: None,
448                    code: Some(id.to_string()),
449                    suggestion: None,
450                    raw: vuln.to_string(),
451                });
452            }
453        }
454
455        // 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!("[{kind_name}] {crate_name}: {title}"),
478                            file: None,
479                            line: None,
480                            column: None,
481                            code: Some(id.to_string()),
482                            suggestion: None,
483                            raw: entry.to_string(),
484                        });
485                    }
486                }
487            }
488        }
489
490        diagnostics
491    }
492}
493
494/// Test failure fixer — parses cargo test output to identify and locate failed tests.
495pub struct TestFixer {
496    workspace_root: PathBuf,
497}
498
499/// A failed test with name, module, failure output, and optional source location.
500#[derive(Debug, Clone, Serialize, Deserialize)]
501pub struct FailedTest {
502    /// Test name (full path)
503    pub name: String,
504    /// Test module path
505    pub module: String,
506    /// Failure message/output
507    pub failure: String,
508    /// Location of the test
509    pub file: Option<PathBuf>,
510    /// Line number
511    pub line: Option<usize>,
512}
513
514impl TestFixer {
515    /// Create a new TestFixer
516    pub fn new(workspace_root: PathBuf) -> Self {
517        Self { workspace_root }
518    }
519
520    /// Run tests and get failures
521    pub async fn check(&self) -> Result<Vec<FailedTest>> {
522        let output = run_cargo_command(
523            &self.workspace_root,
524            &["test", "--no-fail-fast", "--", "--nocapture"],
525        )
526        .await?;
527        Ok(self.parse_test_output(&output))
528    }
529
530    /// Parse test output for failures
531    fn parse_test_output(&self, output: &str) -> Vec<FailedTest> {
532        let mut failures = Vec::new();
533        let mut in_failures_section = false;
534        let mut current_test: Option<String> = None;
535        let mut current_output = String::new();
536
537        for line in output.lines() {
538            // Detect failures section
539            if line.contains("failures:") && !line.contains("test result:") {
540                in_failures_section = true;
541                continue;
542            }
543
544            // End of failures section
545            if in_failures_section && line.starts_with("test result:") {
546                // Save last failure
547                if let Some(test_name) = current_test.take() {
548                    failures.push(FailedTest {
549                        name: test_name.clone(),
550                        module: self.extract_module(&test_name),
551                        failure: current_output.trim().to_string(),
552                        file: None,
553                        line: None,
554                    });
555                }
556                break;
557            }
558
559            // Detect individual test failure
560            if line.starts_with("---- ") && line.ends_with(" stdout ----") {
561                // Save previous failure
562                if let Some(test_name) = current_test.take() {
563                    failures.push(FailedTest {
564                        name: test_name.clone(),
565                        module: self.extract_module(&test_name),
566                        failure: current_output.trim().to_string(),
567                        file: None,
568                        line: None,
569                    });
570                }
571
572                // Start new failure
573                let test_name = line
574                    .trim_start_matches("---- ")
575                    .trim_end_matches(" stdout ----")
576                    .to_string();
577                current_test = Some(test_name);
578                current_output.clear();
579            } else if current_test.is_some() {
580                current_output.push_str(line);
581                current_output.push('\n');
582            }
583        }
584
585        // Also look for simple FAILED lines (but skip "test result:" summary lines)
586        for line in output.lines() {
587            if line.contains("FAILED")
588                && line.starts_with("test ")
589                && !line.starts_with("test result:")
590            {
591                let parts: Vec<&str> = line.split_whitespace().collect();
592                if parts.len() >= 2 {
593                    let test_name = parts[1].trim_end_matches(" ...");
594
595                    // Check if we already have this failure
596                    if !failures.iter().any(|f| f.name == test_name) {
597                        failures.push(FailedTest {
598                            name: test_name.to_string(),
599                            module: self.extract_module(test_name),
600                            failure: line.to_string(),
601                            file: None,
602                            line: None,
603                        });
604                    }
605                }
606            }
607        }
608
609        failures
610    }
611
612    /// Extract module path from test name
613    fn extract_module(&self, test_name: &str) -> String {
614        if let Some(pos) = test_name.rfind("::") {
615            test_name[..pos].to_string()
616        } else {
617            String::new()
618        }
619    }
620}
621
622/// Healer — coordinates CompilerFixer, ClippyFixer, and TestFixer for self-healing.
623pub struct Healer {
624    #[allow(dead_code)]
625    workspace_root: PathBuf,
626    config: HealingConfig,
627    compiler_fixer: CompilerFixer,
628    clippy_fixer: ClippyFixer,
629    test_fixer: TestFixer,
630    audit_fixer: AuditFixer,
631}
632
633impl Healer {
634    /// Create a new Healer
635    pub fn new(workspace_root: PathBuf, config: HealingConfig) -> Self {
636        Self {
637            compiler_fixer: CompilerFixer::new(workspace_root.clone()),
638            clippy_fixer: ClippyFixer::new(workspace_root.clone()),
639            test_fixer: TestFixer::new(workspace_root.clone()),
640            audit_fixer: AuditFixer::new(workspace_root.clone()),
641            workspace_root,
642            config,
643        }
644    }
645
646    /// Get all diagnostics (errors and warnings) from the workspace
647    ///
648    /// This method runs cargo check, clippy, and (if `fix_security` is on)
649    /// `cargo audit` to collect compilation errors, warnings, and security
650    /// advisories — the "compile-as-auditor" amplification (#38) treats the
651    /// toolchain as a unified code reviewer rather than just a build pipeline.
652    ///
653    /// # Returns
654    /// A vector of Diagnostic objects, or an error if the checks fail to run
655    pub async fn get_diagnostics(&self) -> Result<Vec<Diagnostic>> {
656        let mut all = Vec::new();
657
658        if self.config.fix_errors {
659            all.extend(self.compiler_fixer.check().await?);
660        }
661
662        if self.config.fix_warnings {
663            all.extend(self.clippy_fixer.check().await?);
664        }
665
666        if self.config.fix_security {
667            all.extend(self.audit_fixer.check().await?);
668        }
669
670        Ok(all)
671    }
672
673    /// Get all failed tests from the workspace
674    ///
675    /// This method runs cargo test and collects information about failed tests.
676    ///
677    /// # Returns
678    /// A vector of FailedTest objects, or an error if the tests fail to run
679    pub async fn get_failed_tests(&self) -> Result<Vec<FailedTest>> {
680        if self.config.fix_tests {
681            self.test_fixer.check().await
682        } else {
683            Ok(Vec::new())
684        }
685    }
686
687    /// Count total issues concurrently: (errors, warnings, failed_tests).
688    pub async fn count_issues(&self) -> Result<(usize, usize, usize)> {
689        let (diagnostics, tests) = tokio::join!(self.get_diagnostics(), self.get_failed_tests(),);
690        let diagnostics = diagnostics?;
691        let tests = tests?;
692
693        let errors = diagnostics
694            .iter()
695            .filter(|d| d.kind == DiagnosticKind::Error)
696            .count();
697        let warnings = diagnostics
698            .iter()
699            .filter(|d| d.kind == DiagnosticKind::Warning)
700            .count();
701        let failed_tests = tests.len();
702
703        Ok((errors, warnings, failed_tests))
704    }
705
706    /// Format diagnostics for LLM prompt
707    ///
708    /// This method formats compilation diagnostics into a structured format
709    /// suitable for inclusion in an LLM prompt.
710    ///
711    /// # Arguments
712    /// * `diagnostics` - A slice of Diagnostic objects to format
713    ///
714    /// # Returns
715    /// A formatted string ready for use in an LLM prompt
716    pub fn format_diagnostics_for_prompt(&self, diagnostics: &[Diagnostic]) -> String {
717        let mut output = String::new();
718
719        for (i, d) in diagnostics.iter().enumerate() {
720            output.push_str(&format!("\n### Issue {}\n", i + 1));
721            output.push_str(&format!("Type: {:?}\n", d.kind));
722
723            if let Some(ref code) = d.code {
724                output.push_str(&format!("Code: {}\n", code));
725            }
726
727            output.push_str(&format!("Message: {}\n", d.message));
728
729            if let Some(ref file) = d.file {
730                output.push_str(&format!(
731                    "Location: {}:{}:{}\n",
732                    file.display(),
733                    d.line.unwrap_or(0),
734                    d.column.unwrap_or(0)
735                ));
736            }
737
738            if let Some(ref suggestion) = d.suggestion {
739                output.push_str(&format!("Suggestion: {}\n", suggestion));
740            }
741        }
742
743        output
744    }
745
746    /// Format failed tests for LLM prompt
747    pub fn format_tests_for_prompt(&self, tests: &[FailedTest]) -> String {
748        let mut output = String::new();
749
750        for (i, test) in tests.iter().enumerate() {
751            output.push_str(&format!("\n### Failed Test {}\n", i + 1));
752            output.push_str(&format!("Name: {}\n", test.name));
753            output.push_str(&format!("Module: {}\n", test.module));
754            output.push_str(&format!("Failure:\n```\n{}\n```\n", test.failure));
755        }
756
757        output
758    }
759}
760
761/// Run a user-supplied shell command (stage-2 verify gate) from `workspace_root`.
762///
763/// Returns `Ok(None)` if the command exits 0 (passed).
764/// Returns `Ok(Some(diagnostic))` if it exits non-zero (failed) — the
765/// diagnostic message contains the captured stdout+stderr so the heal loop
766/// can pass it back to the LLM as context.
767/// Returns `Err` only if the command itself cannot be spawned.
768pub async fn run_verify_cmd(workspace_root: &Path, cmd: &str) -> Result<Option<Diagnostic>> {
769    let output = Command::new("sh")
770        .arg("-c")
771        .arg(cmd)
772        .current_dir(workspace_root)
773        .stdout(Stdio::piped())
774        .stderr(Stdio::piped())
775        .output()
776        .await
777        .map_err(PawanError::Io)?;
778
779    if output.status.success() {
780        return Ok(None);
781    }
782
783    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
784    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
785    let combined = match (stdout.trim().is_empty(), stderr.trim().is_empty()) {
786        (false, false) => format!("{stdout}\n{stderr}"),
787        (true, _) => stderr,
788        (_, true) => stdout,
789    };
790
791    Ok(Some(Diagnostic {
792        kind: DiagnosticKind::Error,
793        message: format!("verify_cmd `{cmd}` exited with {}", output.status),
794        file: None,
795        line: None,
796        column: None,
797        code: None,
798        suggestion: None,
799        raw: combined,
800    }))
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806
807    #[test]
808    fn test_parse_text_diagnostic() {
809        let output = r#"error[E0425]: cannot find value `x` in this scope
810   --> src/main.rs:10:5
811    |
81210  |     x
813    |     ^ not found in this scope
814"#;
815
816        let fixer = CompilerFixer::new(PathBuf::from("."));
817        let diagnostics = fixer.parse_text_diagnostics(output);
818
819        assert_eq!(diagnostics.len(), 1);
820        assert_eq!(diagnostics[0].kind, DiagnosticKind::Error);
821        assert_eq!(diagnostics[0].code, Some("E0425".to_string()));
822        assert!(diagnostics[0].message.contains("cannot find value"));
823    }
824
825    #[test]
826    fn test_extract_module() {
827        let fixer = TestFixer::new(PathBuf::from("."));
828
829        assert_eq!(
830            fixer.extract_module("crate::module::tests::test_foo"),
831            "crate::module::tests"
832        );
833        assert_eq!(fixer.extract_module("test_foo"), "");
834    }
835
836    #[test]
837    fn test_parse_text_diagnostic_with_location() {
838        let output = "error[E0308]: mismatched types\n   --> src/lib.rs:42:10\n";
839        let fixer = CompilerFixer::new(PathBuf::from("."));
840        let diagnostics = fixer.parse_text_diagnostics(output);
841        assert_eq!(diagnostics.len(), 1);
842        assert_eq!(diagnostics[0].file, Some(PathBuf::from("src/lib.rs")));
843        assert_eq!(diagnostics[0].line, Some(42));
844        assert_eq!(diagnostics[0].column, Some(10));
845    }
846
847    #[test]
848    fn test_parse_text_diagnostic_file_line_only() {
849        // Some diagnostics omit the column
850        let output = "warning: unused variable\n   --> src/main.rs:5\n";
851        let fixer = CompilerFixer::new(PathBuf::from("."));
852        let diagnostics = fixer.parse_text_diagnostics(output);
853        assert_eq!(diagnostics.len(), 1);
854        assert_eq!(diagnostics[0].file, Some(PathBuf::from("src/main.rs")));
855        assert_eq!(diagnostics[0].line, Some(5));
856        assert_eq!(diagnostics[0].column, None);
857    }
858
859    #[test]
860    fn test_parse_text_diagnostic_warning() {
861        let output = "warning: unused variable `x`\n   --> src/lib.rs:3:5\n";
862        let fixer = CompilerFixer::new(PathBuf::from("."));
863        let diagnostics = fixer.parse_text_diagnostics(output);
864        assert_eq!(diagnostics.len(), 1);
865        assert_eq!(diagnostics[0].kind, DiagnosticKind::Warning);
866        assert!(diagnostics[0].message.contains("unused variable"));
867    }
868
869    #[test]
870    fn test_parse_text_diagnostic_with_help() {
871        let output = "error[E0425]: cannot find value `x`\n   --> src/main.rs:10:5\nhelp: consider importing this\n";
872        let fixer = CompilerFixer::new(PathBuf::from("."));
873        let diagnostics = fixer.parse_text_diagnostics(output);
874        assert_eq!(diagnostics.len(), 1);
875        assert_eq!(
876            diagnostics[0].suggestion,
877            Some("consider importing this".to_string())
878        );
879    }
880
881    #[test]
882    fn test_parse_text_multiple_diagnostics() {
883        let output = "error[E0425]: first error\n   --> a.rs:1:1\nerror[E0308]: second error\n   --> b.rs:2:2\n";
884        let fixer = CompilerFixer::new(PathBuf::from("."));
885        let diagnostics = fixer.parse_text_diagnostics(output);
886        assert_eq!(diagnostics.len(), 2);
887        assert_eq!(diagnostics[0].code, Some("E0425".to_string()));
888        assert_eq!(diagnostics[1].code, Some("E0308".to_string()));
889        assert_eq!(diagnostics[0].file, Some(PathBuf::from("a.rs")));
890        assert_eq!(diagnostics[1].file, Some(PathBuf::from("b.rs")));
891    }
892
893    #[test]
894    fn test_parse_text_empty_output() {
895        let fixer = CompilerFixer::new(PathBuf::from("."));
896        let diagnostics = fixer.parse_text_diagnostics("");
897        assert!(diagnostics.is_empty());
898    }
899
900    #[test]
901    fn test_parse_json_diagnostic() {
902        let json_line = r#"{"reason":"compiler-message","message":{"level":"error","message":"unused","code":{"code":"E0001"},"spans":[{"file_name":"src/lib.rs","line_start":5,"column_start":3,"is_primary":true}],"children":[]}}"#;
903        let fixer = CompilerFixer::new(PathBuf::from("."));
904        let diagnostics = fixer.parse_diagnostics(json_line);
905        assert_eq!(diagnostics.len(), 1);
906        assert_eq!(diagnostics[0].kind, DiagnosticKind::Error);
907        assert_eq!(diagnostics[0].file, Some(PathBuf::from("src/lib.rs")));
908        assert_eq!(diagnostics[0].line, Some(5));
909        assert_eq!(diagnostics[0].column, Some(3));
910    }
911
912    #[test]
913    fn test_parse_json_skips_ice() {
914        let json_line = r#"{"reason":"compiler-message","message":{"level":"error","message":"internal compiler error: something broke","spans":[],"children":[]}}"#;
915        let fixer = CompilerFixer::new(PathBuf::from("."));
916        let diagnostics = fixer.parse_diagnostics(json_line);
917        assert!(diagnostics.is_empty());
918    }
919
920    #[test]
921    fn test_parse_test_output_failures() {
922        let output = "---- tests::test_add stdout ----\nthread panicked at 'assertion failed'\n\nfailures:\n    tests::test_add\n\ntest result: FAILED. 1 passed; 1 failed;\n";
923        let fixer = TestFixer::new(PathBuf::from("."));
924        let failures = fixer.parse_test_output(output);
925        assert_eq!(failures.len(), 1);
926        assert_eq!(failures[0].name, "tests::test_add");
927        assert_eq!(failures[0].module, "tests");
928        assert!(failures[0].failure.contains("assertion failed"));
929    }
930
931    #[test]
932    fn test_parse_test_output_no_failures() {
933        let output = "running 5 tests\ntest result: ok. 5 passed; 0 failed;\n";
934        let fixer = TestFixer::new(PathBuf::from("."));
935        let failures = fixer.parse_test_output(output);
936        assert!(failures.is_empty());
937    }
938
939    #[test]
940    fn test_parse_test_output_simple_failed_line() {
941        // Use only the "test X ... FAILED" line without "test result: FAILED"
942        let output = "test my_module::test_thing ... FAILED\n";
943        let fixer = TestFixer::new(PathBuf::from("."));
944        let failures = fixer.parse_test_output(output);
945        assert_eq!(failures.len(), 1);
946        assert_eq!(failures[0].name, "my_module::test_thing");
947        assert_eq!(failures[0].module, "my_module");
948    }
949
950    #[test]
951    fn test_format_diagnostics_for_prompt() {
952        let healer = Healer::new(PathBuf::from("."), HealingConfig::default());
953        let diagnostics = vec![Diagnostic {
954            kind: DiagnosticKind::Error,
955            message: "unused variable".into(),
956            file: Some(PathBuf::from("src/lib.rs")),
957            line: Some(10),
958            column: Some(5),
959            code: Some("E0001".into()),
960            suggestion: Some("remove it".into()),
961            raw: String::new(),
962        }];
963        let output = healer.format_diagnostics_for_prompt(&diagnostics);
964        assert!(output.contains("Issue 1"));
965        assert!(output.contains("E0001"));
966        assert!(output.contains("unused variable"));
967        assert!(output.contains("src/lib.rs:10:5"));
968        assert!(output.contains("remove it"));
969    }
970
971    #[test]
972    fn test_format_tests_for_prompt() {
973        let healer = Healer::new(PathBuf::from("."), HealingConfig::default());
974        let tests = vec![FailedTest {
975            name: "tests::test_foo".into(),
976            module: "tests".into(),
977            failure: "assertion failed".into(),
978            file: None,
979            line: None,
980        }];
981        let output = healer.format_tests_for_prompt(&tests);
982        assert!(output.contains("Failed Test 1"));
983        assert!(output.contains("tests::test_foo"));
984        assert!(output.contains("assertion failed"));
985    }
986
987    #[test]
988    fn test_parse_json_note_and_help_levels() {
989        // Note and Help are valid diagnostic kinds — should not be filtered out.
990        let note_line = r#"{"reason":"compiler-message","message":{"level":"note","message":"for more info, see E0001","spans":[],"children":[]}}"#;
991        let help_line = r#"{"reason":"compiler-message","message":{"level":"help","message":"consider borrowing","spans":[],"children":[]}}"#;
992        let fixer = CompilerFixer::new(PathBuf::from("."));
993        let combined = format!("{}\n{}", note_line, help_line);
994        let diagnostics = fixer.parse_diagnostics(&combined);
995        assert_eq!(diagnostics.len(), 2);
996        assert_eq!(diagnostics[0].kind, DiagnosticKind::Note);
997        assert_eq!(diagnostics[1].kind, DiagnosticKind::Help);
998        assert_eq!(diagnostics[0].file, None);
999        assert_eq!(diagnostics[0].line, None);
1000    }
1001
1002    #[test]
1003    fn test_parse_json_unknown_level_is_filtered() {
1004        // An unrecognized level like "trace" or "debug" should be skipped entirely.
1005        let line = r#"{"reason":"compiler-message","message":{"level":"trace","message":"verbose info","spans":[],"children":[]}}"#;
1006        let fixer = CompilerFixer::new(PathBuf::from("."));
1007        let diagnostics = fixer.parse_diagnostics(line);
1008        assert!(
1009            diagnostics.is_empty(),
1010            "unknown level should be filtered, got {} diagnostics",
1011            diagnostics.len()
1012        );
1013    }
1014
1015    #[test]
1016    fn test_parse_json_suggestion_with_replacement() {
1017        // children[].spans[].suggested_replacement should be combined into the
1018        // suggestion field as "help_msg: replacement_text".
1019        let json = r#"{"reason":"compiler-message","message":{"level":"error","message":"missing semicolon","code":{"code":"E0001"},"spans":[{"file_name":"src/foo.rs","line_start":3,"column_start":10,"is_primary":true}],"children":[{"level":"help","message":"add semicolon","spans":[{"suggested_replacement":";"}]}]}}"#;
1020        let fixer = CompilerFixer::new(PathBuf::from("."));
1021        let diagnostics = fixer.parse_diagnostics(json);
1022        assert_eq!(diagnostics.len(), 1);
1023        let d = &diagnostics[0];
1024        assert!(d.suggestion.is_some(), "suggestion should be populated");
1025        let suggestion = d.suggestion.as_ref().unwrap();
1026        assert!(
1027            suggestion.contains("add semicolon"),
1028            "suggestion missing help text: {}",
1029            suggestion
1030        );
1031        assert!(
1032            suggestion.contains(";"),
1033            "suggestion missing replacement: {}",
1034            suggestion
1035        );
1036    }
1037
1038    #[test]
1039    fn test_parse_json_no_primary_span() {
1040        // When no span has is_primary=true, file/line/column should all be None.
1041        let json = r#"{"reason":"compiler-message","message":{"level":"error","message":"no primary span","code":null,"spans":[{"file_name":"src/x.rs","line_start":1,"is_primary":false}],"children":[]}}"#;
1042        let fixer = CompilerFixer::new(PathBuf::from("."));
1043        let diagnostics = fixer.parse_diagnostics(json);
1044        assert_eq!(diagnostics.len(), 1);
1045        assert_eq!(diagnostics[0].file, None);
1046        assert_eq!(diagnostics[0].line, None);
1047        assert_eq!(diagnostics[0].column, None);
1048    }
1049
1050    #[test]
1051    fn test_parse_mixed_json_and_text_prefers_json() {
1052        // When JSON parsing succeeds on at least one line, text fallback must NOT
1053        // fire — otherwise a single good JSON line would be augmented with
1054        // potentially wrong text-parsed versions of surrounding lines.
1055        let mixed = format!(
1056            "{}\nerror[E0999]: should not be double-parsed\n",
1057            r#"{"reason":"compiler-message","message":{"level":"error","message":"real error","code":{"code":"E0001"},"spans":[{"file_name":"src/a.rs","line_start":1,"column_start":1,"is_primary":true}],"children":[]}}"#
1058        );
1059        let fixer = CompilerFixer::new(PathBuf::from("."));
1060        let diagnostics = fixer.parse_diagnostics(&mixed);
1061        assert_eq!(
1062            diagnostics.len(),
1063            1,
1064            "text fallback must be suppressed when JSON parsing yielded any diagnostics"
1065        );
1066        assert_eq!(diagnostics[0].message, "real error");
1067    }
1068
1069    // ─── Edge cases for text parser (task #23 extension) ─────────────────
1070
1071    #[test]
1072    fn test_parse_text_error_without_code_bracket() {
1073        // Some cargo errors don't have [E0xxx] codes — e.g. "error: cannot
1074        // find crate" or the io::Error reported by rustc. The parser must
1075        // still produce a diagnostic with code=None and the message intact.
1076        let output = "error: cannot find crate `missing`\n   --> src/lib.rs:1:5\n";
1077        let fixer = CompilerFixer::new(PathBuf::from("."));
1078        let diagnostics = fixer.parse_text_diagnostics(output);
1079        assert_eq!(diagnostics.len(), 1);
1080        assert_eq!(diagnostics[0].kind, DiagnosticKind::Error);
1081        assert_eq!(diagnostics[0].code, None, "no [Exxxx] → code is None");
1082        assert!(
1083            diagnostics[0].message.contains("cannot find crate"),
1084            "message must be extracted after 'error: '"
1085        );
1086    }
1087
1088    #[test]
1089    fn test_parse_text_help_before_any_error_is_dropped() {
1090        // A `help:` line appearing before any error/warning has no current
1091        // diagnostic to attach to — it must be silently dropped, not crash.
1092        let output = "help: this is orphaned\nhelp: also orphaned\n";
1093        let fixer = CompilerFixer::new(PathBuf::from("."));
1094        let diagnostics = fixer.parse_text_diagnostics(output);
1095        assert!(
1096            diagnostics.is_empty(),
1097            "orphan help: must not create a diagnostic"
1098        );
1099    }
1100
1101    #[test]
1102    fn test_parse_text_arrow_with_only_filename_no_colons() {
1103        // An unusual --> with just a filename (no :line:column) must not
1104        // panic — the rsplitn(3, ':') yields 1 part which falls through the
1105        // match with `_ => {}`, leaving file/line/column unset.
1106        let output = "error[E0999]: malformed error\n   --> weird_filename\n";
1107        let fixer = CompilerFixer::new(PathBuf::from("."));
1108        let diagnostics = fixer.parse_text_diagnostics(output);
1109        assert_eq!(diagnostics.len(), 1);
1110        // Parser leaves all three None because parts.len() is 1 (no colons)
1111        assert_eq!(diagnostics[0].file, None);
1112        assert_eq!(diagnostics[0].line, None);
1113        assert_eq!(diagnostics[0].column, None);
1114    }
1115
1116    #[test]
1117    fn test_format_diagnostics_empty_vec_produces_empty_output() {
1118        // Regression: format_diagnostics_for_prompt on an empty vec must
1119        // return an empty/minimal string, not panic or iterate a None.
1120        let healer = Healer::new(PathBuf::from("."), HealingConfig::default());
1121        let out = healer.format_diagnostics_for_prompt(&[]);
1122        // No diagnostic markers should appear
1123        assert!(
1124            !out.contains("Issue 1"),
1125            "empty diagnostics should not render any Issue lines, got: {out}"
1126        );
1127    }
1128
1129    #[test]
1130    fn test_extract_module_with_deeply_nested_path() {
1131        // rfind("::") correctly extracts everything before the last `::`
1132        let fixer = TestFixer::new(PathBuf::from("."));
1133        assert_eq!(
1134            fixer.extract_module("a::b::c::d::test_foo"),
1135            "a::b::c::d",
1136            "deeply nested paths strip only the last segment"
1137        );
1138        assert_eq!(
1139            fixer.extract_module("single::test"),
1140            "single",
1141            "single-level path strips to top module"
1142        );
1143        assert_eq!(
1144            fixer.extract_module(""),
1145            "",
1146            "empty string stays empty (no panic)"
1147        );
1148    }
1149
1150    #[test]
1151    fn test_parse_text_diagnostic_with_no_content_at_all() {
1152        // Running parser on lines that don't look like errors at all (build
1153        // progress, linker notes) must return 0 diagnostics.
1154        let output = "   Compiling pawan-core v0.3.1\n    Building [====>    ] 42/100\n   Finished dev [unoptimized] in 2.3s\n";
1155        let fixer = CompilerFixer::new(PathBuf::from("."));
1156        let diagnostics = fixer.parse_text_diagnostics(output);
1157        assert!(
1158            diagnostics.is_empty(),
1159            "build progress lines should not produce diagnostics, got {}",
1160            diagnostics.len()
1161        );
1162    }
1163
1164    // ─── AuditFixer parser tests (compile-as-auditor amplification) ──────
1165
1166    #[test]
1167    fn test_audit_parse_empty_output_returns_empty() {
1168        let diagnostics = AuditFixer::parse_audit_json("");
1169        assert!(diagnostics.is_empty());
1170    }
1171
1172    #[test]
1173    fn test_audit_parse_invalid_json_returns_empty() {
1174        // Garbage in, no panic, empty out — `cargo audit` failures must
1175        // never crash the heal loop.
1176        let diagnostics = AuditFixer::parse_audit_json("not json at all { ] [");
1177        assert!(diagnostics.is_empty());
1178    }
1179
1180    #[test]
1181    fn test_audit_parse_no_findings_returns_empty() {
1182        let json = r#"{"vulnerabilities":{"list":[]},"warnings":{}}"#;
1183        let diagnostics = AuditFixer::parse_audit_json(json);
1184        assert!(diagnostics.is_empty());
1185    }
1186
1187    #[test]
1188    fn test_audit_parse_vulnerability_becomes_error_diagnostic() {
1189        let json = r#"{
1190            "vulnerabilities": {
1191                "list": [{
1192                    "advisory": {
1193                        "id": "RUSTSEC-2023-0071",
1194                        "title": "Marvin Attack: timing sidechannel in RSA"
1195                    },
1196                    "package": {
1197                        "name": "rsa",
1198                        "version": "0.9.10"
1199                    }
1200                }]
1201            },
1202            "warnings": {}
1203        }"#;
1204        let diagnostics = AuditFixer::parse_audit_json(json);
1205        assert_eq!(diagnostics.len(), 1);
1206        let d = &diagnostics[0];
1207        assert_eq!(d.kind, DiagnosticKind::Error);
1208        assert_eq!(d.code.as_deref(), Some("RUSTSEC-2023-0071"));
1209        assert!(d.message.contains("rsa"));
1210        assert!(d.message.contains("0.9.10"));
1211        assert!(d.message.contains("Marvin Attack"));
1212        assert!(d.message.starts_with("[security]"));
1213    }
1214
1215    #[test]
1216    fn test_audit_parse_unmaintained_warning_becomes_warning_diagnostic() {
1217        let json = r#"{
1218            "vulnerabilities": {"list": []},
1219            "warnings": {
1220                "unmaintained": [{
1221                    "advisory": {
1222                        "id": "RUSTSEC-2025-0012",
1223                        "title": "backoff is unmaintained"
1224                    },
1225                    "package": {"name": "backoff", "version": "0.4.0"}
1226                }]
1227            }
1228        }"#;
1229        let diagnostics = AuditFixer::parse_audit_json(json);
1230        assert_eq!(diagnostics.len(), 1);
1231        let d = &diagnostics[0];
1232        assert_eq!(d.kind, DiagnosticKind::Warning);
1233        assert_eq!(d.code.as_deref(), Some("RUSTSEC-2025-0012"));
1234        assert!(d.message.contains("[unmaintained]"));
1235        assert!(d.message.contains("backoff"));
1236    }
1237
1238    #[test]
1239    fn test_audit_parse_mixed_vuln_and_warning_separates_kinds() {
1240        let json = r#"{
1241            "vulnerabilities": {
1242                "list": [{
1243                    "advisory": {"id": "RUSTSEC-2023-0071", "title": "marvin"},
1244                    "package": {"name": "rsa", "version": "0.9.10"}
1245                }]
1246            },
1247            "warnings": {
1248                "unsound": [{
1249                    "advisory": {"id": "RUSTSEC-2026-0097", "title": "rand thread_rng"},
1250                    "package": {"name": "rand", "version": "0.8.5"}
1251                }]
1252            }
1253        }"#;
1254        let diagnostics = AuditFixer::parse_audit_json(json);
1255        assert_eq!(diagnostics.len(), 2);
1256        let errors: Vec<_> = diagnostics
1257            .iter()
1258            .filter(|d| d.kind == DiagnosticKind::Error)
1259            .collect();
1260        let warnings: Vec<_> = diagnostics
1261            .iter()
1262            .filter(|d| d.kind == DiagnosticKind::Warning)
1263            .collect();
1264        assert_eq!(errors.len(), 1, "vulnerability must be Error kind");
1265        assert_eq!(warnings.len(), 1, "unsound entry must be Warning kind");
1266        assert!(warnings[0].message.contains("[unsound]"));
1267    }
1268
1269    #[test]
1270    fn test_audit_parse_handles_missing_fields_gracefully() {
1271        // Real-world JSON has occasional missing optional fields. Parser
1272        // should default to "unknown" rather than panicking.
1273        let json = r#"{
1274            "vulnerabilities": {
1275                "list": [{
1276                    "package": {}
1277                }]
1278            },
1279            "warnings": {}
1280        }"#;
1281        let diagnostics = AuditFixer::parse_audit_json(json);
1282        assert_eq!(diagnostics.len(), 1);
1283        let d = &diagnostics[0];
1284        assert_eq!(d.code.as_deref(), Some("unknown"));
1285        assert!(d.message.contains("unknown"));
1286    }
1287
1288    // ─── Diagnostic::fingerprint tests ───────────────────────────────────
1289
1290    fn make_diag(kind: DiagnosticKind, code: Option<&str>, message: &str) -> Diagnostic {
1291        Diagnostic {
1292            kind,
1293            message: message.to_string(),
1294            file: None,
1295            line: None,
1296            column: None,
1297            code: code.map(str::to_string),
1298            suggestion: None,
1299            raw: String::new(),
1300        }
1301    }
1302
1303    #[test]
1304    fn test_fingerprint_same_diag_is_stable() {
1305        let d = make_diag(
1306            DiagnosticKind::Error,
1307            Some("E0425"),
1308            "cannot find value `x`",
1309        );
1310        assert_eq!(
1311            d.fingerprint(),
1312            d.fingerprint(),
1313            "fingerprint must be deterministic"
1314        );
1315    }
1316
1317    #[test]
1318    fn test_fingerprint_different_code_differs() {
1319        let d1 = make_diag(DiagnosticKind::Error, Some("E0425"), "msg");
1320        let d2 = make_diag(DiagnosticKind::Error, Some("E0308"), "msg");
1321        assert_ne!(
1322            d1.fingerprint(),
1323            d2.fingerprint(),
1324            "different codes must differ"
1325        );
1326    }
1327
1328    #[test]
1329    fn test_fingerprint_different_kind_differs() {
1330        let d1 = make_diag(DiagnosticKind::Error, Some("E0001"), "msg");
1331        let d2 = make_diag(DiagnosticKind::Warning, Some("E0001"), "msg");
1332        assert_ne!(
1333            d1.fingerprint(),
1334            d2.fingerprint(),
1335            "different kinds must differ"
1336        );
1337    }
1338
1339    #[test]
1340    fn test_fingerprint_ignores_raw_field() {
1341        // raw captures full compiler output; it must not affect the fingerprint
1342        // so that the same logical error seen twice (with different raw context)
1343        // is still identified as the same fingerprint.
1344        let mut d1 = make_diag(DiagnosticKind::Error, Some("E0001"), "msg");
1345        let mut d2 = d1.clone();
1346        d1.raw = "first run output".to_string();
1347        d2.raw = "second run output (different)".to_string();
1348        assert_eq!(
1349            d1.fingerprint(),
1350            d2.fingerprint(),
1351            "raw must not affect fingerprint"
1352        );
1353    }
1354
1355    #[test]
1356    fn test_fingerprint_long_message_truncated_to_120_chars() {
1357        // Two diagnostics that share the same first 120 chars but differ after
1358        // must produce the same fingerprint (truncation at 120).
1359        let prefix = "x".repeat(120);
1360        let d1 = make_diag(DiagnosticKind::Error, None, &format!("{prefix}suffix_A"));
1361        let d2 = make_diag(DiagnosticKind::Error, None, &format!("{prefix}suffix_B"));
1362        assert_eq!(
1363            d1.fingerprint(),
1364            d2.fingerprint(),
1365            "messages differing only beyond 120 chars must share a fingerprint"
1366        );
1367    }
1368
1369    // ─── run_verify_cmd tests ──────────────────────────────────────────────
1370
1371    #[test]
1372    fn test_healing_config_default_has_no_verify_cmd() {
1373        let cfg = HealingConfig::default();
1374        assert!(cfg.verify_cmd.is_none(), "verify_cmd must default to None");
1375    }
1376
1377    #[tokio::test]
1378    async fn test_run_verify_cmd_success_returns_none() {
1379        // `true` always exits 0
1380        let result = run_verify_cmd(Path::new("."), "true").await;
1381        assert!(result.is_ok());
1382        assert!(result.unwrap().is_none(), "exit 0 should return Ok(None)");
1383    }
1384
1385    #[tokio::test]
1386    async fn test_run_verify_cmd_failure_returns_some_diagnostic() {
1387        // `false` always exits 1 with no output
1388        let result = run_verify_cmd(Path::new("."), "false").await;
1389        assert!(result.is_ok());
1390        let diag = result.unwrap();
1391        assert!(diag.is_some(), "exit non-zero should return Ok(Some(_))");
1392        let d = diag.unwrap();
1393        assert_eq!(d.kind, DiagnosticKind::Error);
1394        assert!(
1395            d.message.contains("false"),
1396            "message should name the command"
1397        );
1398    }
1399
1400    #[tokio::test]
1401    async fn test_run_verify_cmd_failure_captures_stderr() {
1402        // echo to stderr then exit non-zero
1403        let result =
1404            run_verify_cmd(Path::new("."), "echo 'stage2-failure-marker' >&2; exit 1").await;
1405        assert!(result.is_ok());
1406        let d = result.unwrap().expect("should be Some on failure");
1407        assert!(
1408            d.raw.contains("stage2-failure-marker"),
1409            "stderr output must appear in raw field, got: {:?}",
1410            d.raw
1411        );
1412    }
1413}