CLI & config

Coming from Python / Node? No argparse, no yargs, no dotenv. Reading arguments and environment is a few direct calls under std::env; layering argv over env over defaults is a small std::cli::Resolver. Rich flag parsing (--name=value, subcommands) is library territory, not built into the language.

Arguments and environment

Section titled “Arguments and environment”
fn main() {
let n = std::env::args_count(); // includes the program name
let first = std::env::arg(1); // positional arg 1
let port = std::env::arg_or(2, "8080"); // with a default
let home = std::env::var("HOME"); // environment variable
let debug = std::env::var_exists("DEBUG");
}

Layered configuration

Section titled “Layered configuration”

A common need: a setting should come from a command-line argument if given, else an environment variable, else a built-in default. std::cli::Resolver expresses that precedence directly:

fn main() {
let cfg = std::cli::Resolver {
env_prefix: "MYAPP_",
argv_keys: "host\nport\n",
};
// argv positional "host", else $MYAPP_HOST, else the default
let host = cfg.get("host", "127.0.0.1");
let port = cfg.get_int("port", 8080);
println("listening on ", host, ":", port);
}

The resolver checks the argument (positions come from argv_keys, one key per line, first line mapping to argv[1]), then the prefixed environment variable (MYAPP_HOST), then the supplied default — get for strings, get_int for numbers. Empty values fall through to the next layer rather than counting as “set.”

Interactive terminal I/O

Section titled “Interactive terminal I/O”

For a tool that draws to the terminal or reads keystrokes, a few std:: primitives cover the OS surface without an FFI dependency.

std::term::is_tty(fd) answers “is this a terminal?” — the usual guard for whether to emit color:

let color = std::term::is_tty(2); // fd 2 = stderr

std::term::size() returns a TermSize { cols, rows } record (and {0, 0} when stdout isn’t a tty). std::term::RawMode is a guard locus that puts the terminal in raw mode for its lifetime — no line buffering, no echo — and restores it on scope exit, and on a panic or unhandled error too via an atexit backstop:

fn main() {
let raw = std::term::RawMode { }; // birth: enter raw mode
// ... read keys, draw frames ...
} // dissolve: restore the terminal

For the bytes themselves, std::io::stdin::read_byte(timeout_ms) polls one byte (0..255, -1 on timeout, -2 on EOF), and std::io::stdout::write_bytes(s) does a raw, unbuffered write — it fflushes first so it stays ordered with any println output:

while true {
let b = std::io::stdin::read_byte(100); // 100ms poll
if b == -1 { continue; } // timeout: redraw, tick, …
if b == -2 { break; } // EOF
std::io::stdout::write_bytes("got a key\r\n");
}

These are primitives, not a TUI — key decoding and styling live in a library on top of them.

Where this fits

Section titled “Where this fits”

This is the boundary between the outside world and your program. The idiomatic shape, building on the app locus: main resolves configuration, then constructs the app locus with it.

locus App {
params { host: String = "127.0.0.1"; port: String = "8080"; }
run() { println("listening on ", self.host, ":", self.port); }
}
fn main() {
let cfg = std::cli::Resolver { prefix: "MYAPP" };
App {
host: cfg.get("host", "127.0.0.1"),
port: cfg.get("port", "8080"),
};
}

Configuration enters once, at the edge, and flows inward as typed locus state — never read again from a global deep inside the program. That keeps every setting owned by exactly one locus, the rule from The locus, gently.

Next: seeing what your program is doing — Logging.