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        if let Some(parent) = fs_path.parent() {
95            std::fs::create_dir_all(parent)?;
96        }
97        let file = std::fs::File::create(&fs_path)?;
98        let mut writer = BufWriter::new(file);
99        let size_bytes = std::io::copy(&mut reader, &mut writer)?;
100
101        let now = SystemTime::now()
102            .duration_since(UNIX_EPOCH)
103            .unwrap_or_default()
104            .as_secs();
105
106        let created_at = self
107            .entries
108            .get(key)
109            .map_or(now, |existing| existing.created_at);
110
111        self.entries.insert(
112            key.to_owned(),
113            CacheEntry {
114                file_name,
115                created_at,
116                modified_at: now,
117                size_bytes,
118                max_age_secs: max_age.map(|d| d.as_secs()),
119            },
120        );
121
122        Ok(())
123    }
124
125    /// Remove a cache entry and delete its file from disk.
126    /// Returns `Ok(())` even if the entry did not exist.
127    pub fn remove(&mut self, config: &CacheConfig, key: &str) -> Result<(), CacheError> {
128        if let Some(entry) = self.entries.remove(key) {
129            let fs_path = config.file_path(&entry.file_name);
130            match std::fs::remove_file(&fs_path) {
131                Ok(()) => {}
132                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
133                Err(e) => return Err(CacheError::Io(e)),
134            }
135        }
136        Ok(())
137    }
138
139    /// Check whether a key is present in the manifest.
140    pub fn contains(&self, key: &str) -> bool {
141        self.entries.contains_key(key)
142    }
143
144    /// Get the entry for a key, if it exists.
145    pub fn get(&self, key: &str) -> Option<&CacheEntry> {
146        self.entries.get(key)
147    }
148
149    /// Return the Bevy asset path suitable for `AssetServer::load`.
150    /// Uses the `cache://` asset source scheme.
151    pub fn asset_path(&self, key: &str) -> Option<String> {
152        self.entries
153            .get(key)
154            .map(|entry| format!("cache://{}", entry.file_name))
155    }
156
157    /// Check whether a cached asset exists for `key` — both in the manifest
158    /// and on disk. Returns `false` if the manifest entry is stale (file
159    /// was deleted externally).
160    pub fn is_cached(&self, config: &CacheConfig, key: &str) -> bool {
161        match self.entries.get(key) {
162            Some(entry) => config.file_path(&entry.file_name).exists(),
163            None => false,
164        }
165    }
166
167    /// Returns the total size of all cached assets in bytes, as recorded in
168    /// the manifest. This is a sum of [`CacheEntry::size_bytes`] across all
169    /// entries
170    pub fn total_size_bytes(&self) -> u64 {
171        self.entries.values().map(|e| e.size_bytes).sum()
172    }
173
174    /// If a cached file exists for `key`, load it through the [`AssetServer`]
175    /// and return the typed handle. Returns [`CacheError::NotFound`] when
176    /// there is no manifest entry or the file is missing from disk.
177    ///
178    /// This lets callers skip regenerating a dynamic asset when a valid
179    /// cached copy is already available:
180    ///
181    /// ```rust,ignore
182    /// fn setup(
183    ///     manifest: Res<CacheManifest>,
184    ///     config: Res<CacheConfig>,
185    ///     asset_server: Res<AssetServer>,
186    /// ) {
187    ///     let handle: Handle<Image> = manifest
188    ///         .load_cached(&config, "scene_thumb", &asset_server)
189    ///         .unwrap_or_else(|_| generate_and_cache_thumbnail(/* … */));
190    /// }
191    /// ```
192    pub fn load_cached<A: Asset>(
193        &self,
194        config: &CacheConfig,
195        key: &str,
196        asset_server: &AssetServer,
197    ) -> Result<Handle<A>, CacheError> {
198        let entry = self.entries.get(key).ok_or_else(|| {
199            CacheError::NotFound(format!("no cache entry for key '{key}'"))
200        })?;
201        if !config.file_path(&entry.file_name).exists() {
202            return Err(CacheError::NotFound(format!(
203                "cache file '{}' missing from disk for key '{key}'",
204                entry.file_name
205            )));
206        }
207        let path = format!("cache://{}", entry.file_name);
208        Ok(asset_server.load(path))
209    }
210
211    // ------------------------------------------------------------------
212    // Cleanup helpers (used at exit)
213    // ------------------------------------------------------------------
214
215    /// Remove expired entries and delete their files. An entry is expired
216    /// when its age exceeds the *effective* max-age, which is the greater of
217    /// [`CacheConfig::max_age`] and the entry's own
218    /// [`CacheEntry::max_age_secs`] (if set).
219    /// Returns the number of entries removed.
220    pub fn remove_expired(&mut self, config: &CacheConfig) -> usize {
221        let now = SystemTime::now()
222            .duration_since(UNIX_EPOCH)
223            .unwrap_or_default()
224            .as_secs();
225
226        let global_max = config.max_age.as_secs();
227        let expired_keys: Vec<String> = self
228            .entries
229            .iter()
230            .filter(|(_, entry)| {
231                let effective_max = match entry.max_age_secs {
232                    Some(per_entry) => per_entry.max(global_max),
233                    None => global_max,
234                };
235                now.saturating_sub(entry.modified_at) > effective_max
236            })
237            .map(|(key, _)| key.clone())
238            .collect();
239
240        let count = expired_keys.len();
241        for key in &expired_keys {
242            if let Some(entry) = self.entries.remove(key) {
243                let fs_path = config.file_path(&entry.file_name);
244                let _ = std::fs::remove_file(&fs_path);
245            }
246        }
247        count
248    }
249
250    /// If the manifest exceeds `max_entries`, evict the oldest entries
251    /// (by `modified_at`) until the limit is satisfied. Returns the number
252    /// of entries removed.
253    pub fn enforce_max_entries(&mut self, config: &CacheConfig) -> usize {
254        let max = match config.max_entries {
255            Some(m) => m,
256            None => return 0,
257        };
258
259        if self.entries.len() <= max {
260            return 0;
261        }
262
263        let to_remove_count = self.entries.len() - max;
264
265        let mut by_age: Vec<(String, u64)> = self
266            .entries
267            .iter()
268            .map(|(k, e)| (k.clone(), e.modified_at))
269            .collect();
270        by_age.sort_by_key(|(_, ts)| *ts);
271
272        let mut removed = 0;
273        for (key, _) in by_age.into_iter().take(to_remove_count) {
274            if let Some(entry) = self.entries.remove(&key) {
275                let fs_path = config.file_path(&entry.file_name);
276                let _ = std::fs::remove_file(&fs_path);
277                removed += 1;
278            }
279        }
280        removed
281    }
282}