use std::{
fs::{self, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use miette::{IntoDiagnostic, Result};
use serde::{Serialize, de::DeserializeOwned};
use uuid::Uuid;
use crate::daat_locus_paths::{DaatLocusPaths, daat_locus_paths, daat_locus_paths_sync};
#[derive(Clone, Debug)]
pub struct PersistenceStore {
paths: DaatLocusPaths,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PersistenceFileMode {
Default,
Private,
}
impl PersistenceStore {
pub async fn runtime() -> Self {
Self::for_session(None).await
}
pub fn runtime_sync() -> Self {
Self::for_session_sync(None)
}
pub async fn for_session(session_id: Option<&str>) -> Self {
if let Some(id) = session_id {
let paths = DaatLocusPaths::for_session(id);
let _ = tokio::fs::create_dir_all(paths.state_dir()).await;
let _ = tokio::fs::create_dir_all(paths.memory_dir()).await;
let _ = tokio::fs::create_dir_all(paths.cache_dir()).await;
return Self { paths };
}
Self {
paths: daat_locus_paths().await,
}
}
pub fn for_session_sync(session_id: Option<&str>) -> Self {
if let Some(id) = session_id {
let paths = DaatLocusPaths::for_session(id);
let _ = fs::create_dir_all(paths.state_dir());
let _ = fs::create_dir_all(paths.memory_dir());
let _ = fs::create_dir_all(paths.cache_dir());
return Self { paths };
}
Self {
paths: daat_locus_paths_sync(),
}
}
pub fn config_file(&self, file_name: &str) -> PathBuf {
self.paths.config_file(file_name)
}
pub fn state_file(&self, file_name: &str) -> PathBuf {
self.paths.state_file(file_name)
}
pub fn memory_file(&self, file_name: &str) -> PathBuf {
self.paths.memory_file(file_name)
}
pub async fn read_postcard_state_or_default<T>(&self, file_name: &str, label: &str) -> T
where
T: DeserializeOwned + Default,
{
read_postcard_or_default(&self.state_file(file_name), label).await
}
pub fn read_postcard_state_or_default_sync<T>(&self, file_name: &str, label: &str) -> T
where
T: DeserializeOwned + Default,
{
read_postcard_or_default_sync(&self.state_file(file_name), label)
}
pub async fn read_postcard_memory<T>(&self, file_name: &str, label: &str) -> Option<T>
where
T: DeserializeOwned,
{
read_postcard_optional(&self.memory_file(file_name), label).await
}
pub async fn read_json_config_or_default<T>(&self, file_name: &str, label: &str) -> T
where
T: DeserializeOwned + Default,
{
read_json_or_default(&self.config_file(file_name), label).await
}
pub async fn read_json_memory<T>(&self, file_name: &str, label: &str) -> Option<T>
where
T: DeserializeOwned,
{
read_json_optional(&self.memory_file(file_name), label).await
}
pub fn read_json_file_sync<T>(path: &Path, label: &str) -> Option<T>
where
T: DeserializeOwned,
{
read_json_optional_sync(path, label)
}
pub async fn write_postcard_memory<T>(&self, file_name: &str, value: &T) -> Result<()>
where
T: Serialize + ?Sized + Sync,
{
write_postcard_atomic(
&self.memory_file(file_name),
value,
PersistenceFileMode::Default,
)
.await
}
pub async fn write_json_memory<T>(&self, file_name: &str, value: &T) -> Result<()>
where
T: Serialize + ?Sized + Sync,
{
let bytes = serde_json::to_vec_pretty(value).into_diagnostic()?;
write_bytes_atomic(
self.memory_file(file_name),
bytes,
PersistenceFileMode::Default,
)
.await
.into_diagnostic()
}
pub fn write_json_file_sync<T>(path: &Path, value: &T) -> Result<()>
where
T: Serialize + ?Sized,
{
write_json_pretty_atomic_sync(path, value, PersistenceFileMode::Default)
}
pub async fn read_json_state_or_default<T>(&self, file_name: &str, label: &str) -> T
where
T: DeserializeOwned + Default,
{
read_json_or_default(&self.state_file(file_name), label).await
}
pub async fn write_json_state<T>(&self, file_name: &str, value: &T) -> Result<()>
where
T: Serialize + ?Sized + Sync,
{
let bytes = serde_json::to_vec_pretty(value).into_diagnostic()?;
write_bytes_atomic(
self.state_file(file_name),
bytes,
PersistenceFileMode::Default,
)
.await
.into_diagnostic()
}
}
pub async fn read_postcard_or_default<T>(path: &Path, label: &str) -> T
where
T: DeserializeOwned + Default,
{
read_postcard_optional(path, label)
.await
.unwrap_or_default()
}
pub async fn read_postcard_optional<T>(path: &Path, label: &str) -> Option<T>
where
T: DeserializeOwned,
{
let bytes = match tokio::fs::read(path).await {
Ok(bytes) => bytes,
Err(err) if err.kind() == io::ErrorKind::NotFound => return None,
Err(err) => {
tracing::warn!("failed to read {label} {}: {err}", path.display());
return None;
}
};
match postcard::from_bytes::<T>(&bytes) {
Ok(value) => Some(value),
Err(err) => {
tracing::warn!("failed to decode {label} {}: {err}", path.display());
quarantine_corrupt_file(path, label).await;
None
}
}
}
pub fn read_postcard_or_default_sync<T>(path: &Path, label: &str) -> T
where
T: DeserializeOwned + Default,
{
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == io::ErrorKind::NotFound => return T::default(),
Err(err) => {
tracing::warn!("failed to read {label} {}: {err}", path.display());
return T::default();
}
};
match postcard::from_bytes::<T>(&bytes) {
Ok(value) => value,
Err(err) => {
tracing::warn!("failed to decode {label} {}: {err}", path.display());
quarantine_corrupt_file_sync(path, label);
T::default()
}
}
}
pub async fn read_json_or_default<T>(path: &Path, label: &str) -> T
where
T: DeserializeOwned + Default,
{
read_json_optional(path, label).await.unwrap_or_default()
}
pub async fn read_json_optional<T>(path: &Path, label: &str) -> Option<T>
where
T: DeserializeOwned,
{
let bytes = match tokio::fs::read(path).await {
Ok(bytes) => bytes,
Err(err) if err.kind() == io::ErrorKind::NotFound => return None,
Err(err) => {
tracing::warn!("failed to read {label} {}: {err}", path.display());
return None;
}
};
match serde_json::from_slice::<T>(&bytes) {
Ok(value) => Some(value),
Err(err) => {
tracing::warn!("failed to decode {label} {}: {err}", path.display());
quarantine_corrupt_file(path, label).await;
None
}
}
}
pub fn read_json_optional_sync<T>(path: &Path, label: &str) -> Option<T>
where
T: DeserializeOwned,
{
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == io::ErrorKind::NotFound => return None,
Err(err) => {
tracing::warn!("failed to read {label} {}: {err}", path.display());
return None;
}
};
match serde_json::from_slice::<T>(&bytes) {
Ok(value) => Some(value),
Err(err) => {
tracing::warn!("failed to decode {label} {}: {err}", path.display());
quarantine_corrupt_file_sync(path, label);
None
}
}
}
pub async fn write_postcard_atomic<T>(
path: &Path,
value: &T,
mode: PersistenceFileMode,
) -> Result<()>
where
T: Serialize + ?Sized + Sync,
{
let bytes = postcard::to_allocvec(value).into_diagnostic()?;
write_bytes_atomic(path.to_path_buf(), bytes, mode)
.await
.into_diagnostic()
}
pub fn write_postcard_atomic_sync<T>(
path: &Path,
value: &T,
mode: PersistenceFileMode,
) -> Result<()>
where
T: Serialize + ?Sized,
{
let bytes = postcard::to_allocvec(value).into_diagnostic()?;
write_bytes_atomic_sync(path, &bytes, mode).into_diagnostic()
}
pub fn write_json_pretty_atomic_sync<T>(
path: &Path,
value: &T,
mode: PersistenceFileMode,
) -> Result<()>
where
T: Serialize + ?Sized,
{
let bytes = serde_json::to_vec_pretty(value).into_diagnostic()?;
write_bytes_atomic_sync(path, &bytes, mode).into_diagnostic()
}
pub async fn write_bytes_atomic(
path: PathBuf,
bytes: Vec<u8>,
mode: PersistenceFileMode,
) -> io::Result<()> {
tokio::task::spawn_blocking(move || write_bytes_atomic_sync(&path, &bytes, mode))
.await
.map_err(|err| io::Error::other(format!("atomic write task failed: {err}")))?
}
pub async fn append_bytes_durable(path: PathBuf, bytes: Vec<u8>) -> io::Result<()> {
tokio::task::spawn_blocking(move || append_bytes_durable_sync(&path, &bytes))
.await
.map_err(|err| io::Error::other(format!("durable append task failed: {err}")))?
}
pub fn write_bytes_atomic_sync(
path: &Path,
bytes: &[u8],
mode: PersistenceFileMode,
) -> io::Result<()> {
let parent = path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path has no parent: {}", path.display()),
)
})?;
fs::create_dir_all(parent)?;
let temp_path = create_unique_temp_path(path);
let write_result = (|| {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp_path)?;
if mode == PersistenceFileMode::Private {
set_private_file_permissions(&temp_path);
}
file.write_all(bytes)?;
file.sync_all()?;
drop(file);
fs::rename(&temp_path, path)?;
sync_parent_dir(parent);
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(&temp_path);
}
write_result
}
pub fn append_bytes_durable_sync(path: &Path, bytes: &[u8]) -> io::Result<()> {
let parent = path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path has no parent: {}", path.display()),
)
})?;
fs::create_dir_all(parent)?;
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
file.write_all(bytes)?;
file.flush()?;
file.sync_all()?;
drop(file);
sync_parent_dir(parent);
Ok(())
}
pub fn set_private_file_permissions(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.expect("private persistence file permissions should be set");
}
#[cfg(not(unix))]
{
let _ = path;
}
}
fn create_unique_temp_path(path: &Path) -> PathBuf {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let name = path
.file_name()
.map_or_else(|| "state".into(), |name| name.to_string_lossy());
parent.join(format!(
".{name}.tmp-{}-{}",
std::process::id(),
Uuid::new_v4()
))
}
fn corrupt_path(path: &Path) -> PathBuf {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let name = path
.file_name()
.map_or_else(|| "state".into(), |name| name.to_string_lossy());
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
parent.join(format!("{name}.corrupt-{timestamp}-{}", Uuid::new_v4()))
}
async fn quarantine_corrupt_file(path: &Path, label: &str) {
let quarantine_path = corrupt_path(path);
match tokio::fs::rename(path, &quarantine_path).await {
Ok(()) => tracing::warn!(
"moved corrupt {label} state file from {} to {}",
path.display(),
quarantine_path.display()
),
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => tracing::warn!(
"failed to move corrupt {label} state file {}: {err}",
path.display()
),
}
}
fn quarantine_corrupt_file_sync(path: &Path, label: &str) {
let quarantine_path = corrupt_path(path);
match fs::rename(path, &quarantine_path) {
Ok(()) => tracing::warn!(
"moved corrupt {label} state file from {} to {}",
path.display(),
quarantine_path.display()
),
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => tracing::warn!(
"failed to move corrupt {label} state file {}: {err}",
path.display()
),
}
}
fn sync_parent_dir(path: &Path) {
#[cfg(unix)]
{
fs::File::open(path)
.and_then(|directory| directory.sync_all())
.expect("parent directory should sync after durable write");
}
#[cfg(not(unix))]
{
let _ = path;
}
}
#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};
use super::*;
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
struct TestState {
value: String,
}
#[test]
fn atomic_write_replaces_complete_file() {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("state.bin");
write_bytes_atomic_sync(&path, b"old", PersistenceFileMode::Default).expect("write old");
write_bytes_atomic_sync(&path, b"new", PersistenceFileMode::Default).expect("write new");
assert_eq!(fs::read(&path).expect("read state"), b"new");
assert!(
fs::read_dir(tempdir.path())
.expect("read dir")
.all(|entry| !entry
.expect("dir entry")
.file_name()
.to_string_lossy()
.contains(".tmp-"))
);
}
#[cfg(unix)]
#[test]
fn private_atomic_write_sets_private_permissions() {
use std::os::unix::fs::PermissionsExt;
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("config.toml");
write_bytes_atomic_sync(&path, b"secret", PersistenceFileMode::Private)
.expect("write private");
let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn corrupt_postcard_file_is_quarantined_and_defaults() {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("state.bin");
fs::write(&path, b"not-postcard").expect("write corrupt");
let state: TestState = read_postcard_or_default_sync(&path, "test state");
assert_eq!(state, TestState::default());
assert!(!path.exists());
assert!(
fs::read_dir(tempdir.path())
.expect("read dir")
.any(|entry| entry
.expect("dir entry")
.file_name()
.to_string_lossy()
.contains(".corrupt-"))
);
}
#[test]
fn durable_append_preserves_existing_content() {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("records.jsonl");
append_bytes_durable_sync(&path, b"one\n").expect("append one");
append_bytes_durable_sync(&path, b"two\n").expect("append two");
assert_eq!(
fs::read_to_string(&path).expect("read records"),
"one\ntwo\n"
);
}
}