Skip to main content

bear_cli/cloudkit/
vector_clock.rs

1use std::collections::BTreeMap;
2use std::io::Cursor;
3
4use anyhow::{Result, bail};
5use base64::Engine as _;
6use base64::engine::general_purpose::STANDARD as BASE64;
7use plist::Value;
8
9/// Decode a base64-encoded binary-plist vector clock into a counter map.
10pub fn decode(encoded: &str) -> Result<BTreeMap<String, u64>> {
11    let bytes = BASE64.decode(encoded)?;
12    let value = plist::from_reader(Cursor::new(&bytes))?;
13    parse_dict(value)
14}
15
16fn parse_dict(value: Value) -> Result<BTreeMap<String, u64>> {
17    match value {
18        Value::Dictionary(dict) => {
19            let mut map = BTreeMap::new();
20            for (key, val) in dict {
21                let n = match val {
22                    Value::Integer(i) => i.as_unsigned().unwrap_or(0),
23                    _ => bail!("vector clock value is not an integer"),
24                };
25                map.insert(key, n);
26            }
27            Ok(map)
28        }
29        _ => bail!("vector clock is not a plist dictionary"),
30    }
31}
32
33/// Encode a counter map to base64 binary plist.
34pub fn encode(clock: &BTreeMap<String, u64>) -> Result<String> {
35    let dict: plist::Dictionary = clock
36        .iter()
37        .map(|(k, v)| (k.clone(), Value::Integer((*v).into())))
38        .collect();
39    let mut buf = Vec::new();
40    Value::Dictionary(dict).to_writer_binary(&mut buf)?;
41    Ok(BASE64.encode(&buf))
42}
43
44/// Increment this device's counter, preserving all other device entries.
45/// Pass `None` for `existing` when creating a brand-new note.
46pub fn increment(existing: Option<&str>, device: &str) -> Result<String> {
47    let mut clock = match existing {
48        Some(enc) if !enc.is_empty() => decode(enc)?,
49        _ => BTreeMap::new(),
50    };
51    let max = clock.values().max().copied().unwrap_or(0);
52    clock.insert(device.to_string(), max + 1);
53    encode(&clock)
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn roundtrip_empty() {
62        let enc = encode(&BTreeMap::new()).unwrap();
63        let dec = decode(&enc).unwrap();
64        assert!(dec.is_empty());
65    }
66
67    #[test]
68    fn increment_new_device() {
69        let enc = increment(None, "Bear CLI").unwrap();
70        let clock = decode(&enc).unwrap();
71        assert_eq!(clock["Bear CLI"], 1);
72    }
73
74    #[test]
75    fn increment_preserves_existing() {
76        let initial = {
77            let mut m = BTreeMap::new();
78            m.insert("iPhone".to_string(), 5u64);
79            m.insert("Mac".to_string(), 3u64);
80            encode(&m).unwrap()
81        };
82        let enc = increment(Some(&initial), "Bear CLI").unwrap();
83        let clock = decode(&enc).unwrap();
84        assert_eq!(clock["iPhone"], 5);
85        assert_eq!(clock["Mac"], 3);
86        assert_eq!(clock["Bear CLI"], 6); // max(5,3)+1
87    }
88}