Skip to main content

assay_sim/
report.rs

1use anyhow::Result;
2use assay_evidence::bundle::writer::{ErrorClass, ErrorCode};
3use serde::Serialize;
4
5#[derive(Debug, Serialize, Clone)]
6pub struct SimReport {
7    pub suite: String,
8    pub seed: u64,
9    pub summary: SimSummary,
10    pub results: Vec<AttackResult>,
11    /// True when suite exited early due to time budget
12    #[serde(skip_serializing_if = "std::ops::Not::not")]
13    pub time_budget_exceeded: bool,
14    /// Phases skipped when time budget exceeded
15    #[serde(skip_serializing_if = "Vec::is_empty")]
16    pub skipped_phases: Vec<String>,
17    /// Phases this tier does not run at all, named so a clean summary cannot be
18    /// read as "everything was tried".
19    ///
20    /// Deliberately not merged with `skipped_phases`: a phase absent because the
21    /// tier never includes it is a programme statement, and a phase dropped
22    /// because the budget ran out is a degradation. A reader deciding whether a
23    /// `bypassed=0` run is reassuring needs to tell those apart.
24    #[serde(skip_serializing_if = "Vec::is_empty")]
25    pub phases_not_attempted: Vec<String>,
26}
27
28#[derive(Debug, Serialize, Clone, Default)]
29pub struct SimSummary {
30    pub total: usize,
31    pub passed: usize,   // For invariant checks
32    pub blocked: usize,  // For attacks
33    pub bypassed: usize, // For attacks
34    pub failed: usize,   // For invariant checks
35    pub errors: usize,
36}
37
38#[derive(Debug, Serialize, Clone)]
39pub struct AttackResult {
40    pub name: String,
41    pub status: AttackStatus,
42    pub error_class: Option<String>,
43    pub error_code: Option<String>,
44    pub message: Option<String>,
45    pub duration_ms: u64,
46}
47
48#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
49pub enum AttackStatus {
50    Passed,   // Invariant held
51    Failed,   // Invariant broken
52    Blocked,  // Attack was stopped
53    Bypassed, // Attack succeeded
54    Error,    // Infrastructure error
55}
56
57impl SimReport {
58    pub fn new(suite: &str, seed: u64) -> Self {
59        Self {
60            suite: suite.to_string(),
61            seed,
62            summary: SimSummary::default(),
63            results: Vec::new(),
64            time_budget_exceeded: false,
65            skipped_phases: Vec::new(),
66            phases_not_attempted: Vec::new(),
67        }
68    }
69
70    /// Record the phases this tier never runs.
71    ///
72    /// `total` counts what ran, so without this a tier that omits a whole phase
73    /// and a tier that ran everything produce the same shape of clean report.
74    pub fn set_phases_not_attempted(&mut self, not_attempted: Vec<String>) {
75        self.phases_not_attempted = not_attempted;
76    }
77
78    pub fn set_time_budget_exceeded(&mut self, skipped: Vec<String>) {
79        self.time_budget_exceeded = true;
80        self.skipped_phases = skipped;
81    }
82
83    pub fn add_attack(
84        &mut self,
85        name: &str,
86        result: Result<(ErrorClass, ErrorCode, String), anyhow::Error>,
87        duration_ms: u64,
88    ) {
89        self.summary.total += 1;
90        let res = match result {
91            Ok((class, code, message)) => {
92                self.summary.blocked += 1;
93                AttackResult {
94                    name: name.to_string(),
95                    status: AttackStatus::Blocked,
96                    error_class: Some(format!("{:?}", class)),
97                    error_code: Some(format!("{:?}", code)),
98                    message: Some(message),
99                    duration_ms,
100                }
101            }
102            Err(e) => {
103                self.summary.bypassed += 1;
104                AttackResult {
105                    name: name.to_string(),
106                    status: AttackStatus::Bypassed,
107                    error_class: None,
108                    error_code: None,
109                    message: Some(e.to_string()),
110                    duration_ms,
111                }
112            }
113        };
114        self.results.push(res);
115    }
116
117    /// Add a pre-built AttackResult directly.
118    pub fn add_result(&mut self, result: AttackResult) {
119        self.summary.total += 1;
120        match result.status {
121            AttackStatus::Passed => self.summary.passed += 1,
122            AttackStatus::Failed => self.summary.failed += 1,
123            AttackStatus::Blocked => self.summary.blocked += 1,
124            AttackStatus::Bypassed => self.summary.bypassed += 1,
125            AttackStatus::Error => self.summary.errors += 1,
126        }
127        self.results.push(result);
128    }
129
130    pub fn add_check(&mut self, name: &str, result: Result<()>, duration_ms: u64) {
131        self.summary.total += 1;
132        let res = match result {
133            Ok(_) => {
134                self.summary.passed += 1;
135                AttackResult {
136                    name: name.to_string(),
137                    status: AttackStatus::Passed,
138                    error_class: None,
139                    error_code: None,
140                    message: None,
141                    duration_ms,
142                }
143            }
144            Err(e) => {
145                self.summary.failed += 1;
146                AttackResult {
147                    name: name.to_string(),
148                    status: AttackStatus::Failed,
149                    error_class: None,
150                    error_code: None,
151                    message: Some(e.to_string()),
152                    duration_ms,
153                }
154            }
155        };
156        self.results.push(res);
157    }
158}