1pub 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
22const UNPINNED_CAP_BYTES: u64 = 1_500_000_000;
24
25const 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 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 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 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 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 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 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 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 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 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 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 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 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
459pub 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
486static 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 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 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 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
560pub 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 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
622pub 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 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 assert!(error.contains("PB"), "wants the estimate: {error}");
688 assert!(error.contains(" free"), "wants what the disk has: {error}");
689 }
690
691 #[test]
692 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 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 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 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 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}