Skip to main content

zenith_session/
identity.rs

1//! Document-identity reconciliation: bind a `.zen`'s doc-id to local history,
2//! detecting first-edit / move / copy / adoption. Pure over injected adapters.
3
4use std::path::Path;
5use std::time::UNIX_EPOCH;
6
7use serde::{Deserialize, Serialize};
8
9use crate::adapter::{Clock, Fs, Rng};
10use crate::docid::mint_ulid;
11use crate::error::SessionError;
12use crate::layout::StorePaths;
13
14// ── Data types ────────────────────────────────────────────────────────────────
15
16/// Persisted per-doc metadata, stored as JSON at `meta_file(id)`.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct DocMeta {
19    /// The document id this metadata belongs to.
20    pub doc_id: String,
21    /// Last-known absolute path of the `.zen` on this machine (lossy UTF-8).
22    pub path: String,
23    /// Unix-ms when this local history was first created.
24    pub created_ms: u128,
25    /// Unix-ms of the last reconcile that touched this doc.
26    pub updated_ms: u128,
27}
28
29/// Result of a single reconciliation call, describing what was detected.
30#[derive(Debug, Clone, PartialEq)]
31pub enum Outcome {
32    /// `.zen` had no doc-id; a fresh id was minted. Caller must stamp it into the file.
33    Minted,
34    /// Same doc at its known path; nothing changed but the updated_ms.
35    Matched,
36    /// The doc-id's previously-bound path no longer exists; rebound to the new path.
37    Moved { from: String },
38    /// The doc-id is still bound to a DIFFERENT, still-existing file → this is a copy.
39    /// A new id was minted for this copy; caller must stamp `doc_id` into the file.
40    Copied { previous: String },
41    /// The doc-id was present in the file but no local history existed (e.g. cloned
42    /// from a remote, or first use on this machine); local history was created.
43    Adopted,
44}
45
46/// The return value of [`reconcile`].
47#[derive(Debug, Clone, PartialEq)]
48pub struct Reconciled {
49    /// The effective doc-id that MUST be present in the `.zen` after reconcile
50    /// (newly minted for Minted/Copied; unchanged otherwise).
51    pub doc_id: String,
52    /// What the reconciler determined about this document's identity.
53    pub outcome: Outcome,
54}
55
56// ── Public API ────────────────────────────────────────────────────────────────
57
58/// Reconcile a `.zen`'s identity against local history.
59///
60/// `file_doc_id` is the id currently embedded in the `.zen` (None if absent).
61/// `doc_path` is the document's path as the caller observes it — callers SHOULD
62/// pass an absolute/canonical path; reconcile compares it verbatim (string form).
63pub fn reconcile(
64    fs: &impl Fs,
65    paths: &StorePaths,
66    clock: &impl Clock,
67    rng: &impl Rng,
68    file_doc_id: Option<&str>,
69    doc_path: &Path,
70) -> Result<Reconciled, SessionError> {
71    let now_ms = clock
72        .now()
73        .duration_since(UNIX_EPOCH)
74        .map_err(|e| SessionError::new(format!("system clock is before the unix epoch: {e}")))?
75        .as_millis();
76
77    let path_str = doc_path.to_string_lossy().into_owned();
78
79    match file_doc_id {
80        None => {
81            // No id in file: mint a fresh one.
82            let id = mint_ulid(clock, rng)?;
83            write_meta(
84                fs,
85                paths,
86                &DocMeta {
87                    doc_id: id.clone(),
88                    path: path_str,
89                    created_ms: now_ms,
90                    updated_ms: now_ms,
91                },
92            )?;
93            Ok(Reconciled {
94                doc_id: id,
95                outcome: Outcome::Minted,
96            })
97        }
98        Some(id) => {
99            match read_meta(fs, paths, id)? {
100                None => {
101                    // Id present but no local history: adopt it.
102                    write_meta(
103                        fs,
104                        paths,
105                        &DocMeta {
106                            doc_id: id.to_string(),
107                            path: path_str,
108                            created_ms: now_ms,
109                            updated_ms: now_ms,
110                        },
111                    )?;
112                    Ok(Reconciled {
113                        doc_id: id.to_string(),
114                        outcome: Outcome::Adopted,
115                    })
116                }
117                Some(mut meta) => {
118                    if meta.path == path_str {
119                        // Same doc at same path: just update the timestamp.
120                        meta.updated_ms = now_ms;
121                        write_meta(fs, paths, &meta)?;
122                        Ok(Reconciled {
123                            doc_id: id.to_string(),
124                            outcome: Outcome::Matched,
125                        })
126                    } else if fs.exists(Path::new(&meta.path)) {
127                        // Old path still exists: this is a copy.
128                        let new_id = mint_ulid(clock, rng)?;
129                        write_meta(
130                            fs,
131                            paths,
132                            &DocMeta {
133                                doc_id: new_id.clone(),
134                                path: path_str,
135                                created_ms: now_ms,
136                                updated_ms: now_ms,
137                            },
138                        )?;
139                        Ok(Reconciled {
140                            doc_id: new_id,
141                            outcome: Outcome::Copied {
142                                previous: id.to_string(),
143                            },
144                        })
145                    } else {
146                        // Old path is gone: the file was moved/renamed.
147                        let old_path = std::mem::replace(&mut meta.path, path_str);
148                        meta.updated_ms = now_ms;
149                        write_meta(fs, paths, &meta)?;
150                        Ok(Reconciled {
151                            doc_id: id.to_string(),
152                            outcome: Outcome::Moved { from: old_path },
153                        })
154                    }
155                }
156            }
157        }
158    }
159}
160
161// ── Private helpers ───────────────────────────────────────────────────────────
162
163fn write_meta(fs: &impl Fs, paths: &StorePaths, meta: &DocMeta) -> Result<(), SessionError> {
164    fs.create_dir_all(&paths.doc_dir(&meta.doc_id))?;
165    let json = serde_json::to_vec_pretty(meta)
166        .map_err(|e| SessionError::new(format!("serialize doc meta: {e}")))?;
167    fs.write(&paths.meta_file(&meta.doc_id), &json)
168}
169
170pub(crate) fn read_meta(
171    fs: &impl Fs,
172    paths: &StorePaths,
173    doc_id: &str,
174) -> Result<Option<DocMeta>, SessionError> {
175    let p = paths.meta_file(doc_id);
176    if !fs.exists(&p) {
177        return Ok(None);
178    }
179    let bytes = fs.read(&p)?;
180    let meta = serde_json::from_slice(&bytes)
181        .map_err(|e| SessionError::new(format!("parse doc meta: {e}")))?;
182    Ok(Some(meta))
183}
184
185// ── Tests ─────────────────────────────────────────────────────────────────────
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::adapter::{FakeClock, FakeRng, MemFs};
191    use std::time::{Duration, UNIX_EPOCH};
192
193    fn make_paths() -> StorePaths {
194        StorePaths::new("/data")
195    }
196
197    #[test]
198    fn mints_when_no_id() {
199        let fs = MemFs::new();
200        let paths = make_paths();
201        let clock = FakeClock(UNIX_EPOCH + Duration::from_millis(1000));
202        let rng = FakeRng(0x42);
203        let doc_path = Path::new("/docs/a.zen");
204
205        let result = reconcile(&fs, &paths, &clock, &rng, None, doc_path).unwrap();
206
207        assert!(
208            matches!(result.outcome, Outcome::Minted),
209            "expected Minted, got {:?}",
210            result.outcome
211        );
212        assert_eq!(result.doc_id.len(), 26, "doc_id should be 26 chars");
213
214        // meta.json must now exist under the store.
215        let meta_path = paths.meta_file(&result.doc_id);
216        assert!(fs.exists(&meta_path), "meta.json should exist after mint");
217
218        // The stored meta must bind to the given path.
219        let stored: DocMeta = serde_json::from_slice(&fs.read(&meta_path).unwrap()).unwrap();
220        assert_eq!(stored.path, doc_path.to_string_lossy().as_ref());
221    }
222
223    #[test]
224    fn matches_same_path() {
225        let fs = MemFs::new();
226        let paths = make_paths();
227        let clock1 = FakeClock(UNIX_EPOCH + Duration::from_millis(1000));
228        let rng = FakeRng(0x11);
229        let doc_path = Path::new("/docs/a.zen");
230
231        // First: mint.
232        let minted = reconcile(&fs, &paths, &clock1, &rng, None, doc_path).unwrap();
233        assert!(matches!(minted.outcome, Outcome::Minted));
234
235        // Second: reconcile same id at same path, with a later clock.
236        let clock2 = FakeClock(UNIX_EPOCH + Duration::from_millis(2000));
237        let result = reconcile(&fs, &paths, &clock2, &rng, Some(&minted.doc_id), doc_path).unwrap();
238
239        assert!(
240            matches!(result.outcome, Outcome::Matched),
241            "expected Matched, got {:?}",
242            result.outcome
243        );
244        assert_eq!(result.doc_id, minted.doc_id, "doc_id must be unchanged");
245
246        // updated_ms must reflect the second clock.
247        let meta_path = paths.meta_file(&result.doc_id);
248        let stored: DocMeta = serde_json::from_slice(&fs.read(&meta_path).unwrap()).unwrap();
249        assert_eq!(stored.updated_ms, 2000, "updated_ms should be advanced");
250        assert_eq!(stored.created_ms, 1000, "created_ms should be unchanged");
251    }
252
253    #[test]
254    fn adopts_unknown_id() {
255        let fs = MemFs::new();
256        let paths = make_paths();
257        let clock = FakeClock(UNIX_EPOCH + Duration::from_millis(5000));
258        let rng = FakeRng(0x00);
259        let doc_path = Path::new("/docs/remote.zen");
260        let foreign_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
261
262        let result = reconcile(&fs, &paths, &clock, &rng, Some(foreign_id), doc_path).unwrap();
263
264        assert!(
265            matches!(result.outcome, Outcome::Adopted),
266            "expected Adopted, got {:?}",
267            result.outcome
268        );
269        assert_eq!(result.doc_id, foreign_id, "doc_id must stay unchanged");
270
271        // Local history must now exist.
272        let meta_path = paths.meta_file(foreign_id);
273        assert!(fs.exists(&meta_path), "meta.json should exist after adopt");
274        let stored: DocMeta = serde_json::from_slice(&fs.read(&meta_path).unwrap()).unwrap();
275        assert_eq!(stored.path, doc_path.to_string_lossy().as_ref());
276    }
277
278    #[test]
279    fn moves_when_old_path_gone() {
280        let fs = MemFs::new();
281        let paths = make_paths();
282        let clock = FakeClock(UNIX_EPOCH + Duration::from_millis(1000));
283        let rng = FakeRng(0xAA);
284        let old_path = Path::new("/x/a.zen");
285        let new_path = Path::new("/y/a.zen");
286
287        // Mint for the original path.
288        let minted = reconcile(&fs, &paths, &clock, &rng, None, old_path).unwrap();
289        assert!(matches!(minted.outcome, Outcome::Minted));
290        // Note: /x/a.zen was never created in the MemFs as a file — only meta under
291        // /data exists — so fs.exists("/x/a.zen") is false, triggering the Move branch.
292
293        let result = reconcile(&fs, &paths, &clock, &rng, Some(&minted.doc_id), new_path).unwrap();
294
295        match result.outcome {
296            Outcome::Moved { from } => {
297                assert_eq!(from, "/x/a.zen", "from path should be the original path");
298            }
299            other => panic!("expected Moved, got {other:?}"),
300        }
301        assert_eq!(
302            result.doc_id, minted.doc_id,
303            "doc_id must be unchanged on move"
304        );
305
306        // meta.path should now point to the new path.
307        let meta_path = paths.meta_file(&result.doc_id);
308        let stored: DocMeta = serde_json::from_slice(&fs.read(&meta_path).unwrap()).unwrap();
309        assert_eq!(stored.path, "/y/a.zen");
310    }
311
312    #[test]
313    fn copies_when_old_path_still_exists() {
314        let fs = MemFs::new();
315        let paths = make_paths();
316        // Use an earlier clock for the original mint so the timestamp prefix differs
317        // from the copy's mint, ensuring distinct ULIDs even with the same rng seed.
318        let clock_original = FakeClock(UNIX_EPOCH + Duration::from_millis(1000));
319        let clock_copy = FakeClock(UNIX_EPOCH + Duration::from_millis(2000));
320        let rng = FakeRng(0x55);
321        let path_p1 = Path::new("/docs/original.zen");
322        let path_p2 = Path::new("/docs/copy.zen");
323
324        // Mint for path_p1.
325        let minted = reconcile(&fs, &paths, &clock_original, &rng, None, path_p1).unwrap();
326        assert!(matches!(minted.outcome, Outcome::Minted));
327        let original_id = minted.doc_id.clone();
328
329        // Materialise path_p1 in the MemFs so fs.exists(path_p1) returns true.
330        fs.create_dir_all(Path::new("/docs")).unwrap();
331        fs.write(path_p1, b"zen file content").unwrap();
332        assert!(fs.exists(path_p1), "path_p1 must exist for the copy branch");
333
334        // Reconcile the same id at a different path with a later clock.
335        let result =
336            reconcile(&fs, &paths, &clock_copy, &rng, Some(&original_id), path_p2).unwrap();
337
338        match &result.outcome {
339            Outcome::Copied { previous } => {
340                assert_eq!(previous, &original_id, "previous should be the original id");
341            }
342            other => panic!("expected Copied, got {other:?}"),
343        }
344        assert_ne!(result.doc_id, original_id, "copy must get a new doc_id");
345        assert_eq!(result.doc_id.len(), 26, "new doc_id should be 26 chars");
346
347        // New meta must exist bound to path_p2.
348        let new_meta_path = paths.meta_file(&result.doc_id);
349        assert!(fs.exists(&new_meta_path), "new meta.json should exist");
350        let new_meta: DocMeta = serde_json::from_slice(&fs.read(&new_meta_path).unwrap()).unwrap();
351        assert_eq!(new_meta.path, "/docs/copy.zen");
352
353        // Original meta must be untouched (still bound to path_p1).
354        let orig_meta_path = paths.meta_file(&original_id);
355        let orig_meta: DocMeta =
356            serde_json::from_slice(&fs.read(&orig_meta_path).unwrap()).unwrap();
357        assert_eq!(orig_meta.path, "/docs/original.zen");
358    }
359}