use crate::DATA_VERSION;
use crate::merge::patch::ClausewitzPatch;
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 MOD_DIFF_CACHE_HITS: AtomicUsize = AtomicUsize::new(0);
static MOD_DIFF_CACHE_MISSES: AtomicUsize = AtomicUsize::new(0);
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct ModDiffCacheStats {
pub hits: usize,
pub misses: usize,
}
#[derive(Clone, Debug)]
pub struct ModDiffCache {
root: PathBuf,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct StoredModDiff {
cache_version: String,
target_path: String,
mod_hash: String,
vanilla_hash: String,
foch_version: String,
game_version: String,
patches: Vec<ClausewitzPatch>,
}
impl ModDiffCache {
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 mod-diff cache generation"
);
}
Self { root }
}
pub fn open_default() -> Self {
Self::open(&default_mod_diff_cache_dir())
}
pub fn lookup(
&self,
target_path: &GamePath,
mod_hash: &str,
vanilla_hash: &str,
foch_version: &str,
game_version: &str,
) -> Option<Vec<ClausewitzPatch>> {
let hit = self.lookup_inner(
target_path,
mod_hash,
vanilla_hash,
foch_version,
game_version,
);
if hit.is_some() {
MOD_DIFF_CACHE_HITS.fetch_add(1, Ordering::Relaxed);
} else {
MOD_DIFF_CACHE_MISSES.fetch_add(1, Ordering::Relaxed);
}
hit
}
pub fn store(
&self,
target_path: &GamePath,
mod_hash: &str,
vanilla_hash: &str,
foch_version: &str,
game_version: &str,
patches: &[ClausewitzPatch],
) -> Result<(), CacheError> {
fs::create_dir_all(&self.root).map_err(CacheError::Io)?;
let payload = StoredModDiff {
cache_version: DATA_VERSION.to_string(),
target_path: target_path.as_str().to_string(),
mod_hash: mod_hash.to_string(),
vanilla_hash: vanilla_hash.to_string(),
foch_version: foch_version.to_string(),
game_version: game_version.to_string(),
patches: patches.to_vec(),
};
let encoded =
bincode::serialize(&payload).map_err(|err| CacheError::Encode(err.to_string()))?;
let path = self.cache_file(
target_path,
mod_hash,
vanilla_hash,
foch_version,
game_version,
);
write_atomically(&path, &encoded).map_err(CacheError::Io)?;
Ok(())
}
fn lookup_inner(
&self,
target_path: &GamePath,
mod_hash: &str,
vanilla_hash: &str,
foch_version: &str,
game_version: &str,
) -> Option<Vec<ClausewitzPatch>> {
let path = self.cache_file(
target_path,
mod_hash,
vanilla_hash,
foch_version,
game_version,
);
let raw = fs::read(path).ok()?;
let stored = bincode::deserialize::<StoredModDiff>(&raw).ok()?;
if stored.cache_version != DATA_VERSION
|| stored.target_path != target_path.as_str()
|| stored.mod_hash != mod_hash
|| stored.vanilla_hash != vanilla_hash
|| stored.foch_version != foch_version
|| stored.game_version != game_version
{
return None;
}
Some(stored.patches)
}
fn cache_file(
&self,
target_path: &GamePath,
mod_hash: &str,
vanilla_hash: &str,
foch_version: &str,
game_version: &str,
) -> PathBuf {
self.root.join(cache_filename(
DATA_VERSION,
target_path,
mod_hash,
vanilla_hash,
foch_version,
game_version,
))
}
}
fn default_mod_diff_cache_dir() -> PathBuf {
default_foch_cache_dir().join("diffs")
}
pub fn mod_diff_cache_stats() -> ModDiffCacheStats {
ModDiffCacheStats {
hits: MOD_DIFF_CACHE_HITS.load(Ordering::Relaxed),
misses: MOD_DIFF_CACHE_MISSES.load(Ordering::Relaxed),
}
}
pub fn reset_mod_diff_cache_stats() {
MOD_DIFF_CACHE_HITS.store(0, Ordering::Relaxed);
MOD_DIFF_CACHE_MISSES.store(0, Ordering::Relaxed);
}
fn cache_filename(
cache_version: &str,
target_path: &GamePath,
mod_hash: &str,
vanilla_hash: &str,
foch_version: &str,
game_version: &str,
) -> String {
let key = cache_key(
target_path,
mod_hash,
vanilla_hash,
foch_version,
game_version,
);
format!(
"{}__{}__cv{}__v{}__g{}__{}.bin",
compact_hash(mod_hash),
compact_hash(vanilla_hash),
cache_version,
sanitize_component(foch_version),
sanitize_component(game_version),
key,
)
}
fn cache_key(
target_path: &GamePath,
mod_hash: &str,
vanilla_hash: &str,
foch_version: &str,
game_version: &str,
) -> String {
let mut hasher = blake3::Hasher::new();
update_hash_part(&mut hasher, target_path.as_str().as_bytes());
update_hash_part(&mut hasher, mod_hash.as_bytes());
update_hash_part(&mut hasher, vanilla_hash.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 crate::merge::patch::ListItemTarget;
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 scalar(value: &str) -> AstValue {
AstValue::Scalar {
value: ScalarValue::Number(value.to_string()),
span: span(),
}
}
fn sample_patches() -> Vec<ClausewitzPatch> {
vec![ClausewitzPatch::SetValue {
path: vec!["root".to_string()],
key: "value".to_string(),
old_value: scalar("1"),
new_value: scalar("2"),
}]
}
#[test]
fn mod_diff_cache_lookup_miss_then_store_then_hit() {
let cache = ModDiffCache::open(&cache_dir("mod-diff-hit"));
let patches = sample_patches();
assert!(
cache
.lookup(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37"
)
.is_none()
);
cache
.store(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37",
&patches,
)
.expect("store patches");
assert_eq!(
cache
.lookup(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37"
)
.expect("cache hit"),
patches
);
}
#[test]
fn mod_diff_cache_round_trip_preserves_nonzero_list_occurrence() {
let cache = ModDiffCache::open(&cache_dir("mod-diff-occurrence"));
let patches = vec![ClausewitzPatch::AppendListItem {
path: vec!["root".to_string()],
key: "tag".to_string(),
value: scalar("2"),
target_occurrence: ListItemTarget::new(3, 2, 4),
}];
cache
.store(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37",
&patches,
)
.expect("store occurrence patch");
let restored = cache
.lookup(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37",
)
.expect("cache hit");
assert_eq!(restored, patches);
assert!(matches!(
restored.as_slice(),
[ClausewitzPatch::AppendListItem {
target_occurrence: ListItemTarget {
identity_ordinal: 3,
insertion: crate::merge::patch::ListItemInsertion {
source_slot: 2,
target_ordinal: 4,
},
},
..
}]
));
}
#[test]
fn mod_diff_cache_invalidates_when_vanilla_changes() {
let cache = ModDiffCache::open(&cache_dir("mod-diff-vanilla"));
cache
.store(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37",
&sample_patches(),
)
.expect("store patches");
assert!(
cache
.lookup(
game_path("common/foo.txt"),
"mod-a",
"vanilla-b",
"0.1.0",
"eu4 1.37"
)
.is_none()
);
}
#[test]
fn mod_diff_cache_invalidates_when_mod_changes() {
let cache = ModDiffCache::open(&cache_dir("mod-diff-mod"));
cache
.store(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37",
&sample_patches(),
)
.expect("store patches");
assert!(
cache
.lookup(
game_path("common/foo.txt"),
"mod-b",
"vanilla-a",
"0.1.0",
"eu4 1.37"
)
.is_none()
);
}
#[test]
fn mod_diff_cache_invalidates_when_game_version_changes() {
let cache = ModDiffCache::open(&cache_dir("mod-diff-game-version"));
cache
.store(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.36",
&sample_patches(),
)
.expect("store patches");
assert!(
cache
.lookup(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37",
)
.is_none()
);
}
#[test]
fn mod_diff_cache_filename_encodes_cache_version() {
let current = cache_filename(
DATA_VERSION,
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37",
);
let bumped = cache_filename(
"6.0.1",
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"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 mod_diff_cache_persists_across_processes() {
let dir = cache_dir("mod-diff-persist");
let patches = sample_patches();
ModDiffCache::open(&dir)
.store(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37",
&patches,
)
.expect("store patches");
let reopened = ModDiffCache::open(&dir);
assert_eq!(
reopened
.lookup(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37"
)
.expect("cache hit after reopen"),
patches
);
}
#[test]
fn mod_diff_cache_open_preserves_old_generations_flat_and_temporary_items() {
let input = cache_dir("mod-diff-generation-lifecycle");
let layer_root = input.join("diffs");
let cache = ModDiffCache::open(&layer_root);
let patches = sample_patches();
cache
.store(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37",
&patches,
)
.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("v5.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("dag-base").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 = ModDiffCache::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(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37"
)
.expect("current generation survives cleanup"),
patches
);
let reopened_again = ModDiffCache::open(&layer_root);
assert!(unrelated_entry.exists());
assert!(
reopened_again
.lookup(
game_path("common/foo.txt"),
"mod-a",
"vanilla-a",
"0.1.0",
"eu4 1.37"
)
.is_some()
);
}
}