use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OpenlatchMarker {
pub v: u8,
pub id: String,
pub installed_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hmac: Option<String>,
}
impl OpenlatchMarker {
pub fn new(id: String) -> Self {
Self {
v: 1,
id,
installed_at: Utc::now(),
hmac: None,
}
}
pub fn with_hmac(mut self, hmac: String) -> Self {
self.hmac = Some(hmac);
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum MarkerShape {
Missing,
Legacy,
Current(OpenlatchMarker),
}
pub fn classify_marker(value: &serde_json::Value) -> MarkerShape {
let obj = match value.as_object() {
Some(o) => o,
None => return MarkerShape::Missing,
};
match obj.get("_openlatch") {
Some(serde_json::Value::Bool(true)) => MarkerShape::Legacy,
Some(serde_json::Value::Object(_)) => {
match serde_json::from_value::<OpenlatchMarker>(obj["_openlatch"].clone()) {
Ok(marker) => MarkerShape::Current(marker),
Err(_) => MarkerShape::Legacy,
}
}
_ => MarkerShape::Missing,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn classify_missing_when_no_marker() {
let v = json!({"hooks": []});
assert_eq!(classify_marker(&v), MarkerShape::Missing);
}
#[test]
fn classify_legacy_bool_marker() {
let v = json!({"_openlatch": true, "hooks": []});
assert_eq!(classify_marker(&v), MarkerShape::Legacy);
}
#[test]
fn classify_current_object_marker() {
let v = json!({
"_openlatch": {
"v": 1,
"id": "test-id",
"installed_at": "2026-04-16T12:00:00Z",
"hmac": "dGVzdA"
},
"hooks": []
});
assert!(matches!(classify_marker(&v), MarkerShape::Current(_)));
}
#[test]
fn classify_missing_on_non_object() {
let v = json!("string");
assert_eq!(classify_marker(&v), MarkerShape::Missing);
}
#[test]
fn classify_missing_when_openlatch_false() {
let v = json!({"_openlatch": false});
assert_eq!(classify_marker(&v), MarkerShape::Missing);
}
#[test]
fn marker_new_sets_v1() {
let m = OpenlatchMarker::new("test-id".into());
assert_eq!(m.v, 1);
assert!(m.hmac.is_none());
}
#[test]
fn marker_with_hmac() {
let m = OpenlatchMarker::new("test-id".into()).with_hmac("abc".into());
assert_eq!(m.hmac.as_deref(), Some("abc"));
}
#[test]
fn marker_roundtrip_serde() {
let m = OpenlatchMarker::new("test-id".into()).with_hmac("abc".into());
let json = serde_json::to_value(&m).unwrap();
let m2: OpenlatchMarker = serde_json::from_value(json).unwrap();
assert_eq!(m.v, m2.v);
assert_eq!(m.id, m2.id);
assert_eq!(m.hmac, m2.hmac);
}
}