use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Serialize};
use crate::environment::{JuliaInstall, Package, PackageKind};
use super::model::PackageIndex;
const CACHE_FORMAT: u32 = 1;
fn tool_fingerprint() -> String {
format!("fatou@{}", env!("CARGO_PKG_VERSION"))
}
fn version_segment() -> String {
format!("v{CACHE_FORMAT}-{}", env!("CARGO_PKG_VERSION"))
}
#[derive(Serialize)]
struct CacheEntryRef<'a> {
format: u32,
tool: &'a str,
index: &'a PackageIndex,
}
#[derive(Deserialize)]
struct CacheEntryOwned {
format: u32,
tool: String,
index: PackageIndex,
}
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
pub struct CacheKey;
impl CacheKey {
pub fn for_package(package: &Package) -> Option<String> {
match package.kind {
PackageKind::Registered => package.tree_sha1.clone(),
PackageKind::Dev | PackageKind::Stdlib => None,
}
}
pub fn for_system(install: &JuliaInstall) -> String {
format!("stdlib-{}", install.version)
}
}
#[derive(Debug, Clone)]
pub struct IndexCache {
root: PathBuf,
}
impl IndexCache {
pub fn open() -> Option<Self> {
Some(Self::rooted(resolve_cache_dir()?))
}
pub fn open_at(base: impl Into<PathBuf>) -> Self {
Self::rooted(base.into().join("index"))
}
fn rooted(index_dir: PathBuf) -> Self {
Self {
root: index_dir.join(version_segment()),
}
}
fn entry_path(&self, name: &str, key: &str) -> PathBuf {
self.root.join(name).join(format!("{key}.postcard"))
}
pub fn load(&self, name: &str, key: &str) -> Option<PackageIndex> {
let path = self.entry_path(name, key);
let bytes = std::fs::read(&path).ok()?;
let entry: CacheEntryOwned = match postcard::from_bytes(&bytes) {
Ok(entry) => entry,
Err(err) => {
log::debug!("index cache: undecodable entry {}: {err}", path.display());
return None;
}
};
if entry.format != CACHE_FORMAT || entry.tool != tool_fingerprint() {
return None;
}
Some(entry.index)
}
pub fn store(&self, name: &str, key: &str, index: &PackageIndex) {
let entry = CacheEntryRef {
format: CACHE_FORMAT,
tool: &tool_fingerprint(),
index,
};
let bytes = match postcard::to_stdvec(&entry) {
Ok(bytes) => bytes,
Err(err) => {
log::debug!("index cache: failed to encode {name}/{key}: {err}");
return;
}
};
let path = self.entry_path(name, key);
let Some(parent) = path.parent() else {
return;
};
if let Err(err) = std::fs::create_dir_all(parent) {
log::debug!("index cache: cannot create {}: {err}", parent.display());
return;
}
let tmp = parent.join(format!(
".{key}.{}.{}.tmp",
std::process::id(),
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed),
));
if let Err(err) = std::fs::write(&tmp, &bytes) {
log::debug!("index cache: cannot write {}: {err}", tmp.display());
let _ = std::fs::remove_file(&tmp);
return;
}
if let Err(err) = std::fs::rename(&tmp, &path) {
log::debug!("index cache: cannot rename into {}: {err}", path.display());
let _ = std::fs::remove_file(&tmp);
}
}
}
pub fn resolve_cache_dir() -> Option<PathBuf> {
cache_dir_from(
std::env::var_os("FATOU_CACHE_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty()),
dirs::cache_dir(),
)
}
fn cache_dir_from(env_override: Option<PathBuf>, os_cache: Option<PathBuf>) -> Option<PathBuf> {
if let Some(base) = env_override {
return Some(base.join("index"));
}
os_cache.map(|dir| dir.join("fatou").join("index"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::environment::Uuid;
use crate::index::model::{DefLocation, ExportedName, ModuleIndex, Span, Visibility};
use std::sync::Arc;
fn nil_uuid() -> Uuid {
"00000000-0000-0000-0000-000000000000".parse().unwrap()
}
fn sample_index(name: &str) -> PackageIndex {
let loc = DefLocation {
file: PathBuf::from(format!("src/{name}.jl")),
range: Span { start: 0, end: 3 },
};
PackageIndex {
name: name.to_string(),
root: ModuleIndex {
name: name.to_string(),
bare: false,
loc: loc.clone(),
exports: vec![ExportedName {
name: "foo".to_string(),
visibility: Visibility::Exported,
loc,
}],
functions: Vec::new(),
types: Vec::new(),
consts: Vec::new(),
macros: Vec::new(),
submodules: Vec::new(),
},
members: vec![PathBuf::from(format!("src/{name}.jl"))],
member_modules: Default::default(),
diagnostics: Vec::new(),
}
}
#[test]
fn store_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let cache = IndexCache::open_at(dir.path());
let index = sample_index("Foo");
cache.store("Foo", "abc123", &index);
assert_eq!(cache.load("Foo", "abc123"), Some(index));
}
#[test]
fn missing_entry_is_a_miss() {
let dir = tempfile::tempdir().unwrap();
let cache = IndexCache::open_at(dir.path());
assert_eq!(cache.load("Foo", "abc123"), None);
}
#[test]
fn wrong_key_is_a_miss() {
let dir = tempfile::tempdir().unwrap();
let cache = IndexCache::open_at(dir.path());
cache.store("Foo", "abc123", &sample_index("Foo"));
assert_eq!(cache.load("Foo", "def456"), None);
assert_eq!(cache.load("Bar", "abc123"), None);
}
#[test]
fn format_mismatch_is_a_miss() {
let dir = tempfile::tempdir().unwrap();
let cache = IndexCache::open_at(dir.path());
let bytes = postcard::to_stdvec(&CacheEntryRef {
format: CACHE_FORMAT + 1,
tool: &tool_fingerprint(),
index: &sample_index("Foo"),
})
.unwrap();
let path = cache.entry_path("Foo", "abc123");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, bytes).unwrap();
assert_eq!(cache.load("Foo", "abc123"), None);
}
#[test]
fn tool_mismatch_is_a_miss() {
let dir = tempfile::tempdir().unwrap();
let cache = IndexCache::open_at(dir.path());
let bytes = postcard::to_stdvec(&CacheEntryRef {
format: CACHE_FORMAT,
tool: "fatou@0.0.0-other",
index: &sample_index("Foo"),
})
.unwrap();
let path = cache.entry_path("Foo", "abc123");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, bytes).unwrap();
assert_eq!(cache.load("Foo", "abc123"), None);
}
#[test]
fn corrupt_entry_is_a_miss_not_a_panic() {
let dir = tempfile::tempdir().unwrap();
let cache = IndexCache::open_at(dir.path());
let path = cache.entry_path("Foo", "abc123");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"not postcard at all").unwrap();
assert_eq!(cache.load("Foo", "abc123"), None);
}
#[test]
fn concurrent_stores_leave_a_readable_entry() {
let dir = tempfile::tempdir().unwrap();
let cache = IndexCache::open_at(dir.path());
let index = sample_index("Foo");
std::thread::scope(|scope| {
for _ in 0..8 {
let cache = &cache;
let index = &index;
scope.spawn(move || cache.store("Foo", "abc123", index));
}
});
assert_eq!(cache.load("Foo", "abc123"), Some(index));
}
#[test]
fn cache_key_for_registered_uses_tree_sha1() {
let package = Package {
name: "Foo".to_string(),
uuid: nil_uuid(),
version: Some("1.2.3".to_string()),
tree_sha1: Some("deadbeef".to_string()),
deps: Vec::new(),
kind: PackageKind::Registered,
source: None,
};
assert_eq!(
CacheKey::for_package(&package),
Some("deadbeef".to_string())
);
}
#[test]
fn cache_key_for_dev_and_stdlib_is_none() {
let mut package = Package {
name: "Foo".to_string(),
uuid: nil_uuid(),
version: None,
tree_sha1: None,
deps: Vec::new(),
kind: PackageKind::Dev,
source: None,
};
assert_eq!(CacheKey::for_package(&package), None);
package.kind = PackageKind::Stdlib;
assert_eq!(CacheKey::for_package(&package), None);
}
#[test]
fn env_override_takes_precedence_over_os_cache() {
let resolved = cache_dir_from(
Some(PathBuf::from("/custom/cache")),
Some(PathBuf::from("/os/cache")),
);
assert_eq!(resolved, Some(PathBuf::from("/custom/cache/index")));
}
#[test]
fn os_cache_is_used_without_override() {
let resolved = cache_dir_from(None, Some(PathBuf::from("/os/cache")));
assert_eq!(resolved, Some(PathBuf::from("/os/cache/fatou/index")));
}
#[test]
fn no_location_disables_the_cache() {
assert_eq!(cache_dir_from(None, None), None);
}
#[test]
fn arc_wrapped_index_shares_the_stored_value() {
let dir = tempfile::tempdir().unwrap();
let cache = IndexCache::open_at(dir.path());
let index = Arc::new(sample_index("Foo"));
cache.store("Foo", "k", &index);
assert_eq!(cache.load("Foo", "k").map(Arc::new), Some(index));
}
}