catcolab_document_types/
automerge_json.rs

1//! Utilities for converting between JSON values and Automerge documents.
2
3use automerge::hydrate;
4use automerge::transaction::Transactable;
5use serde_json::Value;
6
7/// Insert a JSON value into a map property.
8fn insert_value_into_map<'a>(
9    tx: &mut automerge::transaction::Transaction<'a>,
10    parent: &automerge::ObjId,
11    key: &str,
12    value: &Value,
13) -> Result<(), automerge::AutomergeError> {
14    match value {
15        Value::String(s) => {
16            // Use ObjType::Text instead of scalar string to avoid ImmutableString in JavaScript
17            let text_id = tx.put_object(parent, key, automerge::ObjType::Text)?;
18            tx.splice_text(&text_id, 0, 0, s.as_str())?;
19        }
20        Value::Number(n) => {
21            if let Some(i) = n.as_i64() {
22                tx.put(parent, key, i)?;
23            } else if let Some(f) = n.as_f64() {
24                tx.put(parent, key, f)?;
25            }
26        }
27        Value::Bool(b) => {
28            tx.put(parent, key, *b)?;
29        }
30        Value::Null => {
31            tx.put(parent, key, ())?;
32        }
33        Value::Object(map) => {
34            let obj_id = tx.put_object(parent, key, automerge::ObjType::Map)?;
35            for (nested_key, nested_val) in map {
36                insert_value_into_map(tx, &obj_id, nested_key.as_str(), nested_val)?;
37            }
38        }
39        Value::Array(arr) => {
40            let list_id = tx.put_object(parent, key, automerge::ObjType::List)?;
41            for (i, item) in arr.iter().enumerate() {
42                insert_value_into_list(tx, &list_id, i, item)?;
43            }
44        }
45    }
46    Ok(())
47}
48
49/// Insert a JSON value into a list at index.
50fn insert_value_into_list<'a>(
51    tx: &mut automerge::transaction::Transaction<'a>,
52    parent: &automerge::ObjId,
53    index: usize,
54    value: &Value,
55) -> Result<(), automerge::AutomergeError> {
56    match value {
57        Value::String(s) => {
58            // Use ObjType::Text instead of scalar string to avoid ImmutableString in JavaScript
59            let text_id = tx.insert_object(parent, index, automerge::ObjType::Text)?;
60            tx.splice_text(&text_id, 0, 0, s.as_str())?;
61        }
62        Value::Number(n) => {
63            if let Some(i) = n.as_i64() {
64                tx.insert(parent, index, i)?;
65            } else if let Some(f) = n.as_f64() {
66                tx.insert(parent, index, f)?;
67            }
68        }
69        Value::Bool(b) => {
70            tx.insert(parent, index, *b)?;
71        }
72        Value::Null => {
73            tx.insert(parent, index, ())?;
74        }
75        Value::Object(map) => {
76            let obj_id = tx.insert_object(parent, index, automerge::ObjType::Map)?;
77            for (nested_key, nested_val) in map {
78                insert_value_into_map(tx, &obj_id, nested_key.as_str(), nested_val)?;
79            }
80        }
81        Value::Array(arr) => {
82            let list_id = tx.insert_object(parent, index, automerge::ObjType::List)?;
83            for (i, item) in arr.iter().enumerate() {
84                insert_value_into_list(tx, &list_id, i, item)?;
85            }
86        }
87    }
88    Ok(())
89}
90
91/// Populate an automerge document from a JSON value.
92pub fn populate_automerge_from_json<'a>(
93    tx: &mut automerge::transaction::Transaction<'a>,
94    obj_id: automerge::ObjId,
95    value: &Value,
96) -> Result<(), automerge::AutomergeError> {
97    let Value::Object(map) = value else {
98        let value_type = match value {
99            Value::Null => "Null",
100            Value::Bool(_) => "Bool",
101            Value::Number(_) => "Number",
102            Value::String(_) => "String",
103            Value::Array(_) => "Array",
104            Value::Object(_) => unreachable!(),
105        };
106
107        return Err(automerge::AutomergeError::InvalidValueType {
108            expected: "Object".to_string(),
109            unexpected: format!("{} as document root", value_type),
110        });
111    };
112
113    for (key, val) in map {
114        insert_value_into_map(tx, &obj_id, key.as_str(), val)?;
115    }
116
117    Ok(())
118}
119
120/// Convert automerge hydrate::Value to serde_json::Value.
121pub fn hydrate_to_json(value: &hydrate::Value) -> Value {
122    match value {
123        hydrate::Value::Scalar(s) => scalar_to_json(s),
124        hydrate::Value::Map(m) => {
125            let mut map = serde_json::Map::new();
126            for (key, map_value) in m.iter() {
127                map.insert(key.to_string(), hydrate_to_json(&map_value.value));
128            }
129            Value::Object(map)
130        }
131        hydrate::Value::List(l) => {
132            Value::Array(l.iter().map(|list_value| hydrate_to_json(&list_value.value)).collect())
133        }
134        hydrate::Value::Text(t) => Value::String(t.to_string()),
135    }
136}
137
138fn scalar_to_json(s: &automerge::ScalarValue) -> Value {
139    use automerge::ScalarValue;
140    match s {
141        ScalarValue::Bytes(b) => {
142            Value::Array(b.iter().map(|v| Value::Number((*v).into())).collect())
143        }
144        ScalarValue::Str(s) => Value::String(s.to_string()),
145        ScalarValue::Int(i) => Value::Number((*i).into()),
146        ScalarValue::Uint(u) => Value::Number((*u).into()),
147        ScalarValue::F64(f) => {
148            serde_json::Number::from_f64(*f).map(Value::Number).unwrap_or(Value::Null)
149        }
150        ScalarValue::Counter(c) => Value::Number(i64::from(c).into()),
151        ScalarValue::Timestamp(t) => Value::Number((*t).into()),
152        ScalarValue::Boolean(b) => Value::Bool(*b),
153        ScalarValue::Null => Value::Null,
154        ScalarValue::Unknown { type_code, bytes } => Value::Object(serde_json::Map::from_iter([
155            ("type_code".to_string(), Value::Number((*type_code).into())),
156            (
157                "bytes".to_string(),
158                Value::Array(bytes.iter().map(|b| Value::Number((*b).into())).collect()),
159            ),
160        ])),
161    }
162}
163
164#[cfg(all(test, feature = "property-tests"))]
165mod tests {
166    use super::*;
167    use crate::common_test::roundtrip_json;
168    use crate::v1::notebook::ModelNotebook;
169    use automerge::Automerge;
170    use test_strategy::proptest;
171
172    /// A `ModelNotebook` survives a JSON → Automerge → JSON roundtrip.
173    #[proptest(cases = 64)]
174    fn model_notebook_roundtrips_through_automerge(notebook: ModelNotebook) {
175        let json = serde_json::to_value(&notebook.0).expect("serialize to JSON");
176        let result = roundtrip_json(&json);
177        proptest::prop_assert_eq!(json, result);
178    }
179
180    /// Non-object root values are rejected by `populate_automerge_from_json`.
181    #[proptest(cases = 64)]
182    fn non_object_root_is_rejected(value: bool) {
183        let json = Value::Bool(value);
184        let mut doc = Automerge::new();
185        let result = doc.transact(|tx| populate_automerge_from_json(tx, automerge::ROOT, &json));
186        proptest::prop_assert!(result.is_err());
187    }
188}