Skip to main content

xchecker_utils/
failure_taxonomy.rs

1//! Failure taxonomy for CI/test failure classification.
2//!
3//! This module provides machine-readable types for classifying CI and test
4//! failures into well-known categories. These classifications can appear in
5//! receipts and dossiers to support automated triage and trend analysis.
6//!
7//! # Failure Classes
8//!
9//! | Class | Description |
10//! |-------|-------------|
11//! | `CorrectnessDefect` | A genuine bug in production or test code |
12//! | `GlobalStateFlake` | Non-deterministic failure from shared mutable state |
13//! | `EnvironmentExhaustion` | Resource limits (disk, memory, file descriptors) |
14//! | `RunnerPerformanceVariance` | Timing-sensitive failures on slow CI runners |
15//! | `PlatformCapabilityMismatch` | OS/platform feature unavailable or behaving differently |
16//! | `ToolchainDrift` | Compiler, dependency, or tooling version mismatch |
17//! | `Unknown` | Not yet classified |
18//!
19//! # Example
20//!
21//! ```rust
22//! use xchecker_utils::failure_taxonomy::{
23//!     ClassifiedFailure, Confidence, FailureClass, FixClass,
24//! };
25//!
26//! let failure = ClassifiedFailure {
27//!     class: FailureClass::GlobalStateFlake,
28//!     confidence: Confidence::High,
29//!     evidence: vec!["test_a and test_b both write to /tmp/shared".into()],
30//!     suggested_fix_class: Some(FixClass::SerializeGlobalStateTests),
31//!     platform: Some("ubuntu-latest".into()),
32//!     test_name: Some("test_concurrent_write".into()),
33//!     file_path: Some("crates/xchecker-utils/tests/integration.rs".into()),
34//! };
35//!
36//! assert_eq!(
37//!     failure.summary(),
38//!     "global_state_flake (high confidence): test_a and test_b both write to /tmp/shared"
39//! );
40//! ```
41
42use serde::{Deserialize, Serialize};
43use std::fmt;
44
45/// Classification of a CI/test failure.
46///
47/// Each variant maps to a well-known failure mode observed across real CI runs.
48/// The `Unknown` variant is used when a failure has not yet been triaged.
49#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum FailureClass {
52    /// A genuine bug in production or test code.
53    CorrectnessDefect,
54    /// Non-deterministic failure caused by shared mutable global state
55    /// (e.g., two tests racing on the same temp directory or environment variable).
56    GlobalStateFlake,
57    /// Failure caused by resource exhaustion (disk space, memory, file descriptors,
58    /// process limits) in the CI environment.
59    EnvironmentExhaustion,
60    /// Timing-sensitive failure that only manifests on slow or overloaded CI runners
61    /// (e.g., a 5-second timeout that passes locally but fails on shared infrastructure).
62    RunnerPerformanceVariance,
63    /// Failure due to an OS or platform capability that is missing or behaves
64    /// differently (e.g., Unix signals on Windows, symlink permissions).
65    PlatformCapabilityMismatch,
66    /// Failure caused by a change in compiler version, dependency version,
67    /// or toolchain configuration (e.g., a new Clippy lint, MSRV bump).
68    ToolchainDrift,
69    /// Not yet classified.
70    Unknown,
71}
72
73impl FailureClass {
74    /// Returns the canonical snake_case string for this class.
75    #[must_use]
76    pub const fn as_str(&self) -> &'static str {
77        match self {
78            Self::CorrectnessDefect => "correctness_defect",
79            Self::GlobalStateFlake => "global_state_flake",
80            Self::EnvironmentExhaustion => "environment_exhaustion",
81            Self::RunnerPerformanceVariance => "runner_performance_variance",
82            Self::PlatformCapabilityMismatch => "platform_capability_mismatch",
83            Self::ToolchainDrift => "toolchain_drift",
84            Self::Unknown => "unknown",
85        }
86    }
87}
88
89impl fmt::Display for FailureClass {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.write_str(self.as_str())
92    }
93}
94
95/// Confidence level for a failure classification.
96#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum Confidence {
99    /// Strong signal (e.g., deterministic repro, clear root cause).
100    High,
101    /// Moderate signal (e.g., pattern match but no isolated repro).
102    Medium,
103    /// Weak signal (e.g., heuristic guess, insufficient data).
104    Low,
105}
106
107impl Confidence {
108    /// Returns the canonical snake_case string for this confidence level.
109    #[must_use]
110    pub const fn as_str(&self) -> &'static str {
111        match self {
112            Self::High => "high",
113            Self::Medium => "medium",
114            Self::Low => "low",
115        }
116    }
117}
118
119impl fmt::Display for Confidence {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        f.write_str(self.as_str())
122    }
123}
124
125/// Suggested remediation strategy for a classified failure.
126#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum FixClass {
129    /// Serialize tests that share global state (e.g., `#[serial_test]`).
130    SerializeGlobalStateTests,
131    /// Mark timing-sensitive assertions as advisory on non-strict runners.
132    AdvisoryPerfOnNonStrictRunner,
133    /// Add a capability-gate skip (e.g., `#[cfg(unix)]`, platform check).
134    CapabilitySkip,
135    /// Update a dependency or pin a toolchain version.
136    DependencyUpdate,
137    /// Fix the production or test code directly.
138    CodeFix,
139    /// Reduce resource consumption or increase resource limits.
140    ResourceOptimization,
141}
142
143impl FixClass {
144    /// Returns the canonical snake_case string for this fix class.
145    #[must_use]
146    pub const fn as_str(&self) -> &'static str {
147        match self {
148            Self::SerializeGlobalStateTests => "serialize_global_state_tests",
149            Self::AdvisoryPerfOnNonStrictRunner => "advisory_perf_on_non_strict_runner",
150            Self::CapabilitySkip => "capability_skip",
151            Self::DependencyUpdate => "dependency_update",
152            Self::CodeFix => "code_fix",
153            Self::ResourceOptimization => "resource_optimization",
154        }
155    }
156}
157
158impl fmt::Display for FixClass {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        f.write_str(self.as_str())
161    }
162}
163
164/// A classified failure with supporting evidence.
165///
166/// Combines a [`FailureClass`] with contextual metadata so that receipts,
167/// dossiers, and dashboards can render actionable triage information.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct ClassifiedFailure {
170    /// The failure classification.
171    pub class: FailureClass,
172    /// How confident the classification is.
173    pub confidence: Confidence,
174    /// Human-readable evidence strings supporting the classification.
175    pub evidence: Vec<String>,
176    /// Suggested remediation strategy, if one applies.
177    pub suggested_fix_class: Option<FixClass>,
178    /// CI platform or OS where the failure was observed (e.g., `"ubuntu-latest"`).
179    pub platform: Option<String>,
180    /// Fully qualified test name, if the failure is test-scoped.
181    pub test_name: Option<String>,
182    /// Source file path associated with the failure, if known.
183    pub file_path: Option<String>,
184}
185
186impl ClassifiedFailure {
187    /// Returns a one-line summary suitable for logs and receipt fields.
188    ///
189    /// Format: `"<class> (<confidence> confidence): <first evidence line>"`
190    ///
191    /// If no evidence is provided, the summary omits the colon-delimited suffix.
192    ///
193    /// # Example
194    ///
195    /// ```rust
196    /// use xchecker_utils::failure_taxonomy::*;
197    ///
198    /// let f = ClassifiedFailure {
199    ///     class: FailureClass::ToolchainDrift,
200    ///     confidence: Confidence::Medium,
201    ///     evidence: vec!["clippy 0.1.81 introduced new lint".into()],
202    ///     suggested_fix_class: Some(FixClass::DependencyUpdate),
203    ///     platform: None,
204    ///     test_name: None,
205    ///     file_path: None,
206    /// };
207    /// assert_eq!(
208    ///     f.summary(),
209    ///     "toolchain_drift (medium confidence): clippy 0.1.81 introduced new lint"
210    /// );
211    /// ```
212    #[must_use]
213    pub fn summary(&self) -> String {
214        match self.evidence.first() {
215            Some(first) => {
216                format!("{} ({} confidence): {first}", self.class, self.confidence)
217            }
218            None => {
219                format!("{} ({} confidence)", self.class, self.confidence)
220            }
221        }
222    }
223}
224
225impl fmt::Display for ClassifiedFailure {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        f.write_str(&self.summary())
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    // ── FailureClass ────────────────────────────────────────────────
236
237    #[test]
238    fn failure_class_display_matches_as_str() {
239        let variants = [
240            FailureClass::CorrectnessDefect,
241            FailureClass::GlobalStateFlake,
242            FailureClass::EnvironmentExhaustion,
243            FailureClass::RunnerPerformanceVariance,
244            FailureClass::PlatformCapabilityMismatch,
245            FailureClass::ToolchainDrift,
246            FailureClass::Unknown,
247        ];
248        for v in &variants {
249            assert_eq!(v.to_string(), v.as_str());
250        }
251    }
252
253    #[test]
254    fn failure_class_serde_roundtrip() {
255        let original = FailureClass::PlatformCapabilityMismatch;
256        let json = serde_json::to_string(&original).unwrap();
257        assert_eq!(json, r#""platform_capability_mismatch""#);
258        let restored: FailureClass = serde_json::from_str(&json).unwrap();
259        assert_eq!(original, restored);
260    }
261
262    #[test]
263    fn failure_class_all_variants_serde_roundtrip() {
264        let variants = [
265            (FailureClass::CorrectnessDefect, "\"correctness_defect\""),
266            (FailureClass::GlobalStateFlake, "\"global_state_flake\""),
267            (
268                FailureClass::EnvironmentExhaustion,
269                "\"environment_exhaustion\"",
270            ),
271            (
272                FailureClass::RunnerPerformanceVariance,
273                "\"runner_performance_variance\"",
274            ),
275            (
276                FailureClass::PlatformCapabilityMismatch,
277                "\"platform_capability_mismatch\"",
278            ),
279            (FailureClass::ToolchainDrift, "\"toolchain_drift\""),
280            (FailureClass::Unknown, "\"unknown\""),
281        ];
282        for (variant, expected_json) in &variants {
283            let json = serde_json::to_string(variant).unwrap();
284            assert_eq!(
285                &json, expected_json,
286                "serialization mismatch for {variant:?}"
287            );
288            let restored: FailureClass = serde_json::from_str(&json).unwrap();
289            assert_eq!(variant, &restored, "roundtrip mismatch for {variant:?}");
290        }
291    }
292
293    // ── Confidence ──────────────────────────────────────────────────
294
295    #[test]
296    fn confidence_display_matches_as_str() {
297        let variants = [Confidence::High, Confidence::Medium, Confidence::Low];
298        for v in &variants {
299            assert_eq!(v.to_string(), v.as_str());
300        }
301    }
302
303    #[test]
304    fn confidence_serde_roundtrip() {
305        let original = Confidence::Medium;
306        let json = serde_json::to_string(&original).unwrap();
307        assert_eq!(json, r#""medium""#);
308        let restored: Confidence = serde_json::from_str(&json).unwrap();
309        assert_eq!(original, restored);
310    }
311
312    // ── FixClass ────────────────────────────────────────────────────
313
314    #[test]
315    fn fix_class_display_matches_as_str() {
316        let variants = [
317            FixClass::SerializeGlobalStateTests,
318            FixClass::AdvisoryPerfOnNonStrictRunner,
319            FixClass::CapabilitySkip,
320            FixClass::DependencyUpdate,
321            FixClass::CodeFix,
322            FixClass::ResourceOptimization,
323        ];
324        for v in &variants {
325            assert_eq!(v.to_string(), v.as_str());
326        }
327    }
328
329    #[test]
330    fn fix_class_serde_roundtrip() {
331        let original = FixClass::SerializeGlobalStateTests;
332        let json = serde_json::to_string(&original).unwrap();
333        assert_eq!(json, r#""serialize_global_state_tests""#);
334        let restored: FixClass = serde_json::from_str(&json).unwrap();
335        assert_eq!(original, restored);
336    }
337
338    // ── ClassifiedFailure ───────────────────────────────────────────
339
340    #[test]
341    fn summary_with_evidence() {
342        let f = ClassifiedFailure {
343            class: FailureClass::GlobalStateFlake,
344            confidence: Confidence::High,
345            evidence: vec!["test_a and test_b both write to /tmp/shared".into()],
346            suggested_fix_class: Some(FixClass::SerializeGlobalStateTests),
347            platform: Some("ubuntu-latest".into()),
348            test_name: Some("test_concurrent_write".into()),
349            file_path: Some("crates/xchecker-utils/tests/integration.rs".into()),
350        };
351        assert_eq!(
352            f.summary(),
353            "global_state_flake (high confidence): test_a and test_b both write to /tmp/shared"
354        );
355    }
356
357    #[test]
358    fn summary_without_evidence() {
359        let f = ClassifiedFailure {
360            class: FailureClass::Unknown,
361            confidence: Confidence::Low,
362            evidence: vec![],
363            suggested_fix_class: None,
364            platform: None,
365            test_name: None,
366            file_path: None,
367        };
368        assert_eq!(f.summary(), "unknown (low confidence)");
369    }
370
371    #[test]
372    fn display_delegates_to_summary() {
373        let f = ClassifiedFailure {
374            class: FailureClass::ToolchainDrift,
375            confidence: Confidence::Medium,
376            evidence: vec!["clippy 0.1.81 introduced new lint".into()],
377            suggested_fix_class: Some(FixClass::DependencyUpdate),
378            platform: None,
379            test_name: None,
380            file_path: None,
381        };
382        assert_eq!(f.to_string(), f.summary());
383    }
384
385    #[test]
386    fn classified_failure_serde_roundtrip() {
387        let original = ClassifiedFailure {
388            class: FailureClass::EnvironmentExhaustion,
389            confidence: Confidence::High,
390            evidence: vec!["disk usage at 98%".into(), "/tmp ran out of inodes".into()],
391            suggested_fix_class: Some(FixClass::ResourceOptimization),
392            platform: Some("windows-latest".into()),
393            test_name: Some("test_large_packet".into()),
394            file_path: Some("crates/xchecker-engine/tests/packet.rs".into()),
395        };
396
397        let json = serde_json::to_string_pretty(&original).unwrap();
398        let restored: ClassifiedFailure = serde_json::from_str(&json).unwrap();
399
400        assert_eq!(original.class, restored.class);
401        assert_eq!(original.confidence, restored.confidence);
402        assert_eq!(original.evidence, restored.evidence);
403        assert_eq!(original.suggested_fix_class, restored.suggested_fix_class);
404        assert_eq!(original.platform, restored.platform);
405        assert_eq!(original.test_name, restored.test_name);
406        assert_eq!(original.file_path, restored.file_path);
407    }
408
409    #[test]
410    fn classified_failure_optional_fields_absent() {
411        let json = r#"{
412            "class": "correctness_defect",
413            "confidence": "high",
414            "evidence": ["assertion failed in line 42"],
415            "suggested_fix_class": null,
416            "platform": null,
417            "test_name": null,
418            "file_path": null
419        }"#;
420        let f: ClassifiedFailure = serde_json::from_str(json).unwrap();
421        assert_eq!(f.class, FailureClass::CorrectnessDefect);
422        assert!(f.suggested_fix_class.is_none());
423        assert!(f.platform.is_none());
424    }
425
426    #[test]
427    fn summary_uses_first_evidence_only() {
428        let f = ClassifiedFailure {
429            class: FailureClass::RunnerPerformanceVariance,
430            confidence: Confidence::Medium,
431            evidence: vec![
432                "timeout after 5s on shared runner".into(),
433                "passes locally in 0.8s".into(),
434            ],
435            suggested_fix_class: Some(FixClass::AdvisoryPerfOnNonStrictRunner),
436            platform: Some("macos-latest".into()),
437            test_name: None,
438            file_path: None,
439        };
440        assert_eq!(
441            f.summary(),
442            "runner_performance_variance (medium confidence): timeout after 5s on shared runner"
443        );
444    }
445}