Skip to main content

mtgjson_sdk/
lib.rs

1//! MTGJSON SDK for Rust.
2//!
3//! Provides a high-level client for querying the complete MTGJSON dataset.
4//! Data is downloaded from the MTGJSON CDN as parquet and JSON files, cached
5//! locally, and queried in-process via DuckDB.
6//!
7//! # Quick start
8//!
9//! ```no_run
10//! use mtgjson_sdk::MtgjsonSdk;
11//!
12//! let mut sdk = MtgjsonSdk::builder().build().unwrap();
13//!
14//! // Query cards
15//! let cards = sdk.cards().get_by_name("Lightning Bolt", None).unwrap();
16//!
17//! // Open a draft booster
18//! let pack = sdk.booster().open_pack("MH3", "draft").unwrap();
19//! ```
20
21#[cfg(feature = "async")]
22pub mod async_client;
23pub mod booster;
24pub mod cache;
25pub mod config;
26pub mod connection;
27pub mod error;
28pub mod models;
29pub mod queries;
30pub mod sql_builder;
31
32#[cfg(feature = "async")]
33pub use async_client::AsyncMtgjsonSdk;
34pub use cache::CacheManager;
35pub use connection::Connection;
36pub use error::{MtgjsonError, Result};
37pub use sql_builder::SqlBuilder;
38
39use std::collections::HashMap;
40use std::fmt;
41use std::path::{Path, PathBuf};
42use std::sync::Arc;
43use std::time::Duration;
44
45/// Callback for download progress reporting.
46///
47/// Arguments: `(filename, bytes_downloaded, total_bytes)`.
48/// `total_bytes` may be `0` if the server did not provide a `Content-Length` header.
49pub type ProgressCallback = Arc<dyn Fn(&str, u64, u64) + Send + Sync>;
50
51// ---------------------------------------------------------------------------
52// MtgjsonSdkBuilder
53// ---------------------------------------------------------------------------
54
55/// Builder for configuring and constructing an [`MtgjsonSdk`] instance.
56///
57/// Use [`MtgjsonSdk::builder()`] to obtain a builder, chain configuration
58/// methods, and call [`build()`](MtgjsonSdkBuilder::build) to create the SDK.
59pub struct MtgjsonSdkBuilder {
60    cache_dir: Option<PathBuf>,
61    offline: bool,
62    timeout: Duration,
63    on_progress: Option<ProgressCallback>,
64}
65
66impl Default for MtgjsonSdkBuilder {
67    fn default() -> Self {
68        Self {
69            cache_dir: None,
70            offline: false,
71            timeout: Duration::from_secs(120),
72            on_progress: None,
73        }
74    }
75}
76
77impl MtgjsonSdkBuilder {
78    /// Set a custom cache directory.
79    ///
80    /// If not set, the platform-appropriate default cache directory is used
81    /// (e.g. `~/.cache/mtgjson-sdk` on Linux, `~/Library/Caches/mtgjson-sdk`
82    /// on macOS, `%LOCALAPPDATA%\mtgjson-sdk` on Windows).
83    pub fn cache_dir<P: AsRef<Path>>(mut self, path: P) -> Self {
84        self.cache_dir = Some(path.as_ref().to_path_buf());
85        self
86    }
87
88    /// Enable or disable offline mode.
89    ///
90    /// When offline, the SDK never downloads from the CDN and only uses
91    /// previously cached data files. Defaults to `false`.
92    pub fn offline(mut self, offline: bool) -> Self {
93        self.offline = offline;
94        self
95    }
96
97    /// Set the HTTP request timeout for CDN downloads.
98    ///
99    /// Defaults to 120 seconds.
100    pub fn timeout(mut self, timeout: Duration) -> Self {
101        self.timeout = timeout;
102        self
103    }
104
105    /// Set a progress callback for CDN downloads.
106    ///
107    /// The callback receives `(filename, bytes_downloaded, total_bytes)` and is
108    /// called periodically during file downloads. `total_bytes` is `0` if the
109    /// server did not provide a `Content-Length` header.
110    pub fn on_progress<F>(mut self, f: F) -> Self
111    where
112        F: Fn(&str, u64, u64) + Send + Sync + 'static,
113    {
114        self.on_progress = Some(Arc::new(f));
115        self
116    }
117
118    /// Build the SDK, initializing the cache and DuckDB connection.
119    ///
120    /// This may trigger a version check against the CDN (unless offline mode
121    /// is enabled) but does **not** download any data files eagerly -- they
122    /// are fetched lazily on first query.
123    pub fn build(self) -> Result<MtgjsonSdk> {
124        let cache = CacheManager::new(
125            self.cache_dir,
126            self.offline,
127            self.timeout,
128            self.on_progress,
129        )?;
130        let conn = Connection::new(cache)?;
131        Ok(MtgjsonSdk { conn })
132    }
133}
134
135// ---------------------------------------------------------------------------
136// MtgjsonSdk
137// ---------------------------------------------------------------------------
138
139/// The main entry point for the MTGJSON SDK.
140///
141/// Wraps a [`Connection`] (which owns the [`CacheManager`] and DuckDB database)
142/// and exposes domain-specific query interfaces as lightweight borrowing wrappers.
143///
144/// Created via [`MtgjsonSdk::builder()`].
145pub struct MtgjsonSdk {
146    conn: Connection,
147}
148
149impl MtgjsonSdk {
150    /// Create a new builder for configuring the SDK.
151    pub fn builder() -> MtgjsonSdkBuilder {
152        MtgjsonSdkBuilder::default()
153    }
154
155    // -- Query accessors ---------------------------------------------------
156
157    /// Access the card query interface.
158    ///
159    /// Returns a lightweight wrapper that borrows from the underlying
160    /// connection and provides methods for querying card data.
161    pub fn cards(&self) -> queries::cards::CardQuery<'_> {
162        queries::cards::CardQuery::new(&self.conn)
163    }
164
165    /// Access the set query interface.
166    pub fn sets(&self) -> queries::sets::SetQuery<'_> {
167        queries::sets::SetQuery::new(&self.conn)
168    }
169
170    /// Access the token query interface.
171    pub fn tokens(&self) -> queries::tokens::TokenQuery<'_> {
172        queries::tokens::TokenQuery::new(&self.conn)
173    }
174
175    /// Access the price query interface.
176    ///
177    /// Requires the `prices_today` table to have been loaded into DuckDB.
178    pub fn prices(&self) -> queries::prices::PriceQuery<'_> {
179        queries::prices::PriceQuery::new(&self.conn)
180    }
181
182    /// Access the legality query interface.
183    pub fn legalities(&self) -> queries::legalities::LegalityQuery<'_> {
184        queries::legalities::LegalityQuery::new(&self.conn)
185    }
186
187    /// Access the identifier query interface.
188    pub fn identifiers(&self) -> queries::identifiers::IdentifierQuery<'_> {
189        queries::identifiers::IdentifierQuery::new(&self.conn)
190    }
191
192    /// Access the deck query interface.
193    ///
194    /// Deck data is loaded from `DeckList.json` via the cache manager.
195    pub fn decks(&self) -> queries::decks::DeckQuery<'_> {
196        queries::decks::DeckQuery::new(&self.conn)
197    }
198
199    /// Access the sealed product query interface.
200    pub fn sealed(&self) -> queries::sealed::SealedQuery<'_> {
201        queries::sealed::SealedQuery::new(&self.conn)
202    }
203
204    /// Access the TCGplayer SKU query interface.
205    ///
206    /// Requires the `tcgplayer_skus` table to have been loaded into DuckDB.
207    pub fn skus(&self) -> queries::skus::SkuQuery<'_> {
208        queries::skus::SkuQuery::new(&self.conn)
209    }
210
211    /// Access the enum/keyword query interface.
212    ///
213    /// Enum data is loaded from JSON files (`Keywords.json`, `CardTypes.json`,
214    /// `EnumValues.json`) via the cache manager.
215    pub fn enums(&self) -> queries::enums::EnumQuery<'_> {
216        queries::enums::EnumQuery::new(&self.conn)
217    }
218
219    /// Access the booster pack simulator.
220    ///
221    /// The simulator reads from the set booster parquet tables to generate
222    /// randomized booster packs matching real-world distribution rules.
223    pub fn booster(&self) -> booster::BoosterSimulator<'_> {
224        booster::BoosterSimulator::new(&self.conn)
225    }
226
227    // -- Metadata and utility methods --------------------------------------
228
229    /// Load and return the MTGJSON metadata (version, date, etc.).
230    ///
231    /// Fetches `Meta.json` from the cache (downloading if necessary) and
232    /// returns the parsed JSON object.
233    pub fn meta(&self) -> Result<serde_json::Value> {
234        self.conn.cache.borrow_mut().load_json("meta")
235    }
236
237    /// Return the list of currently registered DuckDB view names.
238    ///
239    /// Views are registered lazily on first query, so this list grows as
240    /// different query interfaces are used.
241    pub fn views(&self) -> Vec<String> {
242        self.conn.views()
243    }
244
245    /// Execute a raw SQL query against the DuckDB database.
246    ///
247    /// Provides escape-hatch access for queries not covered by the
248    /// domain-specific interfaces.
249    ///
250    /// # Arguments
251    ///
252    /// * `query` - SQL string with `?` positional placeholders.
253    /// * `params` - Parameter values corresponding to the placeholders.
254    ///
255    /// # Returns
256    ///
257    /// A vector of rows, each represented as a `HashMap<String, serde_json::Value>`.
258    pub fn sql(
259        &self,
260        query: &str,
261        params: &[String],
262    ) -> Result<Vec<HashMap<String, serde_json::Value>>> {
263        self.conn.execute(query, params)
264    }
265
266    /// Export the in-memory DuckDB database to a directory on disk.
267    ///
268    /// Uses DuckDB's `EXPORT DATABASE` command to write the database contents
269    /// (schema + data) to the given path.
270    pub fn export_db<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf> {
271        self.conn.export_db(path.as_ref())
272    }
273
274    /// Execute a raw SQL query and return the result as a Polars DataFrame.
275    ///
276    /// This is the Rust equivalent of Python's `sdk.sql("...", as_dataframe=True)`.
277    /// Requires the `polars` cargo feature.
278    #[cfg(feature = "polars")]
279    pub fn sql_df(
280        &self,
281        query: &str,
282        params: &[String],
283    ) -> Result<polars::frame::DataFrame> {
284        self.conn.execute_df(query, params)
285    }
286
287    /// Check for a newer MTGJSON version and reset views if stale.
288    ///
289    /// Returns `true` if the data was stale and views were reset (meaning
290    /// subsequent queries will re-download data), or `false` if already
291    /// up to date.
292    pub fn refresh(&self) -> Result<bool> {
293        let stale = self.conn.cache.borrow_mut().is_stale()?;
294        if stale {
295            self.conn.cache.borrow().clear()?;
296            self.conn.reset_views();
297            eprintln!("MTGJSON data was stale; cache cleared and views reset");
298        }
299        Ok(stale)
300    }
301
302    /// Consume the SDK and release all resources.
303    ///
304    /// Closes the DuckDB connection and HTTP client. This is called
305    /// automatically when the SDK is dropped, but can be invoked explicitly
306    /// for deterministic cleanup.
307    pub fn close(self) {
308        // Connection and CacheManager are dropped automatically
309        drop(self);
310    }
311
312    /// Return a reference to the underlying [`Connection`] for advanced usage.
313    pub fn connection(&self) -> &Connection {
314        &self.conn
315    }
316
317    /// Return a mutable reference to the underlying [`Connection`].
318    pub fn connection_mut(&mut self) -> &mut Connection {
319        &mut self.conn
320    }
321}
322
323// ---------------------------------------------------------------------------
324// Display
325// ---------------------------------------------------------------------------
326
327impl fmt::Display for MtgjsonSdk {
328    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329        let views = self.conn.views();
330        let cache = self.conn.cache.borrow();
331        write!(
332            f,
333            "MtgjsonSdk(cache_dir={}, views=[{}], offline={})",
334            cache.cache_dir.display(),
335            views.join(", "),
336            cache.offline
337        )
338    }
339}