Introduction
Runi is a collection of small, composable Rust libraries for building reliable infrastructure and CLI tools. Each crate is scoped to a single concern and can be used on its own or combined with the rest of the set.
| Crate | Purpose |
|---|---|
runi-core | Foundation types + feature-gated bundle that re-exports the rest |
runi-log | Structured logging with a Uni-style terminal format |
runi-cli | Terminal color detection and Tint styling helpers |
runi-test | Test utilities: rstest, pretty_assertions, proptest |
Quick install
# Cargo.toml
[dependencies]
runi = { package = "runi-core", version = "0.1" }
[dev-dependencies]
runi-test = "0.1"
use runi::{Error, Result};
use runi::log;
The package = "runi-core" alias lets you write use runi::… at the
call site even though the crate ships as runi-core on crates.io.
The runi-core page has the full details.
What this book covers
This book is the user guide for Runi — how to install the crates, compose them, and use the major features end to end. For the full API reference, see each crate's page on docs.rs.
Design principles
- Small, focused crates. Pull in only what you need.
- Rust-native first. Zero JS toolchain; the docs site itself is built with mdBook.
- Readable output. Logs and CLI styling default to something a human wants to look at, with structured/JSON fallbacks for machines.
- Test-friendly.
runi-testbundles the fixtures and assertion helpers we reach for on every project.
Getting Started
Install
Most callers only need runi-core — it bundles every other workspace
sub-crate behind feature flags, all enabled by default. Cargo's
package = "..." alias lets you reach everything through the clean
runi:: namespace:
# Cargo.toml
[dependencies]
runi = { package = "runi-core", version = "0.1" }
[dev-dependencies]
runi-test = "0.1"
runi-test stays out of the bundle because it's a development-only
helper.
A minimal example
use runi::log::{info, warn};
fn main() {
runi::log::init();
info!(app = "demo", "starting up");
warn!("disk space is low");
}
Run it with:
cargo run
# Control the log level with the RUNI_LOG env var:
RUNI_LOG=debug cargo run
When stderr is a terminal you get Uni-style colored output; when it is piped or redirected the same events are emitted as JSON, so log collectors can parse them directly.
Next steps
- Crates Overview — map of the workspace and how the pieces fit together.
runi-core— canonical list of bundled features and the foundation types.
Crates Overview
Runi is a Cargo workspace. Most callers depend on runi-core — which
is both the foundation crate and, via feature flags, a bundle that
re-exports the rest of the workspace. The sub-crates can also be used
directly if you prefer narrower dependencies. Crates share a single
workspace version (see Versioning below) and each has
its own entry on docs.rs.
| Crate | Role | Typical user |
|---|---|---|
runi-core | Foundation types + bundle / re-exports | Most callers |
runi-log | Logging | Application / service authors |
runi-cli | CLI parser + terminal styling | CLI authors |
runi-test | Test utilities | Anyone writing tests |
Following the wvlet/uni pattern,
runi-core plays the dual role of "foundation" and "one-line bundle"
so users can write runi = { package = "runi-core", ... } in their
Cargo.toml and reach every workspace crate through the clean
runi:: namespace. See the runi-core page for the
full alias pattern.
How they fit together
runi-corecarries the foundation types (Error,Result,Config,str_util) and bundles every other workspace sub-crate behind a feature flag of the same name. All bundled features are on by default. The canonical list of bundled features lives on theruni-corepage.- The bundled sub-crates are independent leaves — they don't depend on
runi-coreor on each other, so picking individual crates directly is always an option. runi-testis adev-dependencies-only helper and is not part of the bundle. Depend on it directly in your[dev-dependencies].
Versioning
Runi uses unified workspace versioning — every crate shares the same
version and they are released as a set. The version is defined once in
the workspace Cargo.toml ([workspace.package]) and inherited by each
member. Intra-workspace dependencies live in [workspace.dependencies].
All crates currently track 0.1.x. When the workspace bumps, every
crate bumps together — pick a single version for all four runi-*
entries in your Cargo.toml. Breaking changes will be called out in
each crate's CHANGELOG.md (coming soon).
Independent per-crate versioning can be adopted later if release cadences diverge; for now the set moves as one.
runi-core
runi-core is both the foundation layer and the top-level bundle of
the Runi workspace. It hosts the shared Error, Result, and
Config types and, via feature flags, re-exports the rest of the
workspace so most callers only need a single dependency.
- Crate:
runi-coreon crates.io - API reference: docs.rs/runi-core
Recommended setup — alias to runi
The plain runi name on crates.io is held by an unrelated project, so
this crate ships as runi-core. Cargo lets each consumer rename a
dependency at the call site with the package key, which gives you
the clean runi:: namespace today:
[dependencies]
runi = { package = "runi-core", version = "0.1" }
Then in your code:
use runi::{Error, Result};
use runi::log; // any bundled sub-crate is re-exported as a module
This is the same pattern async-std, http-body-util, and many other
crates use when their preferred name is unavailable. If you'd rather
skip the alias, depend on runi-core directly and import as
runi_core::….
Foundation types — always available
ErrorandResult— the workspace-wide error type, built onthiserror.Config— a small configuration helper.str_util— convenience string helpers.
Bundled sub-crates
Each workspace sub-crate (apart from the dev-only runi-test) is
re-exported as a module gated by a feature flag of the same name —
so runi-log becomes runi_core::log under the log feature. The
default features enable every bundled sub-crate.
This page is the single canonical list; the table gets a new row whenever a sub-crate is added to the workspace.
Opt out of the default bundle to get only the foundation types, or enable a narrower subset:
[dependencies]
runi = { package = "runi-core", version = "0.1", default-features = false } # foundation only
runi = { package = "runi-core", version = "0.1", default-features = false, features = ["log"] } # + one sub-crate
Example
use runi::{Error, Result};
use runi::log;
fn main() -> Result<()> {
log::init();
log::info!("hello from runi");
Ok(())
}
For detailed usage of each bundled sub-crate, follow its book page
(linked in the table above) or its docs.rs entry.
runi-log
Structured logging with a Uni-style terminal format and a JSON fallback
for non-terminal output. Built on top of
tracing.
- Crate:
runi-logon crates.io - API reference: docs.rs/runi-log
Quick start
use runi_log::{info, warn, error};
fn main() {
runi_log::init();
info!(user = "alice", "request received");
warn!(retries = 3, "slow upstream");
error!("failed to connect");
}
The default format looks like:
2026-04-18 23:12:04.123-0700 INFO [my_app] request received - (main.rs:8)
The timestamp is local time with millisecond precision and timezone offset,
the level is right-padded to 5 characters, and the target is reduced to its
leaf segment (so my_app::api becomes api in the brackets).
Controlling log level
runi_log::init() reads the RUNI_LOG environment variable (default
info), using the standard
EnvFilter
syntax:
RUNI_LOG=debug cargo run
RUNI_LOG=warn,my_crate=trace cargo run
Use init_with_env("MY_APP_LOG") to change the env var name, or
init_with_level("debug") to hard-code a level.
Terminal vs JSON
init() detects whether stderr is a terminal:
- Terminal: colored Uni-style output with file/line locations.
- Redirected / piped: JSON with
target,filename, andline_numberfields, suitable for log aggregation pipelines.
runi-cli
runi-cli packs two small things together: a zero-dependency command-line
argument parser (the "launcher") and Tint, a chainable API for ANSI-colored
terminal text. The launcher mirrors Uni's @command / @option /
@argument ergonomics and integrates with Tint for styled help output.
- Crate:
runi-clion crates.io - API reference: docs.rs/runi-cli
The launcher
Single-command CLI
Annotate a struct with #[derive(Command)] and pick up a full trait
implementation — no hand-written schema or parser code.
use runi_cli::{Command, Launcher, Runnable, Result};
/// Greet someone from the command line.
#[derive(Command)]
#[command(name = "greet")]
struct Greeter {
/// Shout instead of whisper.
#[option("-l,--loud")]
loud: bool,
/// How many times to greet.
#[option("-n,--count")]
count: Option<u32>,
/// Who to greet.
#[argument]
target: String,
}
impl Runnable for Greeter {
fn run(&self) -> Result<()> {
let times = self.count.unwrap_or(1);
for _ in 0..times {
if self.loud {
println!("HELLO, {}!", self.target.to_uppercase());
} else {
println!("hello, {}", self.target);
}
}
Ok(())
}
}
fn main() {
Launcher::<Greeter>::of().execute();
}
Invoke as greet --loud -n 3 world. The -h/--help flag is always
available and renders a Tint-styled banner that matches the field
layout.
Subcommands
Nest structs under an enum and generate the dispatch table with the same
#[derive(Command)].
use runi_cli::{Command, Launcher, ParseResult, Result, SubCommandOf, CommandSchema};
struct GitApp { verbose: bool }
impl Command for GitApp {
fn schema() -> CommandSchema {
CommandSchema::new("git", "A toy VCS").flag("-v,--verbose", "Verbose")
}
fn from_parsed(p: &ParseResult) -> Result<Self> {
Ok(Self { verbose: p.flag("--verbose") })
}
}
#[derive(Command)]
#[command(description = "Initialize a repository")]
struct InitCmd {
/// Directory to initialize (defaults to the current one).
#[argument]
dir: Option<String>,
}
impl SubCommandOf<GitApp> for InitCmd {
fn run(&self, git: &GitApp) -> Result<()> {
let dir = self.dir.as_deref().unwrap_or(".");
if git.verbose { println!("initializing {dir}"); }
Ok(())
}
}
#[derive(Command)]
#[command(description = "Clone a repository")]
struct CloneCmd {
/// Repository URL.
#[argument]
url: String,
/// Clone depth.
#[option("--depth")]
depth: Option<u32>,
}
impl SubCommandOf<GitApp> for CloneCmd {
fn run(&self, _: &GitApp) -> Result<()> {
println!("cloning {}", self.url);
Ok(())
}
}
#[derive(Command)]
enum GitSub {
Init(InitCmd),
Clone(CloneCmd),
}
fn main() {
// GitSub::register_on is generated by the enum derive — it attaches
// every variant to the launcher in one shot.
GitSub::register_on(Launcher::<GitApp>::of()).execute();
}
Field type → CLI semantics
The field type is load-bearing. The derive picks the right
ParseResult call based on what you wrote, so a correctly-typed field is
also a correctly-validated CLI.
| Field type | #[option] meaning | #[argument] meaning |
|---|---|---|
bool | Flag: presence sets true | (rejected — use option) |
T: FromArg | Required value | Required positional |
Option<T> | Optional value (None when absent) | Optional positional |
Vec<T> | Repeatable value (-f a -f b → [a, b]) | (rejected — use option) |
T: FromArg is automatic for every T: FromStr — so i32, u64,
f64, String, PathBuf, and your own types with a FromStr impl all
work without extra glue.
Descriptions
Precedence: explicit description = "..." → doc comment (///) →
empty. Doc comments on the struct become the top banner; doc comments on
fields become option/argument descriptions.
Manual implementation (escape hatch)
The derive generates code against a public trait + builder API, so you can always hand-write the impl for dynamic schemas:
use runi_cli::{Command, CommandSchema, ParseResult, Result};
struct MyApp { verbose: bool, file: std::path::PathBuf }
impl Command for MyApp {
fn schema() -> CommandSchema {
CommandSchema::new("my-app", "My application")
.flag("-v,--verbose", "Enable verbose output")
.argument("file", "Input file to process")
}
fn from_parsed(p: &ParseResult) -> Result<Self> {
Ok(Self {
verbose: p.flag("--verbose"),
file: p.require::<std::path::PathBuf>("file")?,
})
}
}
Disabling the derive
The derive ships behind a default derive feature. Turn it off if you
only want the runtime trait + builder:
runi-cli = { version = "*", default-features = false }
Tint — terminal styling
use runi_cli::{Tint, supports_color};
fn main() {
if supports_color() {
println!("{}", Tint::red().bold().paint("error: something went wrong"));
println!("{}", Tint::cyan().italic().paint("hint: try --help"));
}
}
- Foreground colors (basic + bright) and basic backgrounds
- ANSI 256-color and 24-bit RGB support (foreground + background)
- Chainable style modifiers:
bold,italic,underline,dimmed,strikethrough supports_color()(stderr) andsupports_color_stdout()— the launcher uses these internally to keep--help | catplain when redirected.
See runi-cli/examples/tint_demo.rs in the repo for a full Tint demo.
runi-test
Curated test utilities: one dependency that brings
rstest,
pretty_assertions, and
(behind a feature flag) proptest.
- Crate:
runi-teston crates.io - API reference: docs.rs/runi-test
Install
[dev-dependencies]
runi-test = "0.1"
# With property-based testing support:
# runi-test = { version = "0.1", features = ["property"] }
Example
use runi_test::prelude::*;
use runi_test::pretty_assertions::assert_eq;
#[rstest]
#[case(2, 2, 4)]
#[case(3, 5, 8)]
fn adds(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
assert_eq!(a + b, expected);
}
With the property feature enabled you also get proptest's macros and
strategies through runi_test::prelude.