use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
use crate::error::{Result, SdkError};
pub trait State: Serialize + DeserializeOwned + Clone + Send + Sync + 'static {}
impl<T> State for T where T: Serialize + DeserializeOwned + Clone + Send + Sync + 'static {}
pub(crate) struct StateContainer<S: State> {
value: S,
snapshot: Value,
}
impl<S: State> StateContainer<S> {
pub fn new(initial: S) -> Result<Self> {
let snapshot =
serde_json::to_value(&initial).map_err(|e| SdkError::StateSerde(e.to_string()))?;
Ok(Self {
value: initial,
snapshot,
})
}
pub fn get(&self) -> &S {
&self.value
}
pub fn get_mut(&mut self) -> &mut S {
&mut self.value
}
pub fn take_snapshot(&mut self) -> Result<()> {
self.snapshot =
serde_json::to_value(&self.value).map_err(|e| SdkError::StateSerde(e.to_string()))?;
Ok(())
}
pub fn changed_paths(&self) -> Result<Vec<String>> {
let current =
serde_json::to_value(&self.value).map_err(|e| SdkError::StateSerde(e.to_string()))?;
Ok(hypen_engine::diff_paths(&self.snapshot, ¤t)
.into_iter()
.map(|e| e.path)
.collect())
}
pub fn to_json(&self) -> Result<Value> {
serde_json::to_value(&self.value).map_err(|e| SdkError::StateSerde(e.to_string()))
}
pub fn diff_patch(&self) -> Result<Value> {
let current =
serde_json::to_value(&self.value).map_err(|e| SdkError::StateSerde(e.to_string()))?;
let mut patch = serde_json::Map::new();
let Value::Object(current_map) = ¤t else {
return Ok(Value::Object(patch));
};
for entry in hypen_engine::diff_paths(&self.snapshot, ¤t) {
let root = match entry.path.split_once('.') {
Some((head, _)) => head,
None => entry.path.as_str(),
};
if patch.contains_key(root) {
continue;
}
if let Some(new_subtree) = current_map.get(root) {
patch.insert(root.to_string(), new_subtree.clone());
}
}
Ok(Value::Object(patch))
}
}
pub(crate) fn apply_bind<S: State>(current: &S, path: &str, value: Value) -> Result<S> {
let mut json =
serde_json::to_value(current).map_err(|e| SdkError::StateSerde(e.to_string()))?;
hypen_engine::path_set(&mut json, path, value);
serde_json::from_value(json)
.map_err(|e| SdkError::StateSerde(format!("__hypen_bind apply at '{path}': {e}")))
}
pub(crate) fn apply_bind_to_json<S: State>(
state_json: &Value,
path: &str,
value: Value,
) -> Option<Value> {
let mut new_json = state_json.clone();
hypen_engine::path_set(&mut new_json, path, value);
let typed: S = serde_json::from_value(new_json.clone()).ok()?;
serde_json::to_value(&typed).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
struct TestState {
count: i32,
name: String,
items: Vec<String>,
}
#[test]
fn test_diff_no_change() {
let container = StateContainer::new(TestState {
count: 0,
name: "Alice".into(),
items: vec![],
})
.unwrap();
let paths = container.changed_paths().unwrap();
assert!(paths.is_empty());
}
#[test]
fn test_diff_scalar_change() {
let mut container = StateContainer::new(TestState {
count: 0,
name: "Alice".into(),
items: vec![],
})
.unwrap();
container.take_snapshot().unwrap();
container.get_mut().count = 42;
let paths = container.changed_paths().unwrap();
assert_eq!(paths, vec!["count"]);
}
#[test]
fn test_diff_multiple_changes() {
let mut container = StateContainer::new(TestState {
count: 0,
name: "Alice".into(),
items: vec![],
})
.unwrap();
container.take_snapshot().unwrap();
container.get_mut().count = 10;
container.get_mut().name = "Bob".into();
let mut paths = container.changed_paths().unwrap();
paths.sort();
assert_eq!(paths, vec!["count", "name"]);
}
#[test]
fn test_diff_array_change() {
let mut container = StateContainer::new(TestState {
count: 0,
name: "Alice".into(),
items: vec!["a".into()],
})
.unwrap();
container.take_snapshot().unwrap();
container.get_mut().items.push("b".into());
let paths = container.changed_paths().unwrap();
assert!(paths.contains(&"items.1".to_string()));
}
#[test]
fn test_diff_nested_struct() {
#[derive(Clone, Default, Serialize, Deserialize)]
struct Nested {
user: User,
count: i32,
}
#[derive(Clone, Default, Serialize, Deserialize)]
struct User {
name: String,
age: i32,
}
let mut container = StateContainer::new(Nested {
user: User {
name: "Alice".into(),
age: 30,
},
count: 0,
})
.unwrap();
container.take_snapshot().unwrap();
container.get_mut().user.age = 31;
let paths = container.changed_paths().unwrap();
assert_eq!(paths, vec!["user.age"]);
}
#[test]
fn test_diff_delegates_to_engine() {
let old = json!({"a": 1, "b": {"c": 2, "d": 3}});
let new = json!({"a": 1, "b": {"c": 99, "d": 3}, "e": true});
let paths: Vec<String> = hypen_engine::diff_paths(&old, &new)
.into_iter()
.map(|e| e.path)
.collect();
assert!(paths.contains(&"b.c".to_string()));
assert!(paths.contains(&"e".to_string()));
assert!(!paths.contains(&"a".to_string()));
assert!(!paths.contains(&"b.d".to_string()));
}
#[test]
fn test_diff_patch_output() {
let mut container = StateContainer::new(TestState {
count: 0,
name: "Alice".into(),
items: vec![],
})
.unwrap();
container.take_snapshot().unwrap();
container.get_mut().count = 5;
let patch = container.diff_patch().unwrap();
assert_eq!(patch, json!({"count": 5}));
}
#[test]
fn diff_patch_rolls_up_growing_vec_to_root_subtree() {
let mut container = StateContainer::new(TestState {
count: 0,
name: "x".into(),
items: vec![],
})
.unwrap();
container.take_snapshot().unwrap();
container.get_mut().items.push("first".into());
let patch = container.diff_patch().unwrap();
assert_eq!(
patch,
json!({"items": ["first"]}),
"growing vec must emit whole `items` subtree, not `items.0`",
);
}
#[test]
fn diff_patch_rolls_up_nested_object_change_to_root() {
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Debug)]
struct Profile {
name: String,
}
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Debug)]
struct User {
profile: Profile,
age: i32,
}
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Debug)]
struct S {
user: User,
}
let mut container = StateContainer::new(S {
user: User {
profile: Profile { name: "old".into() },
age: 30,
},
})
.unwrap();
container.take_snapshot().unwrap();
container.get_mut().user.profile.name = "new".into();
let patch = container.diff_patch().unwrap();
assert_eq!(
patch,
json!({"user": {"profile": {"name": "new"}, "age": 30}}),
);
}
#[test]
fn test_to_json() {
let container = StateContainer::new(TestState {
count: 42,
name: "Bob".into(),
items: vec!["x".into()],
})
.unwrap();
let json = container.to_json().unwrap();
assert_eq!(json["count"], 42);
assert_eq!(json["name"], "Bob");
}
}