use crate::{ErrorCode, VeloqDiagnostic};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::fs;
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use thiserror::Error;
pub type SidecarResult<T> = Result<T, SidecarError>;
#[derive(Debug, Error)]
pub enum SidecarError {
#[error("reading {label} at {path}")]
Read {
label: &'static str,
path: String,
#[source]
source: std::io::Error,
},
#[error("decoding {label} header")]
DecodeHeader {
label: &'static str,
#[source]
source: bincode::error::DecodeError,
},
#[error("decoding {label}")]
Decode {
label: &'static str,
#[source]
source: bincode::error::DecodeError,
},
#[error("encoding {label}")]
Encode {
label: &'static str,
#[source]
source: bincode::error::EncodeError,
},
#[error("creating {label} parent directory at {path}")]
CreateParent {
label: &'static str,
path: String,
#[source]
source: std::io::Error,
},
#[error("writing {label} temp file at {path}")]
WriteTemp {
label: &'static str,
path: String,
#[source]
source: std::io::Error,
},
#[error("renaming {label} temp into place at {path}")]
Rename {
label: &'static str,
path: String,
#[source]
source: std::io::Error,
},
}
impl VeloqDiagnostic for SidecarError {
fn code(&self) -> ErrorCode {
match self {
Self::Read { .. } => ErrorCode::IO_READ,
Self::DecodeHeader { .. } | Self::Decode { .. } => ErrorCode::SIDECAR_DECODE,
Self::Encode { .. } => ErrorCode::SIDECAR_ENCODE,
Self::CreateParent { .. } => ErrorCode::IO_CREATE_DIR,
Self::WriteTemp { .. } => ErrorCode::IO_WRITE,
Self::Rename { .. } => ErrorCode::IO_PUBLISH,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SourceFingerprint {
pub mtime_secs: i64,
pub size: u64,
}
impl SourceFingerprint {
pub fn of_path(source: &Path) -> std::io::Result<Self> {
let meta = fs::metadata(source)?;
Ok(Self::of_metadata(&meta))
}
pub fn of_metadata(meta: &fs::Metadata) -> Self {
let mtime_secs = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Self {
mtime_secs,
size: meta.len(),
}
}
}
pub struct SidecarCache<T> {
path: PathBuf,
version: u32,
label: &'static str,
_phantom: PhantomData<fn() -> T>,
}
impl<T> SidecarCache<T> {
pub fn new(path: PathBuf, version: u32, label: &'static str) -> Self {
Self {
path,
version,
label,
_phantom: PhantomData,
}
}
pub fn path(&self) -> &Path {
&self.path
}
}
#[derive(Debug, Clone, Copy)]
pub struct SidecarHeader {
pub version: u32,
pub fingerprint: SourceFingerprint,
}
impl<T> SidecarCache<T> {
pub fn read_header(&self) -> SidecarResult<Option<SidecarHeader>> {
if !self.path.exists() {
return Ok(None);
}
let bytes = fs::read(&self.path).map_err(|source| SidecarError::Read {
label: self.label,
path: path_string(&self.path),
source,
})?;
#[derive(serde::Deserialize)]
struct HeaderOnly {
version: u32,
source_mtime_secs: i64,
source_size: u64,
}
let (h, _read): (HeaderOnly, _) =
bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).map_err(
|source| SidecarError::DecodeHeader {
label: self.label,
source,
},
)?;
Ok(Some(SidecarHeader {
version: h.version,
fingerprint: SourceFingerprint {
mtime_secs: h.source_mtime_secs,
size: h.source_size,
},
}))
}
}
impl<T: DeserializeOwned> SidecarCache<T> {
pub fn try_load(&self, source_fp: SourceFingerprint) -> SidecarResult<Option<T>> {
if !self.path.exists() {
return Ok(None);
}
let bytes = fs::read(&self.path).map_err(|source| SidecarError::Read {
label: self.label,
path: path_string(&self.path),
source,
})?;
let (file, _read): (CacheFile<T>, _) =
bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).map_err(
|source| SidecarError::Decode {
label: self.label,
source,
},
)?;
if file.version != self.version {
log::info!(
"{} version mismatch ({} vs {}); rebuilding",
self.label,
file.version,
self.version
);
return Ok(None);
}
if file.source_mtime_secs != source_fp.mtime_secs || file.source_size != source_fp.size {
log::info!(
"trace file changed since {} was written; rebuilding",
self.label
);
return Ok(None);
}
Ok(Some(file.payload))
}
}
impl<T: Serialize> SidecarCache<T> {
pub fn write(&self, source_fp: SourceFingerprint, payload: &T) -> SidecarResult<()> {
let file = CacheFileRef {
version: self.version,
source_mtime_secs: source_fp.mtime_secs,
source_size: source_fp.size,
payload,
};
let bytes = bincode::serde::encode_to_vec(&file, bincode::config::standard()).map_err(
|source| SidecarError::Encode {
label: self.label,
source,
},
)?;
let mut tmp = self.path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp_path = PathBuf::from(tmp);
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).map_err(|source| SidecarError::CreateParent {
label: self.label,
path: path_string(parent),
source,
})?;
}
fs::write(&tmp_path, &bytes).map_err(|source| SidecarError::WriteTemp {
label: self.label,
path: path_string(&tmp_path),
source,
})?;
fs::rename(&tmp_path, &self.path).map_err(|source| SidecarError::Rename {
label: self.label,
path: path_string(&self.path),
source,
})?;
log::info!(
"wrote {}: {} bytes → {}",
self.label,
bytes.len(),
self.path.display()
);
Ok(())
}
}
fn path_string(path: &Path) -> String {
path.display().to_string()
}
#[derive(Serialize, Deserialize)]
struct CacheFile<T> {
version: u32,
source_mtime_secs: i64,
source_size: u64,
payload: T,
}
#[derive(Serialize)]
struct CacheFileRef<'a, T> {
version: u32,
source_mtime_secs: i64,
source_size: u64,
payload: &'a T,
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::fs;
use std::path::PathBuf;
type TestResult<T> = Result<T, Box<dyn Error>>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Demo {
n: u32,
s: String,
}
fn tmpdir() -> TestResult<PathBuf> {
let d = std::env::temp_dir().join(format!(
"veloq-sidecar-test-{}",
std::process::id() as u64 * 1_000_000
+ std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
));
fs::create_dir_all(&d)?;
Ok(d)
}
fn fp(mtime: i64, size: u64) -> SourceFingerprint {
SourceFingerprint {
mtime_secs: mtime,
size,
}
}
#[test]
fn round_trip_load_returns_written_payload() -> TestResult<()> {
let dir = tmpdir()?;
let path = dir.join("demo.cache");
let cache: SidecarCache<Demo> = SidecarCache::new(path, 7, "demo cache");
let payload = Demo {
n: 42,
s: "hello".into(),
};
cache.write(fp(1234, 999), &payload)?;
let back = cache
.try_load(fp(1234, 999))?
.ok_or_else(|| std::io::Error::other("just-written cache should load"))?;
assert_eq!(back, payload);
Ok(())
}
#[test]
fn try_load_missing_returns_none() -> TestResult<()> {
let dir = tmpdir()?;
let path = dir.join("does-not-exist.cache");
let cache: SidecarCache<Demo> = SidecarCache::new(path, 1, "demo cache");
assert!(cache.try_load(fp(0, 0))?.is_none());
Ok(())
}
#[test]
fn version_mismatch_rebuilds() -> TestResult<()> {
let dir = tmpdir()?;
let path = dir.join("demo.cache");
let writer: SidecarCache<Demo> = SidecarCache::new(path.clone(), 1, "demo cache");
writer.write(
fp(1, 1),
&Demo {
n: 1,
s: "x".into(),
},
)?;
let reader: SidecarCache<Demo> = SidecarCache::new(path, 2, "demo cache");
assert!(reader.try_load(fp(1, 1))?.is_none());
Ok(())
}
#[test]
fn source_changed_rebuilds() -> TestResult<()> {
let dir = tmpdir()?;
let path = dir.join("demo.cache");
let cache: SidecarCache<Demo> = SidecarCache::new(path, 1, "demo cache");
cache.write(
fp(1, 100),
&Demo {
n: 1,
s: "x".into(),
},
)?;
assert!(cache.try_load(fp(2, 100))?.is_none(), "mtime change");
assert!(cache.try_load(fp(1, 200))?.is_none(), "size change");
assert!(cache.try_load(fp(1, 100))?.is_some(), "matching fp");
Ok(())
}
#[test]
fn fingerprint_from_real_file_round_trips() -> TestResult<()> {
let dir = tmpdir()?;
let src = dir.join("source.bin");
fs::write(&src, b"hello world")?;
let fp1 = SourceFingerprint::of_path(&src)?;
assert_eq!(fp1.size, 11);
assert!(fp1.mtime_secs > 1_000_000_000);
Ok(())
}
}