- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| README.md | ||
walts-cli
A tiny Rust CLI library for building command-driven applications without proc macros or a large framework.
Quick start
use walts_cli::{Cli, Request};
fn build(request: &Request) -> Result<(), String> {
let path: String = request.arg(0)?;
let output: String = request.get("output")?;
let verbose: bool = request.get("verbose")?;
println!("building {path} -> {output}");
if verbose {
println!("verbose mode enabled");
}
Ok(())
}
fn main() -> Result<(), String> {
let cli = Cli::builder()
.version("0.1.0")
.command("build", build, |cmd| {
cmd.alias("b")
.command_help("Compile a source file")
.arg("path")
.required()
.option("-o", "--output")
.default("out.bin")
.flag("-v", "--verbose")
})
.build()?;
cli.run()
}
Cli::builder() creates a CLI definition. Calling .build() validates the definition and returns a finalized Cli that can be parsed or run.
Commands
Commands are registered on CliBuilder.
let cli = Cli::builder()
.command("build", build, |cmd| {
cmd.command_help("Compile a source file")
})
.command("run", run, |cmd| {
cmd.command_help("Run a source file")
})
.build()?;
Each command receives a parsed Request.
Namespaced commands
Command names may use : to create lightweight namespaces.
let cli = Cli::builder()
.command("remote:add", remote_add, |cmd| {
cmd.command_help("Add a remote")
})
.command("remote:list", remote_list, |cmd| {
cmd.command_help("List remotes")
})
.build()?;
Users still invoke the full command name:
app remote:add
app remote:list
Namespaced commands are grouped under their common root in generated help output.
Positional arguments
Declare positional arguments with .arg().
.command("build", build, |cmd| {
cmd.arg("path")
.required()
})
Read them by index:
let path: String = request.arg(0)?;
Arguments are parsed through FromStr, so they can be requested as any compatible type.
Options
Declare value-taking options with .option().
.command("build", build, |cmd| {
cmd.option("-o", "--output")
})
Read an option by its long-name key without the leading dashes:
let output: String = request.get("output")?;
Flags
Declare boolean flags with .flag().
.command("build", build, |cmd| {
cmd.flag("-v", "--verbose")
})
Read them as booleans:
let verbose: bool = request.get("verbose")?;
Additional short forms
Options and flags may define additional short forms with .short().
.command("build", build, |cmd| {
cmd.flag("-v", "--verbose")
.short("-V")
})
All of these resolve to the same flag:
app build -v
app build -V
app build --verbose
Additional short forms are also shown in command help:
-v, -V, --verbose
Required values
Arguments and options can be marked required.
cmd.arg("path")
.required()
.option("-o", "--output")
.required()
Missing required values return an error.
Default values
Arguments and options may define defaults.
cmd.option("-o", "--output")
.default("out.bin")
Then:
let output: String = request.get("output")?;
returns out.bin when the user does not provide --output.
Flexible argument ordering
Options and flags can appear before, between, or after positional arguments.
app build src/main.rs -v
app build -v src/main.rs
app build -o out.bin src/main.rs
app build src/main.rs -o out.bin
Equals syntax
Long options support --option=value.
app build --output=out.bin src/main.rs
This is equivalent to:
app build --output out.bin src/main.rs
Flags do not accept values.
app build --verbose=true
returns an error.
Repeated options
Repeated options are preserved.
app build -I src -I include
Read them with get_list():
let includes: Vec<String> = request.get_list("include")?;
A single option also works with get_list():
app build -I src
returns:
vec!["src"]
Short and long forms may be mixed:
app build -I src --include include
Repeated equals-form options are also supported:
app build --include=src --include=include
Command aliases
Commands may define one or more aliases.
let cli = Cli::builder()
.command("build", build, |cmd| {
cmd.alias("b")
.alias("compile")
})
.build()?;
All of these resolve to the same command:
app build
app b
app compile
The resulting Request stores the canonical command name.
Aliases are also shown in generated help output.
Help text
Commands may define a description with .command_help().
.command("build", build, |cmd| {
cmd.command_help("Compile a source file")
})
Arguments and options may define their own help text with .help().
.command("build", build, |cmd| {
cmd.arg("path")
.help("Source file to compile")
.option("-o", "--output")
.help("Set output path")
})
A finalized Cli can render help as a string:
let help = cli.help_text(None)?;
Specific command help can be rendered by name:
let help = cli.help_text(Some("build"))?;
External command dependencies
A CLI may declare programs that must exist in PATH.
let cli = Cli::builder()
.requires("jj")
.requires("tea")
.build()?;
Required programs are checked when the CLI runs.
If a dependency cannot be found, the CLI returns an error such as:
required command not found: jj
Dependency lookup supports normal PATH lookup and Windows PATHEXT handling.
Running external commands
This is ever-evolving, but it started out because git sucks and I didn't want obtuse aliases, so right now it has a small helper for invoking external processes.
use walts_cli::run;
run("jj", &["st"])?;
The helper:
- runs the requested executable with arguments
- inherits stdout and stderr
- returns
Ok(())on success - returns a readable error if the process cannot start
- returns a readable error when the child exits unsuccessfully
Example:
jj exited with exit status: 1
Parsing manually
Cli::parse_from() allows deterministic parsing without reading the real process arguments.
This is useful for tests.
let cli = Cli::builder()
.command("build", build, |cmd| {
cmd.arg("path")
.required()
.flag("-v", "--verbose")
})
.build()?;
let outcome = cli.parse_from([
"walts-cli-test",
"build",
"src/main.rs",
"-v",
])?;
parse_from() expects the first item to be the program name, followed by the command and its arguments.
Parse outcomes
Parsing returns a ParseOutcome.
pub enum ParseOutcome {
Request(Request, Handler),
Help,
Version,
}
A normal command produces a Request and its handler.
Help and version requests are handled without invoking a command handler.
Build validation
CLI definitions are validated when .build() is called.
Validation includes:
- duplicate command names
- duplicate command aliases
- command and alias collisions
- invalid command names
- duplicate positional argument names
- invalid positional argument ordering
- malformed short and long options
- duplicate option names and short forms
- invalid option and flag combinations
A successfully built Cli is therefore a finalized CLI definition ready for parsing or execution.
Errors
I'm lazy so currently we use simple Result<_, String> errors instead of a diagnostic framework.
Examples include:
unknown command: wat
unknown option: --wat
missing required argument: path
missing required option: --output
expected value after --output
option does not take a value: --verbose
required command not found: jj
Eventually I'll make them better.
Philosophy
I keep it intentionally small.
It provides the pieces needed for straightforward command-driven applications without introducing proc macros, derive-based command definitions, or a large parser framework.
The library currently focuses on:
- commands
- namespaced commands
- positional arguments
- options
- flags
- required values
- defaults
- command aliases
- additional short option forms
- flexible option ordering
--option=value- repeated options
- generated and grouped help
- CLI definition validation
- external command dependency checks
- external process execution