use std::collections::HashMap;
use serde_json::Value;
pub trait Dirty {
fn changed(&self) -> bool;
fn changes(&self) -> HashMap<String, [Value; 2]>;
fn changed_attributes(&self) -> Vec<String>;
fn previous_changes(&self) -> &HashMap<String, [Value; 2]>;
fn attribute_changed(&self, name: &str) -> bool;
fn attribute_was(&self, name: &str) -> Option<Value>;
fn restore_attributes(&mut self);
fn clear_changes(&mut self);
fn changes_applied(&mut self);
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ChangeTracker {
original_values: HashMap<String, Value>,
changes: HashMap<String, [Value; 2]>,
previous_changes: HashMap<String, [Value; 2]>,
}
impl ChangeTracker {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn track_change(&mut self, name: &str, old: Value, new: Value) {
let key = name.to_owned();
if let Some(original) = self.original_values.get(&key).cloned() {
if new == original {
self.original_values.remove(&key);
self.changes.remove(&key);
} else {
self.changes.insert(key, [original, new]);
}
return;
}
if old == new {
return;
}
self.original_values.insert(key.clone(), old.clone());
self.changes.insert(key, [old, new]);
}
#[must_use]
pub fn changed(&self) -> bool {
!self.changes.is_empty()
}
#[must_use]
pub fn changes(&self) -> &HashMap<String, [Value; 2]> {
&self.changes
}
#[must_use]
pub fn previous_changes(&self) -> &HashMap<String, [Value; 2]> {
&self.previous_changes
}
#[must_use]
pub fn attribute_changed(&self, name: &str) -> bool {
self.changes.contains_key(name)
}
#[must_use]
pub fn attribute_was(&self, name: &str) -> Option<&Value> {
self.original_values.get(name)
}
pub fn restore_attribute(&mut self, name: &str) -> Option<Value> {
self.changes.remove(name);
self.original_values.remove(name)
}
pub fn clear(&mut self) {
self.original_values.clear();
self.changes.clear();
self.previous_changes.clear();
}
pub fn apply(&mut self) {
self.previous_changes = self.changes.clone();
self.original_values.clear();
self.changes.clear();
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use serde_json::{Value, json};
use super::{ChangeTracker, Dirty};
#[derive(Debug, Clone, PartialEq)]
struct TestProfile {
name: String,
age: i32,
tracker: ChangeTracker,
}
impl TestProfile {
fn new(name: &str, age: i32) -> Self {
Self {
name: name.to_owned(),
age,
tracker: ChangeTracker::new(),
}
}
fn set_name(&mut self, new_name: &str) {
let old = Value::String(self.name.clone());
let new = Value::String(new_name.to_owned());
self.tracker.track_change("name", old, new.clone());
self.name = new_name.to_owned();
}
fn set_age(&mut self, new_age: i32) {
let old = json!(self.age);
let new = json!(new_age);
self.tracker.track_change("age", old, new.clone());
self.age = new_age;
}
fn apply_value(&mut self, name: &str, value: Value) {
match name {
"name" => {
if let Value::String(text) = value {
self.name = text;
}
}
"age" => {
if let Some(number) = value.as_i64().and_then(|entry| i32::try_from(entry).ok())
{
self.age = number;
}
}
_ => {}
}
}
}
impl Dirty for TestProfile {
fn changed(&self) -> bool {
self.tracker.changed()
}
fn changes(&self) -> HashMap<String, [Value; 2]> {
self.tracker.changes().clone()
}
fn changed_attributes(&self) -> Vec<String> {
self.tracker.changes().keys().cloned().collect()
}
fn previous_changes(&self) -> &HashMap<String, [Value; 2]> {
self.tracker.previous_changes()
}
fn attribute_changed(&self, name: &str) -> bool {
self.tracker.attribute_changed(name)
}
fn attribute_was(&self, name: &str) -> Option<Value> {
self.tracker.attribute_was(name).cloned()
}
fn restore_attributes(&mut self) {
let names = self.changed_attributes();
for name in names {
if let Some(original) = self.tracker.restore_attribute(&name) {
self.apply_value(&name, original);
}
}
}
fn clear_changes(&mut self) {
self.tracker.clear();
}
fn changes_applied(&mut self) {
self.tracker.apply();
}
}
#[test]
fn track_single_change_records_original_and_new_values() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
assert!(tracker.changed());
assert_eq!(
tracker.changes().get("name"),
Some(&[json!("Alice"), json!("Bob")])
);
assert_eq!(tracker.attribute_was("name"), Some(&json!("Alice")));
}
#[test]
fn track_multiple_changes_preserves_the_first_original_value() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.track_change("name", json!("Bob"), json!("Carol"));
assert_eq!(
tracker.changes().get("name"),
Some(&[json!("Alice"), json!("Carol")])
);
assert_eq!(tracker.attribute_was("name"), Some(&json!("Alice")));
}
#[test]
fn changing_back_to_original_removes_the_change() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.track_change("name", json!("Bob"), json!("Alice"));
assert!(!tracker.changed());
assert!(!tracker.attribute_changed("name"));
assert_eq!(tracker.attribute_was("name"), None);
assert!(tracker.changes().is_empty());
}
#[test]
fn restore_attribute_returns_original_value_and_clears_one_change() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.track_change("age", json!(30), json!(31));
let restored = tracker.restore_attribute("name");
assert_eq!(restored, Some(json!("Alice")));
assert!(!tracker.attribute_changed("name"));
assert!(tracker.attribute_changed("age"));
}
#[test]
fn apply_moves_current_changes_into_previous_changes() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.track_change("age", json!(30), json!(31));
tracker.apply();
assert!(!tracker.changed());
assert!(tracker.changes().is_empty());
assert_eq!(
tracker.previous_changes().get("name"),
Some(&[json!("Alice"), json!("Bob")])
);
assert_eq!(
tracker.previous_changes().get("age"),
Some(&[json!(30), json!(31)])
);
}
#[test]
fn apply_resets_originals_for_future_change_tracking() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.apply();
tracker.track_change("name", json!("Bob"), json!("Carol"));
assert_eq!(tracker.attribute_was("name"), Some(&json!("Bob")));
assert_eq!(
tracker.changes().get("name"),
Some(&[json!("Bob"), json!("Carol")])
);
}
#[test]
fn clear_resets_current_and_previous_tracking() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.apply();
tracker.clear();
assert!(!tracker.changed());
assert!(tracker.changes().is_empty());
assert!(tracker.previous_changes().is_empty());
}
#[test]
fn dirty_trait_reports_changes_and_original_values() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.set_age(31);
let changes = profile.changes();
assert!(profile.changed());
assert!(profile.attribute_changed("name"));
assert_eq!(profile.attribute_was("name"), Some(json!("Alice")));
assert_eq!(changes.get("name"), Some(&[json!("Alice"), json!("Bob")]));
assert_eq!(changes.get("age"), Some(&[json!(30), json!(31)]));
}
#[test]
fn dirty_trait_restore_attributes_reverts_all_values() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.set_age(31);
profile.restore_attributes();
assert_eq!(profile.name, "Alice");
assert_eq!(profile.age, 30);
assert!(!profile.changed());
assert!(profile.changes().is_empty());
}
#[test]
fn dirty_trait_changes_applied_exposes_previous_changes() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.changes_applied();
assert!(!profile.changed());
assert_eq!(
profile.previous_changes().get("name"),
Some(&[json!("Alice"), json!("Bob")])
);
}
#[test]
fn dirty_trait_clear_changes_drops_history() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.changes_applied();
profile.clear_changes();
assert!(profile.previous_changes().is_empty());
assert!(!profile.changed());
}
#[test]
fn track_change_ignores_equal_values() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Alice"));
assert!(!tracker.changed());
assert!(tracker.changes().is_empty());
}
#[test]
fn restore_attribute_returns_none_for_unknown_name() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
assert_eq!(tracker.restore_attribute("email"), None);
assert!(tracker.attribute_changed("name"));
}
#[test]
fn attribute_was_returns_none_for_unknown_attribute() {
let tracker = ChangeTracker::new();
assert_eq!(tracker.attribute_was("name"), None);
}
#[test]
fn apply_without_changes_keeps_previous_changes_empty() {
let mut tracker = ChangeTracker::new();
tracker.apply();
assert!(!tracker.changed());
assert!(tracker.previous_changes().is_empty());
}
#[test]
fn dirty_trait_changed_attributes_lists_each_change_once() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.set_age(31);
let mut changed = profile.changed_attributes();
changed.sort();
assert_eq!(changed, vec!["age".to_owned(), "name".to_owned()]);
}
#[test]
fn restore_attributes_after_apply_is_a_noop() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.changes_applied();
profile.restore_attributes();
assert_eq!(profile.name, "Bob");
assert_eq!(profile.age, 30);
assert!(profile.previous_changes().contains_key("name"));
}
#[test]
fn clear_changes_removes_unsaved_history() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.clear_changes();
assert!(!profile.changed());
assert!(profile.changes().is_empty());
assert_eq!(profile.attribute_was("name"), None);
}
#[test]
fn tracker_is_unchanged_when_new() {
let tracker = ChangeTracker::new();
assert!(!tracker.changed());
assert!(tracker.changes().is_empty());
}
#[test]
fn tracker_attribute_changed_is_false_for_unknown_name() {
let tracker = ChangeTracker::new();
assert!(!tracker.attribute_changed("email"));
}
#[test]
fn tracker_previous_changes_are_empty_when_new() {
let tracker = ChangeTracker::new();
assert!(tracker.previous_changes().is_empty());
}
#[test]
fn tracker_changes_preserve_nested_json_values() {
let mut tracker = ChangeTracker::new();
tracker.track_change(
"settings",
json!({ "theme": "dark", "flags": { "beta": false } }),
json!({ "theme": "light", "flags": { "beta": true } }),
);
assert_eq!(
tracker.changes().get("settings"),
Some(&[
json!({ "theme": "dark", "flags": { "beta": false } }),
json!({ "theme": "light", "flags": { "beta": true } }),
])
);
}
#[test]
fn tracker_multiple_updates_keep_latest_new_value() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.track_change("name", json!("Bob"), json!("Carol"));
tracker.track_change("name", json!("Carol"), json!("Dora"));
assert_eq!(
tracker.changes().get("name"),
Some(&[json!("Alice"), json!("Dora")])
);
}
#[test]
fn tracker_apply_replaces_previous_changes_instead_of_accumulating() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.apply();
tracker.track_change("age", json!(30), json!(31));
tracker.apply();
assert_eq!(tracker.previous_changes().len(), 1);
assert!(!tracker.previous_changes().contains_key("name"));
assert_eq!(
tracker.previous_changes().get("age"),
Some(&[json!(30), json!(31)])
);
}
#[test]
fn tracker_restore_attribute_allows_retracking_from_original_value() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
assert_eq!(tracker.restore_attribute("name"), Some(json!("Alice")));
tracker.track_change("name", json!("Alice"), json!("Carol"));
assert_eq!(tracker.attribute_was("name"), Some(&json!("Alice")));
assert_eq!(
tracker.changes().get("name"),
Some(&[json!("Alice"), json!("Carol")])
);
}
#[test]
fn tracker_restore_attribute_only_clears_requested_entry() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.track_change("age", json!(30), json!(31));
let original = tracker.restore_attribute("age");
assert_eq!(original, Some(json!(30)));
assert!(!tracker.attribute_changed("age"));
assert!(tracker.attribute_changed("name"));
assert_eq!(tracker.attribute_was("name"), Some(&json!("Alice")));
}
#[test]
fn tracker_clear_after_unsaved_changes_drops_current_state() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.track_change("age", json!(30), json!(31));
tracker.clear();
assert!(!tracker.changed());
assert!(tracker.changes().is_empty());
assert!(tracker.previous_changes().is_empty());
}
#[test]
fn tracker_apply_after_clear_keeps_previous_changes_empty() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.clear();
tracker.apply();
assert!(tracker.previous_changes().is_empty());
}
#[test]
fn tracker_reverting_one_attribute_keeps_other_changes() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.track_change("age", json!(30), json!(31));
tracker.track_change("name", json!("Bob"), json!("Alice"));
assert!(!tracker.attribute_changed("name"));
assert_eq!(tracker.changes().get("age"), Some(&[json!(30), json!(31)]));
}
#[test]
fn tracker_attribute_was_uses_last_applied_value_after_new_change() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.apply();
tracker.track_change("name", json!("Bob"), json!("Carol"));
assert_eq!(tracker.attribute_was("name"), Some(&json!("Bob")));
}
#[test]
fn tracker_nested_values_can_revert_to_original_and_clear_state() {
let mut tracker = ChangeTracker::new();
tracker.track_change(
"settings",
json!({ "theme": "dark" }),
json!({ "theme": "light" }),
);
tracker.track_change(
"settings",
json!({ "theme": "light" }),
json!({ "theme": "dark" }),
);
assert!(!tracker.changed());
assert!(tracker.changes().is_empty());
}
#[test]
fn profile_is_clean_when_new() {
let profile = TestProfile::new("Alice", 30);
assert!(!profile.changed());
assert!(profile.changes().is_empty());
}
#[test]
fn profile_previous_changes_are_empty_when_new() {
let profile = TestProfile::new("Alice", 30);
assert!(profile.previous_changes().is_empty());
}
#[test]
fn profile_attribute_changed_is_false_for_unknown_attribute() {
let profile = TestProfile::new("Alice", 30);
assert!(!profile.attribute_changed("email"));
}
#[test]
fn profile_attribute_was_is_none_when_attribute_is_clean() {
let profile = TestProfile::new("Alice", 30);
assert_eq!(profile.attribute_was("name"), None);
}
#[test]
fn profile_changes_report_original_and_new_values() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.set_age(31);
let changes = profile.changes();
assert_eq!(changes.get("name"), Some(&[json!("Alice"), json!("Bob")]));
assert_eq!(changes.get("age"), Some(&[json!(30), json!(31)]));
}
#[test]
fn profile_changed_attributes_include_all_dirty_fields() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.set_age(31);
let mut changed = profile.changed_attributes();
changed.sort();
assert_eq!(changed, vec!["age".to_owned(), "name".to_owned()]);
}
#[test]
fn profile_restore_attributes_clears_changed_attributes() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.set_age(31);
profile.restore_attributes();
assert!(profile.changed_attributes().is_empty());
}
#[test]
fn profile_changes_applied_replaces_previous_changes_on_second_save() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.changes_applied();
profile.set_age(31);
profile.changes_applied();
assert_eq!(profile.previous_changes().len(), 1);
assert!(!profile.previous_changes().contains_key("name"));
assert_eq!(
profile.previous_changes().get("age"),
Some(&[json!(30), json!(31)])
);
}
#[test]
fn profile_restore_attributes_after_multiple_unsaved_changes_restores_saved_values() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.set_age(31);
profile.changes_applied();
profile.set_name("Carol");
profile.set_name("Dora");
profile.set_age(32);
profile.restore_attributes();
assert_eq!(profile.name, "Bob");
assert_eq!(profile.age, 31);
assert!(!profile.changed());
}
#[test]
fn profile_setting_name_to_same_value_is_noop() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Alice");
assert!(!profile.changed());
assert!(profile.changes().is_empty());
}
#[test]
fn profile_setting_age_to_same_value_is_noop() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_age(30);
assert!(!profile.changed());
assert!(profile.changes().is_empty());
}
#[test]
fn profile_clear_changes_after_save_clears_previous_changes_too() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.changes_applied();
profile.clear_changes();
assert!(profile.previous_changes().is_empty());
assert!(!profile.changed());
}
#[test]
fn profile_changes_returns_a_detached_snapshot() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
let mut snapshot = profile.changes();
snapshot.insert("nickname".to_owned(), [json!("Ace"), json!("Bobby")]);
assert!(!profile.changes().contains_key("nickname"));
assert_eq!(
profile.changes().get("name"),
Some(&[json!("Alice"), json!("Bob")])
);
}
#[test]
fn profile_multiple_unsaved_name_changes_keep_first_original_value() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.set_name("Carol");
profile.set_name("Dora");
assert_eq!(profile.attribute_was("name"), Some(json!("Alice")));
assert_eq!(
profile.changes().get("name"),
Some(&[json!("Alice"), json!("Dora")])
);
}
#[test]
fn profile_attribute_was_tracks_last_saved_age_after_save_and_change() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_age(31);
profile.changes_applied();
profile.set_age(32);
assert_eq!(profile.attribute_was("age"), Some(json!(31)));
assert_eq!(profile.changes().get("age"), Some(&[json!(31), json!(32)]));
}
#[test]
fn profile_changes_applied_without_new_changes_clears_previous_changes() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.changes_applied();
profile.changes_applied();
assert!(profile.previous_changes().is_empty());
}
#[test]
fn apply_after_reverting_all_changes_clears_previous_changes() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.track_change("name", json!("Bob"), json!("Alice"));
tracker.apply();
assert!(!tracker.changed());
assert!(tracker.changes().is_empty());
assert!(tracker.previous_changes().is_empty());
}
#[test]
fn restore_attribute_after_apply_keeps_previous_changes_intact() {
let mut tracker = ChangeTracker::new();
tracker.track_change("name", json!("Alice"), json!("Bob"));
tracker.apply();
let restored = tracker.restore_attribute("name");
assert_eq!(restored, None);
assert_eq!(
tracker.previous_changes().get("name"),
Some(&[json!("Alice"), json!("Bob")])
);
}
#[test]
fn profile_restore_attributes_preserves_previous_changes_from_last_save() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.changes_applied();
profile.set_name("Carol");
profile.restore_attributes();
assert_eq!(profile.name, "Bob");
assert!(!profile.changed());
assert_eq!(
profile.previous_changes().get("name"),
Some(&[json!("Alice"), json!("Bob")])
);
}
#[test]
fn profile_clear_changes_rebases_future_tracking_to_current_values() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Bob");
profile.clear_changes();
profile.set_name("Carol");
assert_eq!(profile.attribute_was("name"), Some(json!("Bob")));
assert_eq!(
profile.changes().get("name"),
Some(&[json!("Bob"), json!("Carol")])
);
assert!(profile.previous_changes().is_empty());
}
#[test]
fn rails_setting_new_attributes_should_not_affect_previous_changes() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Jericho Cane");
profile.set_age(31);
profile.changes_applied();
profile.set_name("DudeFella ManGuy");
profile.set_age(32);
assert_eq!(
profile.previous_changes().get("name"),
Some(&[json!("Alice"), json!("Jericho Cane")])
);
assert_eq!(
profile.previous_changes().get("age"),
Some(&[json!(30), json!(31)])
);
}
#[test]
fn rails_previous_value_is_preserved_when_changed_after_save() {
let mut profile = TestProfile::new("Alice", 30);
profile.set_name("Paul");
profile.set_age(31);
profile.changes_applied();
profile.set_name("John");
profile.set_age(32);
let mut changed = profile.changed_attributes();
changed.sort();
assert_eq!(changed, vec!["age".to_owned(), "name".to_owned()]);
assert_eq!(profile.attribute_was("name"), Some(json!("Paul")));
assert_eq!(profile.attribute_was("age"), Some(json!(31)));
assert_eq!(
profile.previous_changes().get("name"),
Some(&[json!("Alice"), json!("Paul")])
);
assert_eq!(
profile.previous_changes().get("age"),
Some(&[json!(30), json!(31)])
);
}
#[test]
#[ignore = "Dirty::restore_attributes restores every tracked attribute and exposes no per-attribute variant"]
fn rails_restore_attributes_can_restore_only_some_attributes() {}
}