Skip to main content

navi_core/
verifier.rs

1//! Core verifier API for NAVI.
2//!
3//! Provides the types and runner for executing verification commands
4//! (build, test, typecheck, lint, or arbitrary commands) and a store
5//! that keeps results keyed by feature ID or tool call ID.
6//!
7//! The `VerifierRunner` uses `tokio::process::Command` so it integrates
8//! seamlessly with the async tool system. It captures stdout, stderr,
9//! exit code, and timing, and classifies failures into machine-readable
10//! error classes with suggested next actions.
11
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::Duration;
17use tokio::sync::RwLock;
18
19// ═════════════════════════════════════════════════════════════════════════════
20// VerifierSpec
21// ═════════════════════════════════════════════════════════════════════════════
22
23/// Describes what should be verified and how.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct VerifierSpec {
26    /// The category of verification: "build", "test", "typecheck", "lint", or "command".
27    #[serde(rename = "verifier_type")]
28    pub verifier_type: String,
29
30    /// The shell command to run (e.g. "cargo build", "cargo test", "cargo check").
31    pub command: String,
32
33    /// Optional working directory override. Defaults to the project root.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub cwd: Option<String>,
36
37    /// Timeout in milliseconds. Defaults to 120_000 (2 minutes) if not set.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub timeout_ms: Option<u64>,
40
41    /// Whether this verification is required for the step to be considered passing.
42    #[serde(default = "default_required")]
43    pub required: bool,
44}
45
46fn default_required() -> bool {
47    true
48}
49
50impl Default for VerifierSpec {
51    fn default() -> Self {
52        Self {
53            verifier_type: "command".to_string(),
54            command: String::new(),
55            cwd: None,
56            timeout_ms: None,
57            required: true,
58        }
59    }
60}
61
62// ═════════════════════════════════════════════════════════════════════════════
63// VerifierResult
64// ═════════════════════════════════════════════════════════════════════════════
65
66/// The outcome of running a single verification command.
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
68pub struct VerifierResult {
69    /// Overall status: "pass", "fail", "error", or "skipped".
70    pub status: String,
71
72    /// The command that was executed.
73    pub command: String,
74
75    /// Wall-clock duration in milliseconds.
76    pub duration_ms: u64,
77
78    /// Captured stdout (lossy UTF-8, truncated to 64 KiB).
79    pub stdout: String,
80
81    /// Captured stderr (lossy UTF-8, truncated to 64 KiB).
82    pub stderr: String,
83
84    /// Exit code from the process, if available.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub exit_code: Option<i32>,
87
88    /// Machine-readable error classification: "compile_error", "test_failure",
89    /// "type_error", "lint_violation", "timeout", "io_error", etc.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub error_class: Option<String>,
92
93    /// Human-readable suggestion for what to do next.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub suggested_next_action: Option<String>,
96}
97
98impl VerifierResult {
99    /// Creates a "pass" result.
100    pub fn pass(command: impl Into<String>, duration_ms: u64) -> Self {
101        Self {
102            status: "pass".to_string(),
103            command: command.into(),
104            duration_ms,
105            stdout: String::new(),
106            stderr: String::new(),
107            exit_code: Some(0),
108            error_class: None,
109            suggested_next_action: None,
110        }
111    }
112
113    /// Creates a "fail" result from a non-zero exit code.
114    pub fn fail(
115        command: impl Into<String>,
116        duration_ms: u64,
117        stdout: String,
118        stderr: String,
119        exit_code: i32,
120        error_class: Option<String>,
121        suggested_next_action: Option<String>,
122    ) -> Self {
123        Self {
124            status: "fail".to_string(),
125            command: command.into(),
126            duration_ms,
127            stdout: truncate_output(&stdout),
128            stderr: truncate_output(&stderr),
129            exit_code: Some(exit_code),
130            error_class,
131            suggested_next_action,
132        }
133    }
134
135    /// Creates an "error" result for a system-level failure (could not start command, etc.).
136    pub fn error(
137        command: impl Into<String>,
138        error_class: impl Into<String>,
139        message: impl Into<String>,
140    ) -> Self {
141        let ec: String = error_class.into();
142        let msg: String = message.into();
143        let suggestion = match ec.as_str() {
144            "timeout" => {
145                Some("Increase timeout_ms or optimise the command to run faster.".to_string())
146            }
147            "io_error" => {
148                Some("Check that the command binary is installed and accessible.".to_string())
149            }
150            _ => Some(format!("System error: {msg}")),
151        };
152        Self {
153            status: "error".to_string(),
154            command: command.into(),
155            duration_ms: 0,
156            stdout: String::new(),
157            stderr: msg.clone(),
158            exit_code: None,
159            error_class: Some(ec),
160            suggested_next_action: suggestion,
161        }
162    }
163
164    /// Creates a "skipped" result.
165    pub fn skipped(command: impl Into<String>, reason: impl Into<String>) -> Self {
166        Self {
167            status: "skipped".to_string(),
168            command: command.into(),
169            duration_ms: 0,
170            stdout: String::new(),
171            stderr: reason.into(),
172            exit_code: None,
173            error_class: None,
174            suggested_next_action: None,
175        }
176    }
177
178    /// Returns true if the verification passed or was skipped.
179    pub fn is_ok(&self) -> bool {
180        matches!(self.status.as_str(), "pass" | "skipped")
181    }
182}
183
184// ═════════════════════════════════════════════════════════════════════════════
185// VerifierRunner
186// ═════════════════════════════════════════════════════════════════════════════
187
188/// Runs verification commands using `tokio::process::Command`.
189pub struct VerifierRunner;
190
191impl VerifierRunner {
192    /// Run a single verification spec and return the result.
193    ///
194    /// The command is executed via `sh -c <command>` under the hood, using
195    /// the project root (or spec-level `cwd`) as the working directory.
196    pub async fn run(spec: &VerifierSpec, project_root: &Path) -> VerifierResult {
197        let command = spec.command.trim().to_string();
198        if command.is_empty() {
199            return VerifierResult::error(
200                &command,
201                "invalid_spec",
202                "verifier command must not be empty",
203            );
204        }
205
206        let cwd = spec
207            .cwd
208            .as_deref()
209            .map(PathBuf::from)
210            .map(|path| {
211                if path.is_absolute() {
212                    path
213                } else {
214                    project_root.join(path)
215                }
216            })
217            .unwrap_or_else(|| project_root.to_path_buf());
218
219        let started = std::time::Instant::now();
220        let timeout_dur = Duration::from_millis(spec.timeout_ms.unwrap_or(120_000));
221
222        // Use wait_with_output which handles pipe reading natively.
223        let output_result = tokio::time::timeout(timeout_dur, async {
224            tokio::process::Command::new("sh")
225                .arg("-c")
226                .arg(&command)
227                .current_dir(&cwd)
228                .stdout(std::process::Stdio::piped())
229                .stderr(std::process::Stdio::piped())
230                .stdin(std::process::Stdio::null())
231                .kill_on_drop(true)
232                .output()
233                .await
234        })
235        .await;
236
237        let elapsed = started.elapsed().as_millis() as u64;
238
239        match output_result {
240            Ok(Ok(output)) => {
241                let stdout = truncate_output(&String::from_utf8_lossy(&output.stdout));
242                let stderr = truncate_output(&String::from_utf8_lossy(&output.stderr));
243                let exit_code = output.status.code();
244
245                if output.status.success() {
246                    VerifierResult {
247                        status: "pass".to_string(),
248                        command,
249                        duration_ms: elapsed,
250                        stdout,
251                        stderr,
252                        exit_code: Some(0),
253                        error_class: None,
254                        suggested_next_action: None,
255                    }
256                } else {
257                    let (error_class, suggested_next_action) =
258                        classify_failure(&spec.verifier_type, &stderr, &stdout, exit_code);
259                    VerifierResult::fail(
260                        command,
261                        elapsed,
262                        stdout,
263                        stderr,
264                        exit_code.unwrap_or(1),
265                        error_class,
266                        suggested_next_action,
267                    )
268                }
269            }
270            Ok(Err(e)) => {
271                // IO error (could not spawn, etc.)
272                VerifierResult {
273                    status: "error".to_string(),
274                    command,
275                    duration_ms: elapsed,
276                    stdout: String::new(),
277                    stderr: format!("failed to spawn command: {e}"),
278                    exit_code: None,
279                    error_class: Some("io_error".to_string()),
280                    suggested_next_action: Some(
281                        "Check that `sh` is available and the command is valid.".to_string(),
282                    ),
283                }
284            }
285            Err(_) => {
286                // Timeout.
287                VerifierResult {
288                    status: "error".to_string(),
289                    command,
290                    duration_ms: elapsed,
291                    stdout: String::new(),
292                    stderr: "Command timed out.".to_string(),
293                    exit_code: None,
294                    error_class: Some("timeout".to_string()),
295                    suggested_next_action: Some(
296                        "Increase timeout_ms or optimise the command to run faster.".to_string(),
297                    ),
298                }
299            }
300        }
301    }
302}
303
304// ═════════════════════════════════════════════════════════════════════════════
305// VerificationStore
306// ═════════════════════════════════════════════════════════════════════════════
307
308/// Thread-safe store that keeps verification results keyed by `feature_id` or
309/// `tool_call_id`.
310#[derive(Debug, Clone, Default)]
311pub struct VerificationStore {
312    inner: Arc<RwLock<HashMap<String, VerifierResult>>>,
313}
314
315impl VerificationStore {
316    /// Create a new, empty store.
317    pub fn new() -> Self {
318        Self {
319            inner: Arc::new(RwLock::new(HashMap::new())),
320        }
321    }
322
323    /// Store a result under the given key.
324    pub async fn store(&self, key: impl Into<String>, result: VerifierResult) {
325        let mut map = self.inner.write().await;
326        map.insert(key.into(), result);
327    }
328
329    /// Retrieve a result by key.
330    pub async fn get(&self, key: &str) -> Option<VerifierResult> {
331        let map = self.inner.read().await;
332        map.get(key).cloned()
333    }
334
335    /// List all stored results, newest first (approximate: insertion order).
336    pub async fn list(&self) -> Vec<(String, VerifierResult)> {
337        let map = self.inner.read().await;
338        let mut items: Vec<(String, VerifierResult)> =
339            map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
340        items.reverse();
341        items
342    }
343
344    /// Remove a result by key.
345    pub async fn remove(&self, key: &str) -> Option<VerifierResult> {
346        let mut map = self.inner.write().await;
347        map.remove(key)
348    }
349
350    /// Clear all stored results.
351    pub async fn clear(&self) {
352        let mut map = self.inner.write().await;
353        map.clear();
354    }
355
356    /// Number of stored results.
357    pub async fn len(&self) -> usize {
358        let map = self.inner.read().await;
359        map.len()
360    }
361
362    /// Returns true if there are no stored results.
363    pub async fn is_empty(&self) -> bool {
364        self.len().await == 0
365    }
366}
367
368// ═════════════════════════════════════════════════════════════════════════════
369// Internal helpers
370// ═════════════════════════════════════════════════════════════════════════════
371
372const OUTPUT_LIMIT_BYTES: usize = 64 * 1024;
373
374fn truncate_output(s: &str) -> String {
375    if s.len() <= OUTPUT_LIMIT_BYTES {
376        s.to_string()
377    } else {
378        let mut truncated = s[..OUTPUT_LIMIT_BYTES].to_string();
379        truncated.push_str("\n... <truncated>");
380        truncated
381    }
382}
383
384/// Classify a failure into error class and suggested next action.
385fn classify_failure(
386    verifier_type: &str,
387    stderr: &str,
388    stdout: &str,
389    exit_code: Option<i32>,
390) -> (Option<String>, Option<String>) {
391    let combined = format!("{stdout}\n{stderr}").to_lowercase();
392
393    match verifier_type {
394        "build" => {
395            if combined.contains("error[E") || combined.contains("error:") {
396                (
397                    Some("compile_error".to_string()),
398                    Some("Fix the compilation error(s) shown above and rebuild.".to_string()),
399                )
400            } else if exit_code == Some(101) {
401                (
402                    Some("compile_error".to_string()),
403                    Some(
404                        "The build failed with a non-zero exit code. Inspect the output."
405                            .to_string(),
406                    ),
407                )
408            } else {
409                (
410                    Some("build_failure".to_string()),
411                    Some("The build did not complete successfully. Check the output.".to_string()),
412                )
413            }
414        }
415        "test" => (
416            Some("test_failure".to_string()),
417            Some("Fix the failing test(s) and re-run.".to_string()),
418        ),
419        "typecheck" => {
420            if combined.contains("error[E") {
421                (
422                    Some("type_error".to_string()),
423                    Some("Fix the type errors shown above.".to_string()),
424                )
425            } else {
426                (
427                    Some("type_error".to_string()),
428                    Some(
429                        "Type checking failed. Inspect the output and fix type issues.".to_string(),
430                    ),
431                )
432            }
433        }
434        "lint" => (
435            Some("lint_violation".to_string()),
436            Some("Fix the lint violations shown above.".to_string()),
437        ),
438        _ => {
439            if combined.contains("not found")
440                || combined.contains("no such file")
441                || combined.contains("command not found")
442            {
443                (
444                    Some("command_not_found".to_string()),
445                    Some("The command was not found. Check that it is installed.".to_string()),
446                )
447            } else {
448                (
449                    Some("execution_failure".to_string()),
450                    Some(
451                        "The command exited with a non-zero status. Inspect the output."
452                            .to_string(),
453                    ),
454                )
455            }
456        }
457    }
458}
459
460// ═════════════════════════════════════════════════════════════════════════════
461// Tests
462// ═════════════════════════════════════════════════════════════════════════════
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use serde_json;
468
469    // ── VerifierSpec serde roundtrip ──────────────────────────────────────
470
471    #[test]
472    fn verifier_spec_serde_roundtrip_full() {
473        let spec = VerifierSpec {
474            verifier_type: "build".to_string(),
475            command: "cargo build".to_string(),
476            cwd: Some("/tmp".to_string()),
477            timeout_ms: Some(300_000),
478            required: true,
479        };
480        let json = serde_json::to_value(&spec).unwrap();
481        let restored: VerifierSpec = serde_json::from_value(json).unwrap();
482        assert_eq!(restored.verifier_type, "build");
483        assert_eq!(restored.command, "cargo build");
484        assert_eq!(restored.cwd, Some("/tmp".to_string()));
485        assert_eq!(restored.timeout_ms, Some(300_000));
486        assert!(restored.required);
487    }
488
489    #[test]
490    fn verifier_spec_serde_roundtrip_minimal() {
491        let spec = VerifierSpec {
492            verifier_type: "test".to_string(),
493            command: "cargo test".to_string(),
494            ..Default::default()
495        };
496        let json = serde_json::to_value(&spec).unwrap();
497        let restored: VerifierSpec = serde_json::from_value(json).unwrap();
498        assert_eq!(restored.verifier_type, "test");
499        assert_eq!(restored.command, "cargo test");
500        assert!(restored.required);
501        assert!(restored.cwd.is_none());
502        assert!(restored.timeout_ms.is_none());
503    }
504
505    #[test]
506    fn verifier_spec_default_required_true() {
507        assert!(default_required());
508    }
509
510    #[test]
511    fn verifier_spec_serde_optional_fields_omit_when_none() {
512        let spec = VerifierSpec {
513            verifier_type: "lint".to_string(),
514            command: "cargo clippy".to_string(),
515            cwd: None,
516            timeout_ms: None,
517            required: false,
518        };
519        let json = serde_json::to_value(&spec).unwrap();
520        // cwd and timeout_ms should be absent from serialized form.
521        assert!(json.get("cwd").is_none());
522        assert!(json.get("timeout_ms").is_none());
523        assert_eq!(json["required"], false);
524    }
525
526    // ── VerifierResult construction ───────────────────────────────────────
527
528    #[test]
529    fn verifier_result_pass() {
530        let r = VerifierResult::pass("echo ok", 42);
531        assert_eq!(r.status, "pass");
532        assert_eq!(r.command, "echo ok");
533        assert_eq!(r.duration_ms, 42);
534        assert!(r.is_ok());
535    }
536
537    #[test]
538    fn verifier_result_fail() {
539        let r = VerifierResult::fail(
540            "cargo build",
541            100,
542            "stdout".to_string(),
543            "error[E0425]".to_string(),
544            101,
545            Some("compile_error".to_string()),
546            Some("Fix errors.".to_string()),
547        );
548        assert_eq!(r.status, "fail");
549        assert_eq!(r.exit_code, Some(101));
550        assert!(!r.is_ok());
551    }
552
553    #[test]
554    fn verifier_result_error() {
555        let r = VerifierResult::error("cargo build", "io_error", "command not found");
556        assert_eq!(r.status, "error");
557        assert!(!r.is_ok());
558        assert!(r.suggested_next_action.is_some());
559    }
560
561    #[test]
562    fn verifier_result_skipped() {
563        let r = VerifierResult::skipped("cargo build", "not needed");
564        assert_eq!(r.status, "skipped");
565        assert!(r.is_ok());
566    }
567
568    #[test]
569    fn verifier_result_is_ok_returns_false_for_error() {
570        let r = VerifierResult::error("cmd", "io_error", "msg");
571        assert!(!r.is_ok());
572    }
573
574    #[test]
575    fn verifier_result_is_ok_returns_false_for_fail() {
576        let r = VerifierResult::fail("cmd", 0, String::new(), String::new(), 1, None, None);
577        assert!(!r.is_ok());
578    }
579
580    // ── VerifierResult serde roundtrip ────────────────────────────────────
581
582    #[test]
583    fn verifier_result_serde_roundtrip() {
584        let r = VerifierResult::fail(
585            "cargo check",
586            150,
587            "".to_string(),
588            "error[E0308]: mismatched types".to_string(),
589            101,
590            Some("compile_error".to_string()),
591            Some("Fix type mismatch.".to_string()),
592        );
593        let json = serde_json::to_value(&r).unwrap();
594        let restored: VerifierResult = serde_json::from_value(json).unwrap();
595        assert_eq!(restored.status, "fail");
596        assert_eq!(restored.command, "cargo check");
597        assert_eq!(restored.exit_code, Some(101));
598        assert_eq!(restored.error_class, Some("compile_error".to_string()));
599    }
600
601    // ── VerifierRunner runs a simple command ──────────────────────────────
602
603    #[tokio::test]
604    async fn verifier_runner_runs_simple_pass() {
605        let spec = VerifierSpec {
606            verifier_type: "command".to_string(),
607            command: "echo hello".to_string(),
608            ..Default::default()
609        };
610        let dir = tempfile::tempdir().unwrap();
611        let result = VerifierRunner::run(&spec, dir.path()).await;
612        assert_eq!(result.status, "pass", "echo should succeed: {:?}", result);
613        assert_eq!(result.exit_code, Some(0));
614        assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
615        assert!(result.duration_ms > 0);
616    }
617
618    #[tokio::test]
619    async fn verifier_runner_captures_exit_code() {
620        let spec = VerifierSpec {
621            verifier_type: "command".to_string(),
622            command: "exit 42".to_string(),
623            ..Default::default()
624        };
625        let dir = tempfile::tempdir().unwrap();
626        let result = VerifierRunner::run(&spec, dir.path()).await;
627        assert_eq!(result.status, "fail", "exit 42 should fail");
628        assert_eq!(result.exit_code, Some(42));
629    }
630
631    #[tokio::test]
632    async fn verifier_runner_captures_stderr() {
633        let spec = VerifierSpec {
634            verifier_type: "command".to_string(),
635            command: "echo error >&2 && exit 1".to_string(),
636            ..Default::default()
637        };
638        let dir = tempfile::tempdir().unwrap();
639        let result = VerifierRunner::run(&spec, dir.path()).await;
640        assert_eq!(result.status, "fail");
641        assert!(result.stderr.contains("error"), "stderr: {}", result.stderr);
642    }
643
644    #[tokio::test]
645    async fn verifier_runner_resolves_relative_cwd_under_project_root() {
646        let dir = tempfile::tempdir().unwrap();
647        let subdir = dir.path().join("checks");
648        std::fs::create_dir_all(&subdir).unwrap();
649        std::fs::write(subdir.join("marker.txt"), "ok").unwrap();
650        let spec = VerifierSpec {
651            verifier_type: "command".to_string(),
652            command: "cat marker.txt".to_string(),
653            cwd: Some("checks".to_string()),
654            ..Default::default()
655        };
656
657        let result = VerifierRunner::run(&spec, dir.path()).await;
658        assert_eq!(result.status, "pass", "{result:?}");
659        assert_eq!(result.stdout, "ok");
660    }
661
662    #[tokio::test]
663    async fn verifier_runner_empty_command_returns_error() {
664        let spec = VerifierSpec {
665            verifier_type: "command".to_string(),
666            command: "".to_string(),
667            ..Default::default()
668        };
669        let dir = tempfile::tempdir().unwrap();
670        let result = VerifierRunner::run(&spec, dir.path()).await;
671        assert_eq!(result.status, "error");
672        assert_eq!(result.error_class, Some("invalid_spec".to_string()));
673    }
674
675    #[tokio::test]
676    async fn verifier_runner_timeout_returns_error() {
677        let spec = VerifierSpec {
678            verifier_type: "command".to_string(),
679            command: "sleep 10".to_string(),
680            timeout_ms: Some(1),
681            ..Default::default()
682        };
683        let dir = tempfile::tempdir().unwrap();
684        let result = VerifierRunner::run(&spec, dir.path()).await;
685        assert_eq!(
686            result.status, "error",
687            "sleep should time out: {:?}",
688            result
689        );
690        assert_eq!(result.error_class, Some("timeout".to_string()));
691    }
692
693    // ── VerificationStore ─────────────────────────────────────────────────
694
695    #[tokio::test]
696    async fn verification_store_store_and_get() {
697        let store = VerificationStore::new();
698        let result = VerifierResult::pass("echo hi", 10);
699        store.store("feature-1", result.clone()).await;
700        let retrieved = store.get("feature-1").await;
701        assert!(retrieved.is_some());
702        assert_eq!(retrieved.unwrap().status, "pass");
703    }
704
705    #[tokio::test]
706    async fn verification_store_get_missing_returns_none() {
707        let store = VerificationStore::new();
708        let retrieved = store.get("nonexistent").await;
709        assert!(retrieved.is_none());
710    }
711
712    #[tokio::test]
713    async fn verification_store_list_returns_all() {
714        let store = VerificationStore::new();
715        store.store("a", VerifierResult::pass("cmd1", 1)).await;
716        store.store("b", VerifierResult::pass("cmd2", 2)).await;
717        let items = store.list().await;
718        assert_eq!(items.len(), 2);
719    }
720
721    #[tokio::test]
722    async fn verification_store_remove() {
723        let store = VerificationStore::new();
724        store.store("x", VerifierResult::pass("cmd", 1)).await;
725        let removed = store.remove("x").await;
726        assert!(removed.is_some());
727        assert!(store.get("x").await.is_none());
728    }
729
730    #[tokio::test]
731    async fn verification_store_clear() {
732        let store = VerificationStore::new();
733        store.store("a", VerifierResult::pass("cmd", 1)).await;
734        store.clear().await;
735        assert!(store.is_empty().await);
736    }
737
738    #[tokio::test]
739    async fn verification_store_is_empty() {
740        let store = VerificationStore::new();
741        assert!(store.is_empty().await);
742        store.store("k", VerifierResult::pass("cmd", 1)).await;
743        assert!(!store.is_empty().await);
744    }
745
746    // ── classify_failure ──────────────────────────────────────────────────
747
748    #[test]
749    fn classify_failure_build_compile_error() {
750        let (class, _) =
751            classify_failure("build", "error[E0425]: cannot find value", "", Some(101));
752        assert_eq!(class, Some("compile_error".to_string()));
753    }
754
755    #[test]
756    fn classify_failure_test_failure() {
757        let (class, _) = classify_failure("test", "test result: FAILED", "", Some(1));
758        assert_eq!(class, Some("test_failure".to_string()));
759    }
760
761    #[test]
762    fn classify_failure_typecheck_type_error() {
763        let (class, _) =
764            classify_failure("typecheck", "error[E0308]: mismatched types", "", Some(1));
765        assert_eq!(class, Some("type_error".to_string()));
766    }
767
768    #[test]
769    fn classify_failure_lint_violation() {
770        let (class, _) = classify_failure("lint", "clippy::style", "", Some(1));
771        assert_eq!(class, Some("lint_violation".to_string()));
772    }
773
774    #[test]
775    fn classify_failure_command_not_found() {
776        let (class, _) = classify_failure("command", "command not found: foo", "", Some(127));
777        assert_eq!(class, Some("command_not_found".to_string()));
778    }
779
780    #[test]
781    fn classify_failure_generic() {
782        let (class, _) = classify_failure("command", "something broke", "", Some(1));
783        assert_eq!(class, Some("execution_failure".to_string()));
784    }
785
786    #[test]
787    fn classify_failure_build_no_rust_error() {
788        let (class, _) = classify_failure("build", "linking failed", "", Some(1));
789        assert_eq!(class, Some("build_failure".to_string()));
790    }
791}