Skip to main content

oximemo_core/
sync.rs

1//! Synchronization records and the dedup algorithm (§9.2).
2//!
3//! Two projections of a memo:
4//! - [`ManifestRecord`]: lightweight (no body) — the agent diffs against its
5//!   local `id → hash` cache to decide what changed.
6//! - [`FullRecord`]: the complete memo body, requested only for ids the diff
7//!   flagged as "needs fetch".
8//!
9//! [`diff_manifest`] implements the agent-side dedup so callers (and tests) can
10//! reason about it without the CLI.
11
12use std::collections::HashMap;
13
14use serde::{Deserialize, Serialize};
15use time::OffsetDateTime;
16
17use crate::memo::{Memo, MemoHash, MemoId};
18
19/// Lightweight manifest entry: identity + content hash + timestamp + tombstone.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ManifestRecord {
22    pub id: MemoId,
23    pub hash: MemoHash,
24    #[serde(with = "time::serde::rfc3339")]
25    pub updated_at: OffsetDateTime,
26    pub deleted: bool,
27}
28
29impl ManifestRecord {
30    pub fn from_memo(n: &Memo) -> Self {
31        Self {
32            id: n.id,
33            hash: n.hash.clone(),
34            updated_at: n.updated_at,
35            deleted: n.deleted_at.is_some(),
36        }
37    }
38}
39
40/// Full memo payload, returned for ids flagged by the diff.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct FullRecord {
43    pub id: MemoId,
44    #[serde(with = "time::serde::rfc3339")]
45    pub created_at: OffsetDateTime,
46    #[serde(with = "time::serde::rfc3339")]
47    pub updated_at: OffsetDateTime,
48    pub hash: MemoHash,
49    pub favorite: bool,
50    pub category: String,
51    pub tags: Vec<String>,
52    pub body: String,
53    pub deleted: bool,
54}
55
56impl FullRecord {
57    pub fn from_memo(n: &Memo) -> Self {
58        Self {
59            id: n.id,
60            created_at: n.created_at,
61            updated_at: n.updated_at,
62            hash: n.hash.clone(),
63            favorite: n.favorite,
64            category: n.category.clone(),
65            tags: n.tags.clone(),
66            body: n.body.clone(),
67            deleted: n.deleted_at.is_some(),
68        }
69    }
70}
71
72/// Result of diffing a manifest against a local cache (§9.2, steps 3–4).
73#[derive(Debug, Clone, Default)]
74pub struct ManifestDiff {
75    /// New or content-changed ids the caller should fetch in full.
76    pub to_fetch: Vec<MemoId>,
77    /// Tombstoned ids the caller should drop from its local cache.
78    pub to_drop: Vec<MemoId>,
79    /// Max `updated_at` across the manifest, for advancing the cursor.
80    pub max_updated_at: Option<OffsetDateTime>,
81}
82
83/// `known` maps `id (hyphenated) → hash ("b3:…")`, exactly what an agent caches.
84pub fn diff_manifest(manifest: &[ManifestRecord], known: &HashMap<String, String>) -> ManifestDiff {
85    let mut diff = ManifestDiff::default();
86    for rec in manifest {
87        let key = rec.id.to_string();
88        // Tombstones are signaled explicitly and must propagate regardless of
89        // whether the agent's cached hash still matches (a soft-delete can leave
90        // the body hash unchanged while `deleted` flips).
91        if rec.deleted {
92            diff.to_drop.push(rec.id);
93        } else {
94            match known.get(&key) {
95                Some(h) if *h == rec.hash.0 => { /* unchanged */ }
96                _ => diff.to_fetch.push(rec.id),
97            }
98        }
99        diff.max_updated_at = Some(match diff.max_updated_at {
100            Some(t) if t >= rec.updated_at => t,
101            None => rec.updated_at,
102            Some(_) => rec.updated_at,
103        });
104    }
105    diff
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use std::collections::HashMap;
112
113    fn memo(body: &str, hash: &str) -> Memo {
114        let id = MemoId::now();
115        let now = OffsetDateTime::now_utc();
116        Memo {
117            id,
118            created_at: now,
119            updated_at: now,
120            hash: MemoHash::from_stored(hash),
121            favorite: false,
122            category: String::new(),
123            tags: vec![],
124            body: body.into(),
125            deleted_at: None,
126        }
127    }
128
129    #[test]
130    fn unchanged_skipped_changed_fetched() {
131        let a = memo("a", "b3:1");
132        let b = memo("b", "b3:2");
133        let manifest = vec![ManifestRecord::from_memo(&a), ManifestRecord::from_memo(&b)];
134        let mut known = HashMap::new();
135        known.insert(a.id.to_string(), "b3:1".to_string()); // a unchanged
136        known.insert(b.id.to_string(), "b3:OLD".to_string()); // b changed
137        let diff = diff_manifest(&manifest, &known);
138        assert_eq!(diff.to_fetch, vec![b.id]);
139        assert!(diff.to_drop.is_empty());
140    }
141
142    #[test]
143    fn deleted_dropped() {
144        let mut n = memo("c", "b3:3");
145        n.deleted_at = Some(OffsetDateTime::now_utc());
146        let manifest = vec![ManifestRecord::from_memo(&n)];
147        let known = HashMap::from([(n.id.to_string(), "b3:3".to_string())]);
148        let diff = diff_manifest(&manifest, &known);
149        assert!(diff.to_fetch.is_empty());
150        assert_eq!(diff.to_drop, vec![n.id]);
151    }
152}