1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct DocMeta {
19 pub doc_id: String,
21 pub path: String,
23 pub created_ms: u128,
25 pub updated_ms: u128,
27}
28
29#[derive(Debug, Clone, PartialEq)]
31pub enum Outcome {
32 Minted,
34 Matched,
36 Moved { from: String },
38 Copied { previous: String },
41 Adopted,
44}
45
46#[derive(Debug, Clone, PartialEq)]
48pub struct Reconciled {
49 pub doc_id: String,
52 pub outcome: Outcome,
54}
55
56pub 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 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 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 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 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 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
161fn 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#[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 let meta_path = paths.meta_file(&result.doc_id);
216 assert!(fs.exists(&meta_path), "meta.json should exist after mint");
217
218 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 let minted = reconcile(&fs, &paths, &clock1, &rng, None, doc_path).unwrap();
233 assert!(matches!(minted.outcome, Outcome::Minted));
234
235 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 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 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 let minted = reconcile(&fs, &paths, &clock, &rng, None, old_path).unwrap();
289 assert!(matches!(minted.outcome, Outcome::Minted));
290 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 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 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 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 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 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 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 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}