1use std::collections::BTreeMap;
2use std::fs;
3use std::path::Path;
4
5use serde::Serialize;
6use serde_json::{Value, json};
7
8use crate::compare::ComparisonResult;
9use crate::probe::{ProbeError, ProbeObservation};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum PhaseStatus {
14 Passed,
15 Failed,
16 RejectedAsExpected,
17 Unavailable,
18 TimedOut,
19 ResourceLimited,
20 NotRun,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum MatrixPhase {
26 ReferenceInputNormalization,
27 ReferenceInputComparison,
28 ViewerInspection,
29 InspectComparison,
30 RewriteRequest,
31 RewriteResponse,
32 ReferenceOutputNormalization,
33 ReferenceOutputComparison,
34 OutputComparison,
35 IdentityVerification,
36 MaskMetrics,
37 ArtifactVerification,
38}
39
40impl MatrixPhase {
41 pub(super) const fn label(self) -> &'static str {
42 match self {
43 Self::ReferenceInputNormalization => "reference_input_normalization",
44 Self::ReferenceInputComparison => "reference_input_comparison",
45 Self::ViewerInspection => "viewer_inspection",
46 Self::InspectComparison => "inspect_comparison",
47 Self::RewriteRequest => "rewrite_request",
48 Self::RewriteResponse => "rewrite_response",
49 Self::ReferenceOutputNormalization => "reference_output_normalization",
50 Self::ReferenceOutputComparison => "reference_output_comparison",
51 Self::OutputComparison => "output_comparison",
52 Self::IdentityVerification => "identity_verification",
53 Self::MaskMetrics => "mask_metrics",
54 Self::ArtifactVerification => "artifact_verification",
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize)]
60pub struct PhaseObservation {
61 pub phase: MatrixPhase,
62 pub status: PhaseStatus,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub command: Option<Vec<String>>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub elapsed_ms: Option<f64>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub peak_rss_bytes: Option<u64>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub output_truncated: Option<bool>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub error_code: Option<String>,
73 pub message: String,
74 pub artifacts: Vec<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub details: Option<Value>,
77}
78
79#[derive(Debug, Serialize)]
80pub struct MatrixObservation {
81 pub schema_version: u8,
82 pub case_id: String,
83 pub pyramid_level: u8,
84 pub status: PhaseStatus,
85 pub phases: Vec<PhaseObservation>,
86 pub inspect_equal: bool,
87 pub roundtrip_equal: Option<bool>,
88 pub rewrite_rejected: bool,
89 pub highdicom_readable: bool,
90 pub coordinate_error_max_px: Option<f64>,
91 pub coordinate_error_median_px: Option<f64>,
92 pub coordinate_error_rms_px: Option<f64>,
93 pub dice: Option<f64>,
94 pub mask_metrics: Option<Value>,
95 pub runtime_ms: f64,
96 pub peak_rss_bytes: u64,
97 pub viewer_runtime: BTreeMap<String, Value>,
98 pub peak_tracked_heap_bytes: u64,
99 pub viewer_implementation: Value,
100 pub probe_commands: BTreeMap<String, Vec<String>>,
101 pub input_bytes: u64,
102 pub output_bytes: Option<u64>,
103 pub input_sop_instance_uid: String,
104 pub output_sop_instance_uid: Option<String>,
105 pub message: String,
106}
107
108#[derive(Debug, Serialize)]
109pub struct CoreMatrixResult {
110 pub observations: Vec<MatrixObservation>,
111}
112
113impl CoreMatrixResult {
114 #[must_use]
115 pub fn is_ok(&self) -> bool {
116 !self.observations.is_empty()
117 && self
118 .observations
119 .iter()
120 .all(|observation| observation.status == PhaseStatus::Passed)
121 }
122}
123
124pub(super) struct CaseRecorder {
125 pub(super) observation: MatrixObservation,
126}
127
128impl CaseRecorder {
129 pub(super) fn new(
130 case_id: &str,
131 path: &Path,
132 pyramid_level: u8,
133 expected: Option<&Value>,
134 ) -> Self {
135 let mut observation = failed_observation(case_id, path, pyramid_level, expected);
136 observation.status = PhaseStatus::NotRun;
137 Self { observation }
138 }
139
140 pub(super) fn record(&mut self, phase: PhaseObservation) {
141 if let Some(elapsed_ms) = phase.elapsed_ms {
142 self.observation.runtime_ms += elapsed_ms;
143 }
144 if let Some(peak_rss_bytes) = phase.peak_rss_bytes {
145 self.observation.peak_rss_bytes = self.observation.peak_rss_bytes.max(peak_rss_bytes);
146 }
147 if let Some(command) = &phase.command {
148 self.observation
149 .probe_commands
150 .insert(phase.phase.label().to_owned(), command.clone());
151 }
152 self.observation.phases.push(phase);
153 }
154
155 pub(super) fn pass(&mut self, phase: MatrixPhase) {
156 self.record(PhaseObservation {
157 phase,
158 status: PhaseStatus::Passed,
159 command: None,
160 elapsed_ms: None,
161 peak_rss_bytes: None,
162 output_truncated: None,
163 error_code: None,
164 message: String::new(),
165 artifacts: Vec::new(),
166 details: None,
167 });
168 }
169
170 pub(super) fn comparison(&mut self, phase: MatrixPhase, result: &ComparisonResult) {
171 self.record(PhaseObservation {
172 phase,
173 status: pass_status(result.is_ok()),
174 command: None,
175 elapsed_ms: None,
176 peak_rss_bytes: None,
177 output_truncated: None,
178 error_code: result.findings.first().map(|finding| finding.code.clone()),
179 message: result
180 .findings
181 .iter()
182 .map(|finding| finding.message.as_str())
183 .collect::<Vec<_>>()
184 .join("; "),
185 artifacts: Vec::new(),
186 details: serde_json::to_value(result).ok(),
187 });
188 }
189
190 pub(super) fn probe(
191 &mut self,
192 phase: MatrixPhase,
193 observation: &ProbeObservation,
194 artifact: Option<&Path>,
195 ) {
196 self.observation.viewer_implementation = observation.report["implementation"].clone();
197 self.observation.viewer_runtime.insert(
198 phase.label().to_owned(),
199 observation.report["runtime"].clone(),
200 );
201 self.observation.peak_tracked_heap_bytes = self.observation.peak_tracked_heap_bytes.max(
202 observation
203 .report
204 .pointer("/runtime/peak_tracked_heap_bytes")
205 .and_then(Value::as_u64)
206 .unwrap_or(0),
207 );
208 if let Some(path) = artifact {
209 self.observation.output_bytes = fs::metadata(path).ok().map(|metadata| metadata.len());
210 }
211 self.record(PhaseObservation {
212 phase,
213 status: PhaseStatus::Passed,
214 command: Some(observation.command.clone()),
215 elapsed_ms: Some(observation.elapsed_ms),
216 peak_rss_bytes: observation
217 .rss_sampled
218 .then_some(observation.peak_rss_bytes),
219 output_truncated: Some(observation.stderr_truncated),
220 error_code: None,
221 message: String::new(),
222 artifacts: artifact
223 .map(|path| vec![path.to_string_lossy().into_owned()])
224 .unwrap_or_default(),
225 details: Some(observation.report.clone()),
226 });
227 }
228
229 pub(super) fn fail_probe(mut self, phase: MatrixPhase, error: ProbeError) -> MatrixObservation {
230 let error_code = error
231 .report
232 .as_ref()
233 .and_then(|report| report.pointer("/error/code"))
234 .and_then(Value::as_str)
235 .map(str::to_owned);
236 let output_truncated = error
237 .process
238 .as_ref()
239 .map(|process| process.stdout_truncated || process.stderr_truncated);
240 let message = error.to_string();
241 self.record(PhaseObservation {
242 phase,
243 status: if message.contains("timed out") {
244 PhaseStatus::TimedOut
245 } else {
246 PhaseStatus::Failed
247 },
248 command: error.command.map(|command| *command),
249 elapsed_ms: None,
250 peak_rss_bytes: error.peak_rss_bytes,
251 output_truncated,
252 error_code,
253 message: message.clone(),
254 artifacts: Vec::new(),
255 details: error.report,
256 });
257 self.observation.message = message;
258 self.finish()
259 }
260
261 pub(super) fn fail(mut self, phase: MatrixPhase, message: String) -> MatrixObservation {
262 self.record(PhaseObservation {
263 phase,
264 status: PhaseStatus::Failed,
265 command: None,
266 elapsed_ms: None,
267 peak_rss_bytes: None,
268 output_truncated: None,
269 error_code: None,
270 message: message.clone(),
271 artifacts: Vec::new(),
272 details: None,
273 });
274 self.observation.message = message;
275 self.finish()
276 }
277
278 pub(super) fn finish(mut self) -> MatrixObservation {
279 self.observation.status = derive_case_status(&self.observation.phases);
280 self.observation
281 }
282}
283
284pub(super) fn derive_case_status(phases: &[PhaseObservation]) -> PhaseStatus {
285 if phases
286 .iter()
287 .any(|phase| phase.status == PhaseStatus::Failed)
288 {
289 PhaseStatus::Failed
290 } else if phases
291 .iter()
292 .any(|phase| phase.status == PhaseStatus::TimedOut)
293 {
294 PhaseStatus::TimedOut
295 } else if phases
296 .iter()
297 .any(|phase| phase.status == PhaseStatus::ResourceLimited)
298 {
299 PhaseStatus::ResourceLimited
300 } else if phases
301 .iter()
302 .any(|phase| phase.status == PhaseStatus::Unavailable)
303 {
304 PhaseStatus::Unavailable
305 } else if phases.is_empty()
306 || phases
307 .iter()
308 .all(|phase| phase.status == PhaseStatus::NotRun)
309 {
310 PhaseStatus::NotRun
311 } else {
312 PhaseStatus::Passed
313 }
314}
315
316pub(super) const fn pass_status(passed: bool) -> PhaseStatus {
317 if passed {
318 PhaseStatus::Passed
319 } else {
320 PhaseStatus::Failed
321 }
322}
323
324fn failed_observation(
325 case_id: &str,
326 path: &Path,
327 pyramid_level: u8,
328 expected: Option<&Value>,
329) -> MatrixObservation {
330 MatrixObservation {
331 schema_version: 2,
332 case_id: case_id.to_owned(),
333 pyramid_level,
334 status: PhaseStatus::Failed,
335 phases: Vec::new(),
336 inspect_equal: false,
337 roundtrip_equal: None,
338 rewrite_rejected: false,
339 highdicom_readable: false,
340 coordinate_error_max_px: None,
341 coordinate_error_median_px: None,
342 coordinate_error_rms_px: None,
343 dice: None,
344 mask_metrics: None,
345 runtime_ms: 0.0,
346 peak_rss_bytes: 0,
347 viewer_runtime: BTreeMap::new(),
348 peak_tracked_heap_bytes: 0,
349 viewer_implementation: json!({}),
350 probe_commands: BTreeMap::new(),
351 input_bytes: fs::metadata(path).map_or(0, |metadata| metadata.len()),
352 output_bytes: None,
353 input_sop_instance_uid: expected
354 .and_then(|value| value.get("sop_instance_uid"))
355 .and_then(Value::as_str)
356 .unwrap_or_default()
357 .to_owned(),
358 output_sop_instance_uid: None,
359 message: String::new(),
360 }
361}