wowlab_engine_adapter_data/remote/cache/
disk.rs1use 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 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}