One Crate a Day: has-flag
Sean Larkin
Posted on January 24, 2023
Welcome to "One Crate a Day", my daily journal as I dive into the world of Rust programming. As a JavaScript developer with a background in open-source, I've decided to take on the challenge of re-writing popular JavaScript packages for the Rust community.
Here are a few tl;dr goals I have for this project:
- 👨🎓Learn Rust & Popular JavaScript modules
- 📦Contribute to the Rust community by publishing new Crates
- 👨🏫Share my learnings, tips, and best practices with you
If you've come here to learn how to install, start Rust from scratch, or setup the toolchain for your IDE/environment, see the Rust Handbook! Otherwise, lets dive in!
Crate #1: has-env-flag
Original Package: sindresorhus/has-flag
Sindre's JavaScript packages exemplify the concepts of single purpose and reusability. Often times only implemented as a single exported function. has-flag
is no different in this regard.
Review
This single function module allows developers to quickly detect the presence of a specific flag in the argv (arguments passed to the script/binary that was run). The code for the module is as follows:
import process from 'process'; // eslint-disable-line node/prefer-global/process
export default function hasFlag(flag, argv = process.argv) {
const prefix = flag.startsWith('-') ? '' : (flag.length === 1 ? '-' : '--');
const position = argv.indexOf(prefix + flag);
const terminatorPosition = argv.indexOf('--');
return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
}
With this module, we can easily check for the presence of a specific argument ("flag") based on what was passed into our script. Here's an example from the README:
// foo.js
import hasFlag from 'has-flag';
hasFlag('unicorn');
//=> true
hasFlag('--unicorn');
//=> true
hasFlag('f');
//=> true
hasFlag('-f');
//=> true
hasFlag('foo=bar');
//=> true
hasFlag('foo');
//=> false
hasFlag('rainbow');
//=> false
$ node foo.js -f --unicorn --foo=bar -- --rainbow
Approach
To start my journey, I decided to craft my unit tests first. This way, I could work backwards until I had my solution.
Learning #1: Cargo uses Conventions
Cargo, which is the out-of-the-box tool for running tests, installing packages/dependencies, and more, has conventions for building libraries versus binaries.
By default, Cargo looks for one of two conventions in the workspace:
Libraries: If you are building a Rust library ("Crate"), Cargo will enforce the presence of
src/lib.rs
in your workspace.Binaries: If you are building a Rust binary, Cargo will enforce the presence of
src/main.rs
.
I love this! Every time I crack open a Rust project, I know exactly where to start/begin reading code.
Learning #2: How to write tests
Next, I learned how to write tests in Rust. From all of the packages I've looked at, and according to the Rust Handbook, unit tests are written in the same file! Additionally, you'll see the usage of Macros heavily in Rust tests. Macros are a powerful tool for creating code expansion at compile time, saving you from writing lines of boilerplate code.
Take the following test I wrote in my module as an example:
// Macro for setting up a test module
#[cfg(test)]
mod tests {
// Gives access to outer scope (not just the `pub fn`'s)
// great to test functions used in Dependency Injection
use super::*;
// Macro that turns a function into a unit test
#[test]
fn args_with_value_not_matching_double_dash() {
let args = vec!["--foo", "--unicorn=rainbow", "--bar"];
let expected_value = "unicorn=rainbow";
assert!(_has_flag(
args.into_iter().map(ToString::to_string),
expected_value
))
}
}
#[cfg(test)]
macro: This is our macro for setting up our test module. It instructs Rust to compile and run the test code only when you runcargo test
#[test]
macro: This macro converts the function into a unit test! You will use macros likeassert!()
orassert_eq!()
to validate the results of the code you want to test.use super::*
: Exposes the outer scope to your inner test module. The glob allows anything defined in the outer scope to be used in your test functions (anything not defined withpub fn
)!
Learning 3: Working with std::env
After implementing the tests I decided to take a crack at writing this module. Much thanks to Steve Klabnik, who helped me with getting the types for the function and setting up dependency injection, this was the first iteration:
pub fn has_flag(flag: &str) -> bool {
_has_flag(std::env::args(), flag)
}
fn _has_flag<I: Iterator<Item = String>>(mut args: I, flag: &str) -> bool {
let prefix = if flag.starts_with('-') {
""
} else {
if flag.len() == 1 {
"-"
} else {
"--"
}
};
let position = args.position(|arg| arg == format!("{}{}", prefix, flag));
let terminator_position = args.position(|arg| arg == "--");
position.is_some() && (!terminator_position.is_some() || position < terminator_position)
}
std::env::args()
: This is how you can accessargv
or arguments that the program was started with.std
is the Rust standard library and is available to all Rust crates by default..position()
: Searches for an element in an iterator, and returns its index. I saw this as comparable to.indexOf
in JavaScript. However I made some mistakes in my code using this and I'll explain why..is_some()
&is_none()
: There is no concept ofnull
in Rust. Rather, Option is a type which is meant to handle the no value being returned. In a few parts of our code, we don't really care what the index is, rather if the index exists..is_some()
is the perfect usage for that here.
Learning 4: Mutability and Bugs!
This code that I initially committed and published actually had a few bugs! However, my initial tests were passing. I had created a GitHub Issue to come back when I had time and implement all of the tests from has-flag
's test suite.
My PR introducing the full tests was failing so I knew there must be a bug in the code itself. After getting some feedback from the "Beginners" channel on The Rust Programming Language Discord, I started to realize some of the mistakes I had made. Here is the updated code which passed tests:
pub fn has_flag(flag: &str) -> bool {
_has_flag(std::env::args(), flag)
}
fn _has_flag<I: Iterator<Item = String>>(args: I, flag: &str) -> bool {
let prefix = if flag.starts_with('-') {
""
} else if flag.len() == 1 {
"-"
} else {
"--"
};
let formatted_flag = format!("{}{}", prefix, flag);
args.take_while(|arg| arg != "--")
.any(|arg| arg == formatted_flag)
}
mut args
=>args
: The first code smell I should have noticed when I wrote my first iteration, was that the Rust compiler was forcing me to addmut
next to myargs
parameter for the function. This didn't make sense to me becausemut
is only needed if I am mutatingargs
..position()
mutates: This led me to noticing that.position()
was not the function I wanted to use..position()
consumes items in the iterator until the predicate is matched. So the code determiningterminator_position
was actually accessing a mutatedargs
. All my tests involving the--
args terminator were failing as a result!.take_while()
: Takes an iterator, and runs through it until the predicate is true, and then returns an iterator of those items up until the predicate. I think of this similar to an iterator filter. This was perfect for us to use here because we don't want to match args passed after the--
terminator. It also massively simplified our code!
Ship it🎉
This iteration passed all of our tests and I was able to happily merge and publish a new release of has-env-flag
. As I continue to work on this journal, I will encounter many new challenges and learnings about Rust, and I can't wait to share them with you.
Resources
Posted on January 24, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.