use std::path::Path;
use std::time::UNIX_EPOCH;
use serde::{Deserialize, Serialize};
use crate::adapter::{Clock, Fs, Rng};
use crate::docid::mint_ulid;
use crate::error::SessionError;
use crate::layout::StorePaths;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DocMeta {
pub doc_id: String,
pub path: String,
pub created_ms: u128,
pub updated_ms: u128,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Outcome {
Minted,
Matched,
Moved { from: String },
Copied { previous: String },
Adopted,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Reconciled {
pub doc_id: String,
pub outcome: Outcome,
}
pub fn reconcile(
fs: &impl Fs,
paths: &StorePaths,
clock: &impl Clock,
rng: &impl Rng,
file_doc_id: Option<&str>,
doc_path: &Path,
) -> Result<Reconciled, SessionError> {
let now_ms = clock
.now()
.duration_since(UNIX_EPOCH)
.map_err(|e| SessionError::new(format!("system clock is before the unix epoch: {e}")))?
.as_millis();
let path_str = doc_path.to_string_lossy().into_owned();
match file_doc_id {
None => {
let id = mint_ulid(clock, rng)?;
write_meta(
fs,
paths,
&DocMeta {
doc_id: id.clone(),
path: path_str,
created_ms: now_ms,
updated_ms: now_ms,
},
)?;
Ok(Reconciled {
doc_id: id,
outcome: Outcome::Minted,
})
}
Some(id) => {
match read_meta(fs, paths, id)? {
None => {
write_meta(
fs,
paths,
&DocMeta {
doc_id: id.to_string(),
path: path_str,
created_ms: now_ms,
updated_ms: now_ms,
},
)?;
Ok(Reconciled {
doc_id: id.to_string(),
outcome: Outcome::Adopted,
})
}
Some(mut meta) => {
if meta.path == path_str {
meta.updated_ms = now_ms;
write_meta(fs, paths, &meta)?;
Ok(Reconciled {
doc_id: id.to_string(),
outcome: Outcome::Matched,
})
} else if fs.exists(Path::new(&meta.path)) {
let new_id = mint_ulid(clock, rng)?;
write_meta(
fs,
paths,
&DocMeta {
doc_id: new_id.clone(),
path: path_str,
created_ms: now_ms,
updated_ms: now_ms,
},
)?;
Ok(Reconciled {
doc_id: new_id,
outcome: Outcome::Copied {
previous: id.to_string(),
},
})
} else {
let old_path = std::mem::replace(&mut meta.path, path_str);
meta.updated_ms = now_ms;
write_meta(fs, paths, &meta)?;
Ok(Reconciled {
doc_id: id.to_string(),
outcome: Outcome::Moved { from: old_path },
})
}
}
}
}
}
}
fn write_meta(fs: &impl Fs, paths: &StorePaths, meta: &DocMeta) -> Result<(), SessionError> {
fs.create_dir_all(&paths.doc_dir(&meta.doc_id))?;
let json = serde_json::to_vec_pretty(meta)
.map_err(|e| SessionError::new(format!("serialize doc meta: {e}")))?;
fs.write(&paths.meta_file(&meta.doc_id), &json)
}
pub(crate) fn read_meta(
fs: &impl Fs,
paths: &StorePaths,
doc_id: &str,
) -> Result<Option<DocMeta>, SessionError> {
let p = paths.meta_file(doc_id);
if !fs.exists(&p) {
return Ok(None);
}
let bytes = fs.read(&p)?;
let meta = serde_json::from_slice(&bytes)
.map_err(|e| SessionError::new(format!("parse doc meta: {e}")))?;
Ok(Some(meta))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::{FakeClock, FakeRng, MemFs};
use std::time::{Duration, UNIX_EPOCH};
fn make_paths() -> StorePaths {
StorePaths::new("/data")
}
#[test]
fn mints_when_no_id() {
let fs = MemFs::new();
let paths = make_paths();
let clock = FakeClock(UNIX_EPOCH + Duration::from_millis(1000));
let rng = FakeRng(0x42);
let doc_path = Path::new("/docs/a.zen");
let result = reconcile(&fs, &paths, &clock, &rng, None, doc_path).unwrap();
assert!(
matches!(result.outcome, Outcome::Minted),
"expected Minted, got {:?}",
result.outcome
);
assert_eq!(result.doc_id.len(), 26, "doc_id should be 26 chars");
let meta_path = paths.meta_file(&result.doc_id);
assert!(fs.exists(&meta_path), "meta.json should exist after mint");
let stored: DocMeta = serde_json::from_slice(&fs.read(&meta_path).unwrap()).unwrap();
assert_eq!(stored.path, doc_path.to_string_lossy().as_ref());
}
#[test]
fn matches_same_path() {
let fs = MemFs::new();
let paths = make_paths();
let clock1 = FakeClock(UNIX_EPOCH + Duration::from_millis(1000));
let rng = FakeRng(0x11);
let doc_path = Path::new("/docs/a.zen");
let minted = reconcile(&fs, &paths, &clock1, &rng, None, doc_path).unwrap();
assert!(matches!(minted.outcome, Outcome::Minted));
let clock2 = FakeClock(UNIX_EPOCH + Duration::from_millis(2000));
let result = reconcile(&fs, &paths, &clock2, &rng, Some(&minted.doc_id), doc_path).unwrap();
assert!(
matches!(result.outcome, Outcome::Matched),
"expected Matched, got {:?}",
result.outcome
);
assert_eq!(result.doc_id, minted.doc_id, "doc_id must be unchanged");
let meta_path = paths.meta_file(&result.doc_id);
let stored: DocMeta = serde_json::from_slice(&fs.read(&meta_path).unwrap()).unwrap();
assert_eq!(stored.updated_ms, 2000, "updated_ms should be advanced");
assert_eq!(stored.created_ms, 1000, "created_ms should be unchanged");
}
#[test]
fn adopts_unknown_id() {
let fs = MemFs::new();
let paths = make_paths();
let clock = FakeClock(UNIX_EPOCH + Duration::from_millis(5000));
let rng = FakeRng(0x00);
let doc_path = Path::new("/docs/remote.zen");
let foreign_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let result = reconcile(&fs, &paths, &clock, &rng, Some(foreign_id), doc_path).unwrap();
assert!(
matches!(result.outcome, Outcome::Adopted),
"expected Adopted, got {:?}",
result.outcome
);
assert_eq!(result.doc_id, foreign_id, "doc_id must stay unchanged");
let meta_path = paths.meta_file(foreign_id);
assert!(fs.exists(&meta_path), "meta.json should exist after adopt");
let stored: DocMeta = serde_json::from_slice(&fs.read(&meta_path).unwrap()).unwrap();
assert_eq!(stored.path, doc_path.to_string_lossy().as_ref());
}
#[test]
fn moves_when_old_path_gone() {
let fs = MemFs::new();
let paths = make_paths();
let clock = FakeClock(UNIX_EPOCH + Duration::from_millis(1000));
let rng = FakeRng(0xAA);
let old_path = Path::new("/x/a.zen");
let new_path = Path::new("/y/a.zen");
let minted = reconcile(&fs, &paths, &clock, &rng, None, old_path).unwrap();
assert!(matches!(minted.outcome, Outcome::Minted));
let result = reconcile(&fs, &paths, &clock, &rng, Some(&minted.doc_id), new_path).unwrap();
match result.outcome {
Outcome::Moved { from } => {
assert_eq!(from, "/x/a.zen", "from path should be the original path");
}
other => panic!("expected Moved, got {other:?}"),
}
assert_eq!(
result.doc_id, minted.doc_id,
"doc_id must be unchanged on move"
);
let meta_path = paths.meta_file(&result.doc_id);
let stored: DocMeta = serde_json::from_slice(&fs.read(&meta_path).unwrap()).unwrap();
assert_eq!(stored.path, "/y/a.zen");
}
#[test]
fn copies_when_old_path_still_exists() {
let fs = MemFs::new();
let paths = make_paths();
let clock_original = FakeClock(UNIX_EPOCH + Duration::from_millis(1000));
let clock_copy = FakeClock(UNIX_EPOCH + Duration::from_millis(2000));
let rng = FakeRng(0x55);
let path_p1 = Path::new("/docs/original.zen");
let path_p2 = Path::new("/docs/copy.zen");
let minted = reconcile(&fs, &paths, &clock_original, &rng, None, path_p1).unwrap();
assert!(matches!(minted.outcome, Outcome::Minted));
let original_id = minted.doc_id.clone();
fs.create_dir_all(Path::new("/docs")).unwrap();
fs.write(path_p1, b"zen file content").unwrap();
assert!(fs.exists(path_p1), "path_p1 must exist for the copy branch");
let result =
reconcile(&fs, &paths, &clock_copy, &rng, Some(&original_id), path_p2).unwrap();
match &result.outcome {
Outcome::Copied { previous } => {
assert_eq!(previous, &original_id, "previous should be the original id");
}
other => panic!("expected Copied, got {other:?}"),
}
assert_ne!(result.doc_id, original_id, "copy must get a new doc_id");
assert_eq!(result.doc_id.len(), 26, "new doc_id should be 26 chars");
let new_meta_path = paths.meta_file(&result.doc_id);
assert!(fs.exists(&new_meta_path), "new meta.json should exist");
let new_meta: DocMeta = serde_json::from_slice(&fs.read(&new_meta_path).unwrap()).unwrap();
assert_eq!(new_meta.path, "/docs/copy.zen");
let orig_meta_path = paths.meta_file(&original_id);
let orig_meta: DocMeta =
serde_json::from_slice(&fs.read(&orig_meta_path).unwrap()).unwrap();
assert_eq!(orig_meta.path, "/docs/original.zen");
}
}