Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

crates.io docs.rs CI license

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.

CratePurpose
runi-coreFoundation types + feature-gated bundle that re-exports the rest
runi-logStructured logging with a Uni-style terminal format
runi-cliTerminal color detection and Tint styling helpers
runi-testTest 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-test bundles 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.

CrateRoleTypical user
runi-coreFoundation types + bundle / re-exportsMost callers
runi-logLoggingApplication / service authors
runi-cliCLI parser + terminal stylingCLI authors
runi-testTest utilitiesAnyone 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-core carries 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 the runi-core page.
  • The bundled sub-crates are independent leaves — they don't depend on runi-core or on each other, so picking individual crates directly is always an option.
  • runi-test is a dev-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.

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

  • Error and Result — the workspace-wide error type, built on thiserror.
  • 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.

FeatureDefaultModuleCrate
logyesruni_core::logruni-log
cliyesruni_core::cliruni-cli

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.

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, and line_number fields, 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.

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
boolFlag: presence sets true(rejected — use option)
T: FromArgRequired valueRequired 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) and supports_color_stdout() — the launcher uses these internally to keep --help | cat plain 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.

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.