use serde_json::Value;
const MAX_UNDO: usize = 100;
#[derive(Debug, Clone)]
struct Snapshot {
request: Value,
rollback: usize,
}
#[derive(Debug, Clone)]
pub struct History {
request: Value,
rollback: usize,
undo_stack: Vec<Snapshot>,
redo_stack: Vec<Snapshot>,
last_edit_key: Option<String>,
feature_counter: u64,
}
impl Default for History {
fn default() -> Self {
Self {
request: empty_request(),
rollback: 0,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
last_edit_key: None,
feature_counter: 0,
}
}
}
fn empty_request() -> Value {
serde_json::json!({ "expressions": "", "configurator": {}, "features": [] })
}
fn trailing_number(id: &str) -> u64 {
let digit_bytes = id
.bytes()
.rev()
.take_while(u8::is_ascii_digit)
.count();
id[id.len() - digit_bytes..].parse().unwrap_or(0)
}
impl History {
pub fn from_request_json(json: &str) -> Result<Self, String> {
let mut request: Value =
serde_json::from_str(json).map_err(|e| format!("history parse: {e}"))?;
if !request.get("features").map(Value::is_array).unwrap_or(false) {
if let Some(obj) = request.as_object_mut() {
obj.insert("features".into(), Value::Array(Vec::new()));
} else {
request = empty_request();
}
}
let stored = request
.as_object_mut()
.and_then(|obj| obj.remove("featureCounter"))
.and_then(|value| value.as_u64());
let mut history = Self {
request,
rollback: 0,
..Self::default()
};
history.rollback = history.len().saturating_sub(1);
history.feature_counter = stored.unwrap_or_else(|| history.max_id_suffix());
Ok(history)
}
fn max_id_suffix(&self) -> u64 {
self.features()
.iter()
.filter_map(|f| {
f.get("inputParams")
.and_then(|p| p.get("id"))
.and_then(Value::as_str)
})
.map(trailing_number)
.max()
.unwrap_or(0)
}
pub fn features(&self) -> &[Value] {
self.request
.get("features")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[])
}
fn features_mut(&mut self) -> &mut Vec<Value> {
let obj = self
.request
.as_object_mut()
.expect("history request is a JSON object");
obj.entry("features")
.or_insert_with(|| Value::Array(Vec::new()));
obj.get_mut("features")
.and_then(Value::as_array_mut)
.expect("features is a JSON array")
}
pub fn len(&self) -> usize {
self.features().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn rollback(&self) -> usize {
self.rollback.min(self.len().saturating_sub(1))
}
pub fn set_rollback(&mut self, index: usize) {
self.rollback = if self.is_empty() {
0
} else {
index.min(self.len() - 1)
};
self.last_edit_key = None;
}
pub fn feature_type(&self, index: usize) -> Option<String> {
self.features()
.get(index)?
.get("type")
.and_then(Value::as_str)
.map(String::from)
}
pub fn feature_id(&self, index: usize) -> Option<String> {
self.features()
.get(index)?
.get("inputParams")
.and_then(|p| p.get("id"))
.and_then(Value::as_str)
.map(String::from)
}
pub fn index_of(&self, id: &str) -> Option<usize> {
self.features().iter().position(|f| {
f.get("inputParams")
.and_then(|p| p.get("id"))
.and_then(Value::as_str)
== Some(id)
})
}
pub fn feature_params(&self, index: usize) -> Option<Value> {
self.features().get(index)?.get("inputParams").cloned()
}
pub fn set_feature_params(&mut self, index: usize, params: Value) {
if index >= self.len() {
return;
}
self.checkpoint(Some(&format!("param:{index}")));
if let Some(feat) = self.features_mut().get_mut(index) {
if let Some(obj) = feat.as_object_mut() {
obj.insert("inputParams".into(), params);
}
}
}
pub fn push_feature(&mut self, feature: Value) {
self.checkpoint(None);
self.features_mut().push(feature);
}
pub fn remove_feature(&mut self, index: usize) {
if index >= self.len() {
return;
}
self.checkpoint(None);
self.features_mut().remove(index);
}
pub fn swap(&mut self, a: usize, b: usize) {
let len = self.len();
if a < len && b < len && a != b {
self.checkpoint(None);
self.features_mut().swap(a, b);
}
}
pub fn next_feature_id(&mut self, base: &str) -> String {
self.feature_counter += 1;
format!("{base}{}", self.feature_counter)
}
pub fn prefix_request(&self) -> Value {
let mut request = self.request.clone();
if let Some(id) = self.feature_id(self.rollback()) {
if let Some(obj) = request.as_object_mut() {
obj.insert("stopAtId".into(), Value::String(id));
}
}
request
}
pub fn listing_json(&self) -> String {
let features: Vec<Value> = self
.features()
.iter()
.enumerate()
.map(|(index, _)| {
serde_json::json!({
"index": index,
"type": self.feature_type(index).unwrap_or_else(|| "?".into()),
"id": self.feature_id(index).unwrap_or_else(|| "(no id)".into()),
})
})
.collect();
serde_json::json!({ "step": self.rollback(), "features": features }).to_string()
}
pub fn request_json(&self) -> String {
if self.feature_counter == 0 {
return self.request.to_string();
}
let mut document = self.request.clone();
if let Some(obj) = document.as_object_mut() {
obj.insert("featureCounter".into(), Value::from(self.feature_counter));
}
document.to_string()
}
fn snapshot(&self) -> Snapshot {
Snapshot {
request: self.request.clone(),
rollback: self.rollback,
}
}
fn restore(&mut self, snap: Snapshot) {
self.request = snap.request;
let last = self.len().saturating_sub(1);
self.rollback = snap.rollback.min(last);
}
fn checkpoint(&mut self, coalesce_key: Option<&str>) {
if coalesce_key.is_some() && coalesce_key == self.last_edit_key.as_deref() {
return;
}
self.undo_stack.push(self.snapshot());
if self.undo_stack.len() > MAX_UNDO {
self.undo_stack.remove(0);
}
self.redo_stack.clear();
self.last_edit_key = coalesce_key.map(str::to_string);
}
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo_stack.is_empty()
}
pub fn undo(&mut self) -> bool {
let Some(prev) = self.undo_stack.pop() else {
return false;
};
self.redo_stack.push(self.snapshot());
self.restore(prev);
self.last_edit_key = None;
true
}
pub fn redo(&mut self) -> bool {
let Some(next) = self.redo_stack.pop() else {
return false;
};
self.undo_stack.push(self.snapshot());
self.restore(next);
self.last_edit_key = None;
true
}
}
impl History {
pub fn expressions(&self) -> String {
self.request
.get("expressions")
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
}
pub fn set_expressions(&mut self, expressions: &str) {
self.checkpoint(Some("expressions"));
if let Some(obj) = self.request.as_object_mut() {
obj.insert(
"expressions".into(),
Value::String(expressions.to_string()),
);
}
}
pub fn configurator(&self) -> Value {
self.request
.get("configurator")
.cloned()
.unwrap_or_else(|| Value::Object(serde_json::Map::new()))
}
}
impl History {
pub fn feature_persistent_data(&self, index: usize) -> Option<Value> {
self.features().get(index)?.get("persistentData").cloned()
}
pub fn set_feature_persistent_field(&mut self, index: usize, key: &str, value: Value) {
if index >= self.len() {
return;
}
self.checkpoint(None);
if let Some(feat) = self.features_mut().get_mut(index).and_then(Value::as_object_mut) {
let entry = feat
.entry("persistentData")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !entry.is_object() {
*entry = Value::Object(serde_json::Map::new());
}
if let Some(obj) = entry.as_object_mut() {
obj.insert(key.to_string(), value);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn seed() -> History {
History::from_request_json(
r#"{"features":[
{"type":"P.CU","inputParams":{"id":"Box","sizeX":20}},
{"type":"P.CY","inputParams":{"id":"Pin","radius":6}},
{"type":"B","inputParams":{"id":"Cut","boolean":{"operation":"SUBTRACT","targets":["Pin"]}}}
]}"#,
)
.unwrap()
}
#[test]
fn loads_and_rolls_to_last() {
let h = seed();
assert_eq!(h.len(), 3);
assert_eq!(h.rollback(), 2);
assert_eq!(h.feature_type(0).as_deref(), Some("P.CU"));
assert_eq!(h.feature_id(2).as_deref(), Some("Cut"));
}
#[test]
fn prefix_request_stops_at_rolled_step() {
let mut h = seed();
h.set_rollback(0);
let req = h.prefix_request();
assert_eq!(req["stopAtId"].as_str(), Some("Box"));
assert_eq!(req["features"].as_array().unwrap().len(), 3);
}
#[test]
fn edit_add_delete_reorder() {
let mut h = seed();
let idx = h.index_of("Pin").unwrap();
h.set_feature_params(idx, serde_json::json!({"id":"Pin","radius":9}));
assert_eq!(h.feature_params(idx).unwrap()["radius"], 9);
h.push_feature(serde_json::json!({"type":"P.CU","inputParams":{"id":"Box"}}));
assert_eq!(h.next_feature_id("Box"), "Box1");
h.swap(0, 1);
assert_eq!(h.feature_id(0).as_deref(), Some("Pin"));
let cut = h.index_of("Cut").unwrap();
h.remove_feature(cut);
assert!(h.index_of("Cut").is_none());
}
#[test]
fn undo_redo_over_edit_and_add() {
let mut h = seed();
assert!(!h.can_undo());
assert!(!h.can_redo());
h.set_rollback(0);
assert!(!h.can_undo());
let box_idx = h.index_of("Box").unwrap();
assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 20);
h.set_feature_params(box_idx, serde_json::json!({ "id": "Box", "sizeX": 50 }));
assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 50);
assert!(h.can_undo());
assert!(h.undo());
assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 20);
assert!(h.can_redo());
assert!(h.redo());
assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 50);
let n = h.len();
h.push_feature(serde_json::json!({ "type": "P.CU", "inputParams": { "id": "Extra" } }));
assert_eq!(h.len(), n + 1);
assert!(h.undo());
assert_eq!(h.len(), n);
assert!(h.index_of("Extra").is_none());
assert!(h.redo());
assert_eq!(h.len(), n + 1);
assert!(h.index_of("Extra").is_some());
h.undo();
assert!(h.can_redo());
h.push_feature(serde_json::json!({ "type": "P.CU", "inputParams": { "id": "Fork" } }));
assert!(!h.can_redo());
}
#[test]
fn drag_edits_coalesce_into_one_undo() {
let mut h = seed();
h.set_rollback(0);
let box_idx = h.index_of("Box").unwrap();
for v in [21, 22, 23, 24, 25] {
h.set_feature_params(box_idx, serde_json::json!({ "id": "Box", "sizeX": v }));
}
assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 25);
assert!(h.undo());
assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 20);
assert!(!h.can_undo());
h.set_feature_params(box_idx, serde_json::json!({ "id": "Box", "sizeX": 30 }));
h.set_rollback(0);
h.set_feature_params(box_idx, serde_json::json!({ "id": "Box", "sizeX": 40 }));
assert!(h.undo());
assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 30);
assert!(h.undo());
assert_eq!(h.feature_params(box_idx).unwrap()["sizeX"], 20);
}
#[test]
fn expressions_get_set_and_undo_coalesces() {
let mut h = seed();
assert_eq!(h.expressions(), ""); h.set_expressions("boxW = 30;");
assert_eq!(h.expressions(), "boxW = 30;");
h.set_expressions("boxW = 40;");
h.set_expressions("boxW = 50;");
assert_eq!(h.expressions(), "boxW = 50;");
assert!(h.undo());
assert_eq!(h.expressions(), "");
assert!(h.configurator().is_object());
}
#[test]
fn next_feature_id_is_shortname_plus_global_counter() {
let mut h = History::default();
assert_eq!(h.next_feature_id("S"), "S1");
assert_eq!(h.next_feature_id("P.CU"), "P.CU2");
assert_eq!(h.next_feature_id("E"), "E3");
}
#[test]
fn counter_is_global_across_types_and_never_reused_on_delete() {
let mut h = History::default();
let s = h.next_feature_id("S");
h.push_feature(serde_json::json!({ "type": "S", "inputParams": { "id": s } }));
let cu = h.next_feature_id("P.CU");
h.push_feature(serde_json::json!({ "type": "P.CU", "inputParams": { "id": cu } }));
let e = h.next_feature_id("E");
h.push_feature(serde_json::json!({ "type": "E", "inputParams": { "id": e } }));
assert_eq!(h.feature_id(0).as_deref(), Some("S1"));
assert_eq!(h.feature_id(1).as_deref(), Some("P.CU2"));
assert_eq!(h.feature_id(2).as_deref(), Some("E3"));
let idx = h.index_of("S1").unwrap();
h.remove_feature(idx);
assert_eq!(h.next_feature_id("P.CY"), "P.CY4");
}
#[test]
fn counter_persists_across_serialize_reload() {
let mut h = History::default();
let first = h.next_feature_id("S"); h.push_feature(serde_json::json!({ "type": "S", "inputParams": { "id": first } }));
let json = h.request_json();
assert!(json.contains("\"featureCounter\":1"), "counter is folded in: {json}");
let mut reloaded = History::from_request_json(&json).unwrap();
assert_eq!(reloaded.next_feature_id("P.CU"), "P.CU2");
}
#[test]
fn unmutated_document_omits_the_counter_key() {
let h = seed();
assert!(!h.request_json().contains("featureCounter"));
}
#[test]
fn undo_does_not_rewind_the_counter() {
let mut h = History::default();
let a = h.next_feature_id("P.CU"); h.push_feature(serde_json::json!({ "type": "P.CU", "inputParams": { "id": a } }));
assert!(h.undo());
assert!(h.index_of("P.CU1").is_none());
assert_eq!(h.next_feature_id("P.CU"), "P.CU2");
}
#[test]
fn counter_safe_inits_above_existing_id_suffixes() {
let mut h = History::from_request_json(
r#"{"features":[
{"type":"P.CU","inputParams":{"id":"P.CU5"}},
{"type":"S","inputParams":{"id":"S2"}}
]}"#,
)
.unwrap();
assert_eq!(h.next_feature_id("E"), "E6");
}
#[test]
fn trailing_number_reads_the_suffix() {
assert_eq!(trailing_number("P.CU12"), 12);
assert_eq!(trailing_number("S1"), 1);
assert_eq!(trailing_number("Box"), 0);
assert_eq!(trailing_number("IMPORT3D"), 0); }
}