2025-09-28
When it comes to learning a new programming language, I’ve found that building a small, practical program is one of the best ways to get started. Recently I began exploring Rust, and to get a feel for the language I decided to write a tool based on a game I was playing at the time: Magic: The Gathering Arena (Arena). The game’s log files contain a lot of information, including records of the cards I had used, but not in an easily readable way. I wanted to build a tool that could extract that data. This article describes how I approached the project using Rust.
I wanted to learn Rust because of its strong focus on safety and performance, as well as the growing ecosystem around it. Frameworks like Tauri and even Zola with which this site is generated are written in Rust. Rust’s ownership model and lifetimes can seem tricky at first glance, so I was looking for a small, manageable project that would give me some hands-on experience without being overwhelming. Parsing game log files felt like the right balance of practical and approachable.
In Arena, two players play against each other with decks made of collectible cards. There is a huge list of cards available, with evocative names such as "Lightning Bolt" and "Serra Angel", that could make up a deck. In general the decks you play are based on the cards available in your collection, so it is nice to look over it, and craft the deck you want to play. Unfortunately while you can go over your collection in the game client, there is no easy way to export your set of cards from it.
This is where the game log of Arena comes in. The log emits a huge number of events, many of which could be analyzed by external tools. While it doesn’t currently include an event for all the cards in your collection, it does show all the cards used in each deck. To make things a bit more tricky, it uses an internal Arena specific card ID to identify the cards, as opposed to the card names more familiar to players. Given these challenges, my goal was straightforward: take an MTG Arena log and turn it into a clear, human-readable list of the cards I have, including their names.
We can structure the code for this application into two main parts. First, we have some data structures to hold the information gathered from the logs, the mapping of arena IDs to names, and the output that we like to show. Second, we have some functions that read the information in from the files, gather the card information from the logs, match this information with the mappings and finally write the output to a file. In addition, we will have unit tests to ensure that the functions work as expected.
With the code structure in mind, let’s walk through the source code of the application, which fits neatly in a single file.
use serde::{Deserialize, Serialize};
use serde_json::{Deserializer, Value};
use std::collections::{HashMap, HashSet};
use std::fs::{write, File};
use std::io::{BufRead, BufReader};
As is usual in many other languages we start by listing our imports first. The serde
framework allows for easy mapping of Rust data structures to other formats. As we are aiming to serialize the extracted information into JSON files we are going to use serde_json
, which is an implementation of serde
for the JSON format. We also have imports from the standard library that we for generic data structures and file writing/reading.
The next part of the code are the data structures that can represent the information in the log file.
// Data structures for the parts of the log that has information about the decks and cards in them.
#[derive(Serialize, Deserialize, Debug, Default)]
#[serde(rename_all = "PascalCase")]
struct DecksWrapper {
decks: HashMap<String, Deck>,
}
#[derive(Serialize, Deserialize, Debug, Default)]
#[serde(rename_all = "PascalCase")]
struct Deck {
main_deck: Vec<CardEntry>,
sideboard: Vec<CardEntry>,
reduced_sideboard: Vec<CardEntry>,
command_zone: Vec<CardEntry>,
companions: Vec<CardEntry>,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
struct CardEntry {
card_id: u64,
quantity: u32,
}
The data structures that we use are defined here using Rust structs. They are made up of either primitive types such as card_id
and quantity
, or collections of other structs such as main_deck
and sideboard
.
In addition, we add attributes to our structs. For example, #[derive(Serialize, Deserialize, Debug, Default)]
lets us automatically serialize and deserialize the structs, print them with Debug, and construct default values. The #[serde(rename_all = "PascalCase")]
attribute tells Serde how to map between Rust’s convention of snake_case field names (e.g. main_deck) and the PascalCase keys used in the Arena log JSON (e.g. MainDeck).
To illustrate with a minimal example, assume we have the following fragment in the JSON logs:
"MainDeck": [
{ "cardId": 1001, "quantity": 2 },
{ "cardId": 1002, "quantity": 3 }
],
This would map to a concrete Rust value such as:
let deck1 = Deck {
main_deck: vec![
CardEntry {
card_id: 1001,
quantity: 2,
},
CardEntry {
card_id: 1002,
quantity: 3,
},
],
// assuming other fields exist and are empty
..Default::default()
};
We’ll see this in action in one of the unit tests later.
Next up are the remaining data structures that we’ll use for representing the full card collection, as well as general card information from Scryfall.
// Data structure that represents how many of each card we have available based on the decks.
#[derive(Serialize, Deserialize, Debug, Default)]
#[serde(rename_all = "camelCase")]
struct CardCollection {
cards: Vec<CardEntry>,
}
// Data structures for Scryfall (general card knowledge) information
#[derive(Serialize, Deserialize, Debug, Default)]
struct ScryfallData {
cards: Vec<ScryfallCard>,
}
#[derive(Serialize, Deserialize, Debug, Default)]
struct ScryfallCard {
arena_id: Option<u64>,
name: String,
}
With these additional structs in place, we now have everything we need to represent both the decks pulled from the Arena logs and the more general card information from Scryfall. But representation alone isn’t enough: we also need a way to process the raw decks into something useful.
That’s where the CardCollection implementation comes in. This type acts as a bridge between individual deck data and the overall view of a player’s collection. By aggregating card entries across decks, we can answer questions like “How many copies of this card do I actually own?” or “Which Arena IDs are present in my collection?”
The following impl block shows how we can construct a CardCollection from a DecksWrapper, as well as provide some helper methods for querying it:
// Implementation that can derive the card collection from the decks.
impl CardCollection {
fn from_decks_wrapper(decks_wrapper: DecksWrapper) -> CardCollection {
use std::collections::HashMap;
let mut card_map: HashMap<u64, u32> = HashMap::new();
for deck in decks_wrapper.decks.values() {
for entry in &deck.main_deck {
*card_map.entry(entry.card_id).or_insert(0) += entry.quantity;
}
for entry in &deck.sideboard {
*card_map.entry(entry.card_id).or_insert(0) += entry.quantity;
}
for entry in &deck.reduced_sideboard {
*card_map.entry(entry.card_id).or_insert(0) += entry.quantity;
}
for entry in &deck.command_zone {
*card_map.entry(entry.card_id).or_insert(0) += entry.quantity;
}
for entry in &deck.companions {
*card_map.entry(entry.card_id).or_insert(0) += entry.quantity;
}
}
let cards = card_map
.into_iter()
.map(|(card_id, quantity)| CardEntry { card_id, quantity })
.collect();
CardCollection { cards }
}
pub fn get_arena_ids(&self) -> HashSet<u64> {
self.cards.iter().map(|entry| entry.card_id).collect()
}
fn search_with_id(&self, card_id: u64) -> CardEntry {
let found_entry = self.cards.iter().find(|c| c.card_id == card_id).cloned();
match found_entry {
Some(result) => return result,
None => {
return CardEntry {
card_id: card_id,
quantity: 0,
}
}
}
}
}
The CardCollection implementation is where we actually make use of the deck data. Let’s go through each method in turn:
from_decks_wrapper This function takes all of the decks inside a DecksWrapper and combines them into a single CardCollection. It does so by walking through every zone of every deck (main_deck, sideboard, etc.), summing the quantities for each card ID into a HashMap<u64, u32>. Once all the decks are processed, that map is converted back into a list of CardEntry values. In short, it turns lots of decks with overlapping cards into one unified collection with aggregated counts.
get_arena_ids
Sometimes we don’t need the full card entries, just the set of card IDs that appear in the collection. This helper method collects all the card_ids into a HashSet
search_with_id
This is a small convenience function for looking up a specific card by its Arena ID. If the card exists in the collection, it returns the corresponding CardEntry. If not, it returns a default CardEntry with the given card_id and a quantity of 0.
This approach avoids dealing with Option
Together, these methods make CardCollection the workhorse of the program: it gives us an aggregated view of what cards we have and lets us easily query or transform that data.
So far, we’ve been working with data structures and helper methods that assume we already have the deck data in memory. But of course, the whole point of this tool is to read the Arena log and extract that information automatically. The parse_decks_from_log function is responsible for this: it scans through the log file line by line, looks for entries that contain deck data, and deserializes the first one it finds into a CardCollection.
// Takes a log file and aims to extract a cardcollection from it.
fn parse_decks_from_log<P: AsRef<std::path::Path>>(path: P) -> Option<CardCollection> {
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
for line in reader.lines().flatten() {
if line.contains("\"Decks\"") {
if let Ok(decks) = serde_json::from_str::<DecksWrapper>(&line) {
return Some(CardCollection::from_decks_wrapper(decks));
}
}
}
None
}
This function ties everything together by connecting the raw log file to the structured data we’ve been working with. A couple of details are worth pointing out:
Return type Option
? operator for error handling The expression File::open(path).ok()? is a compact way of saying: try to open the file, and if that fails, return None immediately. This avoids cluttering the function with explicit match statements while still being safe.
Finding deck data The function looks for lines containing "Decks" because that’s how Arena includes the relevant JSON in its log. Once such a line is found, we attempt to deserialize it into a DecksWrapper.
One section, multiple decks Each log file has only one "Decks" section, but that section may describe several different decks at once. That’s why DecksWrapper contains a HashMap<String, Deck> — the keys are deck identifiers, and the values are the deck lists themselves. Since there’s only ever one "Decks" section, we can safely return after parsing it.
With this in place, we now have a straightforward path:
Open the Arena log.
Parse out the "Decks" section.
Turn it into a CardCollection that we can query and extend later.
Next, we need a way to extract the Scryfall information into in-memory structs, similar to how we did it with the Arena logs. Scryfall provides both online APIs and bulk data files. For our purposes, using the bulk files is simpler and more reliable than hitting the API repeatedly.
The extract_arena_cards
function reads a Scryfall JSON file and filters it down to only the cards that exist in our collection of Arena IDs. It works as follows:
fn extract_arena_cards<P: AsRef<std::path::Path>>(
path: P,
arena_ids: &HashSet<u64>,
) -> Vec<ScryfallCard> {
let file = File::open(path).expect("Failed to open Scryfall file");
let reader = BufReader::new(file);
// Deserialize as a stream of JSON values
let stream = Deserializer::from_reader(reader).into_iter::<Value>();
let mut cards = Vec::new();
// Iterate over each element in the stream, handling Result<Value, Error>
for result in stream {
match result {
Ok(value) => {
// Check if value is an array, and proceed accordingly
match value {
Value::Array(ref array) => {
// Iterate over each element of the array
for element in array {
// Process each element (card) in the array
let json_str = serde_json::to_string_pretty(&element)
.expect("Failed to convert JSON to string");
// Directly use u64 for arena_id
if let Some(arena_id) = element.get("arena_id").and_then(|v| v.as_u64())
{
if arena_ids.contains(&arena_id) {
// Try parsing the card into the struct
match serde_json::from_value::<ScryfallCard>(element.clone()) {
Ok(card) => {
cards.push(card);
}
Err(e) => {
// Print out the error and the value that failed to parse
println!(
"Failed to parse card with arena_id {}: {:?}",
arena_id, e
);
println!("Failed card data: {}", json_str);
}
}
}
}
}
}
_ => {
println!("The provided JSON is not an array");
}
}
}
Err(e) => {
// Handle the error case for the result from the deserializer
println!("Error deserializing JSON: {:?}", e);
}
}
}
cards
}
The function works by taking the path to a Scryfall JSON file along with a set of Arena IDs that we want to include. It reads the file as a stream of JSON values, which is especially useful for large bulk datasets because it avoids loading everything into memory at once. As it iterates over each JSON element, the function checks whether the card has an arena_id and whether that ID is part of our collection. If it is, the element is deserialized into a ScryfallCard struct and added to the output vector.
Errors during deserialization are handled gracefully: the function prints both the error and the raw JSON that caused it, allowing us to debug any problematic entries without stopping the entire process. By filtering and deserializing only the relevant cards, this approach ensures that we keep memory usage low and produce a clean, usable dataset for further processing or analysis.
Once we’ve extracted and filtered the relevant Scryfall cards, the next step is to persist this data so it can be easily inspected, shared, or used by other parts of our program. The save_cards_to_file function does exactly that: it takes a slice of ScryfallCard structs and writes them to a JSON file in a human-readable format.
fn save_cards_to_file<P: AsRef<std::path::Path>>(path: P, cards: &[ScryfallCard]) {
let json = serde_json::to_string_pretty(&cards).expect("Failed to serialize cards.");
write(path, json).expect("Failed to write to file.");
}
Internally, it uses serde_json::to_string_pretty to convert the vector of cards into nicely formatted JSON. Then it writes that string to the specified file path. This simple helper encapsulates the serialization and file writing in one place, so we don’t have to repeat these steps every time we want to save card data.
With all the building blocks in place — parsing Arena logs, aggregating decks into a CardCollection, extracting relevant Scryfall cards, and saving them to a file — we are ready to see everything in action. The main function ties these pieces together into a complete workflow, turning raw log files and bulk data into a clean, usable card collection.
fn main() {
// Step 1: Parse the log file and collect card collection information from available decks.
let deck_file = "input-files/Player.log"; // Example log file path
let card_collection = match parse_decks_from_log(deck_file) {
Some(collection) => collection,
None => {
println!("Failed to parse decks from the log.");
return;
}
};
// Step 2: Gather all the arena card IDs from the collection.
let arena_ids: HashSet<u64> = card_collection.get_arena_ids();
// Step 3: Extract the Scryfall cards that match the arena IDs
let scryfall_file = "input-files/scryfall.json"; // Example Scryfall data file path
let scryfall_cards = extract_arena_cards(scryfall_file, &arena_ids);
// Step 4: Save the filtered Scryfall cards to a file
let output_file = "collection_output.json"; // Example output file path
save_cards_to_file(output_file, &scryfall_cards);
println!(
"Successfully extracted and saved {} different cards.",
scryfall_cards.len()
);
}
The main function follows a clear sequence. First, it parses the Arena log and builds a CardCollection from the available decks. Next, it collects all the unique Arena IDs from that collection. Then, it extracts the corresponding Scryfall cards from the bulk JSON file, filtering only the relevant cards. Finally, it saves the resulting collection to a JSON file, ready for inspection or further analysis. By structuring the workflow this way, each helper function is small and focused, while the main function orchestrates the full process in an easy-to-follow manner.
With the main function, we’ve shown how all the pieces come together to parse Arena logs, enrich the data with Scryfall information, and save the resulting collection. But how can we be sure that each part works correctly? Even in a small project, writing a few unit tests is a good way to verify that our code behaves as expected. The next section demonstrates testing key functionality: parsing decks from JSON, aggregating cards into a CardCollection, and checking that card lookups with search_with_id return the correct results.
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::CardCollection;
use super::CardEntry;
use super::Deck;
use super::DecksWrapper;
#[test]
fn test_decks_wrapper_parsing() {
let json_data = r#"
{
"Decks": {
"abc123": {
"MainDeck": [
{ "cardId": 1001, "quantity": 2 },
{ "cardId": 1002, "quantity": 3 }
],
"Sideboard": [],
"ReducedSideboard": [],
"CommandZone": [],
"Companions": [],
"CardSkins": []
}
}
}
"#;
let parsed: Result<DecksWrapper, _> = serde_json::from_str(json_data);
assert!(parsed.is_ok(), "Failed to parse DecksWrapper");
let decks_wrapper = parsed.unwrap();
let deck = decks_wrapper.decks.get("abc123").expect("Deck not found");
assert_eq!(deck.main_deck.len(), 2);
assert_eq!(deck.main_deck[0].card_id, 1001);
assert_eq!(deck.main_deck[0].quantity, 2);
}
#[test]
fn test_card_collection_creation() {
let deck1 = Deck {
main_deck: vec![
CardEntry {
card_id: 1,
quantity: 2,
},
CardEntry {
card_id: 2,
quantity: 3,
},
],
// assuming other fields exist and are empty
..Default::default()
};
let deck2 = Deck {
main_deck: vec![
CardEntry {
card_id: 1,
quantity: 1,
},
CardEntry {
card_id: 3,
quantity: 4,
},
],
..Default::default()
};
let decks_wrapper: DecksWrapper = DecksWrapper {
decks: (HashMap::from([
(String::from("00001"), deck1),
(String::from("00002"), deck2),
])),
};
let card_collection = CardCollection::from_decks_wrapper(decks_wrapper);
assert_eq!(card_collection.cards.len(), 3);
assert_eq!(
card_collection.search_with_id(1),
CardEntry {
card_id: 1,
quantity: 3
}
);
}
#[test]
fn test_search_with_id_found() {
let collection = CardCollection {
cards: vec![
CardEntry {
card_id: 101,
quantity: 4,
},
CardEntry {
card_id: 202,
quantity: 2,
},
],
};
let result = collection.search_with_id(101);
assert_eq!(result.card_id, 101);
assert_eq!(result.quantity, 4);
}
#[test]
fn test_search_with_id_not_found() {
let collection = CardCollection {
cards: vec![
CardEntry {
card_id: 101,
quantity: 4,
},
CardEntry {
card_id: 202,
quantity: 2,
},
],
};
let result = collection.search_with_id(999);
assert_eq!(result.card_id, 999);
assert_eq!(result.quantity, 0); // Default case
}
}
This small project has been a fun way to dive into Rust. From parsing Arena logs to matching cards with Scryfall data, each step gave me hands-on experience with structs, collections, serialization, and basic file handling. I hope it also highlights the value of writing small, focused functions and a few tests for them. This makes even a modest tool feel solid and reliable.
Beyond the technical details, it’s rewarding to see raw log files transformed into a structured, human-readable collection of cards. While this tool is simple, it illustrates how Rust can help you approach a problem methodically, building up a workflow from small, composable pieces. And with plenty of room for new features or experiments, using Rust ensures that you will always have a winning hand.