use std::path::Path;
use fallow_types::cache_rejection::CacheRejection;
use serde::{Deserialize, Serialize};
use super::{CachedResolvedProject, GRAPH_CACHE_VERSION, GraphCacheManifest};
use crate::graph::ModuleGraph;
pub const GRAPH_CACHE_FILE: &str = "graph-cache.bin";
#[derive(Serialize, Deserialize)]
pub struct GraphCacheStore {
pub version: u32,
pub manifest: GraphCacheManifest,
pub graph: ModuleGraph,
pub resolved_project: CachedResolvedProject,
}
impl GraphCacheStore {
pub fn load(cache_dir: &Path) -> Result<Self, CacheRejection> {
let cache_file = cache_dir.join(GRAPH_CACHE_FILE);
let data = std::fs::read(&cache_file).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
return CacheRejection::Absent;
}
tracing::warn!("Cache file could not be read; check the path and permissions");
CacheRejection::Unreadable
})?;
let payload = read_header(&data)?;
let mut store: Self = match postcard::from_bytes(payload) {
Ok(store) => store,
Err(_) => {
tracing::warn!(
"Graph cache carries the current format version but its payload could not be \
decoded, rebuilding"
);
return Err(CacheRejection::Undecodable);
}
};
if store.version != GRAPH_CACHE_VERSION {
tracing::warn!(
cached_version = store.version,
expected_version = GRAPH_CACHE_VERSION,
"Graph cache header and payload declare different format versions, rebuilding"
);
return Err(CacheRejection::VersionMismatch);
}
store.graph.reconstruct_namespace_imported();
Ok(store)
}
pub fn save(&self, cache_dir: &Path) {
if let Err(error) = std::fs::create_dir_all(cache_dir) {
tracing::debug!("Failed to create graph cache dir: {error}");
return;
}
if let Err(error) = write_cache_gitignore(cache_dir) {
tracing::debug!("Failed to write graph cache .gitignore: {error}");
}
let encoded = match postcard::to_allocvec(self) {
Ok(bytes) => bytes,
Err(error) => {
tracing::debug!("Failed to encode graph cache: {error}");
return;
}
};
let cache_file = cache_dir.join(GRAPH_CACHE_FILE);
if let Err(error) = atomic_write(&cache_file, &framed(self.version, &encoded)) {
tracing::debug!("Failed to write graph cache: {error}");
}
}
}
const GRAPH_CACHE_MAGIC: [u8; 4] = *b"FLWG";
const GRAPH_CACHE_HEADER_LEN: usize = GRAPH_CACHE_MAGIC.len() + 4;
fn framed(version: u32, payload: &[u8]) -> Vec<u8> {
let mut framed = Vec::with_capacity(GRAPH_CACHE_HEADER_LEN + payload.len());
framed.extend_from_slice(&GRAPH_CACHE_MAGIC);
framed.extend_from_slice(&version.to_le_bytes());
framed.extend_from_slice(payload);
framed
}
fn read_header(data: &[u8]) -> Result<&[u8], CacheRejection> {
let Some((header, payload)) = data.split_at_checked(GRAPH_CACHE_HEADER_LEN) else {
tracing::warn!("Graph cache is too short to carry a format header, rebuilding");
return Err(CacheRejection::Undecodable);
};
let (declared_magic, declared_version) = header.split_at(GRAPH_CACHE_MAGIC.len());
if declared_magic != GRAPH_CACHE_MAGIC {
tracing::warn!("Graph cache does not carry fallow's cache framing, rebuilding");
return Err(CacheRejection::Undecodable);
}
let declared = declared_version.try_into().map_or(0, u32::from_le_bytes);
if declared != GRAPH_CACHE_VERSION {
tracing::warn!(
cached_version = declared,
expected_version = GRAPH_CACHE_VERSION,
"Graph cache format upgraded, rebuilding (one-time cost after version bump)"
);
return Err(CacheRejection::VersionMismatch);
}
Ok(payload)
}
fn write_cache_gitignore(cache_dir: &Path) -> std::io::Result<()> {
std::fs::write(cache_dir.join(".gitignore"), "*\n")
}
fn atomic_write(cache_file: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp_file = match cache_file.file_name() {
Some(name) => cache_file.with_file_name({
let mut s = name.to_os_string();
s.push(".tmp");
s
}),
None => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"graph cache file path has no filename component",
));
}
};
{
use std::io::Write as _;
let mut f = std::fs::File::create(&tmp_file)?;
f.write_all(data)?;
let _ = f.sync_all();
}
std::fs::rename(&tmp_file, cache_file)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_blob_without_fallows_framing_is_undecodable() {
assert_eq!(
read_header(b"written-by-an-older-build").err(),
Some(CacheRejection::Undecodable)
);
}
#[test]
fn a_blob_too_short_to_carry_a_header_is_undecodable() {
assert_eq!(
read_header(&[0_u8; 3]).err(),
Some(CacheRejection::Undecodable)
);
}
#[test]
fn a_header_declaring_another_version_is_refused_without_reading_the_payload() {
let blob = framed(GRAPH_CACHE_VERSION + 1, b"payload");
assert_eq!(
read_header(&blob).err(),
Some(CacheRejection::VersionMismatch)
);
}
#[test]
fn a_header_at_the_current_version_hands_back_the_payload_it_frames() {
let blob = framed(GRAPH_CACHE_VERSION, b"payload");
assert_eq!(read_header(&blob), Ok(b"payload".as_slice()));
}
}