libpetri_debug/archive/session_archive.rs
1//! Metadata header for a session archive file.
2//!
3//! Sealed across format versions so callers can pattern-match on [`SessionArchive::V1`]
4//! / [`SessionArchive::V2`] to access v2-only fields with exhaustive coverage:
5//!
6//! ```ignore
7//! match archive {
8//! SessionArchive::V1(v1) => println!("legacy: {}", v1.session_id),
9//! SessionArchive::V2(v2) => println!("v2: {} tags={:?}", v2.session_id, v2.tags),
10//! }
11//! ```
12//!
13//! ## Version contract
14//!
15//! - **v1** (libpetri 1.5.x–1.6.x): original format. Header carries `sessionId`,
16//! `netName`, `dotDiagram`, `startTime`, `eventCount`, and net `structure`.
17//! - **v2** (libpetri 1.7.0+): adds `endTime`, user-defined `tags`, and pre-computed
18//! [`SessionMetadata`] (event-type histogram, first/last event timestamps,
19//! `hasErrors`). Events inside v2 archives are serialized the same way as in v1 —
20//! only the header is enriched.
21//!
22//! The reader peeks the header `version` field via a lenient probe struct and
23//! dispatches to the correct concrete type. Both v1 and v2 archives remain
24//! readable and may coexist in the same storage bucket.
25
26use std::collections::{BTreeMap, HashMap};
27use std::sync::OnceLock;
28
29use serde::{Deserialize, Serialize};
30
31use crate::debug_response::NetStructure;
32
33/// Version written by default by [`SessionArchiveWriter::write`](super::session_archive_writer::SessionArchiveWriter::write).
34pub const CURRENT_VERSION: u32 = 2;
35
36/// Lowest version [`SessionArchiveReader`](super::session_archive_reader::SessionArchiveReader) can decode.
37pub const MIN_SUPPORTED_VERSION: u32 = 1;
38
39/// Legacy v1 archive header (libpetri 1.5.x–1.6.x).
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct SessionArchiveV1 {
43 pub version: u32,
44 pub session_id: String,
45 pub net_name: String,
46 pub dot_diagram: String,
47 pub start_time: String,
48 pub event_count: usize,
49 pub structure: NetStructure,
50}
51
52/// v2 archive header (libpetri 1.7.0+). Adds end time, tags, and pre-computed
53/// metadata so listing tools and samplers can filter/aggregate without scanning
54/// the event body.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct SessionArchiveV2 {
58 pub version: u32,
59 pub session_id: String,
60 pub net_name: String,
61 pub dot_diagram: String,
62 pub start_time: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub end_time: Option<String>,
65 pub event_count: usize,
66 #[serde(default)]
67 pub tags: HashMap<String, String>,
68 pub metadata: SessionMetadata,
69 pub structure: NetStructure,
70}
71
72/// Sealed archive header across all supported format versions.
73///
74/// Callers pattern-match to access v2-only fields (`tags`, `end_time`, `metadata`).
75/// Shared accessors on the enum return sensible defaults for v1 so uniform callers
76/// don't need to branch.
77#[derive(Debug, Clone)]
78pub enum SessionArchive {
79 V1(SessionArchiveV1),
80 V2(SessionArchiveV2),
81}
82
83impl SessionArchive {
84 pub fn version(&self) -> u32 {
85 match self {
86 Self::V1(a) => a.version,
87 Self::V2(a) => a.version,
88 }
89 }
90
91 pub fn session_id(&self) -> &str {
92 match self {
93 Self::V1(a) => &a.session_id,
94 Self::V2(a) => &a.session_id,
95 }
96 }
97
98 pub fn net_name(&self) -> &str {
99 match self {
100 Self::V1(a) => &a.net_name,
101 Self::V2(a) => &a.net_name,
102 }
103 }
104
105 pub fn dot_diagram(&self) -> &str {
106 match self {
107 Self::V1(a) => &a.dot_diagram,
108 Self::V2(a) => &a.dot_diagram,
109 }
110 }
111
112 pub fn start_time(&self) -> &str {
113 match self {
114 Self::V1(a) => &a.start_time,
115 Self::V2(a) => &a.start_time,
116 }
117 }
118
119 pub fn event_count(&self) -> usize {
120 match self {
121 Self::V1(a) => a.event_count,
122 Self::V2(a) => a.event_count,
123 }
124 }
125
126 pub fn structure(&self) -> &NetStructure {
127 match self {
128 Self::V1(a) => &a.structure,
129 Self::V2(a) => &a.structure,
130 }
131 }
132
133 /// v2-only. Returns `None` for v1 archives.
134 pub fn end_time(&self) -> Option<&str> {
135 match self {
136 Self::V1(_) => None,
137 Self::V2(a) => a.end_time.as_deref(),
138 }
139 }
140
141 /// Returns the session tags. v1 archives produce an empty static map.
142 pub fn tags(&self) -> &HashMap<String, String> {
143 static EMPTY: OnceLock<HashMap<String, String>> = OnceLock::new();
144 match self {
145 Self::V1(_) => EMPTY.get_or_init(HashMap::new),
146 Self::V2(a) => &a.tags,
147 }
148 }
149
150 /// Pre-computed aggregate stats. `None` for v1 archives — callers that need
151 /// them for a v1 session should call
152 /// [`compute_metadata`](super::session_metadata::compute_metadata) on the
153 /// event store after [`SessionArchiveReader::read_full`](super::session_archive_reader::SessionArchiveReader::read_full).
154 pub fn metadata(&self) -> Option<&SessionMetadata> {
155 match self {
156 Self::V1(_) => None,
157 Self::V2(a) => Some(&a.metadata),
158 }
159 }
160}
161
162/// Pre-computed aggregate statistics attached to a v2 session archive header.
163///
164/// Computed once during archive write by a single-pass scan of the event store.
165/// Readers can answer `has_errors`, histogram, and first/last timestamp queries
166/// without iterating the event stream — enabling cheap triage, sampling, and
167/// listing of many archives.
168///
169/// `BTreeMap` for the histogram guarantees deterministic JSON key order (matches
170/// Java's `TreeMap` and TypeScript's alphabetical sort).
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct SessionMetadata {
174 #[serde(default)]
175 pub event_type_histogram: BTreeMap<String, u64>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub first_event_time: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub last_event_time: Option<String>,
180 #[serde(default)]
181 pub has_errors: bool,
182}
183
184impl SessionMetadata {
185 /// Returns a `SessionMetadata` with no data. Used as a default for empty
186 /// sessions and as the fallback for v1 archive imports.
187 pub fn empty() -> Self {
188 Self::default()
189 }
190}