Reading command-line arguments takes a few lines. The trouble starts when main also validates URLs, decides what the command means, and prints the result. A parser test now has to launch a process. A wording change can disturb a domain test. The function becomes a knot of unrelated responsibilities.
This tutorial builds a small endpoint registry CLI with the standard library only. The finished program accepts this command:
cargo run --quiet -- add api https://example.com/health
registered api -> https://example.com/health
The code keeps four boundaries visible: process I/O through std::env, parsing raw strings into a Command, applying endpoint rules, and formatting output. Most tests can then call ordinary functions without a terminal or operating-system setup.
1. First-Version Scope
The CLI has one grammar:
endpoint-registry add <name> <http(s)://url>
add validates a name and URL, then reports the registered endpoint. This version does not write to a file or database. Registration means creating a valid domain value for one invocation. Persistence belongs behind a repository boundary later in the series.
The example uses Rust 2024 and has no dependencies:
[package]
name = "endpoint-registry"
version = "0.1.0"
edition = "2024"
publish = false
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
all = "warn"
pedantic = "warn"
Each package's Cargo.toml is its manifest and contains the metadata Cargo needs to compile the package. Setting edition = "2024" makes the example's edition explicit.
1.1. Run the CLI
Its canonical source files are Cargo.toml, src/lib.rs, src/main.rs, and tests/cli.rs; no file outside that directory is required. Run the exact version used for this article with:
cd fixtures/06-endpoint-registry-cli
cargo run --quiet -- add api https://example.com/health
The sections below explain focused excerpts from those files. Use the example paths above when recreating the program so that error types, helper functions, and process tests are included rather than inferred from isolated snippets.
2. Cost of a Large main
A first attempt often looks like this:
fn main() {
let args: Vec<String> = std::env::args().collect();
if args[1] == "add" {
println!("registered {} -> {}", args[2], args[3]);
}
}
Missing arguments cause an indexing panic. URL checks add more branches to main. Because formatting and printing happen in the same function, checking the result requires capturing stdout or spawning the binary. A main function that parses arguments and performs the work is harder to reason about, test, and change.
Use a narrower rule: main may touch the operating system, but it should not make domain decisions. The rest of the program accepts values and returns values.
3. Raw Strings to Command
The parser does not read the environment. It accepts an iterator of owned strings.
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Add { name: String, url: String },
}
#[derive(Debug, PartialEq, Eq)]
pub enum ParseError {
MissingCommand,
UnknownCommand(String),
WrongArgumentCount { command: String },
}
pub fn parse_args(
args: impl IntoIterator<Item = String>,
) -> Result<Command, ParseError> {
let mut args = args.into_iter();
let command = args.next().ok_or(ParseError::MissingCommand)?;
match command.as_str() {
"add" => {
let (Some(name), Some(url), None) =
(args.next(), args.next(), args.next())
else {
return Err(ParseError::WrongArgumentCount { command });
};
Ok(Command::Add { name, url })
}
_ => Err(ParseError::UnknownCommand(command)),
}
}
Once a Command exists, later code does not need to know that the URL occupied a particular index. The let ... else pattern accepts exactly a name and a URL, with no trailing token. Treating missing and extra operands as the same error is a parser policy, not a domain rule.
std::env::args() returns an iterator over the arguments used to start the process. Its first item is traditionally the executable path, so main removes it with skip(1). Do not trust that item for security decisions: it may contain arbitrary text and need not name an existing path.
There is another boundary worth stating. Iterating args() can panic if an argument is not valid Unicode. A tool that must preserve arbitrary operating-system strings should consider args_os() and OsString. This CLI deliberately chooses a String boundary for human-entered Unicode text.
4. Where URL Rules Belong
parse_args checks token shape. Endpoint::new decides whether the name and URL satisfy the registry's current rules.
#[derive(Debug, PartialEq, Eq)]
pub struct Endpoint {
name: String,
url: String,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
Added(Endpoint),
}
#[derive(Debug, PartialEq, Eq)]
pub enum DomainError {
EmptyName,
UnsupportedScheme,
}
impl Endpoint {
pub fn new(name: String, url: String) -> Result<Self, DomainError> {
if name.trim().is_empty() {
return Err(DomainError::EmptyName);
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err(DomainError::UnsupportedScheme);
}
Ok(Self { name, url })
}
}
This is not a complete URL parser. It does not confirm that a host follows https://, for example. A standard-library-only checkpoint can enforce the minimum invariant, an allowed scheme, without pretending to normalize URLs. If the application later needs stricter parsing, change the validation policy and parser together.
Execution stays free of I/O too:
pub fn execute(command: Command) -> Result<Outcome, DomainError> {
match command {
Command::Add { name, url } => {
Endpoint::new(name, url).map(Outcome::Added)
}
}
}
Separate parse and domain errors tell a failing test which boundary rejected the input. A top-level CliError can wrap both when the user-facing path needs one error type.
5. Format Before Printing
render returns a String rather than calling println!.
#[derive(Debug, PartialEq, Eq)]
pub enum CliError {
Parse(ParseError),
Domain(DomainError),
}
impl From<ParseError> for CliError {
fn from(error: ParseError) -> Self {
Self::Parse(error)
}
}
impl From<DomainError> for CliError {
fn from(error: DomainError) -> Self {
Self::Domain(error)
}
}
#[must_use]
pub fn render(outcome: &Outcome) -> String {
match outcome {
Outcome::Added(endpoint) => {
format!("registered {} -> {}", endpoint.name, endpoint.url)
}
}
}
pub fn run(
args: impl IntoIterator<Item = String>,
) -> Result<String, CliError> {
let command = parse_args(args)?;
let outcome = execute(command)?;
Ok(render(&outcome))
}
A test can now compare the success message as plain data. If the CLI later needs JSON output, a different renderer can reuse the parsing and endpoint rules. This example does not yet promise a JSON schema or compatibility contract.
6. Only I/O in main
use std::{env, process::ExitCode};
use endpoint_registry::{USAGE, run};
fn main() -> ExitCode {
match run(env::args().skip(1)) {
Ok(output) => {
println!("{output}");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("error: {error}\n\n{USAGE}");
ExitCode::FAILURE
}
}
}
Primary output goes to stdout. Diagnostics and usage go to stderr. This split keeps errors and progress on stderr while stdout carries the program's primary output. Shell users can redirect or pipe successful records without mixing in diagnostics.
A Rust main function may return ExitCode. This example uses the platform's canonical ExitCode::SUCCESS and ExitCode::FAILURE. Raw numeric meanings and masking vary by platform, so the named constants express the simple success/failure contract more directly.
The separator in the Cargo command matters:
cargo run --quiet -- add api https://example.com/health
--quiet belongs to Cargo. The add, api, and URL after -- go to the program.
7. Function and Process Tests
Parsing, domain validation, and rendering fit fast unit tests.
fn strings(items: &[&str]) -> Vec<String> {
items.iter().map(ToString::to_string).collect()
}
#[test]
fn rejects_unsupported_url_scheme() {
let command = Command::Add {
name: "api".to_owned(),
url: "ftp://example.com".to_owned(),
};
assert_eq!(execute(command), Err(DomainError::UnsupportedScheme));
}
#[test]
fn renders_stable_primary_output() {
let output = run(strings(&[
"add",
"api",
"https://example.com/health",
]));
assert_eq!(
output.as_deref(),
Ok("registered api -> https://example.com/health")
);
}
Function tests do not prove that main routes stdout, stderr, and exit status correctly. One integration test launches the real binary through the CARGO_BIN_EXE_endpoint-registry path that Cargo supplies while compiling integration tests.
#[test]
fn invalid_url_writes_error_and_usage_to_stderr() {
let output = Command::new(env!("CARGO_BIN_EXE_endpoint-registry"))
.args(["add", "api", "ftp://example.com"])
.output()
.expect("binary should run");
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert_eq!(
String::from_utf8_lossy(&output.stderr),
concat!(
"error: endpoint URL must start with http:// or https://\n",
"\n",
"Usage: endpoint-registry add <name> <http(s)://url>\n"
)
);
}
There is no need to choose one test level. Put the many input combinations in cheap function tests, then use a few process tests to verify the wiring. Rust test functions carry the #[test] attribute; cargo test builds a test runner and executes them. The command compiles and runs unit, integration, and documentation tests.
8. CLI Commands and Output
The example uses stable rustc 1.98.1, Cargo 1.98.1, Rust 2024, and no external dependencies.
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
The suite contains four library unit tests and two binary integration tests.
running 4 tests
....
test result: ok. 4 passed; 0 failed
running 2 tests
..
test result: ok. 2 passed; 0 failed
Check the failure path separately:
cargo run --quiet -- add api ftp://example.com
error: endpoint URL must start with http:// or https://
Usage: endpoint-registry add <name> <http(s)://url>
This input exits with status 1 and leaves stdout empty.
9. Parser Limits
This parser stays readable while the grammar has one command. Once a CLI needs interacting flags, optional values, generated help, or shell completion, a dedicated argument-parsing crate may remove substantial bookkeeping. That choice does not require collapsing the boundaries. Let the external parser produce Command; keep domain execution and output adapters separate.
The current registry disappears when the process exits, and URL validation checks only the scheme prefix. Those are explicit limits, not hidden features waiting behind the example. In the next installment, the owned String values inside Command and Endpoint become useful material for tracing moves and ownership across each function call.
Full source code
The complete runnable source for this article is available in the Chapter 06 project on GitHub.
Sources
- Rust standard library:
std::env::args - Rust standard library:
std::process::ExitCode - Rust standard library:
eprintln! - The Rust Programming Language: Accepting Command Line Arguments
- The Rust Programming Language: Refactoring to Improve Modularity and Error Handling
- The Rust Programming Language: How to Write Tests
- The Cargo Book:
cargo test - The Cargo Book: The Manifest Format
Leave a Reply