Skip to main content

observer_core/
report.rs

1// SPDX-FileCopyrightText: 2026 Alexander R. Croft
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use base64::engine::general_purpose::URL_SAFE_NO_PAD;
5use base64::Engine;
6use crate::product::{ProductCertificationRule, ProductStageRunMode, ProductStageSurface};
7use serde::{Deserialize, Serialize, Serializer};
8use serde_json::{Map, Number, Value};
9
10const PREVIEW_LIMIT: usize = 64;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum ReportMode {
14    #[serde(rename = "default")]
15    Default,
16    #[serde(rename = "golden")]
17    Golden,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct ReportHeader {
22    pub k: String,
23    pub v: String,
24    pub inventory_sha256: String,
25    pub suite_sha256: String,
26    pub mode: ReportMode,
27}
28
29impl ReportHeader {
30    pub fn new(inventory_sha256: String, suite_sha256: String, mode: ReportMode) -> Self {
31        Self {
32            k: "observer_report".to_owned(),
33            v: "0".to_owned(),
34            inventory_sha256,
35            suite_sha256,
36            mode,
37        }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct ProductReportHeader {
43    pub k: String,
44    pub v: String,
45    pub product_id: String,
46    pub product_sha256: String,
47    pub certification_rule: ProductCertificationRule,
48    pub mode: ReportMode,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub product_label: Option<String>,
51}
52
53impl ProductReportHeader {
54    pub fn new(
55        product_id: String,
56        product_sha256: String,
57        certification_rule: ProductCertificationRule,
58        mode: ReportMode,
59        product_label: Option<String>,
60    ) -> Self {
61        Self {
62            k: "observer_product_report".to_owned(),
63            v: "0".to_owned(),
64            product_id,
65            product_sha256,
66            certification_rule,
67            mode,
68            product_label,
69        }
70    }
71}
72
73#[derive(Debug, Clone, PartialEq)]
74pub enum ReportRecord {
75    Header(ReportHeader),
76    Assert(AssertRecord),
77    Action(ActionRecord),
78    Artifact(ArtifactRecord),
79    Extract(ExtractRecord),
80    Telemetry(TelemetryRecord),
81    Case(CaseRecord),
82    Summary(SummaryRecord),
83}
84
85#[derive(Debug, Clone, PartialEq)]
86pub enum ProductReportRecord {
87    Header(ProductReportHeader),
88    Stage(ProductStageRecord),
89    Summary(ProductSummaryRecord),
90}
91
92impl Serialize for ProductReportRecord {
93    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
94    where
95        S: Serializer,
96    {
97        match self {
98            ProductReportRecord::Header(header) => header.serialize(serializer),
99            ProductReportRecord::Stage(record) => TaggedRecord {
100                k: "product_stage",
101                inner: record,
102            }
103            .serialize(serializer),
104            ProductReportRecord::Summary(record) => TaggedRecord {
105                k: "product_summary",
106                inner: record,
107            }
108            .serialize(serializer),
109        }
110    }
111}
112
113impl Serialize for ReportRecord {
114    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
115    where
116        S: Serializer,
117    {
118        match self {
119            ReportRecord::Header(header) => header.serialize(serializer),
120            ReportRecord::Assert(record) => TaggedRecord {
121                k: "assert",
122                inner: record,
123            }
124            .serialize(serializer),
125            ReportRecord::Action(record) => TaggedRecord {
126                k: "action",
127                inner: record,
128            }
129            .serialize(serializer),
130            ReportRecord::Artifact(record) => TaggedRecord {
131                k: "artifact",
132                inner: record,
133            }
134            .serialize(serializer),
135            ReportRecord::Extract(record) => TaggedRecord {
136                k: "extract",
137                inner: record,
138            }
139            .serialize(serializer),
140            ReportRecord::Telemetry(record) => TaggedRecord {
141                k: "telemetry",
142                inner: record,
143            }
144            .serialize(serializer),
145            ReportRecord::Case(record) => TaggedRecord {
146                k: "case",
147                inner: record,
148            }
149            .serialize(serializer),
150            ReportRecord::Summary(record) => TaggedRecord {
151                k: "summary",
152                inner: record,
153            }
154            .serialize(serializer),
155        }
156    }
157}
158
159#[derive(Serialize)]
160struct TaggedRecord<'a, T> {
161    k: &'static str,
162    #[serde(flatten)]
163    inner: &'a T,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct AssertRecord {
168    pub case_id: String,
169    pub assert_ix: usize,
170    pub status: Status,
171    pub msg: String,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct ActionRecord {
176    pub case_id: String,
177    pub action_ix: usize,
178    pub action: String,
179    pub status: ActionStatus,
180    pub args: serde_json::Value,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub ok: Option<serde_json::Value>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub fail: Option<ActionFail>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct ActionFail {
189    pub kind: String,
190    pub msg: String,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub code: Option<String>,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub struct ArtifactRecord {
197    pub case_id: String,
198    pub artifact_ix: usize,
199    pub action_ix: usize,
200    pub name: String,
201    pub event: String,
202    pub kind: String,
203    pub status: ActionStatus,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub location: Option<serde_json::Value>,
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub fail: Option<ActionFail>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct ExtractRecord {
212    pub case_id: String,
213    pub extract_ix: usize,
214    pub action_ix: usize,
215    pub source_artifact: String,
216    pub format: String,
217    pub status: ActionStatus,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub select: Option<String>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub summary: Option<serde_json::Value>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub fail: Option<ActionFail>,
224}
225
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227pub struct TelemetryRecord {
228    pub case_id: String,
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub action_ix: Option<usize>,
231    pub scope: TelemetryScope,
232    #[serde(flatten)]
233    pub entry: TelemetryEntry,
234    pub canonical: bool,
235}
236
237impl TelemetryRecord {
238    pub fn action(case_id: String, action_ix: usize, entry: TelemetryEntry) -> Self {
239        Self {
240            case_id,
241            action_ix: Some(action_ix),
242            scope: TelemetryScope::Action,
243            entry,
244            canonical: false,
245        }
246    }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum TelemetryScope {
252    Case,
253    Action,
254}
255
256#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
257pub struct TelemetryEntry {
258    pub name: String,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub unit: Option<String>,
261    #[serde(flatten)]
262    pub value: TelemetryValue,
263}
264
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[serde(tag = "kind", rename_all = "snake_case")]
267pub enum TelemetryValue {
268    Metric { value: Number },
269    Vector { values: Vec<Number> },
270    Tag { value: String },
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub struct CaseRecord {
275    pub case_id: String,
276    pub item_id: String,
277    pub test_name: String,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub case_key: Option<String>,
280    pub status: Status,
281    pub assert_pass: usize,
282    pub assert_fail: usize,
283    pub unhandled_action_fail: usize,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287pub struct SummaryRecord {
288    pub case_pass: usize,
289    pub case_fail: usize,
290    pub assert_pass: usize,
291    pub assert_fail: usize,
292    pub exit_code: i32,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct ProductStageRecord {
297    pub stage_id: String,
298    pub runner_kind: String,
299    pub cwd: String,
300    pub required: bool,
301    pub status: ProductStatus,
302    pub exit_code: i32,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub suite_path: Option<String>,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub surface: Option<ProductStageSurface>,
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub mode: Option<ProductStageRunMode>,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub model_path: Option<String>,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub inventory_path: Option<String>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub config_path: Option<String>,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub filter: Option<String>,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub child_report_path: Option<String>,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub child_report_sha256: Option<String>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub child_inventory_sha256: Option<String>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub child_suite_sha256: Option<String>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub child_model_sha256: Option<String>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub child_case_pass: Option<usize>,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub child_case_fail: Option<usize>,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub child_assert_pass: Option<usize>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub child_assert_fail: Option<usize>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub child_check_pass: Option<usize>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub child_check_fail: Option<usize>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub child_check_validation_fail: Option<usize>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub child_check_runner_fail: Option<usize>,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub fail: Option<ActionFail>,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
348pub struct ProductSummaryRecord {
349    pub product_id: String,
350    pub certification_rule: ProductCertificationRule,
351    pub stage_pass: usize,
352    pub stage_fail: usize,
353    pub stage_runner_error: usize,
354    pub status: ProductStatus,
355    pub exit_code: i32,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "snake_case")]
360pub enum Status {
361    Pass,
362    Fail,
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(rename_all = "snake_case")]
367pub enum ActionStatus {
368    Ok,
369    Fail,
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "snake_case")]
374pub enum ProductStatus {
375    Pass,
376    Fail,
377    RunnerError,
378}
379
380pub fn serialize_report_json<T: Serialize>(value: &T, mode: ReportMode) -> crate::ObserverResult<String> {
381    match mode {
382        ReportMode::Default => serde_json::to_string(value)
383            .map_err(|error| crate::ObserverError::Normalize(error.to_string())),
384        ReportMode::Golden => {
385            let value = serde_json::to_value(value)
386                .map_err(|error| crate::ObserverError::Normalize(error.to_string()))?;
387            let canonical = canonicalize_json(value);
388            serde_json::to_string(&canonical)
389                .map_err(|error| crate::ObserverError::Normalize(error.to_string()))
390        }
391    }
392}
393
394pub fn derive_case_id(item_id: &str, case_key: &str) -> String {
395    let mut bytes = Vec::with_capacity(item_id.len() + case_key.len() + 1);
396    bytes.extend_from_slice(item_id.as_bytes());
397    bytes.push(0x1f);
398    bytes.extend_from_slice(case_key.as_bytes());
399    URL_SAFE_NO_PAD.encode(bytes)
400}
401
402pub fn preview_bytes(bytes: &[u8]) -> (usize, Option<String>, Option<bool>) {
403    if bytes.is_empty() {
404        return (0, None, None);
405    }
406
407    let preview_len = bytes.len().min(PREVIEW_LIMIT);
408    let preview = URL_SAFE_NO_PAD.encode(&bytes[..preview_len]);
409    let truncated = if bytes.len() > preview_len {
410        Some(true)
411    } else {
412        None
413    };
414    (bytes.len(), Some(preview), truncated)
415}
416
417fn canonicalize_json(value: Value) -> Value {
418    match value {
419        Value::Object(object) => {
420            let mut ordered = Map::new();
421            let mut entries = object.into_iter().collect::<Vec<_>>();
422            entries.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes()));
423            for (key, value) in entries {
424                ordered.insert(key, canonicalize_json(value));
425            }
426            Value::Object(ordered)
427        }
428        Value::Array(items) => Value::Array(items.into_iter().map(canonicalize_json).collect()),
429        other => other,
430    }
431}