Skip to main content

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}