Accepting Command-Line Arguments
A good command-line program needs to parse and validate arguments correctly. In this section, we'll improve how our program handles user input.
The Problem with Raw Arguments
Our current approach directly accesses args[1] and args[2], which is error-prone:
let args = std.env.args().collect<Vec<String>>()
if args.len() < 3 {
eprintln!("usage: oxgrep <query> <filename>")
std.process.exit(1)
}
let query = args[1].clone()
let filename = args[2].clone()
This works, but it's hard to maintain and extend. As we add features (like case-insensitive search), the argument parsing becomes messier.
Refactoring into a Config Struct
Let's create a Config struct to encapsulate argument parsing:
import std.fs
import std.env
import std.error.Error
struct Config {
query: String,
filename: String,
ignoreCase: Bool,
}
extension Config {
static fn new(args: &Vec<String>): Result<Config, String> {
if args.len() < 3 {
return Err("not enough arguments".toString())
}
let query = args[1].clone()
let filename = args[2].clone()
let ignoreCase = args.len() > 3 && args[3] == "--ignore-case"
Ok(Config {
query,
filename,
ignoreCase,
})
}
}
fn main(): Result<(), Box<dyn Error>> {
let args = std.env.args().collect<Vec<String>>()
let config = Config.new(&args)
.unwrapOrElse { err ->
eprintln!("Problem parsing arguments: {}", err)
std.process.exit(1)
}?
run(config)
}
fn run(config: Config): Result<(), Box<dyn Error>> {
let contents = std.fs.readToString(&config.filename)?
for line in contents.lines() {
if line.contains(&config.query) {
println!("{}", line)
}
}
Ok(())
}
Now the argument parsing is isolated in the Config struct, making it easy to test and modify.
Improving Error Messages
The generic "not enough arguments" message could be more helpful:
extension Config {
static fn new(args: &Vec<String>): Result<Config, String> {
if args.len() < 3 {
return Err(
"not enough arguments\n\
usage: oxgrep <query> <filename> [--ignore-case]".toString()
)
}
let query = args[1].clone()
let filename = args[2].clone()
let ignoreCase = args.len() > 3 && args[3] == "--ignore-case"
Ok(Config {
query,
filename,
ignoreCase,
})
}
}
Handling Invalid Options
What if the user passes an unrecognized flag? Let's validate:
extension Config {
static fn new(args: &Vec<String>): Result<Config, String> {
if args.len() < 3 {
return Err(
"not enough arguments\n\
usage: oxgrep <query> <filename> [--ignore-case]".toString()
)
}
let query = args[1].clone()
let filename = args[2].clone()
var ignoreCase = false
// Parse remaining arguments
for i in 3..args.len() {
match args[i].asStr() {
"--ignore-case" -> {
ignoreCase = true
},
_ -> {
return Err(format!("Unknown option: {}", args[i]))
}
}
}
Ok(Config {
query,
filename,
ignoreCase,
})
}
}
Iterator-Based Parsing
For more complex programs, you might use iterators for elegant argument parsing:
extension Config {
static fn new(var args: Vec<String>): Result<Config, String> {
args.remove(0) // Remove program name
if args.isEmpty() {
return Err("not enough arguments".toString())
}
let query = args.remove(0)
if args.isEmpty() {
return Err("filename required".toString())
}
let filename = args.remove(0)
let ignoreCase = args.contains(&"--ignore-case".toString())
Ok(Config {
query,
filename,
ignoreCase,
})
}
}
Using Environment for Options
Some tools allow configuration via environment variables. For example, you might set OXGREP_IGNORE_CASE:
extension Config {
static fn new(args: &Vec<String>): Result<Config, String> {
if args.len() < 3 {
return Err("not enough arguments".toString())
}
let query = args[1].clone()
let filename = args[2].clone()
// Check both command-line flag and environment variable
let ignoreCase =
args.len() > 3 && args[3] == "--ignore-case"
|| std.env.var("OXGREP_IGNORE_CASE").isOk()
Ok(Config {
query,
filename,
ignoreCase,
})
}
}
Adding Help Text
A professional CLI tool includes help documentation:
extension Config {
static fn new(args: &Vec<String>): Result<Config, String> {
if args.len() < 2 {
return Err(Self.helpText())
}
// Check for help flag first
if args[1] == "--help" || args[1] == "-h" {
println!("{}", Self.helpText())
std.process.exit(0)
}
if args.len() < 3 {
return Err(Self.helpText())
}
let query = args[1].clone()
let filename = args[2].clone()
let ignoreCase = args.len() > 3 && args[3] == "--ignore-case"
Ok(Config {
query,
filename,
ignoreCase,
})
}
static fn helpText(): String {
"oxgrep - A simple text search tool\n\n\
USAGE:\n \
oxgrep <QUERY> <FILENAME> [OPTIONS]\n\n\
OPTIONS:\n \
--ignore-case Case-insensitive search\n \
--help, -h Show this help message".toString()
}
}
Summary
Good argument parsing leads to better user experience:
- Encapsulation - Use structs to group related arguments
- Validation - Check arguments early and provide helpful error messages
- Flexibility - Support both command-line flags and environment variables
- Documentation - Include helpful error messages and help text
- Extensibility - Make it easy to add new options without refactoring the entire program
Next, we'll improve error handling with custom error types.