Parse CLI arguments without 3rd party crates

Minimal command-line argument parsing in Rust without dependencies.

Sometimes, bringing in a third-party dependency, such as clap, to your project can feel like a bit of a burden. For example, to improve your prospective users’ first impressions, you might want your examples to compile as quickly as possible.

It’s tempting to create something that is a bit of an unwrap-frenzy:

let keys: usize = std::env::args().nth(1).unwrap().parse().unwrap();
let size: usize = std::env::args().nth(2).unwrap().parse().unwrap();
let mode: String = std::env::args().nth(3).unwrap();

Avoid this style! It provides none of the informative error messages that your users are hoping for. This is also write-once, read-never code. We can do better.

Task

For the code examples, assume that we’ve been asked to create an application that can send an HTTP request to a URL, reporting whether an return code in the 200-299 range is returned from the server.

Recommendations

So, what should happen instead of nth(n).unwrap().parse().unwrap()?

First of all, check whether going alone really is a good idea. Once you have outgrown propositional arguments, the  advice in the next few paragraphs no longer applies. As soon as you need to handle options, your time will be better off delegating your parser to people that write parsers.

But if you’re sure…

Give help messages priority

Start with --help. The first thing to include is the ability for people to get some help about what to do if they ask for it.
				
					const HELP: &str =  "is-alive: <endpoint> <timeout>

Indicates whether ENDPOINT returns a response in
the 200 range within TIMEOUT seconds.";

fn main() {
    for arg in std::env::args() {
        match arg.as_str() {
            "help" | "--help" | "-h" | "-?" | "?" => {
                eprintln!("{HELP}");
                std::process::exit(1);
            },
            _ => (),
        } 
    }
}
				
			

Load this example into the Rust playground.

Factor out of main quickly

To avoid cluttering up main with argument-handling logic, you should factor this code out into its own function quite quickly. To allow forward-compatibility with clap, let’s put the parsing logic within an associated function of an Args.
				
					const HELP: &str =  "is-alive: <endpoint> <timeout>

Indicates whether ENDPOINT returns a response in
the 200 range within TIMEOUT seconds.";

struct Args {
    endpoint: String,
    timeout: std::time::Duration,
}

impl Args {
    fn parse() -> Self {
        for arg in std::env::args() {
            match arg.as_str() {
                "help" | "--help" | "-h" | "-?" | "?" => {
                    eprintln!("{HELP}");
                    std::process::exit(1);
                },
                _ => (),
            } 
        }
        todo!()
    } 
}

fn main() {
    let _args = Args::parse();
}
				
			

Load this example in the Rust Playground.

Parse arguments and provide help when there's a problem

We’re now in a possible where we can parse arguments. How do they come? The std::env::args() function returns an iterator. Rust Iterators’ next() method returns Option, which we’ll need to process, parsing it either as a url::Url or as std::time::Duration. Parsing typically returns a Result. Mixing Result and Option together can lead to awkward code. As a least-bad option, you’ll see nested patterns being used with the match statement:
        match args.next().map(|arg| url::Url::parse(&arg)) {
            Some(Ok(url)) => /* .. */,
            Some(Err(err)) => /* .. */,
            None => errors.push(format!("error: ENDPOINT required.")),
        }
The (much longer) code listing also accumulates any errors that might have been caused by invalid input, then prints out a usage line. If you prefer, it’s certainly possible to exit early once you’ve encountered a single error. This makes the code easier, but as a user I prefer to be told all of the errors. Note: the first few lines at start of the listing are part of “Cargo Script”. They enable the code to be copy + pasted as-is into a file, then be immediately compiled and executed. Cargo will download the dependency on the url crate itself.

Load this example into the Rust Playground.

				
					#!/usr/bin/env -S cargo +nightly -Zscript
```cargo
[dependencies]
url = { version = "2.5" }
```
use url;

const HELP: &str = "is-alive

Indicates whether ENDPOINT returns a response in
the 200 range within TIMEOUT seconds.";

struct Args {
    endpoint: url::Url,
    timeout: std::time::Duration,
}

impl Args {
    fn parse() -> Self {
        for arg in std::env::args() {
            match arg.as_str() {
                "help" | "--help" | "-h" | "-?" | "?" => {
                    eprintln!("{HELP}");
                    std::process::exit(1);
                }
                _ => (),
            }
        }
        let mut args = std::env::args();
        let command = args.next().unwrap();
        let mut errors = vec![];

        let mut endpoint = None;
        let mut timeout = None;

        match args.next().map(|arg| url::Url::parse(&arg)) {
            Some(Ok(url)) => endpoint = Some(url),
            Some(Err(err)) => {
                errors.push(format!("error: unable to parse ENDPOINT as a URL ({err})."))
            }
            None => errors.push(format!("error: ENDPOINT required.")),
        }

        match args.next().map(|arg| {
            arg.parse::<u64>()
                .map(|t| std::time::Duration::from_secs(t))
        }) {
            Some(Ok(t)) => timeout = Some(t),
            Some(Err(err)) => errors.push(format!(
                "error: unable to parse TIMEOUT as a number of seconds ({err})."
            )),
            None => errors.push(format!("error: TIMEOUT required.")),
        }

        if let (Some(endpoint), Some(timeout)) = (endpoint, timeout) {
            Args { endpoint, timeout }
        } else {
            for e in errors {
                eprintln!("{e}")
            }
            eprintln!("usage: {command} <endpoint> <timeout>");

            std::process::exit(1);
        }
    }
}

fn main() {
    let _args = Args::parse();
}

				
			

Contents