Reference for Rust CLI/TUI best practices and known improvement areas in this project. Use this when implementing features or refactoring so changes stay aligned with current ecosystem conventions (2025–2026).
For how the codebase is organized today, see architecture.md.
Modern Rust CLI tools (ripgrep, bat, starship, uv-style) generally follow:
| Area | Recommended approach |
|---|---|
| Structure | Thin main.rs, library crate (lib.rs), commands/ per subcommand, separate client/ for I/O |
| CLI | clap derive API, global flags, shell completions via clap_complete |
| Async | tokio only at I/O boundaries; keep business logic sync where possible |
| Errors | thiserror in library/public APIs; anyhow at the binary boundary with .context() |
| Logging | tracing + tracing-subscriber (not raw println! for diagnostics) |
| HTTP | reqwest + rustls (avoid OpenSSL), typed endpoints, serde models |
| TUI | ratatui + crossterm, event loop + channels for background work |
| Testing | assert_cmd + wiremock / httpmock for integration tests |
| Distribution | strip = true, cross-compiled release artifacts, optional self-update — see releases.md |
- Rust CLI tools best practices guide
- Modern Rust CLI development (2026 cheat sheet)
- Microsoft RustTraining — error handling patterns
- Ratatui FAQ — when to use tokio
- Ratatui async actions tutorial
- Ratatui templates — component / event-driven-async
Current layout (workspace — see Gap 4):
romm-cli/ # workspace root
├── romm-api/ # RommClient, endpoints, core, config, error, types
├── romm-cli/ # commands, CLI binary, completions (re-exports romm_api)
└── romm-tui/ # TUI screens, handlers, romm-tui binary
- Library-first — binaries are thin wrappers; logic lives in the crate so CLI, TUI, and tests share it.
- One HTTP client type — everything goes through
RommClient; no scatteredreqwestcalls. - Separate frontends —
romm-cliandromm-tuiare independent crates; both depend onromm-apionly. - Typed errors at boundaries — e.g.
ApiError,ConfigError,DownloadError; compose with#[from]; useanyhowonly inmain. - Background work via channels — TUI main loop polls/receives; network/download runs on tokio tasks.
- Library crate with
client,commands,endpoints,core,frontendsplit clapderive, global--json/--verbosereqwestwithrustls, no native-tls (good for cross-builds)tracing,keyring,wiremock/assert_cmdin dev-dependencies- TUI uses screen modules + background task polling
- Release profile strips binaries
Each gap below is a self-contained guideline. When working on one, read its section and check off acceptance criteria before considering it done.
Current state: RommClient and load_config return typed errors (ApiError, ConfigError, DownloadError, composed as RommError). Some legacy command handlers still use anyhow internally and are wrapped at the CLI boundary. TUI background channels carry RommError and map to UI text via user_message() at apply time.
Recommended approach:
- Define per-domain error enums with
thiserror:ApiError— HTTP status, auth, rate limitsConfigError— missing file, invalid JSON, keyring failuresDownloadError— I/O, checksum, interrupted transfer
- Use
#[from]for automatic conversion from lower-level errors (reqwest::Error,serde_json::Error,std::io::Error). - Compose at higher levels with
#[error(transparent)]or wrapper variants. - Reserve
anyhowformain.rs/ top-levelrun_app()only; map typed errors to user-facing messages and exit codes there.
Rule of thumb: Use thiserror when callers need to handle errors (match on variants). Use anyhow when callers only need to report errors (log, display, exit).
Example pattern:
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("unauthorized — check credentials")]
Unauthorized,
#[error("request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("invalid response: {0}")]
Parse(#[from] serde_json::Error),
}Acceptance criteria:
- Public API of
RommClientreturns typed errors (or a composedRommError), notanyhow::Error - CLI/TUI map known variants to actionable messages (e.g. “run
romm-cli init” on auth failure) viauser_message()/set_error(RommError) - Error chains preserved via
#[source]/#[from]for debugging with{:#}on typed variants
References: Microsoft error handling patterns, thiserror + anyhow guide
Current state: Completions are generated at runtime via romm-cli completions <shell> (clap_complete in the completions subcommand). No committed static scripts or build-time generation.
Recommended approach:
- Keep
clap_completeas a normal dependency; generate fromCli/Commandsin thecompletionshandler. - Document install steps in cli.md (
romm-cli completions bash > …).
Acceptance criteria:
-
romm-cli completions <shell>covers bash, zsh, fish, PowerShell, and Elvish - cli.md section explains how to install completions per shell
References: Clap completions docs, Rust CLI best practices guide — completions section
Current state: exit_code() in romm-api/src/error.rs maps RommError to romm_api::exit::{SUCCESS,GENERAL,USAGE,CONFIG,API}. romm-cli/src/main.rs calls std::process::exit(exit_code(&e)). Legacy command handlers still return anyhow::Result; from_anyhow() downcasts typed domain errors at the CLI boundary. Clap parse errors exit with 2 before run_app() runs.
Recommended approach:
- Define a small
ExitCodeenum or constants:0— success1— general/unknown failure2— usage / invalid arguments (clap already uses 2 for parse errors if configured)3— configuration / auth error4— API / network error
- Map typed errors (Gap 1) to codes in
run_app()beforestd::process::exit. - Document exit codes in cli.md for scripting users.
Acceptance criteria:
- Scripts can distinguish auth/config failures from generic errors
- Exit codes documented in cli.md or
--helplong about text - Integration tests assert expected exit codes where relevant
References: Rust CLI error handling with clap
Current state: Workspace split complete (2026-06-06). Three members: romm-api, romm-cli, romm-tui. Rationale: workspace-split ADR. romm-cli re-exports romm_api for crates.io consumers; new embedders should depend on romm-api directly.
romm-cli/ # workspace root
├── romm-api/ # client, endpoints, types, core (no clap/ratatui)
├── romm-cli/ # binary + commands
└── romm-tui/ # binary + tui/ (depends on romm-api)
romm-openapi-genbin lives inromm-api; shell completions viaromm-cli completions <shell>.
Acceptance criteria:
- Decision criteria documented; split enables third frontends without CLI/TUI deps
-
cargo test -p romm-apiruns without TUI feature graph
References: Rust project structure best practices
Current state: tui/app/run.rs delegates to TuiSession + poll_frame_events → map_event → App::update. Background work drains via drain_background_events → Action. Screen key mapping lives in handlers/screen_keys.rs; complex screens still use per-screen *Key actions applied in update.rs.
Recommended approach (Ratatui async template):
- Add
tui/event.rs(ortui/app/event.rs):Event— raw input (key, resize, tick, background message)Action— semantic intents (NavigateHome,StartDownload,Quit)
- Main loop becomes: poll events → map to actions →
app.update(action)→ render. - Background tasks send
Actionontokio::sync::mpscinstead of mutating shared state directly where possible.
Acceptance criteria:
- Input handling centralized; render screens do not read
crossterm(widgets/path picker and setup wizard input are the boundary) - Background completions arrive as actions via
drain_background_events→App::update -
run.rsis loop + dispatch; business logic lives inupdate.rs/ handlers
References: Ratatui event-driven-async template, Full async actions tutorial, Component template
Current state: Layered merge in load_config() (defaults → config.json → env → keyring) plus narrow command-specific CLI runtime overrides. Documented in module docs, api.md, and architecture.md.
Precedence model (per field):
- Built-in defaults
config.json- Environment variables (override file)
- OS keyring (resolve secret sentinels after merge)
- Command-specific CLI runtime (
download --output,sync run --download-dir;init/auth loginpersist to file)
There is no global CLI > env for connection settings on normal commands.
Acceptance criteria:
- Single documented precedence order (see api.md and
config.rsmodule docs) - Sensitive values never logged by
tracing(passwords, tokens, API keys) -
ROMM_*env vars listed in api.md
References: Modern Rust CLI — serde flatten
Current state: CliPresentation centralizes color (NO_COLOR, CLICOLOR), progress (stderr / suppressed in JSON), and JSON stdout. Actionable errors via user_message(); verbose chains on stderr with --verbose. Help examples and json-output.md document scripting surfaces.
Recommended approach:
| Item | Guideline |
|---|---|
| Color | Respect NO_COLOR and CLICOLOR=0; disable styling when stdout is not a TTY |
| Errors | Print to stderr; use {:#} for full anyhow chains in verbose mode only |
| JSON mode | Stable field names; version schema in docs if scripts depend on it |
| Progress | Use indicatif for long operations; suppress bars when --json or non-TTY |
| Help text | Every subcommand has about + examples where non-obvious |
Acceptance criteria:
- No ANSI codes when
NO_COLORis set -
--jsonnever interleaves human progress UI on stdout - Error messages suggest next step where possible (init, auth, check URL)
References: Building CLI tools with Clap (2026)
| Project / template | Learn from it |
|---|---|
| ratatui/templates — component | Tokio + screen components + async events |
| ratatui/templates — event-driven-async | Minimal event/action loop |
| ripgrep, bat, fd | CLI ergonomics, performance, error messages |
| starship, uv | Self-update, cross-platform releases, feature flags |
- Logic in
core/orclient/, not duplicated in CLI and TUI - New API surface goes through
endpoints/+RommClient - Errors typed or given context; no new bare
unwrap()in production paths - Tests: unit tests for pure logic;
wiremock/httpmockfor HTTP;assert_cmdfor CLI if behavior is user-visible - TUI changes: background work does not block the render loop
-
romm-clichanges do not add a dependency onromm-tui
Last updated: 2026-06-25 — TUI background errors typed; plan doc statuses refreshed.