use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use alba_core::BeamId;
use alba_executors::{OutputLine, Stream};
use serde::{Deserialize, Serialize};
pub(crate) const FORMAT_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct Manifest {
pub version: u32,
pub fingerprint: String,
pub outputs: Vec<String>,
pub duration_ms: u64,
}
#[derive(Serialize, Deserialize)]
struct StoredLine {
stream: String,
text: String,
}
pub(crate) struct CacheStore {
dir: PathBuf,
}
impl CacheStore {
pub(crate) fn new(dir: PathBuf) -> Self {
Self { dir }
}
pub(crate) fn load(&self, id: &BeamId) -> Option<Manifest> {
let bytes = std::fs::read(self.entry(id, "json")).ok()?;
let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
(manifest.version == FORMAT_VERSION).then_some(manifest)
}
pub(crate) fn store(&self, id: &BeamId, manifest: &Manifest, logs: &[OutputLine]) {
let _ = self.try_store(id, manifest, logs);
}
pub(crate) fn load_logs(&self, id: &BeamId) -> Vec<OutputLine> {
let Ok(content) = std::fs::read_to_string(self.entry(id, "log")) else {
return Vec::new();
};
content
.lines()
.filter_map(|line| serde_json::from_str::<StoredLine>(line).ok())
.map(|line| OutputLine {
stream: match line.stream.as_str() {
"stderr" => Stream::Stderr,
_ => Stream::Stdout,
},
text: line.text,
})
.collect()
}
fn try_store(&self, id: &BeamId, manifest: &Manifest, logs: &[OutputLine]) -> io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
let mut log_lines = String::new();
for line in logs {
let stored = StoredLine {
stream: match line.stream {
Stream::Stdout => "stdout".to_string(),
Stream::Stderr => "stderr".to_string(),
},
text: line.text.clone(),
};
if let Ok(json) = serde_json::to_string(&stored) {
log_lines.push_str(&json);
log_lines.push('\n');
}
}
write_atomic(&self.entry(id, "log"), log_lines.as_bytes())?;
let bytes = serde_json::to_vec_pretty(manifest)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
write_atomic(&self.entry(id, "json"), &bytes)
}
fn entry(&self, id: &BeamId, extension: &str) -> PathBuf {
let name = blake3::hash(id.0.as_bytes()).to_hex();
self.dir.join(format!("{}.{extension}", &name[..32]))
}
}
fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let tmp = temporary_path(path);
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path)
}
fn temporary_path(path: &Path) -> PathBuf {
static NEXT: AtomicU64 = AtomicU64::new(0);
let mut tmp = path.as_os_str().to_owned();
tmp.push(format!(
".{}.{}.tmp",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
PathBuf::from(tmp)
}
#[cfg(test)]
mod tests {
use super::*;
use alba_executors::Stream;
fn store_in(dir: &std::path::Path) -> CacheStore {
CacheStore::new(dir.join("cache"))
}
fn manifest(fingerprint: &str) -> Manifest {
Manifest {
version: FORMAT_VERSION,
fingerprint: fingerprint.to_string(),
outputs: vec!["out.txt".to_string()],
duration_ms: 1234,
}
}
fn line(stream: Stream, text: &str) -> OutputLine {
OutputLine {
stream,
text: text.to_string(),
}
}
#[test]
fn a_stored_manifest_loads_back_identically() {
let dir = tempfile::tempdir().unwrap();
let store = store_in(dir.path());
let id = BeamId("build".to_string());
store.store(&id, &manifest("fp1"), &[]);
assert_eq!(store.load(&id), Some(manifest("fp1")));
}
#[test]
fn a_beam_never_stored_loads_nothing() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
store_in(dir.path()).load(&BeamId("build".to_string())),
None
);
}
#[test]
fn namespaced_ids_are_stored_safely_and_separately() {
let dir = tempfile::tempdir().unwrap();
let store = store_in(dir.path());
let plain = BeamId("build".to_string());
let namespaced = BeamId("api:build".to_string());
store.store(&plain, &manifest("fp-plain"), &[]);
store.store(&namespaced, &manifest("fp-ns"), &[]);
assert_eq!(store.load(&plain).unwrap().fingerprint, "fp-plain");
assert_eq!(store.load(&namespaced).unwrap().fingerprint, "fp-ns");
}
#[test]
fn a_corrupted_manifest_is_a_miss_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let store = store_in(dir.path());
let id = BeamId("build".to_string());
store.store(&id, &manifest("fp1"), &[]);
for entry in std::fs::read_dir(dir.path().join("cache")).unwrap() {
let path = entry.unwrap().path();
if path.extension().is_some_and(|ext| ext == "json") {
std::fs::write(&path, "not json at all").unwrap();
}
}
assert_eq!(store.load(&id), None);
}
#[test]
fn a_manifest_from_another_format_version_is_a_miss() {
let dir = tempfile::tempdir().unwrap();
let store = store_in(dir.path());
let id = BeamId("build".to_string());
let old = Manifest {
version: FORMAT_VERSION + 1,
..manifest("fp1")
};
store.store(&id, &old, &[]);
assert_eq!(store.load(&id), None);
}
#[test]
fn logs_roundtrip_with_stream_and_order_preserved() {
let dir = tempfile::tempdir().unwrap();
let store = store_in(dir.path());
let id = BeamId("build".to_string());
let logs = vec![
line(Stream::Stdout, "compiling"),
line(Stream::Stderr, "warning: unused"),
line(Stream::Stdout, "done"),
];
store.store(&id, &manifest("fp1"), &logs);
assert_eq!(store.load_logs(&id), logs);
}
#[test]
fn two_writes_of_the_same_entry_use_different_temporary_files() {
let path = Path::new("/cache/abc.json");
let (first, second) = (temporary_path(path), temporary_path(path));
assert_ne!(first, second);
assert_eq!(first.parent(), Some(Path::new("/cache")));
}
#[test]
fn missing_logs_load_as_empty() {
let dir = tempfile::tempdir().unwrap();
assert!(
store_in(dir.path())
.load_logs(&BeamId("build".to_string()))
.is_empty()
);
}
}