Skip to main content

wowlab_engine_adapter_data/remote/cache/
disk.rs

1//! Persists patch-versioned cache entries as JSON.
2
3use serde::{Serialize, de::DeserializeOwned};
4use wowlab_fs::{
5    atomic, directory, file,
6    path::{Path, PathBuf},
7};
8use wowlab_types::data::ItemScalingData;
9
10use super::{
11    CacheError, CacheOperation, DISK_CATEGORIES, DISK_CATEGORY_SCALING, GameDataCache, entry_key,
12};
13
14fn read_json<T>(path: &Path, entry_key: Option<String>) -> Result<Option<T>, CacheError>
15where
16    T: DeserializeOwned,
17{
18    let bytes = match file::read_bytes(path) {
19        Ok(bytes) => bytes,
20        Err(source) if source.is_not_found() => return Ok(None),
21        Err(source) => {
22            let error = CacheError::filesystem(CacheOperation::ReadEntry, entry_key, source);
23
24            tracing::warn!(error = %error, "Failed to open cache file; treating as a miss");
25
26            return Err(error);
27        }
28    };
29
30    match serde_json::from_slice(&bytes) {
31        Ok(value) => Ok(Some(value)),
32        Err(source) => {
33            let error = CacheError::json(CacheOperation::ReadEntry, path, entry_key, source);
34
35            tracing::warn!(error = %error, "Removing corrupt cache file");
36
37            if let Err(source) = directory::remove_file_if_exists(path) {
38                let remove_error =
39                    CacheError::filesystem(CacheOperation::DeleteEntry, None, source);
40
41                tracing::warn!(
42                    error = %remove_error,
43                    "Failed to remove corrupt cache file"
44                );
45            }
46
47            Err(error)
48        }
49    }
50}
51
52fn write_json<T>(path: &Path, entry_key: Option<String>, value: &T) -> Result<(), CacheError>
53where
54    T: Serialize,
55{
56    let contents = serde_json::to_vec(value).map_err(|source| {
57        CacheError::json(CacheOperation::WriteEntry, path, entry_key.clone(), source)
58    })?;
59
60    if let Some(parent) = path.parent() {
61        directory::ensure(parent).map_err(|source| {
62            CacheError::filesystem(CacheOperation::CreateDirectory, entry_key.clone(), source)
63        })?;
64    }
65
66    atomic::replace(path, contents)
67        .map_err(|source| CacheError::filesystem(CacheOperation::CommitEntry, entry_key, source))
68}
69
70impl GameDataCache {
71    pub(super) fn read_disk<T>(&self, category: &str, key: i32) -> Result<Option<T>, CacheError>
72    where
73        T: DeserializeOwned,
74    {
75        read_json(
76            &self.disk_path(category, key),
77            Some(entry_key(category, key)),
78        )
79    }
80
81    pub(super) fn read_disk_or_miss<T>(&self, category: &str, key: i32) -> Option<T>
82    where
83        T: DeserializeOwned,
84    {
85        self.read_disk(category, key).unwrap_or_default()
86    }
87
88    pub(super) fn write_disk<T>(
89        &self,
90        category: &str,
91        key: i32,
92        value: &T,
93    ) -> Result<(), CacheError>
94    where
95        T: Serialize,
96    {
97        write_json(
98            &self.disk_path(category, key),
99            Some(entry_key(category, key)),
100            value,
101        )
102    }
103
104    pub(super) fn read_scaling_data_disk_or_miss(&self) -> Option<ItemScalingData> {
105        read_json(
106            &self.scaling_data_disk_path(),
107            Some("scaling_data:all".to_string()),
108        )
109        .unwrap_or_default()
110    }
111
112    pub(super) fn write_scaling_data_disk(
113        &self,
114        value: &ItemScalingData,
115    ) -> Result<(), CacheError> {
116        write_json(
117            &self.scaling_data_disk_path(),
118            Some("scaling_data:all".to_string()),
119            value,
120        )
121    }
122
123    #[cfg(test)]
124    pub(super) fn remove_disk(&self, category: &str, key: i32) -> Result<(), CacheError> {
125        let path = self.disk_path(category, key);
126
127        directory::remove_file_if_exists(&path).map_err(|source| {
128            CacheError::filesystem(
129                CacheOperation::DeleteEntry,
130                Some(entry_key(category, key)),
131                source,
132            )
133        })?;
134
135        Ok(())
136    }
137
138    pub(super) fn clear_disk(&self) -> Result<(), CacheError> {
139        for category in DISK_CATEGORIES {
140            let dir = self.cache_dir.join(category);
141
142            directory::remove_tree_if_exists(&dir).map_err(|source| {
143                CacheError::filesystem(CacheOperation::ClearCategory, None, source)
144            })?;
145        }
146
147        Ok(())
148    }
149
150    pub(super) fn scaling_data_disk_path(&self) -> PathBuf {
151        self.cache_dir.join(DISK_CATEGORY_SCALING).join("all.json")
152    }
153
154    #[cfg(test)]
155    pub(super) fn count_disk_entries(&self, category: &str) -> usize {
156        let dir = self.cache_dir.join(category);
157        let entries = directory::entries(&dir).unwrap_or_default();
158
159        entries
160            .iter()
161            .filter(|entry| {
162                entry
163                    .path()
164                    .extension()
165                    .is_some_and(|extension| extension == "json")
166            })
167            .count()
168    }
169
170    /// `Ok(true)` on patch match, `Ok(false)` when no version file exists; a read error returns `Err` so a transient failure never wipes the cache.
171    pub(super) fn patch_matches(&self) -> Result<bool, CacheError> {
172        let path = self.patch_version_path();
173
174        match file::read_text_if_exists(&path) {
175            Ok(Some(stored)) => Ok(stored.trim() == self.patch),
176            Ok(None) => Ok(false),
177            Err(source) => Err(CacheError::filesystem(
178                CacheOperation::ReadPatchVersion,
179                None,
180                source,
181            )),
182        }
183    }
184
185    pub(super) fn write_patch_version(&self) -> Result<(), CacheError> {
186        let path = self.patch_version_path();
187
188        atomic::replace(&path, &self.patch).map_err(|source| {
189            CacheError::filesystem(CacheOperation::WritePatchVersion, None, source)
190        })
191    }
192
193    fn disk_path(&self, category: &str, key: i32) -> PathBuf {
194        self.cache_dir.join(category).join(format!("{key}.json"))
195    }
196
197    fn patch_version_path(&self) -> PathBuf {
198        self.cache_dir.join("patch_version")
199    }
200}