use std::ops::{Deref, DerefMut};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
#[derive(Debug, Clone)]
pub struct TrackedResource<T> {
resource: T,
original_state: Option<Value>,
}
impl<T: Serialize + DeserializeOwned + Clone> TrackedResource<T> {
#[must_use]
pub const fn new(resource: T) -> Self {
Self {
resource,
original_state: None,
}
}
#[must_use]
pub fn from_existing(resource: T) -> Self {
let original_state = serde_json::to_value(&resource).ok();
Self {
resource,
original_state,
}
}
#[must_use]
#[allow(clippy::option_if_let_else)]
pub fn is_dirty(&self) -> bool {
match &self.original_state {
None => true, Some(original) => {
let current = serde_json::to_value(&self.resource).ok();
current.as_ref() != Some(original)
}
}
}
#[must_use]
pub fn changed_fields(&self) -> Value {
let current = serde_json::to_value(&self.resource).unwrap_or(Value::Null);
match &self.original_state {
None => current, Some(original) => diff_json_objects(original, ¤t),
}
}
pub fn mark_clean(&mut self) {
self.original_state = serde_json::to_value(&self.resource).ok();
}
#[must_use]
pub const fn inner(&self) -> &T {
&self.resource
}
#[must_use]
pub fn inner_mut(&mut self) -> &mut T {
&mut self.resource
}
#[must_use]
pub fn into_inner(self) -> T {
self.resource
}
#[must_use]
pub const fn is_new(&self) -> bool {
self.original_state.is_none()
}
}
fn diff_json_objects(original: &Value, current: &Value) -> Value {
match (original, current) {
(Value::Object(orig_map), Value::Object(curr_map)) => {
let mut diff = serde_json::Map::new();
for (key, curr_value) in curr_map {
match orig_map.get(key) {
Some(orig_value) => {
if orig_value != curr_value {
if orig_value.is_object() && curr_value.is_object() {
let nested_diff = diff_json_objects(orig_value, curr_value);
if !nested_diff.is_null()
&& nested_diff.as_object().is_some_and(|m| !m.is_empty())
{
diff.insert(key.clone(), nested_diff);
}
} else {
diff.insert(key.clone(), curr_value.clone());
}
}
}
None => {
diff.insert(key.clone(), curr_value.clone());
}
}
}
Value::Object(diff)
}
_ => {
if original == current {
Value::Null
} else {
current.clone()
}
}
}
}
impl<T> Deref for TrackedResource<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.resource
}
}
impl<T> DerefMut for TrackedResource<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.resource
}
}
const _: fn() = || {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<TrackedResource<String>>();
};
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct TestProduct {
id: Option<u64>,
title: String,
vendor: String,
tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct TestProductWithNested {
id: u64,
title: String,
options: TestOptions,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct TestOptions {
name: String,
values: Vec<String>,
}
#[test]
fn test_tracked_resource_new_captures_no_initial_state() {
let product = TestProduct {
id: None,
title: "New Product".to_string(),
vendor: "Vendor".to_string(),
tags: vec![],
};
let tracked = TrackedResource::new(product);
assert!(tracked.is_new());
assert!(tracked.original_state.is_none());
}
#[test]
fn test_is_dirty_returns_false_for_unchanged_resource() {
let product = TestProduct {
id: Some(123),
title: "Test".to_string(),
vendor: "Vendor".to_string(),
tags: vec!["tag1".to_string()],
};
let tracked = TrackedResource::from_existing(product);
assert!(!tracked.is_dirty());
}
#[test]
fn test_is_dirty_returns_true_after_field_modification() {
let product = TestProduct {
id: Some(123),
title: "Original".to_string(),
vendor: "Vendor".to_string(),
tags: vec![],
};
let mut tracked = TrackedResource::from_existing(product);
assert!(!tracked.is_dirty());
tracked.title = "Modified".to_string();
assert!(tracked.is_dirty());
}
#[test]
fn test_changed_fields_returns_empty_for_unchanged_resource() {
let product = TestProduct {
id: Some(123),
title: "Test".to_string(),
vendor: "Vendor".to_string(),
tags: vec![],
};
let tracked = TrackedResource::from_existing(product);
let changes = tracked.changed_fields();
assert!(changes.is_object());
assert!(changes.as_object().unwrap().is_empty());
}
#[test]
fn test_changed_fields_returns_only_modified_fields() {
let product = TestProduct {
id: Some(123),
title: "Original Title".to_string(),
vendor: "Original Vendor".to_string(),
tags: vec!["tag1".to_string()],
};
let mut tracked = TrackedResource::from_existing(product);
tracked.title = "New Title".to_string();
let changes = tracked.changed_fields();
assert_eq!(changes.get("title"), Some(&json!("New Title")));
assert!(changes.get("vendor").is_none()); assert!(changes.get("id").is_none()); assert!(changes.get("tags").is_none()); }
#[test]
fn test_changed_fields_handles_nested_object_changes() {
let product = TestProductWithNested {
id: 123,
title: "Test".to_string(),
options: TestOptions {
name: "Color".to_string(),
values: vec!["Red".to_string(), "Blue".to_string()],
},
};
let mut tracked = TrackedResource::from_existing(product);
tracked.options.name = "Size".to_string();
let changes = tracked.changed_fields();
assert!(changes.get("options").is_some());
let options_changes = changes.get("options").unwrap();
assert_eq!(options_changes.get("name"), Some(&json!("Size")));
assert!(options_changes.get("values").is_none());
}
#[test]
fn test_mark_clean_resets_dirty_state() {
let product = TestProduct {
id: Some(123),
title: "Original".to_string(),
vendor: "Vendor".to_string(),
tags: vec![],
};
let mut tracked = TrackedResource::from_existing(product);
tracked.title = "Modified".to_string();
assert!(tracked.is_dirty());
tracked.mark_clean();
assert!(!tracked.is_dirty());
let changes = tracked.changed_fields();
assert!(changes.as_object().unwrap().is_empty());
}
#[test]
fn test_new_resources_serialize_all_fields() {
let product = TestProduct {
id: None,
title: "New Product".to_string(),
vendor: "New Vendor".to_string(),
tags: vec!["tag1".to_string()],
};
let tracked = TrackedResource::new(product);
let changes = tracked.changed_fields();
assert!(changes.get("id").is_some());
assert!(changes.get("title").is_some());
assert!(changes.get("vendor").is_some());
assert!(changes.get("tags").is_some());
}
#[test]
fn test_deref_allows_field_access() {
let product = TestProduct {
id: Some(123),
title: "Test".to_string(),
vendor: "Vendor".to_string(),
tags: vec![],
};
let tracked = TrackedResource::from_existing(product);
assert_eq!(tracked.title, "Test");
assert_eq!(tracked.vendor, "Vendor");
}
#[test]
fn test_deref_mut_allows_field_modification() {
let product = TestProduct {
id: Some(123),
title: "Original".to_string(),
vendor: "Vendor".to_string(),
tags: vec![],
};
let mut tracked = TrackedResource::from_existing(product);
tracked.title = "Modified".to_string();
tracked.tags.push("new_tag".to_string());
assert_eq!(tracked.title, "Modified");
assert_eq!(tracked.tags, vec!["new_tag".to_string()]);
}
#[test]
fn test_into_inner_returns_resource() {
let product = TestProduct {
id: Some(123),
title: "Test".to_string(),
vendor: "Vendor".to_string(),
tags: vec![],
};
let tracked = TrackedResource::from_existing(product.clone());
let inner = tracked.into_inner();
assert_eq!(inner, product);
}
#[test]
fn test_is_new_differentiates_new_and_existing() {
let new_product = TestProduct {
id: None,
title: "New".to_string(),
vendor: "Vendor".to_string(),
tags: vec![],
};
let existing_product = TestProduct {
id: Some(123),
title: "Existing".to_string(),
vendor: "Vendor".to_string(),
tags: vec![],
};
let new_tracked = TrackedResource::new(new_product);
assert!(new_tracked.is_new());
let existing_tracked = TrackedResource::from_existing(existing_product);
assert!(!existing_tracked.is_new());
}
#[test]
fn test_changed_fields_detects_array_modifications() {
let product = TestProduct {
id: Some(123),
title: "Test".to_string(),
vendor: "Vendor".to_string(),
tags: vec!["original".to_string()],
};
let mut tracked = TrackedResource::from_existing(product);
tracked.tags.push("new_tag".to_string());
let changes = tracked.changed_fields();
assert!(changes.get("tags").is_some());
}
#[test]
fn test_diff_json_objects_handles_added_fields() {
let original = json!({"a": 1});
let current = json!({"a": 1, "b": 2});
let diff = diff_json_objects(&original, ¤t);
assert_eq!(diff.get("b"), Some(&json!(2)));
assert!(diff.get("a").is_none()); }
#[test]
fn test_multiple_modifications_and_mark_clean() {
let product = TestProduct {
id: Some(123),
title: "Original".to_string(),
vendor: "Original Vendor".to_string(),
tags: vec![],
};
let mut tracked = TrackedResource::from_existing(product);
tracked.title = "First Change".to_string();
assert!(tracked.is_dirty());
tracked.mark_clean();
assert!(!tracked.is_dirty());
tracked.vendor = "New Vendor".to_string();
assert!(tracked.is_dirty());
let changes = tracked.changed_fields();
assert!(changes.get("title").is_none()); assert_eq!(changes.get("vendor"), Some(&json!("New Vendor")));
}
}