A command line interface for HomeBank

7 min read Original article ↗

I have used HomeBank for many years to manage my personal finances. It’s a great tool with a lot of features going for it:

  • cross-platform across Windows, macOS, and Linux
  • offline-only, so you don’t need to worry about others snooping on your financial data
  • translated in 56 languages
  • supports as many accounts and currencies as you like
  • transaction tags, categories, memos, and more
  • split transactions
  • transaction templates and scheduled transactions
  • easy-to-understand view for filtering transactions by date, category, or other info
  • monthly or yearly budgets per category
  • gasoline and yearly mileage tracking for your vehicle(s)
  • graphing capabilities to show changes over time
  • import from/export to a few different file formats
  • statistical reports
  • so much more

Best of all, HomeBank is free and open source software. You can see its code and its entire development history (since 1995!) on launchpad.

With all that said, my main gripe with HomeBank is that it’s not very programmatic. When tax season comes around, it’s a bit tedious to click a bunch of buttons to select the specific type of transaction I want, like home expenses or income, and then doing it all again for a different amount to calculate 1.

But these kinds of repetitive and similar tasks are where APIs and programmatic access work wonders. Since I have some previous experience building a command line interface for another financially-related problem, I wanted to try my hand at doing something like this for HomeBank 2.

Building an interface for HomeBank

The first step for building an application that uses data from another is to figure out where that data is stored and what format its in. To my surprise, the financial data for HomeBank is stored in an XML file with the .xhb extension. This made it extremely easy to figure out what the structure of the data was and to make some early proofs-of-concept. The codebase is also clearly organized, so you can see how certain structures, enums, and values are handled internally by HomeBank.

With my previous CLI application experience, I wanted to write this project in Rust. I’ve really enjoyed the CLI parsing crates that are written in Rust 3, it’s parsing libraries are excellent and efficient, and I just like writing in the language. But since HomeBank is written in C, there were two options for how to begin:

  1. write a C <-> Rust foreign function interface to use HomeBank code directly
  2. write a Rust crate that re-implements a lot of data-level HomeBank functionality from scratch

I decided to go with Some(2). But clocking in at almost 50 000 lines of C code, there was no way I was going to encapsulate the entire thing. So I decided to prioritize a couple things:

  1. faithfully represent the data structures
  2. make everything easy to query

If I was going to try and access my financial data in a more programmatic way, filtering all the data I’ve recorded was going to be important. The general plan was to put essentially all the functionality in a homebank-db library crate with a thin CLI binary crate to wrap it up for the end user.

I figured that one of three main approaches to designing the library was going to work:

  1. Parse the XML and filter/query it on the fly to keep a minimal overhead and fast processing
  2. Create an SQL database on the fly and write functions that interacted with that database
  3. Write good data structures and just load everything into memory

Despite using HomeBank for years, I found that my entire XML file of financial data is a meager 400 kB. The binary executable was going to be bigger than this database. There was no need to worry about creating a separate database or filtering data on the fly, so I went with the third option.

Both the library and CLI binary can be found in this git repo. You can see the similarity in the codebase structures in the screenshot below, with the original HomeBank code on the left and my implementation in Rust on the right.

A side-by-side comparison of the original HomeBank code (left) and my implementation in Rust (right) -80%

After spending a bunch of time on the data structures, lifetimes, and parsing functions, everything else kind of fell into place. The thiserror crate is fantastic for writing custom errors in libraries and made my job much easier.

There was some tricky work figuring out how to represent transactions that were split across categories without unnecessarily duplicating data or having lifetime parameters sprinkled everywhere in the code. But for the most part, the library is a collection of modules that parses the XML file, validates the inputs, stores the data its relevant structure, and performs a series of .filter() operations to query the dataset.

I tried to follow a practice of test-driven development for this project, so I used the original codebase for inspiration and wrote a bunch of parsing code for safe handling of the data 4. I used cargo-nextest for running and checking tests in the entire workspace, which is becoming a nice tool in my Rust workbench.

Building a CLI for HomeBank

Compared to the internals of the library, designing the CLI was easy. At the moment, there are two main subcommands: query and sum. query lets you query the database for accounts, categories, currencies, groups, payees, or transactions. sum is just the sum of a query of transactions.

The nice thing for me is that because of how structopt is set up, I didn’t need to copy the CLI flags for the query subcommand onto the sum subcommand. I could use this struct for top-level CLI parsing:

#[derive(Debug, StructOpt)]
#[structopt(author, about)]
pub struct CliOpts {
    #[structopt(
        short = "c",
        long = "config",
        help = "Path to hb configuration file",
        default_value = &DEFAULT_CFG
    )]
    pub path: PathBuf,

    // make optional subcommands
    #[structopt(subcommand)]
    pub subcmd: Option<SubCommand>,
}

And this struct for the subcommand parsing:

#[derive(Debug, StructOpt)]
pub enum SubCommand {
    #[structopt(
        about = "Perform a query on the HomeBank database",
        visible_alias = "q"
    )]
    Query(QueryOpts),
    #[structopt(
        about = "Calculate a sum of transactions in a query",
        visible_alias = "s"
    )]
    Sum(QueryTransactions),
}

// ...

#[derive(Debug, StructOpt)]
pub struct QueryOpts {
    #[structopt(subcommand)]
    query_type: QueryType,
}

#[derive(Debug, StructOpt)]
pub enum QueryType {
    Accounts(QueryAccounts),
    Categories(QueryCategories),
    Currencies(QueryCurrencies),
    Groups(QueryGroups),
    Payees(QueryPayees),
    Transactions(QueryTransactions),
}

And since I used the QueryTransactions struct in both QueryType and SubCommand, all the CLI flags used for querying transactions were automatically available to sum. That ensures that the flags will always be in sync with each other for a unified user experience.

In the end, the CLI is just a thin wrapper around the library. The CLI parsing is done with clap and structopt; there is some configuration dotfile processing with dirs, lazy_static, serde, and toml; and some friendly application error handling with anyhow.

Now, I can quickly find out how much I spent on groceries last year with this simple command:

> hb sum -d 2021-01-01 -D 2022-01-01 -c Groceries
-6168.98

Conclusions

I love HomeBank. As free software, it helped me manage my finances during a time in my life where I couldn’t afford flashier or more expensive software. And now that I have more, I’ve found that I don’t actually need flashier or more expensive software. HomeBank does basically everything I need in a way that I want. This made it easy to build upon the foundation that HomeBank has provided with my own tools. If you want to try out the hb CLI, check out my git repo.

Because of how much value it has given me, I’ve donated to the project. If you also value HomeBank, I’d encourage you to donate, too.

And if you haven’t heard of HomeBank and are looking for a financial management tool, I’d highly recommend giving it a try. I found it almost 10 years ago and it has served me well ever since.

Comments on Mastodon.