use std::fs::File;
use std::io::Write;
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde::de::DeserializeOwned;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SidecarIoError {
#[error("system sidecar create dir {path:?}: {source}")]
CreateDir {
path: PathBuf,
source: std::io::Error,
},
#[error("system sidecar read {path:?}: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("system sidecar write {path:?}: {source}")]
Write {
path: PathBuf,
source: std::io::Error,
},
#[error("system sidecar encode {path:?}: {source}")]
Encode {
path: PathBuf,
source: serde_json::Error,
},
#[error("system sidecar decode {path:?}: {source}")]
Decode {
path: PathBuf,
source: serde_json::Error,
},
}
fn write_err(path: &Path) -> impl FnOnce(std::io::Error) -> SidecarIoError + '_ {
move |source| SidecarIoError::Write {
path: path.to_path_buf(),
source,
}
}
pub struct SystemSidecar<T> {
path: PathBuf,
_marker: PhantomData<fn() -> T>,
}
impl<T> Clone for SystemSidecar<T> {
fn clone(&self) -> Self {
Self {
path: self.path.clone(),
_marker: PhantomData,
}
}
}
impl<T> std::fmt::Debug for SystemSidecar<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SystemSidecar")
.field("path", &self.path)
.finish()
}
}
impl<T> SystemSidecar<T> {
pub fn new(data_path: impl AsRef<Path>, file_name: &str) -> Self {
let mut path = data_path.as_ref().to_path_buf();
path.push("_system");
path.push(file_name);
Self {
path,
_marker: PhantomData,
}
}
pub fn at_path(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
_marker: PhantomData,
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Result<T, SidecarIoError>
where
T: DeserializeOwned + Default,
{
let bytes = match std::fs::read(&self.path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(T::default()),
Err(source) => {
return Err(SidecarIoError::Read {
path: self.path.clone(),
source,
});
}
};
if bytes.is_empty() {
return Ok(T::default());
}
serde_json::from_slice(&bytes).map_err(|source| SidecarIoError::Decode {
path: self.path.clone(),
source,
})
}
pub fn store(&self, value: &T) -> Result<(), SidecarIoError>
where
T: Serialize,
{
self.store_value(value)
}
pub fn store_value<S>(&self, value: &S) -> Result<(), SidecarIoError>
where
S: Serialize + ?Sized,
{
let parent = self.path.parent().filter(|p| !p.as_os_str().is_empty());
if let Some(parent) = parent {
std::fs::create_dir_all(parent).map_err(|source| SidecarIoError::CreateDir {
path: parent.to_path_buf(),
source,
})?;
}
let json = serde_json::to_vec_pretty(value).map_err(|source| SidecarIoError::Encode {
path: self.path.clone(),
source,
})?;
let tmp = self.path.with_extension("tmp");
{
let mut f = File::create(&tmp).map_err(write_err(&tmp))?;
f.write_all(&json).map_err(write_err(&tmp))?;
f.sync_all().map_err(write_err(&tmp))?;
}
std::fs::rename(&tmp, &self.path).map_err(write_err(&self.path))?;
Self::sync_dir(parent.unwrap_or_else(|| Path::new(".")))?;
Ok(())
}
fn sync_dir(dir: &Path) -> Result<(), SidecarIoError> {
#[cfg(unix)]
{
let f = File::open(dir).map_err(write_err(dir))?;
f.sync_all().map_err(write_err(dir))?;
}
#[cfg(not(unix))]
let _ = dir;
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct VecSidecar<T> {
inner: SystemSidecar<Vec<T>>,
}
impl<T> VecSidecar<T> {
pub fn new(data_path: impl AsRef<Path>, file_name: &str) -> Self {
Self {
inner: SystemSidecar::new(data_path, file_name),
}
}
pub fn path(&self) -> &Path {
self.inner.path()
}
pub fn load(&self) -> Result<Vec<T>, SidecarIoError>
where
T: DeserializeOwned,
{
self.inner.load()
}
pub fn store(&self, rows: &[T]) -> Result<(), SidecarIoError>
where
T: Serialize,
{
self.inner.store_value(rows)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_loads_default() {
let dir = tempfile::tempdir().unwrap();
let sidecar: SystemSidecar<Vec<String>> = SystemSidecar::new(dir.path(), "missing.json");
assert_eq!(sidecar.load().unwrap(), Vec::<String>::new());
}
#[test]
fn empty_file_loads_default() {
let dir = tempfile::tempdir().unwrap();
let sidecar: SystemSidecar<Vec<String>> = SystemSidecar::new(dir.path(), "empty.json");
std::fs::create_dir_all(sidecar.path().parent().unwrap()).unwrap();
std::fs::write(sidecar.path(), b"").unwrap();
assert_eq!(sidecar.load().unwrap(), Vec::<String>::new());
}
#[test]
fn store_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let sidecar: SystemSidecar<Vec<String>> = SystemSidecar::new(dir.path(), "rows.json");
let rows = vec!["a".to_owned(), "b".to_owned()];
sidecar.store(&rows).unwrap();
assert_eq!(sidecar.load().unwrap(), rows);
assert!(sidecar.path().ends_with("_system/rows.json"));
}
#[test]
fn at_path_uses_exact_location() {
let dir = tempfile::tempdir().unwrap();
let exact = dir.path().join("declared_plugins.json");
let sidecar: SystemSidecar<Vec<u32>> = SystemSidecar::at_path(&exact);
sidecar.store(&vec![7]).unwrap();
assert_eq!(sidecar.path(), exact);
assert!(exact.exists());
assert_eq!(sidecar.load().unwrap(), vec![7]);
}
#[test]
fn vec_sidecar_stores_borrowed_slice_and_loads_empty_default() {
let dir = tempfile::tempdir().unwrap();
let sidecar: VecSidecar<String> = VecSidecar::new(dir.path(), "rows.json");
assert_eq!(sidecar.load().unwrap(), Vec::<String>::new());
let rows = vec!["a".to_owned(), "b".to_owned()];
sidecar.store(&rows).unwrap();
assert_eq!(sidecar.load().unwrap(), rows);
assert!(sidecar.path().ends_with("_system/rows.json"));
}
#[test]
fn store_replaces_previous_and_leaves_no_temp() {
let dir = tempfile::tempdir().unwrap();
let sidecar: SystemSidecar<Vec<u32>> = SystemSidecar::new(dir.path(), "nums.json");
sidecar.store(&vec![1, 2, 3]).unwrap();
sidecar.store(&vec![9]).unwrap();
assert_eq!(sidecar.load().unwrap(), vec![9]);
let tmp = sidecar.path().with_extension("tmp");
assert!(!tmp.exists(), "temp file leaked: {tmp:?}");
}
}