use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest};
use crate::batching::hybrid_batcher::{SizedItem, BatchableItem};
use crate::submit_options::SubmitOptions;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ContentType {
Json,
#[default]
Binary,
}
impl ContentType {
#[must_use]
pub fn detect(content: &[u8]) -> Self {
if content.is_empty() {
return ContentType::Binary;
}
let first = content.iter().find(|b| !b.is_ascii_whitespace());
match first {
Some(b'{') | Some(b'[') | Some(b'"') => {
if serde_json::from_slice::<serde_json::Value>(content).is_ok() {
ContentType::Json
} else {
ContentType::Binary
}
}
_ => ContentType::Binary
}
}
#[inline]
#[must_use]
pub fn is_json(&self) -> bool {
matches!(self, ContentType::Json)
}
#[inline]
#[must_use]
pub fn is_binary(&self) -> bool {
matches!(self, ContentType::Binary)
}
#[inline]
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
ContentType::Json => "json",
ContentType::Binary => "binary",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncItem {
pub object_id: String,
pub version: u64,
pub updated_at: i64,
#[serde(default)]
pub content_type: ContentType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub batch_id: Option<String>,
pub trace_parent: Option<String>,
pub trace_state: Option<String>,
#[doc(hidden)]
pub priority_score: f64,
#[serde(alias = "merkle_root")] pub content_hash: String,
pub last_accessed: u64,
pub access_count: u64,
#[serde(with = "serde_bytes")]
pub content: Vec<u8>,
pub home_instance_id: Option<String>,
#[serde(default = "default_state")]
pub state: String,
#[serde(skip)]
pub(crate) submit_options: Option<SubmitOptions>,
#[serde(skip)]
cached_size: OnceLock<usize>,
}
fn default_state() -> String {
"default".to_string()
}
impl SyncItem {
pub fn new(object_id: String, content: Vec<u8>) -> Self {
let content_type = ContentType::detect(&content);
let content_hash = hex::encode(Sha256::digest(&content));
Self {
object_id,
version: 1,
updated_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64,
content_type,
batch_id: None,
trace_parent: None,
trace_state: None,
priority_score: 0.0,
content_hash,
last_accessed: 0,
access_count: 0,
content,
home_instance_id: None,
state: "default".to_string(),
submit_options: None, cached_size: OnceLock::new(),
}
}
pub fn from_json(object_id: String, value: serde_json::Value) -> Self {
let content = serde_json::to_vec(&value).unwrap_or_default();
let mut item = Self::new(object_id, content);
item.content_type = ContentType::Json; item
}
pub fn from_serializable<T: Serialize>(object_id: String, value: &T) -> Result<Self, serde_json::Error> {
let content = serde_json::to_vec(value)?;
let mut item = Self::new(object_id, content);
item.content_type = ContentType::Json;
Ok(item)
}
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn reconstruct(
object_id: String,
version: u64,
updated_at: i64,
content_type: ContentType,
content: Vec<u8>,
batch_id: Option<String>,
trace_parent: Option<String>,
content_hash: String,
home_instance_id: Option<String>,
state: String,
access_count: u64,
last_accessed: u64,
) -> Self {
Self {
object_id,
version,
updated_at,
content_type,
batch_id,
trace_parent,
trace_state: None,
priority_score: 0.0,
content_hash,
last_accessed,
access_count,
content,
home_instance_id,
state,
submit_options: None,
cached_size: OnceLock::new(),
}
}
#[must_use]
pub fn with_options(mut self, options: SubmitOptions) -> Self {
self.submit_options = Some(options);
self
}
#[must_use]
pub fn with_state(mut self, state: impl Into<String>) -> Self {
self.state = state.into();
self
}
#[must_use]
pub fn effective_options(&self) -> SubmitOptions {
self.submit_options.clone().unwrap_or_default()
}
#[must_use]
pub fn content_as_json(&self) -> Option<serde_json::Value> {
serde_json::from_slice(&self.content).ok()
}
#[cfg(feature = "otel")]
pub fn with_current_trace_context(mut self) -> Self {
use opentelemetry::trace::TraceContextExt;
use tracing_opentelemetry::OpenTelemetrySpanExt;
let cx = tracing::Span::current().context();
let span_ref = cx.span();
let sc = span_ref.span_context();
if sc.is_valid() {
self.trace_parent = Some(format!(
"00-{}-{}-{:02x}",
sc.trace_id(),
sc.span_id(),
sc.trace_flags().to_u8()
));
}
self
}
}
impl SizedItem for SyncItem {
fn size_bytes(&self) -> usize {
*self.cached_size.get_or_init(|| {
std::mem::size_of::<Self>()
+ self.object_id.len()
+ self.trace_parent.as_ref().map_or(0, String::len)
+ self.trace_state.as_ref().map_or(0, String::len)
+ self.content_hash.len()
+ self.content.len()
+ self.home_instance_id.as_ref().map_or(0, String::len)
})
}
}
impl BatchableItem for SyncItem {
fn id(&self) -> &str {
&self.object_id
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_new_sync_item() {
let item = SyncItem::new("test-id".to_string(), b"hello".to_vec());
assert_eq!(item.object_id, "test-id");
assert_eq!(item.version, 1);
assert!(item.updated_at > 0);
assert!(item.batch_id.is_none());
assert!(item.trace_parent.is_none());
assert!(item.trace_state.is_none());
assert_eq!(item.priority_score, 0.0);
assert_eq!(item.content_hash, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824");
assert_eq!(item.last_accessed, 0);
assert_eq!(item.access_count, 0);
assert!(item.home_instance_id.is_none());
assert_eq!(item.content, b"hello");
}
#[test]
fn test_from_json() {
let item = SyncItem::from_json("test-id".to_string(), json!({"key": "value"}));
assert_eq!(item.object_id, "test-id");
let parsed: serde_json::Value = serde_json::from_slice(&item.content).unwrap();
assert_eq!(parsed, json!({"key": "value"}));
}
#[test]
fn test_content_as_json() {
let item = SyncItem::from_json("test".into(), json!({"nested": {"key": 42}}));
let parsed = item.content_as_json().unwrap();
assert_eq!(parsed["nested"]["key"], 42);
let binary_item = SyncItem::new("bin".into(), vec![0xFF, 0xFE, 0x00]);
assert!(binary_item.content_as_json().is_none());
}
#[test]
fn test_size_bytes_calculation() {
let item = SyncItem::from_json(
"uk.nhs.patient.record.123456".to_string(),
json!({"name": "John Doe", "age": 42, "conditions": ["diabetes", "hypertension"]})
);
let size = item.size_bytes();
assert!(size > 0);
assert!(size > std::mem::size_of::<SyncItem>());
}
#[test]
fn test_size_bytes_cached() {
let item = SyncItem::new("test".to_string(), b"data".to_vec());
let size1 = item.size_bytes();
let size2 = item.size_bytes();
assert_eq!(size1, size2);
}
#[test]
fn test_size_includes_optional_fields() {
let mut item = SyncItem::new("test".to_string(), vec![]);
item.trace_parent = Some("00-abc123-def456-01".to_string());
item.trace_state = Some("vendor=data".to_string());
item.home_instance_id = Some("instance-1".to_string());
let size = item.size_bytes();
assert!(size > std::mem::size_of::<SyncItem>() + "test".len());
}
#[test]
fn test_serialize_deserialize() {
let item = SyncItem::from_json(
"test-id".to_string(),
json!({"nested": {"key": "value"}, "array": [1, 2, 3]})
);
let json_str = serde_json::to_string(&item).unwrap();
let deserialized: SyncItem = serde_json::from_str(&json_str).unwrap();
assert_eq!(deserialized.object_id, item.object_id);
assert_eq!(deserialized.version, item.version);
assert_eq!(deserialized.content, item.content);
}
#[test]
fn test_serialize_skips_none_batch_id() {
let item = SyncItem::new("test".to_string(), vec![]);
let json_str = serde_json::to_string(&item).unwrap();
assert!(!json_str.contains("batch_id"));
}
#[test]
fn test_serialize_includes_batch_id_when_some() {
let mut item = SyncItem::new("test".to_string(), vec![]);
item.batch_id = Some("batch-123".to_string());
let json_str = serde_json::to_string(&item).unwrap();
assert!(json_str.contains("batch_id"));
assert!(json_str.contains("batch-123"));
}
#[test]
fn test_clone() {
let item = SyncItem::from_json("original".to_string(), json!({"key": "value"}));
let cloned = item.clone();
assert_eq!(cloned.object_id, item.object_id);
assert_eq!(cloned.content, item.content);
}
#[test]
fn test_debug_format() {
let item = SyncItem::new("test".to_string(), vec![]);
let debug_str = format!("{:?}", item);
assert!(debug_str.contains("SyncItem"));
assert!(debug_str.contains("test"));
}
#[test]
fn test_updated_at_is_recent() {
let before = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let item = SyncItem::new("test".to_string(), vec![]);
let after = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
assert!(item.updated_at >= before);
assert!(item.updated_at <= after);
}
#[test]
fn test_large_content_size() {
let large_data: Vec<u8> = (0..10000u32).flat_map(|i| i.to_le_bytes()).collect();
let item = SyncItem::new("large".to_string(), large_data);
let size = item.size_bytes();
assert!(size > 10000, "Large content should result in large size");
}
#[test]
fn test_state_default() {
let item = SyncItem::new("test".to_string(), b"data".to_vec());
assert_eq!(item.state, "default");
}
#[test]
fn test_state_with_state_builder() {
let item = SyncItem::new("test".to_string(), b"data".to_vec())
.with_state("delta");
assert_eq!(item.state, "delta");
}
#[test]
fn test_state_with_state_chaining() {
let item = SyncItem::from_json("test".into(), json!({"key": "value"}))
.with_state("pending");
assert_eq!(item.state, "pending");
assert_eq!(item.object_id, "test");
}
#[test]
fn test_state_serialization() {
let item = SyncItem::new("test".to_string(), b"data".to_vec())
.with_state("custom_state");
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("\"state\":\"custom_state\""));
let parsed: SyncItem = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.state, "custom_state");
}
#[test]
fn test_state_deserialization_default() {
let json = r#"{
"object_id": "test",
"version": 1,
"updated_at": 12345,
"priority_score": 0.0,
"merkle_root": "",
"last_accessed": 0,
"access_count": 0,
"content": [100, 97, 116, 97]
}"#;
let item: SyncItem = serde_json::from_str(json).unwrap();
assert_eq!(item.state, "default");
}
}