Skip to main content

Building a CLI tool

Minimal​

note
env needs its mount, and the failure names something else

core.base.env declares args() -> List<Text>, and all three spellings compile today:

mountcall site
mount core.base.env;env.args()
mount core.base.env.{args};args()
(none needed)core.base.env.args()

Omit the mount and the bare env does not fail as an unknown name. It resolves to core/meta/contexts.vr's fn env(key: Text) -> Maybe<Text>, which the prelude makes visible, so the error is no method named 'args' found for type 'fn(Text) -> Maybe<Text>' — a type error about a different env entirely. An earlier version of this note read that as name resolution being broken for the module. It is not; the mount was missing.

mount core.base.env;

fn main() {
let argv = env.args();
match argv.get(1) {
Maybe.Some(first) => {
if first == "--help" || first == "-h" {
print_help();
} else {
greet(&first);
}
}
Maybe.None => print_help(),
}
}

fn greet(name: &Text) {
print(f"Hello, {name}!");
}

fn print_help() {
print("usage: greet <name>");
}

With subcommands​

mount core.base.env;

type Command is
| Build { release: Bool, target: Maybe<Text> }
| Test { filter: Maybe<Text> }
| Version;

fn parse_args(args: &List<Text>) -> Result<Command, Error> {
match args.get(1).map(|s| s.as_str()) {
Maybe.Some("build") => {
let mut release = false;
let mut target = Maybe.None;
for a in args.iter().skip(2) {
match a.as_str() {
"--release" => release = true,
s if s.starts_with("--target=")
=> target = Maybe.Some(s[9..].to_string()),
_ => return Result.Err(Error.new(&f"unknown flag {a}")),
}
}
Result.Ok(Command.Build { release, target })
}
Maybe.Some("test") => {
let filter = args.get(2).cloned();
Result.Ok(Command.Test { filter })
}
Maybe.Some("version") => Result.Ok(Command.Version),
Maybe.Some(c) => Result.Err(Error.new(&f"unknown command: {c}")),
Maybe.None => Result.Err(Error.new(&"no command given")),
}
}

fn main() {
match parse_args(&env.args()).and_then(dispatch) {
Result.Ok(()) => (),
Result.Err(e) => { eprintln(&f"error: {e}"); env.exit(1); }
}
}

Reading config files​

fn load_config() -> Result<Config, Error> {
let home = env.home_dir().unwrap_or(Text.from("."));
let path = match env.var_opt(&Text.from("MYTOOL_CONFIG")) {
Some(p) => p,
None => f"{home}/.mytool/config.toml",
};

match fs.read_to_string(&Path.from(&path)) {
Result.Ok(text) => toml_parse(&text).map_err(Error.from),
Result.Err(e) if e.kind == IoErrorKind.NotFound => Result.Ok(Config.default()),
Result.Err(e) => Result.Err(Error.from(e)),
}
}

Notes:

  • Verum has no format! macro — string interpolation uses the f"..." literal.
  • Maybe<T> (the Option-equivalent) deconstructs through match, not Rust-style .unwrap_or_else(closure). Use .unwrap_or(default) for an eager default value.

Coloured error output​

caution
Style does not render itself

Style describes an attribute set for the terminal RENDERER (core.term.render.buffer.set_style paints it into a cell buffer). It has no paint, no to_ansi, and nothing that turns a Style plus a Text into an escape-wrapped string — checked against its 25 methods.

Writing colour straight to a stream means emitting the escape yourself, which is what core/cli/help.vr does.

const RED_BOLD: Text = "\x1b[1;31m";
const RESET: Text = "\x1b[0m";

fn report_error(e: &Error) {
eprint(&f"{RED_BOLD}error: {RESET}");
eprintln(&e.to_string());
for src in e.chain().skip(1) {
eprintln(&f" caused by: {src}");
}
}

Exit codes​

fn main() {
match run() {
Result.Ok(()) => env.exit_success(),
Result.Err(e) if e.is_user_error() => { report_error(&e); env.exit(1); }
Result.Err(e) if e.is_config_error() => { report_error(&e); env.exit(78); } // sysexits EX_CONFIG
Result.Err(e) => { report_error(&e); env.exit(1); }
}
}

Progress bars & spinners​

mount term.widget.{Spinner, SpinnerFrames};

async fn slow_task() {
let mut spin = Spinner.new().frames(&SpinnerFrames.Dots);
spin.start(&"loading");
let result = heavy_compute().await;
spin.stop(&f"done in {result.elapsed:?}");
}

See also​