I'm trying to build a simple card game, but that's not even the horrifying part of this story. The horrifying part is that I'm keeping my data in Microsoft Excel.

It's perfect! It flattens the data into something that can be more easily expressed as an object, and the relationships between cards are easy to model. Should one card always be 20% more expensive than another, or half as expensive? Easy solution: use an Excel formula. Spreadsheets were built for this. But reading a spreadsheet from a game is madness.

Initially I exported the spreadsheet as Tab-separated Values (TSV), a simple format that obeys CSV rules and Excel can export natively. This happily reduced the problem to reading TSV, but as I wrote a Rust parser for reading that TSV there were enough unwraps and error handling occurring that I decided it would be easier to move all this error-prone part further back in my asset pipeline.

So I decided to turn TSV directly into a .rs file. This is the madness, the horrifying part which is why someone ought to take my keyboard away.

My go-to language for hacky things is Ruby, so I wrote a small Ruby script which can read the TSV and then echo it back out using an ERB template to turn it into Rust code.

# tsv2rs.rb

require 'csv'
require 'erb'

RS ='src/cards.rs'

def value_or_default(value, default)
  if value == nil or value.empty?; default; else value; end

File.new(RS, File::CREAT|File::TRUNC|File::RDWR, 0644)
    .write ERB.new(File.read(RST)).result(binding)

It's very simple, aside from the helper function value_or_default it's just two lines: open a file, then write out the result.

The ERB template is where frightening things happen, where we write code which writes code. But it's not that scary, actually. I want something static, but also in a Vec, so lazy_static showed up. After that it's very simple Rust constructors.

use lazy_static::lazy_static;
use crate::card::*;

lazy_static! {
  pub static ref CARDS: Vec<Card> = vec![<%
  first = true
  CSV.foreach(TSV, col_sep: "\t") do |row|
    # skips header rows, assumes your first column is always a number
    next unless row.first.match? /^[0-9]+$/
  <% unless first %>,<% end %>
  <% first = false %>
  Card {
    atlas_row: <%= row[0] %>,
    atlas_col: <%= row[1] %>,
    category: Category::<%= row[2].capitalize %>,
    card_name: "<%= row[3] %>".to_owned(),
    cost: PlayCost {
      bricks: <%= value_or_default row[4], 0 %>,
  <% end %>

There's some complexity in keeping track of the first time the loop has been iterated over so a comma can be printed between items, but otherwise it's plain ERB that shouldn't look terribly unfamiliar to anyone who's used Ruby on Rails before. Or Jinja with Python.

I highly suggest running cargo fmt afterward, because ERB isn't well known for cleaning up whitespace effectively.

Following this, it was time to glue everything together. For this I found that PowerShell worked marvelously. I found a StackOverflow answer which provided the basics of a script which can save Excel files as other formats, then the Microsoft documentation provided the right format code for the TSV I want. Finally, the command runs the Ruby script from earlier.

# xlsx2tsv2rs.ps1
$ExcelFile = Join-Path $(Get-Location) ARCOMAGE_CARDS.xlsx
$TsvFile = Join-Path $(Get-Location) ARCOMAGE_CARDS.tsv
$TsvFmtCode = -4158
$Excel = New-Object -Com Excel.Application
$WorkBook = $Excel.Workbooks.Open($ExcelFile)
$WorkBook.SaveAs($TsvFile, $TsvFmtCode)
ruby tsv2rs.rb

The natural next step for this is to use a build.rs file to invoke this automatically when cargo build is run, however, there's a reason why I don't think that's a good idea: it destroys cross-platform compatibility. The way it is now, I can rebuild my static objects when I'm on a Windows PC which has PowerShell, Excel, and Ruby. While PowerShell is now cross-platform, the only other native platform for Excel is Mac. I'm not even sure if PowerShell on Mac would interface with Excel for Mac the way it has on Windows.

And that's how I learned to convert Excel to Rust.