Skip to main content

mtgjson_sdk/
cache.rs

1//! Version-aware CDN download and local file cache manager.
2//!
3//! Downloads and caches MTGJSON data files from the CDN. Checks Meta.json for
4//! version changes and re-downloads when stale. Individual files are downloaded
5//! lazily on first access.
6
7use crate::config;
8use crate::error::{MtgjsonError, Result};
9use crate::ProgressCallback;
10use flate2::read::GzDecoder;
11use reqwest::blocking::Client;
12use std::fs;
13use std::io::{BufReader, Read, Write};
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17/// Downloads and caches MTGJSON data files from the CDN.
18///
19/// Checks Meta.json for version changes and re-downloads when stale.
20/// Individual files are downloaded lazily on first access.
21pub struct CacheManager {
22    /// Directory where cached files are stored.
23    pub cache_dir: PathBuf,
24    /// If true, never download from CDN (use cached files only).
25    pub offline: bool,
26    timeout: Duration,
27    client: Option<Client>,
28    remote_ver: Option<String>,
29    on_progress: Option<ProgressCallback>,
30}
31
32impl CacheManager {
33    /// Create a new cache manager.
34    ///
35    /// If `cache_dir` is `None`, uses the platform-appropriate default cache directory.
36    /// Creates the cache directory if it does not exist.
37    pub fn new(
38        cache_dir: Option<PathBuf>,
39        offline: bool,
40        timeout: Duration,
41        on_progress: Option<ProgressCallback>,
42    ) -> Result<Self> {
43        let dir = cache_dir.unwrap_or_else(config::default_cache_dir);
44        fs::create_dir_all(&dir)?;
45        Ok(Self {
46            cache_dir: dir,
47            offline,
48            timeout,
49            client: None,
50            remote_ver: None,
51            on_progress,
52        })
53    }
54
55    /// Lazy HTTP client, created on first use.
56    pub fn client(&mut self) -> &Client {
57        if self.client.is_none() {
58            self.client = Some(
59                Client::builder()
60                    .timeout(self.timeout)
61                    .redirect(reqwest::redirect::Policy::limited(10))
62                    .build()
63                    .expect("failed to build HTTP client"),
64            );
65        }
66        self.client.as_ref().unwrap()
67    }
68
69    /// Read the locally cached version string from `version.txt`.
70    fn local_version(&self) -> Option<String> {
71        let version_file = self.cache_dir.join("version.txt");
72        if version_file.exists() {
73            fs::read_to_string(&version_file)
74                .ok()
75                .map(|s| s.trim().to_string())
76        } else {
77            None
78        }
79    }
80
81    /// Save a version string to `version.txt` in the cache directory.
82    fn save_version(&self, version: &str) {
83        let version_file = self.cache_dir.join("version.txt");
84        let _ = fs::write(version_file, version);
85    }
86
87    /// Fetch the current MTGJSON version from Meta.json on the CDN.
88    ///
89    /// Returns the version string (e.g. `"5.2.2+20240101"`), or `None` if
90    /// offline or the CDN is unreachable. Caches the result for subsequent calls.
91    pub fn remote_version(&mut self) -> Result<Option<String>> {
92        if self.remote_ver.is_some() {
93            return Ok(self.remote_ver.clone());
94        }
95        if self.offline {
96            return Ok(None);
97        }
98        let client = self.client().clone();
99        match client.get(config::META_URL).send() {
100            Ok(resp) => {
101                let resp = resp.error_for_status()?;
102                let data: serde_json::Value = resp.json()?;
103                // Try data.version first, then meta.version
104                let version = data
105                    .get("data")
106                    .and_then(|d| d.get("version"))
107                    .and_then(|v| v.as_str())
108                    .or_else(|| {
109                        data.get("meta")
110                            .and_then(|m| m.get("version"))
111                            .and_then(|v| v.as_str())
112                    })
113                    .map(|s| s.to_string());
114                self.remote_ver = version.clone();
115                Ok(version)
116            }
117            Err(e) => {
118                eprintln!("Failed to fetch MTGJSON version from CDN: {}", e);
119                Ok(None)
120            }
121        }
122    }
123
124    /// Check if local cache is out of date compared to the CDN.
125    ///
126    /// Returns `true` if there is no local cache or the CDN has a newer version.
127    /// Returns `false` if up to date or if the CDN is unreachable.
128    pub fn is_stale(&mut self) -> Result<bool> {
129        let local = self.local_version();
130        match local {
131            None => Ok(true),
132            Some(local_ver) => {
133                let remote = self.remote_version()?;
134                match remote {
135                    None => Ok(false), // Can't check, assume fresh
136                    Some(remote_ver) => Ok(local_ver != remote_ver),
137                }
138            }
139        }
140    }
141
142    /// Download a single file from the CDN.
143    ///
144    /// Downloads to a temp file first and renames on success, so an
145    /// interrupted download never leaves a corrupt partial file behind.
146    fn download_file(&mut self, filename: &str, dest: &Path) -> Result<()> {
147        let url = format!("{}/{}", config::CDN_BASE, filename);
148        eprintln!("Downloading {}", url);
149
150        if let Some(parent) = dest.parent() {
151            fs::create_dir_all(parent)?;
152        }
153
154        let tmp_dest = dest.with_extension(format!(
155            "{}.tmp",
156            dest.extension()
157                .and_then(|e| e.to_str())
158                .unwrap_or("")
159        ));
160
161        let client = self.client().clone();
162        let on_progress = self.on_progress.clone();
163        let fname = filename.to_string();
164
165        let result = (|| -> Result<()> {
166            let mut resp = client.get(&url).send()?.error_for_status()?;
167            let total = resp.content_length().unwrap_or(0);
168
169            if on_progress.is_some() {
170                // Stream with progress reporting
171                let mut file = fs::File::create(&tmp_dest)?;
172                let mut downloaded: u64 = 0;
173                let mut buf = [0u8; 8192];
174                loop {
175                    let n = resp.read(&mut buf)?;
176                    if n == 0 {
177                        break;
178                    }
179                    file.write_all(&buf[..n])?;
180                    downloaded += n as u64;
181                    if let Some(ref cb) = on_progress {
182                        cb(&fname, downloaded, total);
183                    }
184                }
185            } else {
186                // Bulk download (original behavior)
187                let bytes = resp.bytes()?;
188                fs::write(&tmp_dest, &bytes)?;
189            }
190
191            fs::rename(&tmp_dest, dest)?;
192            Ok(())
193        })();
194
195        if result.is_err() {
196            // Clean up partial temp file on any error
197            let _ = fs::remove_file(&tmp_dest);
198        }
199
200        result
201    }
202
203    /// Ensure a parquet file is cached locally, downloading if needed.
204    ///
205    /// # Arguments
206    ///
207    /// * `view_name` - Logical view name (e.g. `"cards"`, `"sets"`).
208    ///
209    /// # Returns
210    ///
211    /// Local filesystem path to the cached parquet file.
212    pub fn ensure_parquet(&mut self, view_name: &str) -> Result<PathBuf> {
213        let parquet_files = config::parquet_files();
214        let filename = parquet_files.get(view_name).ok_or_else(|| {
215            MtgjsonError::NotFound(format!("Unknown parquet view: {}", view_name))
216        })?;
217
218        let local_path = self.cache_dir.join(filename);
219
220        if !local_path.exists() || self.is_stale()? {
221            if self.offline {
222                if local_path.exists() {
223                    return Ok(local_path);
224                }
225                return Err(MtgjsonError::NotFound(format!(
226                    "Parquet file {} not cached and offline mode is enabled",
227                    filename
228                )));
229            }
230            self.download_file(filename, &local_path)?;
231            // Update version after successful download
232            if let Ok(Some(version)) = self.remote_version() {
233                self.save_version(&version);
234            }
235        }
236
237        Ok(local_path)
238    }
239
240    /// Ensure a JSON file is cached locally, downloading if needed.
241    ///
242    /// # Arguments
243    ///
244    /// * `name` - Logical file name (e.g. `"meta"`, `"all_prices_today"`).
245    ///
246    /// # Returns
247    ///
248    /// Local filesystem path to the cached JSON file.
249    pub fn ensure_json(&mut self, name: &str) -> Result<PathBuf> {
250        let json_files = config::json_files();
251        let filename = json_files.get(name).ok_or_else(|| {
252            MtgjsonError::NotFound(format!("Unknown JSON file: {}", name))
253        })?;
254
255        let local_path = self.cache_dir.join(filename);
256
257        if !local_path.exists() || self.is_stale()? {
258            if self.offline {
259                if local_path.exists() {
260                    return Ok(local_path);
261                }
262                return Err(MtgjsonError::NotFound(format!(
263                    "JSON file {} not cached and offline mode is enabled",
264                    filename
265                )));
266            }
267            self.download_file(filename, &local_path)?;
268            // Update version after successful download
269            if let Ok(Some(version)) = self.remote_version() {
270                self.save_version(&version);
271            }
272        }
273
274        Ok(local_path)
275    }
276
277    /// Load and parse a JSON file (handles `.gz` transparently).
278    ///
279    /// If the cached file is corrupt (truncated download, disk error),
280    /// it is deleted automatically so the next call re-downloads a fresh copy.
281    pub fn load_json(&mut self, name: &str) -> Result<serde_json::Value> {
282        let path = self.ensure_json(name)?;
283
284        let parse_result = if path.extension().and_then(|e| e.to_str()) == Some("gz") {
285            let file = fs::File::open(&path)?;
286            let reader = BufReader::new(file);
287            let decoder = GzDecoder::new(reader);
288            let mut buf_reader = BufReader::new(decoder);
289            let mut contents = String::new();
290            buf_reader.read_to_string(&mut contents)?;
291            serde_json::from_str(&contents).map_err(MtgjsonError::from)
292        } else {
293            let contents = fs::read_to_string(&path)?;
294            serde_json::from_str(&contents).map_err(MtgjsonError::from)
295        };
296
297        match parse_result {
298            Ok(value) => Ok(value),
299            Err(e) => {
300                eprintln!(
301                    "Corrupt cache file {}: {} -- removing",
302                    path.display(),
303                    e
304                );
305                let _ = fs::remove_file(&path);
306                Err(MtgjsonError::NotFound(format!(
307                    "Cache file '{}' was corrupt and has been removed. \
308                     Retry to re-download. Original error: {}",
309                    path.file_name()
310                        .and_then(|n| n.to_str())
311                        .unwrap_or("unknown"),
312                    e
313                )))
314            }
315        }
316    }
317
318    /// Remove all cached files and recreate the cache directory.
319    pub fn clear(&self) -> Result<()> {
320        if self.cache_dir.exists() {
321            fs::remove_dir_all(&self.cache_dir)?;
322            fs::create_dir_all(&self.cache_dir)?;
323        }
324        Ok(())
325    }
326
327    /// Close the HTTP client, if open.
328    pub fn close(&mut self) {
329        self.client = None;
330    }
331}