use serde::{Deserialize, Serialize};
use super::identity::PhaseIdentity;
use super::storage::OpCounts;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CheckpointData {
SessionStart {
at: String,
version: u32,
session: String,
started_at: String,
invocation: u32,
},
SessionEnd {
at: String,
outcome: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
PhaseDeclared {
at: String,
identity: PhaseIdentity,
skip_eligible: bool,
},
PhaseStarted { at: String, identity: PhaseIdentity },
PhaseProgress {
at: String,
identity: PhaseIdentity,
op_counts: OpCounts,
#[serde(default, skip_serializing_if = "Option::is_none")]
cursor_state: Option<serde_json::Value>,
},
PhaseCompleted {
at: String,
identity: PhaseIdentity,
duration_secs: f64,
op_counts: OpCounts,
},
PhaseFailed {
at: String,
identity: PhaseIdentity,
error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
op_counts: Option<OpCounts>,
},
PhaseHash {
at: String,
identity: PhaseIdentity,
hash_hex: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
params_consumed: Option<String>,
},
ScopeEnter {
at: String,
kind: String,
coords: std::collections::BTreeMap<String, serde_json::Value>,
path: Vec<std::collections::BTreeMap<String, serde_json::Value>>,
},
ScopeExit {
at: String,
kind: String,
coords: std::collections::BTreeMap<String, serde_json::Value>,
path: Vec<std::collections::BTreeMap<String, serde_json::Value>>,
outcome: String,
},
}
impl CheckpointData {
pub fn at(&self) -> &str {
match self {
Self::SessionStart { at, .. }
| Self::SessionEnd { at, .. }
| Self::PhaseDeclared { at, .. }
| Self::PhaseStarted { at, .. }
| Self::PhaseProgress { at, .. }
| Self::PhaseCompleted { at, .. }
| Self::PhaseFailed { at, .. }
| Self::PhaseHash { at, .. }
| Self::ScopeEnter { at, .. }
| Self::ScopeExit { at, .. } => at,
}
}
pub fn event_type(&self) -> Option<crate::lifecycle::EventType> {
use crate::lifecycle::EventType;
match self {
Self::SessionStart { .. } => Some(EventType::SessionStart),
Self::SessionEnd { .. } => Some(EventType::SessionEnd),
Self::PhaseDeclared { .. } => None,
Self::PhaseStarted { .. } => Some(EventType::PhaseStart),
Self::PhaseProgress { .. } => Some(EventType::Update),
Self::PhaseCompleted { .. } | Self::PhaseFailed { .. } => Some(EventType::PhaseEnd),
Self::PhaseHash { .. } => None,
Self::ScopeEnter { .. } => Some(EventType::ScopeStart),
Self::ScopeExit { .. } => Some(EventType::ScopeEnd),
}
}
}
pub fn hash_to_hex(bytes: &[u8; 32]) -> String {
let mut s = String::with_capacity(64);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
pub fn hex_to_hash(hex: &str) -> Option<[u8; 32]> {
if hex.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
let pair = &hex[i * 2..i * 2 + 2];
*byte = u8::from_str_radix(pair, 16).ok()?;
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_round_trip() {
let h = [0xabu8; 32];
let hex = hash_to_hex(&h);
assert_eq!(hex.len(), 64);
assert_eq!(hex_to_hash(&hex), Some(h));
}
#[test]
fn hex_to_hash_rejects_malformed() {
assert!(hex_to_hash("").is_none());
assert!(hex_to_hash("ab").is_none()); assert!(hex_to_hash(&"z".repeat(64)).is_none()); }
#[test]
fn session_start_round_trip_via_serde() {
let e = CheckpointData::SessionStart {
at: "2026-05-07T12:00:00Z".into(),
version: 1,
session: "test".into(),
started_at: "2026-05-07T12:00:00Z".into(),
invocation: 1,
};
let line = serde_json::to_string(&e).unwrap();
assert!(line.contains("\"type\":\"session_start\""), "line: {line}");
let parsed: CheckpointData = serde_json::from_str(&line).unwrap();
match parsed {
CheckpointData::SessionStart { invocation, .. } => {
assert_eq!(invocation, 1);
}
_ => panic!("expected SessionStart"),
}
}
#[test]
fn unknown_type_fails_to_parse() {
let line = r#"{"type":"future_event","at":"2026-05-07T12:00:00Z"}"#;
let r: Result<CheckpointData, _> = serde_json::from_str(line);
assert!(r.is_err(), "unknown type must error at parse");
}
}