Skip to main content

bevy_cache/
manifest.rs

1use bevy::prelude::*;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::io::{BufWriter, Read};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use crate::config::CacheConfig;
8use crate::error::CacheError;
9
10/// A single entry in the cache manifest.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct CacheEntry {
13    /// Filename on disk including extension (e.g. `"scene_01.png"`).
14    pub file_name: String,
15    /// Unix timestamp when the entry was first created.
16    pub created_at: u64,
17    /// Unix timestamp when the entry was last modified.
18    pub modified_at: u64,
19    /// Size of the cached data in bytes.
20    pub size_bytes: u64,
21    /// Per-entry maximum age in seconds. When set **and** greater than
22    /// [`CacheConfig::max_age`], this value is used instead of the global
23    /// default during expiry checks. An entry can extend its lifetime
24    /// beyond the global policy but never shorten it.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub max_age_secs: Option<u64>,
27}
28
29/// Manifest tracking all cached assets. Persisted as RON on disk.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Resource, Default)]
31pub struct CacheManifest {
32    pub entries: HashMap<String, CacheEntry>,
33}
34
35impl CacheManifest {
36    // ------------------------------------------------------------------
37    // Persistence
38    // ------------------------------------------------------------------
39
40    /// Load the manifest from the filesystem. Returns `Default` if the file
41    /// does not exist.
42    pub fn load_from_disk(config: &CacheConfig) -> Result<Self, CacheError> {
43        let path = config.manifest_fs_path();
44        match std::fs::read_to_string(&path) {
45            Ok(contents) => {
46                let manifest: CacheManifest = ron::from_str(&contents)?;
47                Ok(manifest)
48            }
49            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
50            Err(e) => Err(CacheError::Io(e)),
51        }
52    }
53
54    /// Persist the manifest to disk as pretty-printed RON.
55    pub fn save_to_disk(&self, config: &CacheConfig) -> Result<(), CacheError> {
56        config.ensure_cache_dir()?;
57        let path = config.manifest_fs_path();
58        let pretty = ron::ser::PrettyConfig::default();
59        let serialized =
60            ron::ser::to_string_pretty(self, pretty).map_err(CacheError::RonSerialize)?;
61        std::fs::write(path, serialized)?;
62        Ok(())
63    }
64
65    // ------------------------------------------------------------------
66    // Entry management
67    // ------------------------------------------------------------------
68
69    /// Store the contents of `reader` in the cache under `key` with the given
70    /// file `extension` (e.g. `"png"`). The file written to disk will be named
71    /// `"{key}.{extension}"`. Overwrites any existing entry for the same key.
72    ///
73    /// Accepting a [`Read`] instead of a byte slice means the data is streamed
74    /// directly to disk without requiring an intermediate in-memory buffer.
75    /// Use [`std::io::Cursor`] to wrap an existing slice or `Vec<u8>` when the
76    /// data is already in memory.
77    ///
78    /// `max_age` optionally sets a per-entry lifetime. It only takes effect
79    /// when it exceeds [`CacheConfig::max_age`]; an entry cannot shorten
80    /// its lifetime below the global policy.
81    pub fn store<R: Read>(
82        &mut self,
83        config: &CacheConfig,
84        key: &str,
85        extension: &str,
86        mut reader: R,
87        max_age: Option<Duration>,
88    ) -> Result<(), CacheError> {
89        config.validate_key(key)?;
90        config.ensure_cache_dir()?;
91
92        let file_name = format!("{key}.{extension}");
93        let fs_path = config.file_path(&file_name);
94        let file = std::fs::File::create(&fs_path)?;
95        let mut writer = BufWriter::new(file);
96        let size_bytes = std::io::copy(&mut reader, &mut writer)?;
97
98        let now = SystemTime::now()
99            .duration_since(UNIX_EPOCH)
100            .unwrap_or_default()
101            .as_secs();
102
103        let created_at = self
104            .entries
105            .get(key)
106            .map_or(now, |existing| existing.created_at);
107
108        self.entries.insert(
109            key.to_owned(),
110            CacheEntry {
111                file_name,
112                created_at,
113                modified_at: now,
114                size_bytes,
115                max_age_secs: max_age.map(|d| d.as_secs()),
116            },
117        );
118
119        Ok(())
120    }
121
122    /// Remove a cache entry and delete its file from disk.
123    /// Returns `Ok(())` even if the entry did not exist.
124    pub fn remove(&mut self, config: &CacheConfig, key: &str) -> Result<(), CacheError> {
125        if let Some(entry) = self.entries.remove(key) {
126            let fs_path = config.file_path(&entry.file_name);
127            match std::fs::remove_file(&fs_path) {
128                Ok(()) => {}
129                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
130                Err(e) => return Err(CacheError::Io(e)),
131            }
132        }
133        Ok(())
134    }
135
136    /// Check whether a key is present in the manifest.
137    pub fn contains(&self, key: &str) -> bool {
138        self.entries.contains_key(key)
139    }
140
141    /// Get the entry for a key, if it exists.
142    pub fn get(&self, key: &str) -> Option<&CacheEntry> {
143        self.entries.get(key)
144    }
145
146    /// Return the Bevy asset path suitable for `AssetServer::load`.
147    /// Uses the `cache://` asset source scheme.
148    pub fn asset_path(&self, key: &str) -> Option<String> {
149        self.entries
150            .get(key)
151            .map(|entry| format!("cache://{}", entry.file_name))
152    }
153
154    /// Check whether a cached asset exists for `key` — both in the manifest
155    /// and on disk. Returns `false` if the manifest entry is stale (file
156    /// was deleted externally).
157    pub fn is_cached(&self, config: &CacheConfig, key: &str) -> bool {
158        match self.entries.get(key) {
159            Some(entry) => config.file_path(&entry.file_name).exists(),
160            None => false,
161        }
162    }
163
164    /// Returns the total size of all cached assets in bytes, as recorded in
165    /// the manifest. This is a sum of [`CacheEntry::size_bytes`] across all
166    /// entries
167    pub fn total_size_bytes(&self) -> u64 {
168        self.entries.values().map(|e| e.size_bytes).sum()
169    }
170
171    /// If a cached file exists for `key`, load it through the [`AssetServer`]
172    /// and return the typed handle. Returns [`CacheError::NotFound`] when
173    /// there is no manifest entry or the file is missing from disk.
174    ///
175    /// This lets callers skip regenerating a dynamic asset when a valid
176    /// cached copy is already available:
177    ///
178    /// ```rust,ignore
179    /// fn setup(
180    ///     manifest: Res<CacheManifest>,
181    ///     config: Res<CacheConfig>,
182    ///     asset_server: Res<AssetServer>,
183    /// ) {
184    ///     let handle: Handle<Image> = manifest
185    ///         .load_cached(&config, "scene_thumb", &asset_server)
186    ///         .unwrap_or_else(|_| generate_and_cache_thumbnail(/* … */));
187    /// }
188    /// ```
189    pub fn load_cached<A: Asset>(
190        &self,
191        config: &CacheConfig,
192        key: &str,
193        asset_server: &AssetServer,
194    ) -> Result<Handle<A>, CacheError> {
195        let entry = self.entries.get(key).ok_or_else(|| {
196            CacheError::NotFound(format!("no cache entry for key '{key}'"))
197        })?;
198        if !config.file_path(&entry.file_name).exists() {
199            return Err(CacheError::NotFound(format!(
200                "cache file '{}' missing from disk for key '{key}'",
201                entry.file_name
202            )));
203        }
204        let path = format!("cache://{}", entry.file_name);
205        Ok(asset_server.load(path))
206    }
207
208    // ------------------------------------------------------------------
209    // Cleanup helpers (used at exit)
210    // ------------------------------------------------------------------
211
212    /// Remove expired entries and delete their files. An entry is expired
213    /// when its age exceeds the *effective* max-age, which is the greater of
214    /// [`CacheConfig::max_age`] and the entry's own
215    /// [`CacheEntry::max_age_secs`] (if set).
216    /// Returns the number of entries removed.
217    pub fn remove_expired(&mut self, config: &CacheConfig) -> usize {
218        let now = SystemTime::now()
219            .duration_since(UNIX_EPOCH)
220            .unwrap_or_default()
221            .as_secs();
222
223        let global_max = config.max_age.as_secs();
224        let expired_keys: Vec<String> = self
225            .entries
226            .iter()
227            .filter(|(_, entry)| {
228                let effective_max = match entry.max_age_secs {
229                    Some(per_entry) => per_entry.max(global_max),
230                    None => global_max,
231                };
232                now.saturating_sub(entry.modified_at) > effective_max
233            })
234            .map(|(key, _)| key.clone())
235            .collect();
236
237        let count = expired_keys.len();
238        for key in &expired_keys {
239            if let Some(entry) = self.entries.remove(key) {
240                let fs_path = config.file_path(&entry.file_name);
241                let _ = std::fs::remove_file(&fs_path);
242            }
243        }
244        count
245    }
246
247    /// If the manifest exceeds `max_entries`, evict the oldest entries
248    /// (by `modified_at`) until the limit is satisfied. Returns the number
249    /// of entries removed.
250    pub fn enforce_max_entries(&mut self, config: &CacheConfig) -> usize {
251        let max = match config.max_entries {
252            Some(m) => m,
253            None => return 0,
254        };
255
256        if self.entries.len() <= max {
257            return 0;
258        }
259
260        let to_remove_count = self.entries.len() - max;
261
262        let mut by_age: Vec<(String, u64)> = self
263            .entries
264            .iter()
265            .map(|(k, e)| (k.clone(), e.modified_at))
266            .collect();
267        by_age.sort_by_key(|(_, ts)| *ts);
268
269        let mut removed = 0;
270        for (key, _) in by_age.into_iter().take(to_remove_count) {
271            if let Some(entry) = self.entries.remove(&key) {
272                let fs_path = config.file_path(&entry.file_name);
273                let _ = std::fs::remove_file(&fs_path);
274                removed += 1;
275            }
276        }
277        removed
278    }
279}