Building a CLI tool with Rust!
For the past 6 months, I’ve been playing around with and teaching myself Rust, Mozilla’s new systems programming language, by trying to write a CLI tool with it.
The Rust website has some one-liners that you can copy and paste into your shell to install Rust and the associated toolchains. These use a tool called rustup to detect your operating system, download the appropriate files and put them in the appropriate locations. This is easy, but since it installs Rust to a global location, it has the same downsides as installing Ruby or Python to a global location does: namely that all of your projects have to use the same version of the language.
Luckily, Rust has a tool analogous to rbenv called multirust that will allow you to download multiple versions of Rust and associate your different projects with specific versions.
In the end I went with multirust to install Rust Nightly to my system.
Projects in Rust are created through the package manager and build tool Cargo. Projects are called “crates” and Cargo has the ability to generate 2 different kinds of crates depending on the intended output of the project: library crates to produce libraries that can be included in other code; and binary crates to produce stand-alone binaries to be executed.
I knew I wanted to create a binary from my crate, so I initially reached for a binary crate. However, after a little more thought, I ended up going with something more like a hybrid structure to allow me to split up my code into a core library and then a binary that would import and use that library.
By convention all of the code for a crate goes in the src directory. Library crates have a src/lib.rs file while binary crates have a src/main.rs file.
For gear, I actually have 2 subdirectories under src that split the source code up into 2 sections: src/gear is a library crate that contains common code and functionality; src/bin is a binary crate that imports the library from src/gear and is eventually compiled into the final executable.
Since this is a little non-standard, I had to do some extra tweaking of my crate’s Cargo.toml file:
name = "gear"
path = "src/gear/lib.rs"
name = "gear"
test = false
doc = false
This [lib] section defines the library crate. It will be named “gear” and its main file is in src/gear/lib.rs.
The [[bin]] section defines the “gear” binary. Since I indicated the binary would be named “gear”, Cargo automatically infers that the main file for this binary should be located in src/bin/gear.rs.
One of the big draws for me for Rust was the ability to create a single file that a user could download matching their platform and operating system and be able to run without needing to install any other dependencies. Rust is great for this since it can compile binaries for 32- and 64-bit Linux, OS X and Windows.
Building all of those artifacts, however, was a real challenge. Cross-compilation on Rust is possible, but there doesn’t seem to be that much documentation or built-in support for it in the tools. It’s possible to download standard libraries and toolchains for other platforms, but getting the right linkers and libraries seemed difficult, and maybe impossible in the case of different operating systems.
Setting up the Travis CI builds was the most straightforward. These use the .travis.yml file to set up a build matrix with all of the Linux and OS X target triples that I want to build for. I had initially tried using Travis’s containerized infrastructure, but was running into some problems installing the 32-bit dependencies. I ended up switching to Travis’s Trusty infrastructure instead so I could use apt-get to install the i386 architecture and cross-compiling requirements. I use multirust to install the toolchain for the current target, and then just use Cargo to build for that target.
Getting AppVeyor going was considerably more tricky. The 2 biggest problems I faced were getting OpenSSL to compile, and getting cmake into the path.
To compile and link with OpenSSL successfully, I took a page from multirust-rs’s book and added some extra Cargo configs specifically for AppVeyor to tell it to use some extra paths in order to find headers. Doing that makes some sense to me, but I’m not really sure why I needed to specify these explicitly in the first place.
Cmake was another problem altogether. Compiling for the GNU targets, cmake would fail with an unhelpful error telling me it could not find a make program. The solution here ended up being that I needed to put C:\msys64\usr\bin on my path as well as the architecture-specific C:\msys64\mingw32\usr\bin or C:\mysys63\mingw64\usr\bin. Apparently the appropriate make program only exists in the generic folder, but not in the architecture-specific ones. Again it makes sense why doing this works, but I don’t know why I needed to do this in the first place.
With the build infrastructure set up, I could now actually release binaries!
I chose to use GitHub Releases to release the software as that seemed like a neat and simple way to manage it. Releases hooks into the tags in your repo, so creating a new release is as easy as creating a new tag and pushing it to GitHub.
Both Travis CI and AppVeyor support uploading artifacts to a GitHub Release. On Travis, I have a bash script that packages the binary built by Cargo up into a tar archive, which is then uploaded by Travis to the release matching the current tag. On AppVeyor, I similarly create a zip archive with the executable built by Cargo and push that AppVeyor’s artifacts store; AppVeyor then uploads the artifacts to GitHub for me.
The end result is that when I want to release a new version, I just need to make a new tag. Travis CI and AppVeyor then handle building the binaries and uploading them to GitHub for me. And I get a convenient list of past and current releases to direct users to!
All in all, building a CLI tool with Rust has been pretty straightforward. The most confusing thing for me was setting up the multi-platform build infrastructure, and that mostly because I have never worked with Windows as a development environment before and so I wasn’t familiar with common pitfalls or hangups.
Splitting the project into a library and binary that includes that library was very easy. Cargo made it very easy for me to do this.