Skip to main content

kimun_notes/server_client/
reconcile.rs

1//! Hash-diff reconciliation: the correctness backbone. Given the
2//! vault's authoritative `{note-path → hash}` and the server's, compute exactly
3//! which notes to push and which to delete so the two agree.
4
5use std::collections::HashMap;
6
7/// What a reconciliation pass must do to bring the server in step with the vault.
8#[derive(Debug, Default, PartialEq, Eq)]
9pub struct ReconcilePlan {
10    /// Notes present in the vault but missing from the server, or whose hash
11    /// differs — must be (re)pushed.
12    pub to_push: Vec<String>,
13    /// Notes the server holds that no longer exist in the vault — must be deleted.
14    pub to_delete: Vec<String>,
15}
16
17impl ReconcilePlan {
18    pub fn is_empty(&self) -> bool {
19        self.to_push.is_empty() && self.to_delete.is_empty()
20    }
21}
22
23/// Diffs the vault's authoritative hash set against the server's.
24pub fn diff(local: &HashMap<String, String>, server: &HashMap<String, String>) -> ReconcilePlan {
25    let mut plan = ReconcilePlan::default();
26
27    for (path, hash) in local {
28        match server.get(path) {
29            Some(server_hash) if server_hash == hash => {} // already in sync
30            _ => plan.to_push.push(path.clone()),          // missing or changed
31        }
32    }
33    for path in server.keys() {
34        if !local.contains_key(path) {
35            plan.to_delete.push(path.clone());
36        }
37    }
38
39    plan.to_push.sort();
40    plan.to_delete.sort();
41    plan
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    fn map(pairs: &[(&str, &str)]) -> HashMap<String, String> {
49        pairs
50            .iter()
51            .map(|(p, h)| (p.to_string(), h.to_string()))
52            .collect()
53    }
54
55    #[test]
56    fn identical_sets_need_nothing() {
57        let plan = diff(
58            &map(&[("a", "1"), ("b", "2")]),
59            &map(&[("a", "1"), ("b", "2")]),
60        );
61        assert!(plan.is_empty());
62    }
63
64    #[test]
65    fn new_and_changed_notes_are_pushed() {
66        // "a" changed (1→2), "c" is new, "b" unchanged.
67        let local = map(&[("a", "2"), ("b", "2"), ("c", "9")]);
68        let server = map(&[("a", "1"), ("b", "2")]);
69        let plan = diff(&local, &server);
70        assert_eq!(plan.to_push, vec!["a".to_string(), "c".to_string()]);
71        assert!(plan.to_delete.is_empty());
72    }
73
74    #[test]
75    fn notes_gone_from_the_vault_are_deleted() {
76        let local = map(&[("a", "1")]);
77        let server = map(&[("a", "1"), ("stale", "7")]);
78        let plan = diff(&local, &server);
79        assert!(plan.to_push.is_empty());
80        assert_eq!(plan.to_delete, vec!["stale".to_string()]);
81    }
82
83    #[test]
84    fn empty_server_pushes_everything() {
85        let plan = diff(&map(&[("a", "1"), ("b", "2")]), &HashMap::new());
86        assert_eq!(plan.to_push, vec!["a".to_string(), "b".to_string()]);
87    }
88}