ajour_core/
cache.rs

1use crate::error::{CacheError, FilesystemError};
2use crate::fs::{config_dir, PersistentData};
3use crate::parse::Fingerprint;
4use crate::repository::RepositoryKind;
5use crate::{
6    addon::{Addon, AddonFolder},
7    catalog::{download_catalog, Catalog},
8};
9use crate::{config::Flavor, error::DownloadError};
10
11use async_std::fs::rename;
12use async_std::sync::{Arc, Mutex};
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16use std::collections::HashMap;
17use std::convert::TryFrom;
18use std::path::PathBuf;
19
20#[derive(Serialize, Deserialize, Default, Debug)]
21pub struct FingerprintCache(HashMap<Flavor, Vec<Fingerprint>>);
22
23impl FingerprintCache {
24    pub(crate) fn get_mut_for_flavor(&mut self, flavor: Flavor) -> &mut Vec<Fingerprint> {
25        self.0.entry(flavor).or_default()
26    }
27
28    pub(crate) fn flavor_exists(&self, flavor: Flavor) -> bool {
29        self.0.contains_key(&flavor)
30    }
31
32    pub(crate) fn delete_flavor(&mut self, flavor: Flavor) {
33        self.0.remove_entry(&flavor);
34    }
35}
36
37impl PersistentData for FingerprintCache {
38    fn relative_path() -> PathBuf {
39        PathBuf::from("cache/fingerprints.yml")
40    }
41}
42
43pub async fn load_fingerprint_cache() -> Result<FingerprintCache, CacheError> {
44    // Migrate from the old location to the new location, if exists
45    {
46        let old_location = config_dir().join("fingerprints.yml");
47
48        if old_location.exists() {
49            let new_location = FingerprintCache::path()?;
50
51            rename(old_location, new_location)
52                .await
53                .map_err(FilesystemError::Io)?;
54        }
55    }
56
57    Ok(FingerprintCache::load_or_default()?)
58}
59
60#[derive(Serialize, Deserialize, Debug)]
61pub enum AddonCache {
62    V1(HashMap<Flavor, Vec<AddonCacheEntry>>),
63}
64
65impl Default for AddonCache {
66    fn default() -> Self {
67        AddonCache::V1(Default::default())
68    }
69}
70
71impl AddonCache {
72    pub(crate) fn get_mut_for_flavor(&mut self, flavor: Flavor) -> &mut Vec<AddonCacheEntry> {
73        match self {
74            AddonCache::V1(cache) => cache.entry(flavor).or_default(),
75        }
76    }
77}
78
79impl PersistentData for AddonCache {
80    fn relative_path() -> PathBuf {
81        PathBuf::from("cache/addons.yml")
82    }
83}
84
85pub async fn load_addon_cache() -> Result<AddonCache, CacheError> {
86    Ok(AddonCache::load_or_default()?)
87}
88
89/// Update the cache with input entry. If an entry already exists in the cache,
90/// with the same folder names as the input entry, that entry will be deleted
91/// before inserting the input entry.
92pub async fn update_addon_cache(
93    addon_cache: Arc<Mutex<AddonCache>>,
94    entry: AddonCacheEntry,
95    flavor: Flavor,
96) -> Result<AddonCacheEntry, CacheError> {
97    // Lock mutex to get mutable access and block other tasks from trying to update
98    let mut addon_cache = addon_cache.lock().await;
99
100    // Get entries for flavor
101    let entries = addon_cache.get_mut_for_flavor(flavor);
102
103    // Remove old entry, if it exists. Will remove entry if either folder names or title match
104    entries.retain(|e| !(e.folder_names == entry.folder_names || e.title == entry.title));
105
106    // Add new entry
107    entries.push(entry.clone());
108
109    // Persist changes to filesystem
110    addon_cache.save()?;
111
112    Ok(entry)
113}
114
115/// Remove the cache entry that has the same folder names
116/// as the input entry. Will return the removed entry, if applicable.
117pub async fn remove_addon_cache_entry(
118    addon_cache: Arc<Mutex<AddonCache>>,
119    entry: AddonCacheEntry,
120    flavor: Flavor,
121) -> Result<Option<AddonCacheEntry>, CacheError> {
122    // Lock mutex to get mutable access and block other tasks from trying to update
123    let mut addon_cache = addon_cache.lock().await;
124
125    // Get entries for flavor
126    let entries = addon_cache.get_mut_for_flavor(flavor);
127
128    // Remove old entry, if it exists. Will remove entry if either folder names or title match
129    if let Some(idx) = entries
130        .iter()
131        .position(|e| e.folder_names == entry.folder_names || e.title == entry.title)
132    {
133        let entry = entries.remove(idx);
134
135        // Persist changes to filesystem
136        addon_cache.save()?;
137
138        Ok(Some(entry))
139    } else {
140        Ok(None)
141    }
142}
143
144/// Removes addon cache entires that have folder
145/// names that are missing in the input `folders`
146///
147/// Pass `false` to save_cache for testing purposes
148pub async fn remove_addon_entries_with_missing_folders(
149    addon_cache: Arc<Mutex<AddonCache>>,
150    flavor: Flavor,
151    folders: &[AddonFolder],
152    save_cache: bool,
153) -> Result<usize, CacheError> {
154    // Name of all folders to check against
155    let folder_names = folders.iter().map(|f| f.id.clone()).collect::<Vec<_>>();
156
157    // Lock mutex to get mutable access and block other tasks from trying to update
158    let mut addon_cache = addon_cache.lock().await;
159
160    // Get entries for flavor
161    let entries = addon_cache.get_mut_for_flavor(flavor);
162
163    // Get the idx of any entry that has a folder name that's missing
164    // from our input folders
165    let entries_to_delete = entries
166        .iter()
167        .cloned()
168        .enumerate()
169        .filter(|(_, entry)| !entry.folder_names.iter().all(|f| folder_names.contains(&f)))
170        .map(|(idx, _)| idx)
171        .collect::<Vec<_>>();
172
173    if !entries_to_delete.is_empty() {
174        // Remove each entry, accounting for offset since items shift left on
175        // each remove
176        for (offset, idx) in entries_to_delete.iter().enumerate() {
177            entries.remove(*idx - offset);
178        }
179
180        // Persist changes to filesystem
181        if save_cache {
182            addon_cache.save()?;
183        }
184    }
185
186    // Return number of entries deleted
187    Ok(entries_to_delete.len())
188}
189
190#[derive(Serialize, Deserialize, Clone, Debug)]
191pub struct AddonCacheEntry {
192    pub title: String,
193    pub repository: RepositoryKind,
194    pub repository_id: String,
195    pub primary_folder_id: String,
196    pub folder_names: Vec<String>,
197    pub modified: DateTime<Utc>,
198    pub external_release_id: Option<ExternalReleaseId>,
199}
200
201impl TryFrom<&Addon> for AddonCacheEntry {
202    type Error = CacheError;
203
204    fn try_from(addon: &Addon) -> Result<Self, CacheError> {
205        if let (Some(repository), Some(repository_id)) =
206            (addon.repository_kind(), addon.repository_id())
207        {
208            let mut folder_names: Vec<_> = addon.folders.iter().map(|a| a.id.clone()).collect();
209            folder_names.sort();
210
211            let external_release_id = if repository == RepositoryKind::TownlongYak {
212                addon.file_id().map(ExternalReleaseId::FileId)
213            } else {
214                addon
215                    .version()
216                    .map(str::to_string)
217                    .map(ExternalReleaseId::Version)
218            };
219
220            Ok(AddonCacheEntry {
221                title: addon.title().to_owned(),
222                repository,
223                repository_id: repository_id.to_owned(),
224                primary_folder_id: addon.primary_folder_id.clone(),
225                folder_names,
226                modified: Utc::now(),
227                external_release_id,
228            })
229        } else {
230            Err(CacheError::AddonMissingRepo {
231                title: addon.title().to_owned(),
232            })
233        }
234    }
235}
236
237#[derive(Serialize, Deserialize, Clone, Debug)]
238pub enum ExternalReleaseId {
239    FileId(i64),
240    Version(String),
241}
242
243#[derive(Serialize, Deserialize, Debug)]
244pub struct CatalogCache {
245    etag: String,
246    catalog: Catalog,
247}
248
249impl PersistentData for CatalogCache {
250    fn relative_path() -> PathBuf {
251        PathBuf::from("cache/catalog.yml")
252    }
253}
254
255pub async fn catalog_download_latest_or_use_cache() -> Result<Catalog, DownloadError> {
256    let maybe_cached_catalog = CatalogCache::load();
257
258    // If no cache file exists yet, this will be None and download_catalog will
259    // always download the latest catalog
260    let cached_etag = maybe_cached_catalog.as_ref().map(|c| c.etag.clone()).ok();
261
262    if let Some((downloaded_etag, downloaded_catalog)) = download_catalog(cached_etag).await? {
263        // Etag didn't match latest catalog, so we downloaded new one. Let's update
264        // our cache with it
265        if let Some(etag) = downloaded_etag {
266            // Save it as cache
267            let new_cache = CatalogCache {
268                catalog: downloaded_catalog.clone(),
269                etag,
270            };
271            new_cache.save()?;
272        }
273
274        Ok(downloaded_catalog)
275    } else {
276        // If download_catalog returns None, we have the latest cache file, so use it
277        let cache = maybe_cached_catalog?;
278
279        Ok(cache.catalog)
280    }
281}
282
283#[cfg(test)]
284mod test {
285    use super::*;
286    use crate::repository::RepositoryIdentifiers;
287
288    use async_std::task;
289
290    #[test]
291    fn test_remove_entries_with_missing_folders() {
292        task::block_on(async {
293            let flavor = Flavor::Retail;
294
295            let addon_folders = (0..30)
296                .map(|idx| AddonFolder {
297                    id: format!("folder_{}", idx + 1),
298                    title: format!("folder_{}", idx + 1),
299                    interface: Default::default(),
300                    path: Default::default(),
301                    author: Default::default(),
302                    notes: Default::default(),
303                    version: Default::default(),
304                    repository_identifiers: RepositoryIdentifiers {
305                        curse: Some(idx as i32),
306                        ..Default::default()
307                    },
308                    dependencies: Default::default(),
309                    fingerprint: Default::default(),
310                })
311                .collect::<Vec<_>>();
312
313            let cache = {
314                let cache: Arc<Mutex<AddonCache>> = Default::default();
315                let mut cache_lock = cache.lock_arc().await;
316
317                let entries = cache_lock.get_mut_for_flavor(flavor);
318
319                entries.extend(addon_folders.chunks(10).enumerate().map(|(idx, folders)| {
320                    AddonCacheEntry {
321                        title: format!("Test{}", idx + 1),
322                        repository: RepositoryKind::Tukui,
323                        repository_id: format!("{}", idx + 1),
324                        primary_folder_id: folders.first().map(|f| f.id.clone()).unwrap(),
325                        folder_names: folders.iter().map(|f| f.id.clone()).collect(),
326                        modified: Utc::now(),
327                        external_release_id: None,
328                    }
329                }));
330
331                cache
332            };
333
334            // Remove partial 1 folder from the first 10, then all folders of
335            // the last 10. Only the middle 10 folders are fully in tact,
336            // meaning on the 2nd entry should remain after this operation
337            let num_deleted = remove_addon_entries_with_missing_folders(
338                cache.clone(),
339                flavor,
340                &addon_folders[5..20],
341                false,
342            )
343            .await
344            .unwrap();
345
346            assert_eq!(num_deleted, 2);
347
348            let mut cache_lock = cache.lock().await;
349
350            let entries = cache_lock.get_mut_for_flavor(flavor);
351
352            let names = entries.iter().map(|e| e.title.clone()).collect::<Vec<_>>();
353
354            assert_eq!(names, vec!["Test2".to_string()]);
355        });
356    }
357}