Skip to main content

fakecloud_iotdata/
shared.rs

1//! The device-shadow state machine shared by the IoT Data Plane handlers:
2//! deep-merge with null-deletion semantics, `state.delta` computation,
3//! per-leaf `metadata` timestamp stamping, and epoch-time helpers.
4//!
5//! Kept in one place so update / get / delete cannot diverge on the shadow
6//! document's wire shape.
7
8use serde_json::{json, Map, Value};
9
10/// Current time as epoch seconds. AWS shadow `timestamp` / `metadata` leaves
11/// are epoch **seconds**.
12pub fn now_secs() -> i64 {
13    chrono::Utc::now().timestamp()
14}
15
16/// Current time as epoch milliseconds. Retained-message `lastModifiedTime` is
17/// epoch **millis**.
18pub fn now_millis() -> i64 {
19    chrono::Utc::now().timestamp_millis()
20}
21
22/// Deep-merge `patch` into `target`. A `null` leaf in `patch` deletes that key
23/// from `target` (AWS shadow semantics); nested objects merge recursively;
24/// every other value overwrites.
25pub fn merge_into(target: &mut Value, patch: &Value) {
26    let (Some(target_obj), Some(patch_obj)) = (target.as_object_mut(), patch.as_object()) else {
27        // Non-object patch overwrites wholesale.
28        *target = patch.clone();
29        return;
30    };
31    for (k, v) in patch_obj {
32        if v.is_null() {
33            target_obj.remove(k);
34        } else if v.is_object() && target_obj.get(k).map(Value::is_object).unwrap_or(false) {
35            merge_into(target_obj.get_mut(k).expect("present"), v);
36        } else {
37            target_obj.insert(k.clone(), v.clone());
38        }
39    }
40}
41
42/// Compute `state.delta`: the subset of `desired` whose value differs from
43/// `reported` (a leaf missing from `reported`, or nested objects that differ).
44/// Returns `None` when there is no difference.
45pub fn compute_delta(desired: &Value, reported: &Value) -> Option<Value> {
46    let desired_obj = desired.as_object()?;
47    let empty = Map::new();
48    let reported_obj = reported.as_object().unwrap_or(&empty);
49    let mut out = Map::new();
50    for (k, dv) in desired_obj {
51        match reported_obj.get(k) {
52            None => {
53                out.insert(k.clone(), dv.clone());
54            }
55            Some(rv) => {
56                if dv.is_object() && rv.is_object() {
57                    if let Some(sub) = compute_delta(dv, rv) {
58                        out.insert(k.clone(), sub);
59                    }
60                } else if dv != rv {
61                    out.insert(k.clone(), dv.clone());
62                }
63            }
64        }
65    }
66    if out.is_empty() {
67        None
68    } else {
69        Some(Value::Object(out))
70    }
71}
72
73/// Stamp per-leaf `metadata` timestamps for every leaf touched by `patch`.
74/// A `null` leaf in `patch` removes the corresponding metadata leaf; objects
75/// recurse; arrays become an array of `{ "timestamp": ts }` (one per element);
76/// scalars become `{ "timestamp": ts }`.
77pub fn stamp_metadata(meta: &mut Value, patch: &Value, ts: i64) {
78    let Some(patch_obj) = patch.as_object() else {
79        return;
80    };
81    if !meta.is_object() {
82        *meta = json!({});
83    }
84    let meta_obj = meta.as_object_mut().expect("meta is object");
85    for (k, v) in patch_obj {
86        if v.is_null() {
87            meta_obj.remove(k);
88        } else if v.is_object() {
89            let entry = meta_obj.entry(k.clone()).or_insert_with(|| json!({}));
90            stamp_metadata(entry, v, ts);
91        } else {
92            meta_obj.insert(k.clone(), metadata_leaf(v, ts));
93        }
94    }
95}
96
97/// Build a fresh metadata leaf for a value: an object of leaves recurses, an
98/// array becomes per-element metadata, a scalar becomes `{ "timestamp": ts }`.
99fn metadata_leaf(v: &Value, ts: i64) -> Value {
100    match v {
101        Value::Object(_) => {
102            let mut m = json!({});
103            stamp_metadata(&mut m, v, ts);
104            m
105        }
106        Value::Array(items) => Value::Array(items.iter().map(|it| metadata_leaf(it, ts)).collect()),
107        _ => json!({ "timestamp": ts }),
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn merge_overwrites_and_recurses() {
117        let mut t = json!({"a": 1, "nested": {"x": 1, "y": 2}});
118        merge_into(&mut t, &json!({"a": 5, "nested": {"y": 9, "z": 3}}));
119        assert_eq!(t, json!({"a": 5, "nested": {"x": 1, "y": 9, "z": 3}}));
120    }
121
122    #[test]
123    fn merge_null_deletes_leaf() {
124        let mut t = json!({"a": 1, "b": 2});
125        merge_into(&mut t, &json!({"b": null}));
126        assert_eq!(t, json!({"a": 1}));
127    }
128
129    #[test]
130    fn delta_is_desired_minus_reported() {
131        let d = json!({"color": "red", "power": "on", "nested": {"a": 1}});
132        let r = json!({"color": "blue", "power": "on", "nested": {"a": 1}});
133        let delta = compute_delta(&d, &r).unwrap();
134        assert_eq!(delta, json!({"color": "red"}));
135    }
136
137    #[test]
138    fn delta_none_when_equal() {
139        let d = json!({"color": "red"});
140        assert!(compute_delta(&d, &d).is_none());
141    }
142
143    #[test]
144    fn delta_includes_missing_reported_key() {
145        let delta = compute_delta(&json!({"a": 1, "b": 2}), &json!({"a": 1})).unwrap();
146        assert_eq!(delta, json!({"b": 2}));
147    }
148
149    #[test]
150    fn metadata_stamped_at_leaves() {
151        let mut meta = json!({});
152        stamp_metadata(
153            &mut meta,
154            &json!({"color": "red", "nested": {"a": 1}}),
155            1000,
156        );
157        assert_eq!(meta["color"], json!({"timestamp": 1000}));
158        assert_eq!(meta["nested"]["a"], json!({"timestamp": 1000}));
159    }
160
161    #[test]
162    fn metadata_null_removes_leaf() {
163        let mut meta = json!({"color": {"timestamp": 1}, "power": {"timestamp": 1}});
164        stamp_metadata(&mut meta, &json!({"power": null}), 2000);
165        assert!(meta.get("power").is_none());
166        assert_eq!(meta["color"], json!({"timestamp": 1}));
167    }
168}