Skip to main content

Build a typed CLI tool

Time: 30 minutes. Prerequisites: Hello, World.

We'll build wordcount — counts lines, words, bytes in input files, with flags, a configurable output format, tests, and a bench.

1. Scaffold

$ verum new wordcount
$ cd wordcount

Verum.toml:

[cog]
name = "wordcount"
version = "0.1.0"
edition = "2026"
profile = "application"

2. Parse arguments

Replace src/main.vr:

type OutputFormat is Text | Json | Csv;

type Args is {
paths: List<Text>,
format: OutputFormat,
show_help: Bool,
};

fn parse_args(argv: &List<Text>) -> Result<Args, Text> {
let mut paths = list![];
let mut format = OutputFormat.Text;
let mut show_help = false;

let mut i = 1;
while i < argv.len() {
match argv[i].as_str() {
"-h" | "--help" => show_help = true,
"--json" => format = OutputFormat.Json,
"--csv" => format = OutputFormat.Csv,
s if s.starts_with("--format=") => {
match &s[9..] {
"text" => format = OutputFormat.Text,
"json" => format = OutputFormat.Json,
"csv" => format = OutputFormat.Csv,
f => return Result.Err(f"unknown format: {f}".to_string()),
}
}
s if s.starts_with("-") => return Result.Err(f"unknown flag: {s}".to_string()),
s => paths.push(s.to_string()),
}
i += 1;
}
Result.Ok(Args { paths, format, show_help })
}

3. Count

type Counts is { lines: Int, words: Int, bytes: Int, path: Text };

fn count_one(path: &Path) -> IoResult<Counts> using [IO] {
let text = fs::read_to_string(path)?;
let bytes = text.len();
let lines = text.lines().count();
let words = text.split_whitespace().count();
Result.Ok(Counts { lines, words, bytes, path: path.as_text().to_string() })
}

fn count_all(paths: &List<Text>) -> IoResult<List<Counts>> using [IO] {
let mut out = list![];
for p in paths {
let path = Path.from(p);
out.push(count_one(&path)?);
}
Result.Ok(out)
}

4. Format output

fn format_counts(counts: &List<Counts>, fmt: OutputFormat) -> Text {
match fmt {
OutputFormat.Text => {
let mut s = Text.with_capacity(256);
for c in counts {
s.push_str(&f"{c.lines:>6} {c.words:>6} {c.bytes:>8} {c.path}\n");
}
s
}
OutputFormat.Csv => {
let mut s = "path,lines,words,bytes\n".to_string();
for c in counts {
s.push_str(&f"{c.path},{c.lines},{c.words},{c.bytes}\n");
}
s
}
OutputFormat.Json => {
let mut s = "[\n".to_string();
for (i, c) in counts.iter().enumerate() {
let sep = if i + 1 == counts.len() { "" } else { "," };
s.push_str(&f" {{\"path\": \"{c.path}\", \"lines\": {c.lines}, \"words\": {c.words}, \"bytes\": {c.bytes}}}{sep}\n");
}
s.push_str("]\n");
s
}
}
}

5. Main

fn print_help() using [IO] {
print(&"usage: wordcount [--format=text|json|csv] FILE...\n");
}

fn main() using [IO] {
let argv = env::args();
let args = match parse_args(&argv) {
Result.Ok(a) => a,
Result.Err(e) => { eprint(&f"error: {e}"); exit(2); }
};

if args.show_help { print_help(); return; }
if args.paths.is_empty() { print_help(); exit(2); }

match count_all(&args.paths) {
Result.Ok(counts) => print(&format_counts(&counts, args.format)),
Result.Err(e) => { eprint(&f"error: {e}"); exit(1); }
}
}

6. Run it

$ echo "hello world\nsecond line" > /tmp/a.txt
$ verum run -- /tmp/a.txt
2 4 22 /tmp/a.txt

$ verum run -- --json /tmp/a.txt
[
{"path": "/tmp/a.txt", "lines": 2, "words": 4, "bytes": 22}
]

7. Add tests

@cfg(test)
module tests {
use .super.*;

@test
fn parses_format_flag() {
let args = parse_args(&list!["prog".to_string(), "--json".to_string(), "a.txt".to_string()])
.expect("should parse");
assert(args.format is OutputFormat.Json);
assert_eq(args.paths.len(), 1);
}

@test
fn counts_simple_file() using [IO] {
let tmp = Path.from(&env::temp_dir()).join(&Path.from("wc_test.txt"));
fs::write_text(&tmp, &"hello world\nfoo bar baz").unwrap();
let c = count_one(&tmp).unwrap();
assert_eq(c.lines, 2);
assert_eq(c.words, 5);
fs::remove_file(&tmp).ok();
}

@test
fn formats_csv_header() {
let counts = list![Counts { lines: 1, words: 2, bytes: 10, path: "x".to_string() }];
let out = format_counts(&counts, OutputFormat.Csv);
assert(out.starts_with("path,lines,words,bytes\n"));
}
}
$ verum test
running 3 tests
test tests::parses_format_flag ... ok
test tests::counts_simple_file ... ok
test tests::formats_csv_header ... ok
all 3 tests passed

8. Benchmark

@cfg(bench)
module benches {
use .super.*;
use core.runtime.Bencher;

@bench
fn bench_count_1kb(b: &mut Bencher) using [IO] {
let tmp = Path.from(&env::temp_dir()).join(&Path.from("bench.txt"));
fs::write_text(&tmp, &"lorem ipsum ".repeat(80)).unwrap();
b.iter(|| count_one(&tmp).unwrap());
fs::remove_file(&tmp).ok();
}
}
$ verum bench
bench_count_1kb ... 12,450 ns/iter (+/- 430)

9. Ship it

$ verum build --release
$ cp target/release/wordcount ~/bin/

What you learned

  • Parsing flags by hand (for small tools; use a cog for complex CLIs).
  • using [IO] at the top, flowing through count_one and format_counts.
  • @cfg(test) and @cfg(bench) modules co-located with code.
  • fs::read_to_string, text.split_whitespace().count(), text.lines().count().
  • Format specs in f"{c.lines:>6}" for aligned output.

Next