use crate::DATA_VERSION;
use crate::game::eu4::script::parser::AstStatement;
use crate::model::GamePath;
use crate::platform::cache_store::CacheError;
use crate::platform::cache_store::generation::{ensure as ensure_generation, generation_dir};
use crate::platform::cache_store::{default_foch_cache_dir, write_atomically};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
const HASH_HEX_LEN: usize = 16;
const COMPACT_HASH_LEN: usize = 12;
static DAG_BASE_CACHE_HITS: AtomicUsize = AtomicUsize::new(0);
static DAG_BASE_CACHE_MISSES: AtomicUsize = AtomicUsize::new(0);
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct DagBaseCacheStats {
pub hits: usize,
pub misses: usize,
}
#[derive(Clone, Debug)]
pub struct DagBaseCache {
root: PathBuf,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct StoredDagBase {
cache_version: String,
deps_hash: String,
file_path: String,
foch_version: String,
game_version: String,
statements: Vec<AstStatement>,
}
impl DagBaseCache {
pub fn open(cache_dir: &Path) -> Self {
let root = generation_dir(cache_dir, DATA_VERSION);
if let Err(error) = ensure_generation(cache_dir, DATA_VERSION) {
tracing::warn!(
cache_dir = %cache_dir.display(),
cache_version = DATA_VERSION,
%error,
"failed to create current DAG-base cache generation"
);
}
Self { root }
}
pub fn open_default() -> Self {
Self::open(&default_dag_base_cache_dir())
}
pub fn lookup(
&self,
deps_hash: &str,
file_path: &GamePath,
foch_version: &str,
game_version: &str,
) -> Option<Vec<AstStatement>> {
let hit = self.lookup_inner(deps_hash, file_path, foch_version, game_version);
if hit.is_some() {
DAG_BASE_CACHE_HITS.fetch_add(1, Ordering::Relaxed);
} else {
DAG_BASE_CACHE_MISSES.fetch_add(1, Ordering::Relaxed);
}
hit
}
pub fn store(
&self,
deps_hash: &str,
file_path: &GamePath,
foch_version: &str,
game_version: &str,
statements: &[AstStatement],
) -> Result<(), CacheError> {
fs::create_dir_all(&self.root).map_err(CacheError::Io)?;
let payload = StoredDagBase {
cache_version: DATA_VERSION.to_string(),
deps_hash: deps_hash.to_string(),
file_path: file_path.as_str().to_string(),
foch_version: foch_version.to_string(),
game_version: game_version.to_string(),
statements: statements.to_vec(),
};
let encoded =
bincode::serialize(&payload).map_err(|err| CacheError::Encode(err.to_string()))?;
let path = self.cache_file(deps_hash, file_path, foch_version, game_version);
write_atomically(&path, &encoded).map_err(CacheError::Io)?;
Ok(())
}
fn lookup_inner(
&self,
deps_hash: &str,
file_path: &GamePath,
foch_version: &str,
game_version: &str,
) -> Option<Vec<AstStatement>> {
let path = self.cache_file(deps_hash, file_path, foch_version, game_version);
let raw = fs::read(path).ok()?;
let stored = bincode::deserialize::<StoredDagBase>(&raw).ok()?;
if stored.cache_version != DATA_VERSION
|| stored.deps_hash != deps_hash
|| stored.file_path != file_path.as_str()
|| stored.foch_version != foch_version
|| stored.game_version != game_version
{
return None;
}
Some(stored.statements)
}
fn cache_file(
&self,
deps_hash: &str,
file_path: &GamePath,
foch_version: &str,
game_version: &str,
) -> PathBuf {
self.root.join(cache_filename(
DATA_VERSION,
deps_hash,
file_path,
foch_version,
game_version,
))
}
}
fn default_dag_base_cache_dir() -> PathBuf {
default_foch_cache_dir().join("dag-base")
}
pub fn dag_base_cache_stats() -> DagBaseCacheStats {
DagBaseCacheStats {
hits: DAG_BASE_CACHE_HITS.load(Ordering::Relaxed),
misses: DAG_BASE_CACHE_MISSES.load(Ordering::Relaxed),
}
}
pub fn reset_dag_base_cache_stats() {
DAG_BASE_CACHE_HITS.store(0, Ordering::Relaxed);
DAG_BASE_CACHE_MISSES.store(0, Ordering::Relaxed);
}
fn cache_filename(
cache_version: &str,
deps_hash: &str,
file_path: &GamePath,
foch_version: &str,
game_version: &str,
) -> String {
let key = cache_key(deps_hash, file_path, foch_version, game_version);
format!(
"{}__cv{}__v{}__g{}__{}.bin",
compact_hash(deps_hash),
cache_version,
sanitize_component(foch_version),
sanitize_component(game_version),
key,
)
}
fn cache_key(
deps_hash: &str,
file_path: &GamePath,
foch_version: &str,
game_version: &str,
) -> String {
let mut hasher = blake3::Hasher::new();
update_hash_part(&mut hasher, deps_hash.as_bytes());
update_hash_part(&mut hasher, file_path.as_str().as_bytes());
update_hash_part(&mut hasher, foch_version.as_bytes());
update_hash_part(&mut hasher, game_version.as_bytes());
hasher.finalize().to_hex()[..HASH_HEX_LEN].to_string()
}
fn update_hash_part(hasher: &mut blake3::Hasher, bytes: &[u8]) {
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
}
fn compact_hash(value: &str) -> String {
let compact: String = value.chars().take(COMPACT_HASH_LEN).collect();
sanitize_component(if compact.is_empty() { value } else { &compact })
}
fn sanitize_component(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() {
"unknown".to_string()
} else {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::eu4::script::parser::{AstValue, ScalarValue, Span, SpanRange};
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0);
fn game_path(text: &str) -> &GamePath {
GamePath::new(text).expect("valid game path")
}
fn cache_dir(name: &str) -> PathBuf {
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"));
let path = repo_root.join("target").join("test-cache").join(format!(
"{name}-{}-{}",
std::process::id(),
TEST_COUNTER.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("create cache dir");
path
}
fn span() -> SpanRange {
SpanRange {
start: Span {
line: 1,
column: 1,
offset: 0,
},
end: Span {
line: 1,
column: 2,
offset: 1,
},
}
}
fn statement(key: &str, value: &str) -> AstStatement {
AstStatement::Assignment {
key: key.to_string(),
key_span: span(),
value: AstValue::Scalar {
value: ScalarValue::Number(value.to_string()),
span: span(),
},
span: span(),
}
}
fn sample_statements() -> Vec<AstStatement> {
vec![statement("foo", "1"), statement("bar", "2")]
}
#[test]
fn dag_base_cache_lookup_with_same_deps_hash_hits() {
let cache = DagBaseCache::open(&cache_dir("dag-base-hit"));
let statements = sample_statements();
assert!(
cache
.lookup("deps-a", game_path("common/foo.txt"), "0.1.0", "eu4 1.37")
.is_none()
);
cache
.store(
"deps-a",
game_path("common/foo.txt"),
"0.1.0",
"eu4 1.37",
&statements,
)
.expect("store base");
assert_eq!(
cache
.lookup("deps-a", game_path("common/foo.txt"), "0.1.0", "eu4 1.37")
.expect("cache hit"),
statements
);
}
#[test]
fn dag_base_cache_invalidates_when_dep_set_changes() {
let cache = DagBaseCache::open(&cache_dir("dag-base-deps"));
cache
.store(
"deps-a-b",
game_path("common/foo.txt"),
"0.1.0",
"eu4 1.37",
&sample_statements(),
)
.expect("store base");
assert!(
cache
.lookup("deps-a-c", game_path("common/foo.txt"), "0.1.0", "eu4 1.37")
.is_none()
);
}
#[test]
fn dag_base_cache_invalidates_when_dep_content_changes() {
let cache = DagBaseCache::open(&cache_dir("dag-base-content"));
cache
.store(
"hash-before",
game_path("common/foo.txt"),
"0.1.0",
"eu4 1.37",
&sample_statements(),
)
.expect("store base");
assert!(
cache
.lookup(
"hash-after",
game_path("common/foo.txt"),
"0.1.0",
"eu4 1.37"
)
.is_none()
);
}
#[test]
fn dag_base_cache_invalidates_when_game_version_changes() {
let cache = DagBaseCache::open(&cache_dir("dag-base-game-version"));
cache
.store(
"deps-a",
game_path("common/foo.txt"),
"0.1.0",
"eu4 1.36",
&sample_statements(),
)
.expect("store base");
assert!(
cache
.lookup("deps-a", game_path("common/foo.txt"), "0.1.0", "eu4 1.37")
.is_none()
);
}
#[test]
fn dag_base_cache_filename_encodes_cache_version() {
let current = cache_filename(
DATA_VERSION,
"deps-a",
game_path("common/foo.txt"),
"0.1.0",
"eu4 1.37",
);
let bumped = cache_filename(
"12.0.1",
"deps-a",
game_path("common/foo.txt"),
"0.1.0",
"eu4 1.37",
);
assert_ne!(current, bumped);
assert!(current.contains(&format!("__cv{}__", DATA_VERSION)));
assert!(current.contains("__v0.1.0__"));
assert!(current.contains("__geu4_1.37__"));
}
#[test]
fn dag_base_cache_independent_per_file_path() {
let cache = DagBaseCache::open(&cache_dir("dag-base-path"));
cache
.store(
"deps-a",
game_path("common/foo.txt"),
"0.1.0",
"eu4 1.37",
&sample_statements(),
)
.expect("store base");
assert!(
cache
.lookup("deps-a", game_path("common/bar.txt"), "0.1.0", "eu4 1.37")
.is_none()
);
}
#[test]
fn dag_base_cache_open_preserves_old_generations_flat_and_temporary_items() {
let input = cache_dir("dag-base-generation-lifecycle");
let layer_root = input.join("dag-base");
let cache = DagBaseCache::open(&layer_root);
let statements = sample_statements();
cache
.store(
"deps-a",
game_path("common/foo.txt"),
"0.1.0",
"eu4 1.37",
&statements,
)
.expect("store current generation entry");
let current_generation = layer_root.join(format!("v{DATA_VERSION}"));
assert_eq!(cache.root, current_generation);
let old_generation = layer_root.join("v11.0.0");
fs::create_dir_all(&old_generation).expect("create old generation");
fs::write(old_generation.join("obsolete.bin"), b"obsolete").expect("seed old generation");
let legacy_entry = layer_root.join("legacy.bin");
fs::write(&legacy_entry, b"legacy").expect("seed legacy flat entry");
let temporary_entry = layer_root.join("orphan.bin.tmp");
fs::write(&temporary_entry, b"temporary").expect("seed temporary entry");
let unrelated_entry = input.join("diffs").join("v1").join("keep.bin");
fs::create_dir_all(unrelated_entry.parent().expect("unrelated parent"))
.expect("create unrelated layer");
fs::write(&unrelated_entry, b"keep").expect("seed unrelated layer");
let reopened = DagBaseCache::open(&layer_root);
assert!(old_generation.is_dir());
assert!(legacy_entry.is_file());
assert!(temporary_entry.is_file());
assert!(unrelated_entry.exists());
assert_eq!(
reopened
.lookup("deps-a", game_path("common/foo.txt"), "0.1.0", "eu4 1.37")
.expect("current generation survives cleanup"),
statements
);
let reopened_again = DagBaseCache::open(&layer_root);
assert!(unrelated_entry.exists());
assert!(
reopened_again
.lookup("deps-a", game_path("common/foo.txt"), "0.1.0", "eu4 1.37")
.is_some()
);
}
}