Skip to main content

oxibrain_core/
sync.rs

1//! Vault sync classification — a pure decision function (P9).
2//!
3//! Given scanned markdown files and the ledger's known note content hashes per
4//! source path, decide per file whether sync must ingest it (`New`), skip it
5//! (`Unchanged` — an episode with this exact content already exists), or ingest
6//! it as a new version (`Modified` — the path is known with different content).
7//!
8//! The store fetches the `KnownNotes` map; this module only decides. Modified
9//! files append a new episode; the previous episode remains (append-only
10//! ledger, P1). Stale claims surface via `contradictions` and are removed with
11//! `retract` — sync itself never retracts.
12
13use crate::types::ContentHash;
14use oxibrain_ports::Timestamp;
15use std::collections::{HashMap, HashSet};
16
17/// A scanned candidate file for sync.
18///
19/// `path` is relative to the sync root with forward slashes (stable across
20/// machines and working directories). `modified` is the file's mtime and
21/// becomes the episode's `occurred_at`, so episode ids are stable across
22/// re-syncs of an unchanged tree.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SyncFile {
25    pub path: String,
26    pub content_hash: ContentHash,
27    pub modified: Timestamp,
28}
29
30/// What sync must do with one scanned file.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum SyncAction {
33    /// No live episode for this path — ingest.
34    New(SyncFile),
35    /// An episode with this exact content exists — skip (idempotent no-op).
36    Unchanged(String),
37    /// The path is known with different content — ingest as a new episode.
38    /// The previous episode remains (P1); see the module docs.
39    Modified(SyncFile),
40}
41
42/// Known note content hashes per source path, as read from the ledger
43/// (`store::ledger::note_hashes_by_path`).
44pub type KnownNotes = HashMap<String, HashSet<ContentHash>>;
45
46/// Classify scanned files against known notes.
47///
48/// Pure and total: every input file appears in exactly one output action
49/// (conservation), and the output preserves the input order (callers pass the
50/// scan's deterministic path order).
51pub fn classify(files: Vec<SyncFile>, known: &KnownNotes) -> Vec<SyncAction> {
52    files
53        .into_iter()
54        .map(|f| match known.get(&f.path) {
55            // An empty set means no live episode for the path (e.g. all
56            // versions redacted) — same decision as an unknown path.
57            Some(hashes) if !hashes.is_empty() => {
58                if hashes.contains(&f.content_hash) {
59                    SyncAction::Unchanged(f.path)
60                } else {
61                    SyncAction::Modified(f)
62                }
63            }
64            _ => SyncAction::New(f),
65        })
66        .collect()
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use crate::content_hash;
73    use proptest::prelude::*;
74
75    fn file(path: &str, content: &str, t: i64) -> SyncFile {
76        SyncFile {
77            path: path.into(),
78            content_hash: content_hash(content),
79            modified: Timestamp(t),
80        }
81    }
82
83    fn known(entries: &[(&str, &[&str])]) -> KnownNotes {
84        entries
85            .iter()
86            .map(|(path, contents)| {
87                (
88                    (*path).to_string(),
89                    contents.iter().map(|c| content_hash(c)).collect(),
90                )
91            })
92            .collect()
93    }
94
95    #[test]
96    fn unknown_path_is_new() {
97        let actions = classify(vec![file("a.md", "hello", 1)], &known(&[]));
98        assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
99    }
100
101    #[test]
102    fn matching_hash_is_unchanged() {
103        let actions = classify(
104            vec![file("a.md", "hello", 2)],
105            &known(&[("a.md", &["hello"])]),
106        );
107        assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
108    }
109
110    #[test]
111    fn mismatched_hash_is_modified() {
112        let actions = classify(
113            vec![file("a.md", "hello v2", 2)],
114            &known(&[("a.md", &["hello"])]),
115        );
116        assert_eq!(
117            actions,
118            vec![SyncAction::Modified(file("a.md", "hello v2", 2))]
119        );
120    }
121
122    #[test]
123    fn any_prior_version_hash_counts_as_unchanged() {
124        // The path has two versions ingested already; current content matches
125        // the older one exactly — still a no-op (content identity, not recency).
126        let actions = classify(
127            vec![file("a.md", "hello", 3)],
128            &known(&[("a.md", &["hello", "hello v2"])]),
129        );
130        assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
131    }
132
133    #[test]
134    fn empty_known_entry_is_new() {
135        // A path with no live episodes (e.g. all redacted) is New, not Modified.
136        let mut map = KnownNotes::new();
137        map.insert("a.md".into(), HashSet::new());
138        let actions = classify(vec![file("a.md", "hello", 1)], &map);
139        assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
140    }
141
142    #[test]
143    fn output_preserves_input_order() {
144        let files = vec![
145            file("b.md", "b", 1),
146            file("a.md", "a", 1),
147            file("c.md", "c", 1),
148        ];
149        let actions = classify(files, &known(&[]));
150        let paths: Vec<&str> = actions
151            .iter()
152            .map(|a| match a {
153                SyncAction::New(f) | SyncAction::Modified(f) => f.path.as_str(),
154                SyncAction::Unchanged(p) => p.as_str(),
155            })
156            .collect();
157        assert_eq!(paths, vec!["b.md", "a.md", "c.md"]);
158    }
159
160    proptest! {
161        #![proptest_config(ProptestConfig::with_cases(64))]
162
163        /// Conservation and classification law: for arbitrary inputs, every
164        /// file lands in exactly one action, and the action agrees with the
165        /// known map per the module rules.
166        #[test]
167        fn classify_is_total_and_correct(
168            files in proptest::collection::vec(
169                (".*a?b?c?[0-9]{0,3}\\.md", ".*", 0i64..1000),
170                0..16
171            ),
172            known in proptest::collection::vec(
173                (".*a?b?c?[0-9]{0,3}\\.md", proptest::collection::vec(".*", 0..3)),
174                0..8
175            ),
176        ) {
177            let sync_files: Vec<SyncFile> = files
178                .iter()
179                .map(|(p, c, t)| file(p, c, *t))
180                .collect();
181            let mut map = KnownNotes::new();
182            for (p, cs) in &known {
183                let set: HashSet<ContentHash> = cs.iter().map(|c| content_hash(c)).collect();
184                map.insert(p.clone(), set);
185            }
186            let actions = classify(sync_files.clone(), &map);
187
188            // Conservation: one action per input, same order.
189            assert_eq!(actions.len(), sync_files.len());
190            for (f, a) in sync_files.iter().zip(&actions) {
191                let got_path = match a {
192                    SyncAction::New(sf) | SyncAction::Modified(sf) => &sf.path,
193                    SyncAction::Unchanged(p) => p,
194                };
195                assert_eq!(got_path, &f.path);
196                // Classification law.
197                let entry = map.get(&f.path);
198                let expect_unchanged = entry.is_some_and(|s| s.contains(&f.content_hash));
199                let expect_new = entry.is_none_or(|s| s.is_empty());
200                match a {
201                    SyncAction::Unchanged(_) => assert!(expect_unchanged),
202                    SyncAction::New(_) => assert!(!expect_unchanged && expect_new),
203                    SyncAction::Modified(_) => assert!(!expect_unchanged && !expect_new),
204                }
205            }
206        }
207    }
208}