use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use rustyfi_loader::LoadedProgram;
use rustyfi_syntax::RustyfiVersion;
use sha2::{Digest, Sha256};
use crate::format::OutputFormat;
pub struct Cache {
dir: PathBuf,
}
pub struct Hit {
pub pdf: Vec<u8>,
pub pages: usize,
pub lines: usize,
}
impl Cache {
pub fn open(override_dir: Option<PathBuf>) -> Option<Cache> {
let dir = resolve_dir(override_dir);
std::fs::create_dir_all(&dir).ok()?;
Some(Cache { dir })
}
fn pdf_path(&self, key: &str) -> PathBuf {
self.dir.join(format!("{key}.pdf"))
}
fn meta_path(&self, key: &str) -> PathBuf {
self.dir.join(format!("{key}.meta"))
}
pub fn get(&self, key: &str) -> Option<Hit> {
let pdf = std::fs::read(self.pdf_path(key)).ok()?;
let meta = std::fs::read_to_string(self.meta_path(key)).ok()?;
let (pages, lines) = parse_meta(&meta)?;
Some(Hit { pdf, pages, lines })
}
pub fn put(&self, key: &str, pdf: &[u8], pages: usize, lines: usize) -> std::io::Result<()> {
write_atomic(&self.pdf_path(key), pdf)?;
write_atomic(
&self.meta_path(key),
format!("{pages}\n{lines}\n").as_bytes(),
)?;
Ok(())
}
}
pub fn compute_key(
program: &LoadedProgram,
compiler_version: &str,
target: RustyfiVersion,
entry: &Path,
font_store: Option<&rustyfi_pdf::TtfFontStore>,
format: OutputFormat,
deps_lock: Option<&str>,
) -> Option<String> {
hash_inputs(
program.files.iter().map(|f| f.path.as_path()),
compiler_version,
target,
entry,
font_store,
format,
deps_lock,
)
}
fn hash_inputs<'a>(
paths: impl Iterator<Item = &'a Path>,
compiler_version: &str,
target: RustyfiVersion,
entry: &Path,
font_store: Option<&rustyfi_pdf::TtfFontStore>,
format: OutputFormat,
deps_lock: Option<&str>,
) -> Option<String> {
let mut h = Sha256::new();
h.update(b"rustyfi-compile-cache\x00v2\x00");
h.update(compiler_version.as_bytes());
h.update(b"\x00");
h.update(target.to_string().as_bytes());
h.update(b"\x00");
let entry_name = entry
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
h.update(entry_name.as_bytes());
h.update(b"\x00");
for path in paths {
let bytes = std::fs::read(path).ok()?;
h.update((bytes.len() as u64).to_le_bytes());
h.update(&bytes);
}
h.update(b"\x00fonts\x00");
match font_store {
Some(store) => {
h.update(b"\x01");
h.update((store.num_files() as u64).to_le_bytes());
for i in 0..store.num_files() {
let bytes = store.file_bytes(i);
h.update((bytes.len() as u64).to_le_bytes());
h.update(bytes);
}
}
None => h.update(b"\x00"),
}
h.update(b"\x00format\x00");
h.update(format.cache_tag().as_bytes());
h.update(b"\x00deps_lock\x00");
match deps_lock {
Some(digest) => {
h.update(b"\x01");
h.update(digest.as_bytes());
}
None => h.update(b"\x00"),
}
Some(hex(&h.finalize()))
}
fn resolve_dir(override_dir: Option<PathBuf>) -> PathBuf {
if let Some(dir) = override_dir {
return dir;
}
if let Some(xdg) = non_empty_env("XDG_CACHE_HOME") {
return PathBuf::from(xdg).join("rustyfi");
}
if let Some(home) = non_empty_env("HOME") {
return PathBuf::from(home).join(".cache").join("rustyfi");
}
std::env::temp_dir().join("rustyfi-cache")
}
fn non_empty_env(var: &str) -> Option<std::ffi::OsString> {
std::env::var_os(var).filter(|v| !v.is_empty())
}
fn parse_meta(s: &str) -> Option<(usize, usize)> {
let mut it = s.split_whitespace();
let pages = it.next()?.parse().ok()?;
let lines = it.next()?.parse().ok()?;
Some((pages, lines))
}
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
static SEQ: AtomicU64 = AtomicU64::new(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let name = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let tmp = path.with_file_name(format!(".{name}.tmp.{}.{seq}", std::process::id()));
std::fs::write(&tmp, bytes)?;
match std::fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e)
}
}
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"rustyfi-cache-unit-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn key_is_stable_for_identical_inputs() {
let dir = scratch();
let a = dir.join("a.saty");
std::fs::write(&a, b"@require: foo\ndocument (||) '<>\n").unwrap();
let k1 = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
None,
)
.unwrap();
let k2 = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
None,
)
.unwrap();
assert_eq!(k1, k2, "same inputs must hash identically");
assert_eq!(k1.len(), 64, "sha-256 hex is 64 chars");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn key_changes_when_a_byte_changes() {
let dir = scratch();
let a = dir.join("a.saty");
std::fs::write(&a, b"document (||) '<>\n").unwrap();
let before = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
None,
)
.unwrap();
std::fs::write(&a, b"document (||) '< >\n").unwrap();
let after = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
None,
)
.unwrap();
assert_ne!(before, after, "a one-byte edit must change the key");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn key_changes_when_compiler_version_bumps() {
let dir = scratch();
let a = dir.join("a.saty");
std::fs::write(&a, b"document (||) '<>\n").unwrap();
let v1 = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
None,
)
.unwrap();
let v2 = hash_inputs(
[a.as_path()].into_iter(),
"0.2.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
None,
)
.unwrap();
assert_ne!(v1, v2, "a compiler-version bump must invalidate the key");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn key_changes_with_the_input_set() {
let dir = scratch();
let a = dir.join("a.saty");
let b = dir.join("b.satyh");
std::fs::write(&a, b"document (||) '<>\n").unwrap();
std::fs::write(&b, b"let x = 1\n").unwrap();
let one = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
None,
)
.unwrap();
let two = hash_inputs(
[b.as_path(), a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
None,
)
.unwrap();
assert_ne!(one, two, "adding a resolved dependency must change the key");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn key_changes_when_a_font_store_is_configured() {
let Some(font_path) = find_test_font() else {
eprintln!("skipping: no DejaVuSans-like TrueType font found on this system");
return;
};
let dir = scratch();
let a = dir.join("a.saty");
std::fs::write(&a, b"document (||) '<>\n").unwrap();
let store = rustyfi_pdf::TtfFontStore::load(&font_path, None, None).expect("load font");
let without_font = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
None,
)
.unwrap();
let with_font = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
Some(&store),
OutputFormat::Pdf,
None,
)
.unwrap();
assert_ne!(
without_font, with_font,
"configuring a font must change the cache key vs. the base-14 path"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn key_changes_with_output_format() {
let dir = scratch();
let a = dir.join("a.saty");
std::fs::write(&a, b"document (||) '<>\n").unwrap();
let pdf = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
None,
)
.unwrap();
let html = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Html,
None,
)
.unwrap();
assert_ne!(
pdf, html,
"--format pdf and --format html must hash to different keys"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn key_changes_with_deps_lock_digest() {
let dir = scratch();
let a = dir.join("a.saty");
std::fs::write(&a, b"document (||) '<>\n").unwrap();
let no_lock = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
None,
)
.unwrap();
let lock_a = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
Some("digest-aaaa"),
)
.unwrap();
let lock_b = hash_inputs(
[a.as_path()].into_iter(),
"0.1.0",
RustyfiVersion::DEFAULT,
&a,
None,
OutputFormat::Pdf,
Some("digest-bbbb"),
)
.unwrap();
assert_ne!(
no_lock, lock_a,
"an absent lock vs. a present one must differ"
);
assert_ne!(
lock_a, lock_b,
"two different lock digests must hash to different keys"
);
std::fs::remove_dir_all(&dir).ok();
}
fn find_test_font() -> Option<PathBuf> {
if let Ok(output) = std::process::Command::new("fc-match")
.args(["--format=%{file}", "DejaVuSans"])
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() && Path::new(&path).is_file() {
return Some(PathBuf::from(path));
}
}
}
for candidate in [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/run/current-system/sw/share/fonts/truetype/DejaVuSans.ttf",
"/run/current-system/sw/share/X11/fonts/DejaVuSans.ttf",
] {
if Path::new(candidate).is_file() {
return Some(PathBuf::from(candidate));
}
}
None
}
#[test]
fn store_round_trips_pdf_and_counts() {
let dir = scratch();
let cache = Cache { dir: dir.clone() };
let key = "deadbeef";
assert!(cache.get(key).is_none(), "empty cache is a miss");
cache.put(key, b"%PDF-1.7 fake", 3, 12).unwrap();
let hit = cache.get(key).expect("stored key must hit");
assert_eq!(hit.pdf, b"%PDF-1.7 fake");
assert_eq!((hit.pages, hit.lines), (3, 12));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_sidecar_is_a_miss() {
let dir = scratch();
let cache = Cache { dir: dir.clone() };
std::fs::write(cache.pdf_path("orphan"), b"%PDF-").unwrap();
assert!(cache.get("orphan").is_none());
std::fs::remove_dir_all(&dir).ok();
}
}