Skip to main content

manabrew_art_cache/
lib.rs

1//! Card art on disk, keyed by the path below `cards.scryfall.io` so a request
2//! path maps straight to a file. That is what lets one machine serve its cache
3//! to another.
4
5pub mod server;
6
7pub use server::ArtServer;
8
9use std::collections::HashSet;
10use std::io::Read;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::Mutex;
14use std::time::SystemTime;
15
16use serde::Serialize;
17
18pub const CACHE_DIR: &str = "image-cache";
19const PINNED_FILE: &str = "pinned.json";
20const UPSTREAM_ORIGIN: &str = "https://cards.scryfall.io";
21
22/// Unpinned entries only, so browsing cannot evict a deliberate download.
23const UNPINNED_CAP_BYTES: u64 = 1_500_000_000;
24
25/// Written volume rather than a count: a sweep walks the tree.
26const SWEEP_EVERY_BYTES: u64 = 64 * 1024 * 1024;
27
28#[derive(Debug, Clone, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct CacheStats {
31    pub files: u64,
32    pub bytes: u64,
33    pub pinned_files: u64,
34    pub pinned_bytes: u64,
35}
36
37#[derive(Debug, Clone, Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct PreseedResult {
40    pub already_cached: u32,
41    pub fetched: u32,
42    pub failed: u32,
43}
44
45pub struct ImageCache {
46    root: PathBuf,
47    pinned: Mutex<HashSet<String>>,
48    written_since_sweep: AtomicU64,
49    /// So `stats` and `evict` do not each walk a tree heading for 100k files.
50    totals: Mutex<CacheStats>,
51    client: reqwest::Client,
52}
53
54impl ImageCache {
55    pub fn new(root: PathBuf) -> Self {
56        let pinned = load_pinned(&root.join(PINNED_FILE));
57        Self {
58            root,
59            pinned: Mutex::new(pinned),
60            written_since_sweep: AtomicU64::new(0),
61            totals: Mutex::new(CacheStats {
62                files: 0,
63                bytes: 0,
64                pinned_files: 0,
65                pinned_bytes: 0,
66            }),
67            client: reqwest::Client::builder()
68                .user_agent(concat!("manabrew-desktop/", env!("CARGO_PKG_VERSION")))
69                .build()
70                .unwrap_or_default(),
71        }
72    }
73
74    /// A network listener answers whatever it is asked for, so this is the only
75    /// thing between a request and the rest of the disk.
76    fn path_for(&self, key: &str) -> Option<PathBuf> {
77        if key.is_empty() || key.len() > 512 {
78            return None;
79        }
80        let mut path = self.root.clone();
81        for segment in key.split('/') {
82            if segment.is_empty() || segment == "." || segment == ".." {
83                return None;
84            }
85            // Every character NTFS refuses, not just the obvious two.
86            if segment.contains(['\\', ':', '?', '*', '<', '>', '|', '"']) {
87                return None;
88            }
89            path.push(segment);
90        }
91        Some(path)
92    }
93
94    pub fn read(&self, key: &str) -> Option<Vec<u8>> {
95        let path = self.path_for(key)?;
96        let mut file = std::fs::File::open(&path).ok()?;
97        let mut bytes = Vec::new();
98        file.read_to_end(&mut bytes).ok()?;
99        Some(bytes)
100    }
101
102    pub fn contains(&self, key: &str) -> bool {
103        self.path_for(key).map(|p| p.is_file()).unwrap_or(false)
104    }
105
106    fn store(&self, key: &str, bytes: &[u8], pin: bool) -> Result<(), String> {
107        let path = self
108            .path_for(key)
109            .ok_or_else(|| "bad cache key".to_string())?;
110        if let Some(parent) = path.parent() {
111            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
112        }
113        // Renamed into place, so a half-written file is never served.
114        let temp = path.with_extension("part");
115        let replaced = std::fs::metadata(&path).ok().map(|m| m.len());
116        let was_pinned = self.is_pinned(key);
117        std::fs::write(&temp, bytes).map_err(|e| e.to_string())?;
118        std::fs::rename(&temp, &path).map_err(|e| e.to_string())?;
119        // Content first: `note_stored` only touches the pinned half for a key
120        // already in it, so `pin` can claim a new one without double-counting.
121        self.note_stored(bytes.len() as u64, replaced, was_pinned);
122        if pin {
123            self.pin(key);
124        }
125        let written = self
126            .written_since_sweep
127            .fetch_add(bytes.len() as u64, Ordering::Relaxed)
128            + bytes.len() as u64;
129        if written >= SWEEP_EVERY_BYTES {
130            self.written_since_sweep.store(0, Ordering::Relaxed);
131            self.evict();
132        }
133        Ok(())
134    }
135
136    fn pin(&self, key: &str) {
137        let newly = match self.pinned.lock() {
138            Ok(mut pinned) => {
139                let newly = pinned.insert(key.to_string());
140                if newly {
141                    save_pinned(&self.root.join(PINNED_FILE), &pinned);
142                }
143                newly
144            }
145            Err(_) => return,
146        };
147        if !newly {
148            return;
149        }
150        // Totals count files, not keys, so a pin only moves them if one exists.
151        // `preseed`'s common path is this call alone.
152        let Some(size) = self
153            .path_for(key)
154            .and_then(|path| std::fs::metadata(path).ok())
155            .map(|meta| meta.len())
156        else {
157            return;
158        };
159        if let Ok(mut totals) = self.totals.lock() {
160            totals.pinned_files += 1;
161            totals.pinned_bytes += size;
162        }
163    }
164
165    fn is_pinned(&self, key: &str) -> bool {
166        self.pinned
167            .lock()
168            .map(|pinned| pinned.contains(key))
169            .unwrap_or(false)
170    }
171
172    pub async fn get_or_fetch(&self, key: &str) -> Option<Vec<u8>> {
173        if let Some(bytes) = self.read(key) {
174            return Some(bytes);
175        }
176        let bytes = self.fetch(key).await.ok()?;
177        let _ = self.store(key, &bytes, false);
178        Some(bytes)
179    }
180
181    async fn fetch(&self, key: &str) -> Result<Vec<u8>, String> {
182        // Before the request, so a bad key cannot become a fetch of something
183        // else.
184        self.path_for(key)
185            .ok_or_else(|| "bad cache key".to_string())?;
186        let url = format!("{UPSTREAM_ORIGIN}/{key}");
187        let response = self
188            .client
189            .get(&url)
190            .send()
191            .await
192            .map_err(|e| format!("{url}: {e}"))?;
193        if !response.status().is_success() {
194            return Err(format!("{url}: HTTP {}", response.status()));
195        }
196        response
197            .bytes()
198            .await
199            .map(|b| b.to_vec())
200            .map_err(|e| format!("{url}: {e}"))
201    }
202
203    pub async fn preseed(&self, keys: &[String]) -> PreseedResult {
204        let mut result = PreseedResult {
205            already_cached: 0,
206            fetched: 0,
207            failed: 0,
208        };
209        for key in keys {
210            if self.contains(key) {
211                self.pin(key);
212                result.already_cached += 1;
213                continue;
214            }
215            match self.fetch(key).await {
216                Ok(bytes) => match self.store(key, &bytes, true) {
217                    Ok(()) => result.fetched += 1,
218                    Err(_) => result.failed += 1,
219                },
220                Err(_) => result.failed += 1,
221            }
222        }
223        result
224    }
225
226    /// The estimate comes from the caller: the per-variant sizes are measured in
227    /// the UI and a second copy here could disagree.
228    pub fn check_room_for(&self, estimate_bytes: u64) -> Result<(), String> {
229        std::fs::create_dir_all(&self.root).map_err(|e| e.to_string())?;
230        let available = fs4::available_space(&self.root).map_err(|e| e.to_string())?;
231        if available >= estimate_bytes {
232            return Ok(());
233        }
234        Err(format!(
235            "not enough room: this needs about {} and the disk has {} free",
236            human_bytes(estimate_bytes),
237            human_bytes(available)
238        ))
239    }
240
241    pub fn stats(&self) -> CacheStats {
242        self.totals
243            .lock()
244            .map(|totals| totals.clone())
245            .unwrap_or(CacheStats {
246                files: 0,
247                bytes: 0,
248                pinned_files: 0,
249                pinned_bytes: 0,
250            })
251    }
252
253    /// The one full walk, at startup, and the only place `.part` files left by a
254    /// cancelled download are swept.
255    pub fn reconcile(&self) {
256        let mut totals = CacheStats {
257            files: 0,
258            bytes: 0,
259            pinned_files: 0,
260            pinned_bytes: 0,
261        };
262        for entry in self.walk_all() {
263            if entry.partial {
264                let _ = std::fs::remove_file(&entry.path);
265                continue;
266            }
267            totals.files += 1;
268            totals.bytes += entry.size;
269            if entry.pinned {
270                totals.pinned_files += 1;
271                totals.pinned_bytes += entry.size;
272            }
273        }
274        if let Ok(mut held) = self.totals.lock() {
275            *held = totals;
276        }
277    }
278
279    /// `was_pinned` is the state *before* the write, so this never double-counts
280    /// a key `pin` is about to claim.
281    fn note_stored(&self, size: u64, replaced: Option<u64>, was_pinned: bool) {
282        let Ok(mut totals) = self.totals.lock() else {
283            return;
284        };
285        match replaced {
286            Some(old) => totals.bytes = totals.bytes.saturating_sub(old) + size,
287            None => {
288                totals.files += 1;
289                totals.bytes += size;
290            }
291        }
292        if !was_pinned {
293            return;
294        }
295        match replaced {
296            Some(old) => totals.pinned_bytes = totals.pinned_bytes.saturating_sub(old) + size,
297            None => {
298                totals.pinned_files += 1;
299                totals.pinned_bytes += size;
300            }
301        }
302    }
303
304    fn note_removed(&self, size: u64, pinned: bool) {
305        let Ok(mut totals) = self.totals.lock() else {
306            return;
307        };
308        totals.files = totals.files.saturating_sub(1);
309        totals.bytes = totals.bytes.saturating_sub(size);
310        if pinned {
311            totals.pinned_files = totals.pinned_files.saturating_sub(1);
312            totals.pinned_bytes = totals.pinned_bytes.saturating_sub(size);
313        }
314    }
315
316    pub fn clear(&self, include_pinned: bool) -> Result<(), String> {
317        if include_pinned {
318            if self.root.exists() {
319                std::fs::remove_dir_all(&self.root).map_err(|e| e.to_string())?;
320            }
321            if let Ok(mut pinned) = self.pinned.lock() {
322                pinned.clear();
323                save_pinned(&self.root.join(PINNED_FILE), &pinned);
324            }
325            self.reconcile();
326            return Ok(());
327        }
328        for entry in self.walk() {
329            if !entry.pinned && std::fs::remove_file(&entry.path).is_ok() {
330                self.note_removed(entry.size, false);
331            }
332        }
333        Ok(())
334    }
335
336    /// Drops the mark without deleting: the art stays until it loses on age.
337    pub fn unpin_all(&self) {
338        if let Ok(mut pinned) = self.pinned.lock() {
339            pinned.clear();
340            save_pinned(&self.root.join(PINNED_FILE), &pinned);
341        }
342        if let Ok(mut totals) = self.totals.lock() {
343            totals.pinned_files = 0;
344            totals.pinned_bytes = 0;
345        }
346    }
347
348    fn evict(&self) {
349        let mut unpinned: Vec<Entry> = self.walk().into_iter().filter(|e| !e.pinned).collect();
350        let mut total: u64 = unpinned.iter().map(|e| e.size).sum();
351        if total <= UNPINNED_CAP_BYTES {
352            return;
353        }
354        unpinned.sort_by_key(|e| e.modified);
355        for entry in unpinned {
356            if total <= UNPINNED_CAP_BYTES {
357                break;
358            }
359            if std::fs::remove_file(&entry.path).is_ok() {
360                total = total.saturating_sub(entry.size);
361                self.note_removed(entry.size, false);
362            }
363        }
364    }
365
366    fn walk(&self) -> Vec<Entry> {
367        self.walk_all().into_iter().filter(|e| !e.partial).collect()
368    }
369
370    fn walk_all(&self) -> Vec<Entry> {
371        let mut out = Vec::new();
372        let mut stack = vec![self.root.clone()];
373        while let Some(dir) = stack.pop() {
374            let Ok(entries) = std::fs::read_dir(&dir) else {
375                continue;
376            };
377            for entry in entries.flatten() {
378                let path = entry.path();
379                let Ok(meta) = entry.metadata() else { continue };
380                if meta.is_dir() {
381                    stack.push(path);
382                    continue;
383                }
384                if path.file_name().is_some_and(|n| n == PINNED_FILE) {
385                    continue;
386                }
387                let partial = path.extension().is_some_and(|e| e == "part");
388                let Some(key) = self.key_of(&path) else {
389                    continue;
390                };
391                out.push(Entry {
392                    pinned: !partial && self.is_pinned(&key),
393                    partial,
394                    path,
395                    size: meta.len(),
396                    modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
397                });
398            }
399        }
400        out
401    }
402
403    fn key_of(&self, path: &Path) -> Option<String> {
404        let relative = path.strip_prefix(&self.root).ok()?;
405        let mut key = String::new();
406        for segment in relative.components() {
407            let segment = segment.as_os_str().to_str()?;
408            if !key.is_empty() {
409                key.push('/');
410            }
411            key.push_str(segment);
412        }
413        Some(key)
414    }
415}
416
417struct Entry {
418    path: PathBuf,
419    size: u64,
420    modified: SystemTime,
421    pinned: bool,
422    /// A `.part` left by a cancelled or crashed download.
423    partial: bool,
424}
425
426fn human_bytes(bytes: u64) -> String {
427    const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
428    let mut value = bytes as f64;
429    let mut unit = 0;
430    while value >= 1024.0 && unit < UNITS.len() - 1 {
431        value /= 1024.0;
432        unit += 1;
433    }
434    if unit == 0 {
435        format!("{bytes} B")
436    } else {
437        format!("{value:.1} {}", UNITS[unit])
438    }
439}
440
441fn load_pinned(path: &Path) -> HashSet<String> {
442    std::fs::read_to_string(path)
443        .ok()
444        .and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())
445        .map(HashSet::from_iter)
446        .unwrap_or_default()
447}
448
449fn save_pinned(path: &Path, pinned: &HashSet<String>) {
450    if let Some(parent) = path.parent() {
451        let _ = std::fs::create_dir_all(parent);
452    }
453    let list: Vec<&String> = pinned.iter().collect();
454    if let Ok(raw) = serde_json::to_string(&list) {
455        let _ = std::fs::write(path, raw);
456    }
457}
458
459/// The path a `/scryfall-img/` request is asking for. The query and fragment
460/// go the way `key_from_url` already drops them: on the LAN listener this is
461/// the only thing shaping network input before `path_for` sees it.
462pub fn key_from_request_path(path: &str) -> Option<&str> {
463    path.trim_start_matches('/')
464        .strip_prefix("scryfall-img/")
465        .map(|key| key.split(['?', '#']).next().unwrap_or(""))
466        .filter(|key| !key.is_empty())
467}
468
469pub fn mime_for(key: &str) -> &'static str {
470    let lower = key.to_ascii_lowercase();
471    if lower.ends_with(".png") {
472        "image/png"
473    } else if lower.ends_with(".webp") {
474        "image/webp"
475    } else if lower.ends_with(".gif") {
476        "image/gif"
477    } else if lower.ends_with(".svg") {
478        "image/svg+xml"
479    } else {
480        "image/jpeg"
481    }
482}
483
484const BULK_INDEX_URL: &str = "https://api.scryfall.com/bulk-data/oracle-cards";
485
486/// Downloading every card is a long job somebody can change their mind about.
487static CANCEL_BULK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
488
489#[derive(Debug, Clone, Serialize)]
490#[serde(rename_all = "camelCase")]
491pub struct BulkProgress {
492    pub done: u32,
493    pub total: u32,
494    pub bytes: u64,
495}
496
497impl ImageCache {
498    /// `api.scryfall.com` requires a `User-Agent` and an `Accept` on every
499    /// request and asks that the agent name a version. The image CDN is neither
500    /// rate limited nor covered by that, so only the two calls to the API go
501    /// through here.
502    fn api_request(&self, url: &str) -> reqwest::RequestBuilder {
503        self.client
504            .get(url)
505            .header(reqwest::header::ACCEPT, "application/json;q=0.9,*/*;q=0.8")
506    }
507
508    /// Every distinct card Scryfall knows, at the variants asked for. One
509    /// printing each (`oracle_cards`), which is the set a name-keyed engine
510    /// draws from.
511    async fn bulk_urls(&self, variants: &[String]) -> Result<Vec<String>, String> {
512        #[derive(serde::Deserialize)]
513        struct Index {
514            jsonl_download_uri: String,
515        }
516        let index: Index = self
517            .api_request(BULK_INDEX_URL)
518            .send()
519            .await
520            .map_err(|e| e.to_string())?
521            .json()
522            .await
523            .map_err(|e| e.to_string())?;
524
525        let body = self
526            .api_request(&index.jsonl_download_uri)
527            .send()
528            .await
529            .map_err(|e| e.to_string())?
530            .bytes()
531            .await
532            .map_err(|e| e.to_string())?;
533
534        let reader = std::io::BufReader::new(flate2::read::GzDecoder::new(&body[..]));
535        let mut urls = Vec::new();
536        for line in std::io::BufRead::lines(reader) {
537            let Ok(line) = line else { break };
538            let Ok(card) = serde_json::from_str::<serde_json::Value>(&line) else {
539                continue;
540            };
541            // A double-faced card carries its art per face rather than at the
542            // top level, and both faces get drawn.
543            let faces = card
544                .get("card_faces")
545                .and_then(|f| f.as_array())
546                .map(|faces| faces.iter().filter_map(|f| f.get("image_uris")).collect())
547                .unwrap_or_else(|| card.get("image_uris").into_iter().collect::<Vec<_>>());
548            for uris in faces {
549                for variant in variants {
550                    if let Some(url) = uris.get(variant.as_str()).and_then(|u| u.as_str()) {
551                        urls.push(url.to_string());
552                    }
553                }
554            }
555        }
556        Ok(urls)
557    }
558}
559
560/// The cache key inside a full CDN url.
561pub fn key_from_url(url: &str) -> Option<String> {
562    let rest = url.strip_prefix(UPSTREAM_ORIGIN)?.trim_start_matches('/');
563    let path = rest.split(['?', '#']).next().unwrap_or("");
564    (!path.is_empty()).then(|| path.to_string())
565}
566
567impl ImageCache {
568    /// Fetches every card's art at the given variants, pinning as it goes.
569    /// Resumable by design: what is already on disk is skipped, so a cancelled
570    /// run picks up where it stopped. `progress` is called roughly every 25
571    /// cards and at the end, so a caller can render it however it likes.
572    pub async fn download_all(
573        &self,
574        variants: &[String],
575        estimate_bytes: u64,
576        progress: impl Fn(BulkProgress),
577    ) -> Result<PreseedResult, String> {
578        self.check_room_for(estimate_bytes)?;
579        CANCEL_BULK.store(false, Ordering::Relaxed);
580
581        let urls = self.bulk_urls(variants).await?;
582        let keys: Vec<String> = urls.iter().filter_map(|url| key_from_url(url)).collect();
583        let total = keys.len() as u32;
584
585        let mut result = PreseedResult {
586            already_cached: 0,
587            fetched: 0,
588            failed: 0,
589        };
590        let mut bytes = 0u64;
591        for (index, key) in keys.iter().enumerate() {
592            if CANCEL_BULK.load(Ordering::Relaxed) {
593                break;
594            }
595            if self.contains(key) {
596                self.pin(key);
597                result.already_cached += 1;
598            } else {
599                match self.fetch(key).await {
600                    Ok(body) => {
601                        bytes += body.len() as u64;
602                        match self.store(key, &body, true) {
603                            Ok(()) => result.fetched += 1,
604                            Err(_) => result.failed += 1,
605                        }
606                    }
607                    Err(_) => result.failed += 1,
608                }
609            }
610            if index % 25 == 0 || index + 1 == keys.len() {
611                progress(BulkProgress {
612                    done: index as u32 + 1,
613                    total,
614                    bytes,
615                });
616            }
617        }
618        Ok(result)
619    }
620}
621
622/// Stops a running [`ImageCache::download_all`].
623pub fn cancel_download() {
624    CANCEL_BULK.store(true, Ordering::Relaxed);
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use std::sync::Arc;
631
632    fn cache() -> (ImageCache, tempfile::TempDir) {
633        let dir = tempfile::tempdir().unwrap();
634        (ImageCache::new(dir.path().to_path_buf()), dir)
635    }
636
637    #[test]
638    fn a_key_cannot_leave_the_cache() {
639        let (cache, _dir) = cache();
640        for key in [
641            "../secret",
642            "normal/../../secret",
643            "/etc/passwd",
644            "normal/./x.jpg",
645            "",
646        ] {
647            assert!(cache.path_for(key).is_none(), "{key} should be refused");
648        }
649        assert!(cache.path_for("normal/front/a/b/x.jpg").is_some());
650    }
651
652    #[test]
653    fn pinned_art_survives_eviction_and_unpinned_does_not() {
654        let (cache, _dir) = cache();
655        cache.store("normal/keep.jpg", b"pinned", true).unwrap();
656        cache.store("normal/drop.jpg", b"loose", false).unwrap();
657
658        cache.clear(false).unwrap();
659
660        assert!(cache.contains("normal/keep.jpg"));
661        assert!(!cache.contains("normal/drop.jpg"));
662    }
663
664    #[test]
665    fn a_pin_survives_a_reopen() {
666        let dir = tempfile::tempdir().unwrap();
667        {
668            let cache = ImageCache::new(dir.path().to_path_buf());
669            cache.store("normal/keep.jpg", b"pinned", true).unwrap();
670        }
671        let reopened = ImageCache::new(dir.path().to_path_buf());
672        assert!(reopened.is_pinned("normal/keep.jpg"));
673    }
674
675    #[test]
676    /// Nothing checked free space, so it filled a disk and failed per image.
677    fn a_download_bigger_than_the_disk_is_refused_before_it_starts() {
678        let (cache, _dir) = cache();
679        assert!(cache.check_room_for(1024).is_ok());
680
681        let error = cache.check_room_for(u64::MAX).expect_err("must refuse");
682        assert!(
683            error.contains("not enough room"),
684            "the message has to say it is a space problem: {error}"
685        );
686        // Both numbers, so the message says what to free up.
687        assert!(error.contains("PB"), "wants the estimate: {error}");
688        assert!(error.contains(" free"), "wants what the disk has: {error}");
689    }
690
691    #[test]
692    /// `walk` skips `.part`, so nothing counted, evicted or cleaned them.
693    fn a_startup_reconcile_sweeps_what_a_cancelled_download_left() {
694        let (cache, dir) = cache();
695        cache.store("a.jpg", b"1234", true).unwrap();
696        let orphan = dir.path().join("half.jpg.part");
697        std::fs::write(&orphan, b"partial").unwrap();
698
699        cache.reconcile();
700
701        assert!(!orphan.exists(), "a .part file must not outlive a restart");
702        let stats = cache.stats();
703        assert_eq!(stats.files, 1, "and must never be counted as cached art");
704        assert_eq!(stats.bytes, 4);
705    }
706
707    #[test]
708    fn the_totals_follow_what_eviction_and_clearing_actually_removed() {
709        let (cache, _dir) = cache();
710        cache.store("keep.jpg", b"1234", true).unwrap();
711        cache.store("drop.jpg", b"12", false).unwrap();
712        assert_eq!(cache.stats().files, 2);
713
714        cache.clear(false).unwrap();
715        let stats = cache.stats();
716        assert_eq!(stats.files, 1, "the unpinned one is gone");
717        assert_eq!(stats.bytes, 4);
718        assert_eq!(stats.pinned_files, 1);
719
720        cache.reconcile();
721        let walked = cache.stats();
722        assert_eq!(
723            (walked.files, walked.bytes, walked.pinned_bytes),
724            (stats.files, stats.bytes, stats.pinned_bytes),
725            "the running totals must agree with a fresh walk of the same tree"
726        );
727    }
728
729    #[tokio::test]
730    /// Scryfall requires both on every `api.scryfall.com` request.
731    async fn api_requests_identify_this_build_and_say_what_they_accept() {
732        let server = tiny_http::Server::http("127.0.0.1:0").expect("bind");
733        let port = server.server_addr().to_ip().unwrap().port();
734        let seen = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
735        let record = seen.clone();
736        std::thread::spawn(move || {
737            if let Ok(request) = server.recv() {
738                let headers = request
739                    .headers()
740                    .iter()
741                    .map(|h| (h.field.as_str().as_str().to_string(), h.value.to_string()))
742                    .collect::<Vec<_>>();
743                record.lock().unwrap().extend(headers);
744                let _ = request.respond(tiny_http::Response::empty(204));
745            }
746        });
747
748        let (cache, _dir) = cache();
749        let _ = cache
750            .api_request(&format!("http://127.0.0.1:{port}/bulk"))
751            .send()
752            .await;
753
754        let headers = seen.lock().unwrap().clone();
755        let value = |name: &str| {
756            headers
757                .iter()
758                .find(|(field, _)| field.eq_ignore_ascii_case(name))
759                .map(|(_, v)| v.clone())
760                .unwrap_or_default()
761        };
762        assert_eq!(value("Accept"), "application/json;q=0.9,*/*;q=0.8");
763        assert_eq!(
764            value("User-Agent"),
765            concat!("manabrew-desktop/", env!("CARGO_PKG_VERSION")),
766            "Scryfall asks the agent to name the application and its version"
767        );
768    }
769
770    #[test]
771    /// The rejection list read as complete and stopped at two characters.
772    fn a_key_cannot_carry_a_character_no_filesystem_will_take() {
773        let (cache, _dir) = cache();
774        for bad in ["a?.jpg", "a*.jpg", "a<.jpg", "a>.jpg", "a|.jpg", "a\".jpg"] {
775            assert!(cache.path_for(bad).is_none(), "{bad} should be refused");
776        }
777        assert!(cache.path_for("front/a1b2.jpg").is_some());
778    }
779
780    #[test]
781    /// The only thing shaping network input before `path_for`.
782    fn a_request_path_drops_its_query_and_fragment() {
783        assert_eq!(
784            key_from_request_path("/scryfall-img/front/a.jpg?v=2"),
785            Some("front/a.jpg")
786        );
787        assert_eq!(
788            key_from_request_path("/scryfall-img/front/a.jpg#x"),
789            Some("front/a.jpg")
790        );
791        assert_eq!(key_from_request_path("/scryfall-img/?v=2"), None);
792    }
793
794    #[tokio::test]
795    /// `preseed`'s common path is `contains` then `pin` and no store at all.
796    async fn pinning_art_that_was_already_cached_moves_it_into_the_pinned_half() {
797        let (cache, _dir) = cache();
798        cache.store("browsed.jpg", b"12345678", false).unwrap();
799        assert_eq!(cache.stats().pinned_files, 0);
800
801        cache.preseed(&["browsed.jpg".to_string()]).await;
802
803        let stats = cache.stats();
804        assert_eq!(stats.files, 1, "nothing was fetched, so nothing was added");
805        assert_eq!(stats.pinned_files, 1, "but it is a deliberate keep now");
806        assert_eq!(stats.pinned_bytes, 8);
807    }
808
809    #[test]
810    fn re_storing_a_pinned_key_adjusts_its_bytes_and_nothing_else() {
811        let (cache, _dir) = cache();
812        cache.store("a.jpg", b"1234", true).unwrap();
813        cache.store("a.jpg", b"123456", true).unwrap();
814
815        let stats = cache.stats();
816        assert_eq!((stats.files, stats.bytes), (1, 6));
817        assert_eq!((stats.pinned_files, stats.pinned_bytes), (1, 6));
818    }
819
820    #[test]
821    fn storing_over_an_unpinned_file_and_pinning_it_counts_once() {
822        let (cache, _dir) = cache();
823        cache.store("a.jpg", b"1234", false).unwrap();
824        cache.store("a.jpg", b"123456", true).unwrap();
825
826        let stats = cache.stats();
827        assert_eq!((stats.files, stats.bytes), (1, 6));
828        assert_eq!(
829            (stats.pinned_files, stats.pinned_bytes),
830            (1, 6),
831            "one file, its current size, counted once"
832        );
833
834        cache.reconcile();
835        let walked = cache.stats();
836        assert_eq!(
837            (walked.pinned_files, walked.pinned_bytes),
838            (stats.pinned_files, stats.pinned_bytes),
839            "the running totals must agree with a walk of the same tree"
840        );
841    }
842
843    #[test]
844    fn stats_separate_the_deliberate_half() {
845        let (cache, _dir) = cache();
846        cache.store("a.jpg", b"1234", true).unwrap();
847        cache.store("b.jpg", b"12", false).unwrap();
848
849        let stats = cache.stats();
850        assert_eq!(stats.files, 2);
851        assert_eq!(stats.bytes, 6);
852        assert_eq!(stats.pinned_files, 1);
853        assert_eq!(stats.pinned_bytes, 4);
854    }
855
856    #[test]
857    fn urls_map_to_keys() {
858        assert_eq!(
859            key_from_url("https://cards.scryfall.io/normal/front/a/b/x.jpg").as_deref(),
860            Some("normal/front/a/b/x.jpg")
861        );
862        assert_eq!(key_from_url("https://example.com/x.jpg"), None);
863        assert_eq!(key_from_url("https://cards.scryfall.io/"), None);
864    }
865
866    #[test]
867    fn request_paths_map_to_keys() {
868        assert_eq!(
869            key_from_request_path("/scryfall-img/normal/front/a/b/x.jpg"),
870            Some("normal/front/a/b/x.jpg")
871        );
872        assert_eq!(key_from_request_path("/index.html"), None);
873        assert_eq!(key_from_request_path("/scryfall-img/"), None);
874    }
875}