Skip to main content

bear_cli/cloudkit/
vector_clock.rs

1use std::collections::BTreeMap;
2use std::io::Cursor;
3use std::process::Command;
4
5use anyhow::{Result, bail};
6use base64::Engine as _;
7use base64::engine::general_purpose::STANDARD as BASE64;
8use plist::Value;
9use uuid::Uuid;
10
11/// Decode a base64-encoded binary-plist vector clock into a counter map.
12pub fn decode(encoded: &str) -> Result<BTreeMap<String, u64>> {
13    let bytes = BASE64.decode(encoded)?;
14    let value = plist::from_reader(Cursor::new(&bytes))?;
15    parse_dict(value)
16}
17
18fn parse_dict(value: Value) -> Result<BTreeMap<String, u64>> {
19    match value {
20        Value::Dictionary(dict) => {
21            let mut map = BTreeMap::new();
22            for (key, val) in dict {
23                let n = match val {
24                    Value::Integer(i) => i.as_unsigned().unwrap_or(0),
25                    _ => bail!("vector clock value is not an integer"),
26                };
27                map.insert(key, n);
28            }
29            Ok(map)
30        }
31        _ => bail!("vector clock is not a plist dictionary"),
32    }
33}
34
35/// Encode a counter map to base64 binary plist.
36pub fn encode(clock: &BTreeMap<String, u64>) -> Result<String> {
37    let dict: plist::Dictionary = clock
38        .iter()
39        .map(|(k, v)| (k.clone(), Value::Integer((*v).into())))
40        .collect();
41    let mut buf = Vec::new();
42    Value::Dictionary(dict).to_writer_binary(&mut buf)?;
43    Ok(BASE64.encode(&buf))
44}
45
46/// Return a stable UUID-like key for the local device, matching the shape Bear
47/// uses in vector clocks.
48pub fn local_device_id() -> String {
49    if let Ok(output) = Command::new("ioreg")
50        .args(["-rd1", "-c", "IOPlatformExpertDevice"])
51        .output()
52    {
53        if output.status.success() {
54            let stdout = String::from_utf8_lossy(&output.stdout);
55            for line in stdout.lines() {
56                if let Some((_, rest)) = line.split_once("\"IOPlatformUUID\" = \"") {
57                    if let Some((uuid, _)) = rest.split_once('"') {
58                        let uuid = uuid.trim();
59                        if !uuid.is_empty() {
60                            return uuid.to_uppercase();
61                        }
62                    }
63                }
64            }
65        }
66    }
67
68    Uuid::new_v4().to_string().to_uppercase()
69}
70
71/// Increment this device's counter, preserving all other device entries.
72/// Pass `None` for `existing` when creating a brand-new note.
73pub fn increment(existing: Option<&str>, device: &str) -> Result<String> {
74    let mut clock = match existing {
75        Some(enc) if !enc.is_empty() => decode(enc)?,
76        _ => BTreeMap::new(),
77    };
78    let max = clock.values().max().copied().unwrap_or(0);
79    clock.insert(device.to_string(), max + 1);
80    encode(&clock)
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn roundtrip_empty() {
89        let enc = encode(&BTreeMap::new()).unwrap();
90        let dec = decode(&enc).unwrap();
91        assert!(dec.is_empty());
92    }
93
94    #[test]
95    fn increment_new_device() {
96        let enc = increment(None, "94536980-D452-5A88-9C1C-4A4022CFD280").unwrap();
97        let clock = decode(&enc).unwrap();
98        assert_eq!(clock["94536980-D452-5A88-9C1C-4A4022CFD280"], 1);
99    }
100
101    #[test]
102    fn increment_preserves_existing() {
103        let initial = {
104            let mut m = BTreeMap::new();
105            m.insert("iPhone".to_string(), 5u64);
106            m.insert("Mac".to_string(), 3u64);
107            encode(&m).unwrap()
108        };
109        let enc = increment(Some(&initial), "Bear CLI").unwrap();
110        let clock = decode(&enc).unwrap();
111        assert_eq!(clock["iPhone"], 5);
112        assert_eq!(clock["Mac"], 3);
113        assert_eq!(clock["Bear CLI"], 6); // max(5,3)+1
114    }
115}