Flawless scripting with Rust

Flawless scripting with Rust

Over the past year, I've become acquainted with the Rust programming language.

I can't really pin down when I started getting interested in it, I was probably biased by the huge hype that revolved around it since a lot of peers were talking about it and I was spending an unusual time lurking on the r/rust subreddit, so at some point I sat down, downloaded the Rust Toolchain installer and decided to give it a go.

It was nothing like what I've been coding with up until then. I did my time with C++ and I can't say I enjoyed it that much, but overall, even if I've always been coding with higher-level languages, Rust looked like a challenge I could not pass.

I picked up The Rust Programming Language book and went through it multiple times, and after spending hours bashing my head against the keyboard or fighting countless (lost) battles with the compiler, once it started to click, the struggle and frustration turned into satisfaction and fun.

The biggest challenge for me was finding use in Rust for my needs. Even if I was liking the language, I could not find a specific scope for it, and for that reason, I used it as a Swiss army knife to get more and more practice. It wasn't of course the best idea, as sometimes I was forcing the language to work in some ways that it was not intended to.

In the end, I began to code some small microservices at work to improve our internal QA workflow, for both automation and manual QA engineers, and in my spare time for some side activities. What really impressed me over the last few weeks, however, is the discovery that Rust files can also be executed as a simple script.

Wait, what?

The definition I just gave is extremely rough and approximate, but according to the definition of rust-script, "with rust-script Rust files and expressions can be executed just like a shell or Python script".

The example found on the GitHub page is pretty straightforward: after installing rust-script, you create a file, add the proper shabang, write some (valid) Rust code, make it executable and it is ready to run!

#!/usr/bin/env rust-script
//! Dependencies can be specified in the script file itself as follows:
//!
//! ```cargo
//! [dependencies]
//! rand = "0.8.0"
//! ```

use rand::prelude::*;

fn main() {
    let x: u64 = random();
    println!("A random number: {}", x);
}

$ ./script.rs
A random number: 9240261453149857564
Code snippet from https://github.com/fornwall/rust-script

But that's not all, it can also run simple expressions by passing the -e option:

$ rust-script -e "print!(\"Hello world!\")"         
Hello world!   
An oversimplified usage example of the -e option

But how does it work? Usually, to be able to build a Rust application you need a proper project structure, with a manifest file containing the Rust version you want to use, its dependencies, and other minor details that help the toolchain build the final executable.

rust-script, in this regard, does the following:

  • It creates a Cargo project in the ~/.cache/<rust-project> folder on your machine.
  • It wraps the expression given in a fn main() {} function if needed, for instance when you pass an expression with the -e parameter.
  • It generates a Cargo.toml file for you. All you have to do in this regard is declare additional dependencies with the --dep parameter or inner line documentation.

In a few words, it just creates a standard Rust project and runs the built executable for you.

I was a bit skeptical at the beginning, so I added --cargo-output to a simple invocation to see what was going on (as the output related to the build step is quiet by default):

$ rust-script --cargo-output -e "print!(\"Hello world\")"
   Compiling expr v0.1.0 (/home/nick/.cache/rust-script/projects/925350e4bb9a73e58f87395a)
    Finished release [optimized] target(s) in 0.22s
     Running `.cache/rust-script/binaries/release/expr_925350e4bb9a73e58f87395a`
Hello world%     

As you can see in the snipped above, a release build is created from the generated project located at /home/nick/.cache/rust-script/projects/925350e4bb9a73e58f87395a. If we dig a bit and see what's in there, this is the outcome:

$ ls -l /home/nick/.cache/rust-script/projects/925350e4bb9a73e58f87395a        
total 16
-rw-r--r-- 1 nick nick 148 Jul 17 14:35 Cargo.lock
-rw------- 1 nick nick 195 Jul 17 14:35 Cargo.toml
-rw------- 1 nick nick 728 Jul 17 14:35 expr.rs
-rw------- 1 nick nick 214 Jul 17 14:35 metadata.json

We have 4 files in here (I'm not going to follow the order above):

  • expr.rs is the Rust file containing a main  function with the expression we passed to rust-script:
$ cat expr.rs

use std::any::{Any, TypeId};

fn main() {
    let exit_code = match try_main() {
        Ok(()) => None,
        Err(e) => {
            use std::io::{self, Write};
            let _ = writeln!(io::stderr(), "Error: {}", e);
            Some(1)
        },
    };
    if let Some(exit_code) = exit_code {
        std::process::exit(exit_code);
    }
}

fn try_main() -> Result<(), Box<dyn std::error::Error>> {
    fn _rust_script_is_empty_tuple<T: ?Sized + Any>(_s: &T) -> bool {
        TypeId::of::<()>() == TypeId::of::<T>()
    }
    match {print!("Hello world")} {
        __rust_script_expr if !_rust_script_is_empty_tuple(&__rust_script_expr) => println!("{:?}", __rust_script_expr),
        _ => {}
    }
    Ok(())
}

As you might notice, the generated file is a bit more verbose than expected.

  • Cargo.toml is the manifest file containing all the information cargo needs to build the executable. Names, version, and authors are placeholders as there's no need to create a crate to publish somewhere. After all, it's just a local script.
$ cat Cargo.toml

[[bin]]
name = "expr_925350e4bb9a73e58f87395a"
path = "expr.rs"

[dependencies]

[package]
authors = ["Anonymous"]
edition = "2018"
name = "expr"
version = "0.1.0"
[profile.release]
strip = true
  • Cargo.lock is where our dependency versions are pinned:
$ cat Cargo.lock

# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3

[[package]]
name = "expr"
version = "0.1.0"
  • And lastly, the metadata.json file. It contains bits of information used by rust-script and it's not really of interest to us, so we can just ignore it.

Being a Rust project under the hood, depending on its size and dependencies it might take a bit to compile. While this is unavoidable since dependencies need to be downloaded and the code compiled at least once, the following invocations of the same script - as long as its content is unchanged - will run immediately as the entire build process is cached (in our case in /home/nick/.cache/rust-script/projects/925350e4bb9a73e58f87395a).

Something's missing, isn't it?

One thing that might disappoint the reader is that in the examples above there is no testing code. Why would anyone prefer rust-script to Bash (although tests in Bash are not my favorite) or any other high-level scripting language (Python for instance?).

Truth is, rust-script also supports running unit tests!

Let's go back and consider the first snippet taken from the rust-script GitHub repo once again, and this time we change a few things in there:

#!/usr/bin/env rust-script

//! Dependencies can be specified in the script file itself as follows:
//!
//! ```cargo
//! [dependencies]
//! rand = "0.8.0"
//! ```

use rand::prelude::*;

fn main() {
    let x: i8 = rand::thread_rng().gen_range(0..100);
    println!("A random number from 0 to 100: {}", x);
    let y: i8 = rand::thread_rng().gen_range(0..100);
    println!("Another random number from 0 to 100: {}", y);

    println!(
        "The subtraction of these two random numbers: {}",
        subtract(x, y)
    );
}

fn subtract(n1: i8, n2: i8) -> i8 {
    n1 - n2
}

#[cfg(test)]
mod tests {
    use crate::subtract;

    #[test]
    fn the_subtraction_works() {
        let expected_result = -35;
        assert_eq!(expected_result, subtract(-10, 25));
    }
}
Move fromu64 to i8 and reduced range of the possible numbers are for the sake of simplicity

This script now prints two random numbers and subtracts the second from the first. The subtract function has a simple signature, and takes two numbers, n1 and n2, and returns the result to the caller.

To make sure our subtract code works as expected, we can either run the script multiple times, write down the numbers on a piece of paper, subtract them manually, and verify the result is what we expect. While this might sound cool with some old-school vibes to some, please do not do that.

If you notice the code block beginning with #[cfg(test)], it contains a simple testing function that performs an assertion between an expected value and the actual value computed by our function.

We can run this test with rust-script by adding the --test option:

 $ rust-script --test --cargo-output script.rs
   Compiling script v0.1.0 (/home/nick/.cache/rust-script/projects/43de84a97d5d7d01f2db850b)
    Finished test [unoptimized + debuginfo] target(s) in 0.28s
     Running unittests (/home/nick/.cache/rust-script/binaries/debug/deps/script_43de84a97d5d7d01f2db850b-2f022673f5d0a470)

running 1 test
test tests::the_subtraction_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Sounds cool, but how can this be useful?

For some of our automation tools at work, we use Rust. As a matter of fact, a few of our pre-commit hooks in some repositories run Rust code to make sure that code and/or raw files are formatted as expected. If you happened to read my ongoing Exploring Mobile UI Testing series, in the repository folder I put a pre-commit hook that does exactly that, to make sure JSON files are actually humanly readable so we don't spend centuries trying to decode kilometric lines.

The main issue with using Rust in these situations, however, lies in the need to build multiple executables depending on the OS and arch in use. In our case, some engineers have an Apple Silicon MacBook Pro, and some others an Intel MacBook Pro, so every release requires at least two different binaries to make anyone able to run the script on their machine. And the list could probably get longer and the entire process wearisome if more people with different hardware capabilities needed access to that.

With rust-script, since the source of some binaries is pretty small and really does a few things, we can finally have it compiled on the fly (you have to install the Rust toolchain first, but we expect that as a precondition) the first time, and have instant subsequent executions as the code usually never changes.


rust-script gives developers another option when they need to code simple scripts for different purposes, and they do not want to go for bare Bash or another scripting language they might not be familiar with.

In my opinion, as much as I like Bash and pipe-chaining commands in some of our scripts and I find them fascinating, to say the least, having this alternative makes Rust a good choice thanks to the safety and guarantees provided by its compile-time checks, the possibility to write and run unit tests easily, and full IDE support for the generated project.

Niccolò Forlini

Niccolò Forlini

Senior Mobile Engineer