rtb-docs v0.1 — Interactive docs browser¶
Status: IMPLEMENTED — library ships DocsBrowser, DocsServer,
fuzzy + full-text search, HTML rendering, and the ai trait seam.
CLI dispatch (docs list | show | browse | serve | ask) lands in the
v0.2.x CLI-dispatch follow-up.
Target crate: rtb-docs (currently a stub).
Parent contract:
§10 TUI components and docs
of the framework spec.
Consumes: rtb-assets (for the embedded doc tree), rtb-app
(via the Command plugin trait + App::metadata), rtb-cli (to
register the docs subcommand). Optional at the ai Cargo-feature
level: rtb-ai for streaming Q&A (deferred to v0.3 — this crate
ships the integration seam but leaves the concrete wiring for when
rtb-ai lands).
Triggers: a real docs subcommand registered into
rtb-cli::BUILTIN_COMMANDS, replacing the v0.1 FeatureDisabled stub.
1. Motivation¶
Downstream tools want shippable documentation without pushing users to
a browser. A CLI-native docs browser:
- keeps documentation discoverable when the machine is offline
(rtb-assets embeds the tree into the binary),
- survives as the canonical reference when the tool's website rots,
- gives RTB itself a dogfood target for rtb-tui widgets when that
crate lands in v0.4.
v0.1 scope is deliberately small: a two-pane TUI (index on the left,
rendered markdown on the right), keyboard navigation, search.
AI-powered Q&A (docs ask "how do I configure X") is stubbed behind
the ai Cargo feature and becomes live when rtb-ai ships in v0.3;
this spec documents the seam but does not implement the AI backend.
2. Public API¶
2.1 Library surface¶
//! Interactive markdown docs browser and `docs` subcommand.
pub mod render; // Markdown → ratatui text conversion.
pub mod index; // Index parsing + fuzzy search.
pub struct DocsBrowser { /* fields non-public */ }
impl DocsBrowser {
/// Construct from an `App` and an asset-tree root path (relative
/// to `App::assets`).
pub fn new(app: &rtb_app::App, root: &str) -> Result<Self, DocsError>;
/// Run the interactive TUI. Returns when the user quits.
pub async fn run(self) -> Result<(), DocsError>;
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[non_exhaustive]
pub enum DocsError {
#[error("docs root not found in assets: {0}")]
#[diagnostic(code(rtb::docs::root_missing))]
RootMissing(String),
#[error("index file not found or malformed: {0}")]
#[diagnostic(code(rtb::docs::index_malformed))]
IndexMalformed(String),
#[error("markdown parse failed for {path}: {reason}")]
#[diagnostic(code(rtb::docs::markdown_error))]
MarkdownError { path: String, reason: String },
#[error("terminal initialisation failed: {0}")]
#[diagnostic(code(rtb::docs::terminal))]
Terminal(String),
#[error("AI feature not enabled")]
#[diagnostic(
code(rtb::docs::ai_disabled),
help("rebuild with `--features ai` to enable `docs ask`"),
)]
AiDisabled,
#[error(transparent)]
#[diagnostic(transparent)]
Assets(#[from] rtb_assets::AssetError),
#[error("I/O error: {0}")]
#[diagnostic(code(rtb::docs::io))]
Io(#[from] std::sync::Arc<std::io::Error>),
}
2.2 docs CLI command¶
USAGE:
<tool> docs [SUBCOMMAND]
SUBCOMMANDS:
browse Interactive two-pane terminal browser (default).
serve Spin up a local HTTP server rendering the embedded docs
as HTML. Airgap-friendly — no internet, no source tree.
list Print the index to stdout.
show Print a single doc to stdout, rendered markdown.
ask Ask a question; streams the answer (requires `ai` feat).
OPTIONS:
-o, --output <FORMAT> text | markdown | json (per-subcommand).
-p, --port <PORT> docs serve only; default 0 (auto-pick).
--bind <ADDR> docs serve only; default 127.0.0.1.
--open docs serve only; auto-open browser.
-h, --help Show help.
<tool> docs with no arguments defaults to browse.
2.3 Index format¶
Docs live under a directory in the tool's asset tree. The directory
contains an _index.yaml describing the navigation structure:
# docs/_index.yaml — shipped inside the tool's rtb-assets tree.
title: My Tool Documentation
sections:
- title: Getting started
pages:
- { path: intro.md, title: Introduction }
- { path: install.md, title: Install }
- title: Reference
pages:
- { path: config.md, title: Configuration reference }
- { path: commands.md, title: Commands reference }
_index.yaml is optional. When absent, DocsBrowser::new falls back
to a depth-first scan of .md files under root, using each file's
first # Heading as the title.
2.4 Two-pane layout¶
┌─ My Tool Documentation ─────────────────────────────────────────┐
│ ▸ Getting started │ │
│ └ Introduction │ # Introduction │
│ └ Install │ │
│ ▸ Reference │ Paragraphs rendered by termimad, │
│ └ Configuration │ flowing to terminal width. │
│ └ Commands │ │
│ │ │
│ /search │ ↑↓ scroll Enter open q quit │
└─────────────────────────────────────────────────────────────────┘
Keybindings:
| Key | Action |
|---|---|
↑ / ↓ / j / k |
Move the cursor (left pane) / scroll content (right pane). |
Enter |
Open the selected page in the right pane. |
Tab |
Swap focus between panes. |
/ |
Enter search mode — fuzzy match against page titles. |
Esc |
Leave search mode; or quit if not in search mode. |
q |
Quit. |
? |
Show a keybinding cheat-sheet overlay. |
2.5 docs serve — embedded HTML server (airgapped-friendly)¶
docs serve is for end-users on air-gapped machines who would rather
read the docs in a real web browser than in a terminal. The server
renders the binary's embedded markdown tree (via rtb-assets) as
HTML on demand — no source tree, no internet, no zensical required.
Everything the end-user needs is already inside the tool binary at the
version they're running.
pub struct DocsServer { /* fields non-public */ }
impl DocsServer {
pub fn new(app: &rtb_app::App, root: &str) -> Result<Self, DocsError>;
/// Bind and run forever (or until `shutdown` fires).
pub async fn run(
self,
bind: std::net::SocketAddr,
shutdown: tokio_util::sync::CancellationToken,
) -> Result<(), DocsError>;
/// Return the bound socket address (useful when `port: 0` picks one).
pub fn local_addr(&self) -> std::net::SocketAddr;
}
Implementation:
- HTTP stack:
hyper1.x +axum— the small, well-maintained canonical pair in the Rust ecosystem. We avoid a bespoke server so the TLS / HTTP-semantics / graceful-shutdown story is already-solved. - Route shape:
GET /→ rendered index page linking every entry in the tool's_index.yaml(or the fallback scan).GET /<path>.html→ a single markdown page rendered to HTML.GET /assets/<path>→ static files from the embedded asset tree (images referenced by docs, CSS, etc.).GET /search?q=<text>→ JSON search endpoint backed by the sametantivyindex used by the TUI browser.- Rendering:
pulldown-cmark::html::push_htmlproduces the body; the page is wrapped in a minimal HTML shell styled to match the Zensical microsite theme so the look doesn't drift between the shipped static site and the in-binary rendering. Theme colours + CSS live in a singleinclude_str!-embedded stylesheet. - Binding: defaults to
127.0.0.1:0(loopback, auto-port). Binding to any non-loopback address requires an explicit--bindflag — no accidental exposure on a LAN interface. --open: callsrtb_cli::browser::open_url("http://127.0.0.1:<port>/")after the server binds.- Graceful shutdown:
Ctrl+C→CancellationToken::cancel()→axumcompletes in-flight responses and exits. - No authentication, no TLS — loopback only. Tools that need authenticated docs surfaces run a different thing.
2.6 AI seam (deferred)¶
The ai Cargo feature, off by default, pulls in a trait-object
dependency on rtb-ai (when that crate ships in v0.3). The seam here:
#[cfg(feature = "ai")]
pub mod ask {
/// Trait implemented by `rtb-ai` at v0.3. Abstracted here so
/// `rtb-docs` v0.1 doesn't depend on the concrete crate.
#[async_trait::async_trait]
pub trait AiAnswerStream: Send + Sync + 'static {
async fn ask(
&self,
context: &str,
question: &str,
) -> Result<
Box<dyn tokio_stream::Stream<Item = String> + Send + Unpin>,
crate::DocsError,
>;
}
}
Without the feature, docs ask returns DocsError::AiDisabled with
the help text in §2.1. With the feature but without a registered
AiAnswerStream implementation, same result.
Online-only. AI Q&A is hosted-provider only (Claude, OpenAI,
Gemini, etc. — whatever rtb-ai exposes when it ships). Embedding a
local model in the tool binary is explicitly out of scope for
every milestone of rtb-docs: model weights are hundreds of MB to
multiple GB, and running them on end-user hardware is a commitment
no CLI framework should make on behalf of its tool authors. Tools
whose users are offline-only either don't enable the ai feature,
or (post-v0.3) point rtb-ai at a self-hosted inference endpoint on
the user's network — the Q&A stream still flows over a trait that
doesn't care where the bytes come from.
3. Rendering¶
- Parser:
pulldown-cmark(fast, spec-compliant CommonMark). - Renderer:
tui-markdown(v0.3.x, by a ratatui core maintainer) convertspulldown-cmarkevents intoratatui::text::Text, which the two-pane app renders into aParagraphwidget inside the right-hand sub-rect. Nottermimad. termimad writes raw ANSI to stdout, which is incompatible with ratatui's double-buffered diff-render model; broot (termimad's flagship consumer) bypasses ratatui entirely for that reason. See O1 for the full comparison. - Missing-feature compensation.
tui-markdown0.3.x does not yet support tables, links (as renderedSpanemphasis), or images.rtb-docsships a small internal renderer layer (crate::render::extras) that: - maps GFM tables onto
ratatui::widgets::Tablewith column constraints inferred from the first non-header row, - renders links as underlined
Spans with a themeable accent colour, keeping the URL in a side-channel forEnter-to-open, - stubs images as
[image: <alt-text>]inline. Upstream-able work — we contribute back totui-markdownwhere the maintainers accept it. - Syntax highlighting:
tui-markdown'shighlight-codefeature routes fenced code blocks throughsyntect. Enabled by default. - Relative links resolve against the doc tree root via the
safe_joinlexical checkrtb-assetsuses — traversal out of the doc tree is rejected before rendering. - External links (
https://…,mailto:) route throughrtb_cli::browser::open_url, inheriting the scheme allowlist.
4. Search¶
Two layers, both shipped at v0.1 for parity with gtb's docs browser:
- Title search — fuzzy match on page titles via
fuzzy-matcher(SkimMatcherV2). Fast; runs in-memory against the index; suitable for "I know roughly what the page is called." - Full-text search —
tantivy-backed index over the rendered markdown body of every embedded page. Built once atDocsBrowser::newinto a temporary in-memory directory (tantivy::directory::RamDirectory); no on-disk persistence. Suitable for "I remember a phrase, not the page title."
Keybindings + UX:
| Key in right pane | Action |
|---|---|
/ |
Enter title-search mode. Live fuzzy match on page titles. |
? (in search mode) |
Switch to full-text mode. Query is submitted on Enter; results are ranked by tantivy::collector::TopDocs and show a 140-character body snippet with the match highlighted. |
Enter |
Open the top-ranked match in the right pane. |
Esc |
Leave search mode. |
Full-text mode costs a small compile-time dep (tantivy is ~1 MB of
compiled code post-strip) and a small runtime cost (~50 ms index build
per 100 pages on commodity hardware). Both are acceptable for the v0.1
surface; no feature flag.
5. Acceptance criteria¶
5.1 Unit tests (T#)¶
- T1 —
DocsBrowser::newfails withRootMissingwhen the asset path doesn't exist. - T2 —
_index.yamlparse error surfaces asIndexMalformed. - T3 — Missing
_index.yamltriggers fallback scan. Verified against a fixture with three markdown files. - T4 — Fallback scan extracts
# Headingas title. - T5 — Markdown renderer round-trips a trivial document. Input
# Title\n\nBodyproduces non-empty output containing "Title" and "Body". - T6 — Code fence survives rendering.
\``rust\nlet x = 1;\n```appears in output with therust` language hint preserved as a label. - T7 — Relative link between two docs opens the target. Simulated
by constructing a
DocsBrowser, invoking the "follow link" action, asserting the new page is rendered. - T8 — External link routes through
rtb_cli::browser::open_url. Verified via a test double. - T9 —
Escin non-search mode quits the browser. - T10 — Fuzzy search matches across sections. Given pages titled "Introduction", "Install", "Configuration", query "conf" ranks "Configuration" first.
- T11 — Without
aifeature,docs askreturnsAiDisabled. - T12 —
tantivyindex is built at browser construction and returns non-empty search hits for a phrase present in a fixture page body. - T13 — Full-text search ranks exact-phrase matches above partial
matches. Verified by asserting ordering on
TopDocsresults for a two-page fixture. - T14 —
DocsServer::newfails withRootMissingwhen the asset path doesn't exist (same contract asDocsBrowser). - T15 —
DocsServer::runbinds to the requested address andlocal_addr()returns the concrete port (when user requested:0). - T16 —
GET /<existing>.htmlreturns status 200 and a body containing the rendered markdown. - T17 —
GET /missing.htmlreturns status 404 with a JSON error body. - T18 —
GET /search?q=phrasereturns JSON of ranked hits. - T19 —
GET /assets/../../etc/passwd(traversal attempt) is rejected at the router layer before touching the asset tree. - T20 — Graceful shutdown.
CancellationToken::cancel()causesDocsServer::runto returnOk(())within 200 ms; in-flight requests complete.
5.2 Gherkin acceptance (S#)¶
crates/rtb-docs/tests/features/docs.feature:
- S1 — Tool embeds docs, user runs
docs list. Output includes every page title from the fixture_index.yaml. - S2 —
docs show introprints rendered markdown to stdout. - S3 —
docswith no args launches the browser. (Via thecrosstermtest double.) - S4 — Fallback scan produces an index when
_index.yamlis absent. - S5 — A broken link inside a doc renders as a dimmed label, does not crash.
- S6 —
docs ask "how do I configure X"without theaifeature produces a diagnostic with the configured help text. - S7 —
docs servespins up a loopback HTTP server and a request to/returns HTML that lists every page from the fixture. - S8 —
docs serve --opencallsrtb_cli::browser::open_url. Verified with a test double. - S9 — Full-text search from the TUI finds a phrase in a page
body that isn't in the page title. Scenario: query
"cancellation token", assert theApp::shutdownreference page is the top hit even though its title is just "App context".
5.3 E2E¶
- E1 —
examples/minimal docs listproduces the canonical fixture index. Verified viaassert_cmd.
6. Security & operational requirements¶
#![forbid(unsafe_code)]at the crate root.- No filesystem access outside
rtb-assets. The docs browser cannot be used to read arbitrary files on disk. - Links resolved inside the doc tree go through the same
safe_joinlexical checkrtb-assetsuses. A link like[x](../../etc/passwd)is rejected before rendering. - External links (
http(s)://,mailto:) go throughrtb_cli::browser::open_url— existing scheme allowlist, URL-length bound, control-char rejection apply. termimadoutput is sanitised — the renderer strips ANSI escape sequences from user-supplied markdown text before emission, so a doc author cannot ship a page that repositions the cursor in unexpected ways.- AI Q&A (deferred): the streamed response bytes pass through
rtb_redact::stringbefore rendering, so responses that hallucinate credentials cannot leak to the terminal buffer.
7. Non-goals (explicit)¶
- Writing / editing docs. Read-only browser. Doc updates happen
in the tool's source tree and re-ship on
rtb-assetsrebuild. - Live reload. Docs are embedded at compile time; neither the
browser nor the
docs serveHTTP server watches for changes. - Non-Markdown formats. No AsciiDoc, no reST, no man pages. Markdown only.
- Tables of contents inside individual docs.
pulldown-cmarkparses them; RTB doesn't add a jump-to-heading shortcut in v0.1. - Offline / embedded AI. The
aifeature is online-only, by design — see §2.6. Embedding model weights in a CLI binary is not on any milestone. - Authenticated
docs serve. Loopback-only, no auth. Tools that need an authenticated docs surface are serving the wrong audience with this feature. - TLS on
docs serve. Loopback; TLS would be pointless overhead.
8. Rollout plan¶
- Land spec + Gherkin + failing unit tests with skeleton types.
- Implement
index.rs(YAML parse + fallback scan). - Implement
render.rs—pulldown-cmark→ratatui::text::Textviatui-markdown, plus the in-house extras layer for tables, links, image-stubs. - Implement the
ratatuitwo-pane app loop. - Implement fuzzy title search.
- Implement
tantivyfull-text search (indexer + TUI integration). - Implement
docs serve—axumroutes, HTML renderer, search JSON endpoint,safe_jointraversal guard, graceful shutdown. - Register the
docsCLI command intoBUILTIN_COMMANDSwithbrowse/serve/list/showsubcommands (andaskgated on theaifeature). - Implement the framework-docs-merge logic (O5 resolution) so built-in commands' docs appear alongside tool-author docs.
- Document in
docs/components/rtb-docs.md. - Land the
aifeature seam empty — becomes live whenrtb-aiships in v0.3.
9. Open questions¶
- O1 — Markdown renderer choice. Resolved:
tui-markdown0.3.x as the base, with an in-house extras layer for tables, links, and image-stubs.termimadwas the initial proposal; research (commissioned during spec review) found it architecturally incompatible with ratatui's diff-render model. termimad emits raw ANSI directly to stdout; ratatui keeps a diffedBufferofCells. The two corrupt each other in a two-pane layout, and termimad's author's flagship (broot) deliberately avoids ratatui.tui-markdownis by a ratatui core maintainer, tracks the ratatui version cadence by default, and shipssyntectcode highlighting. Its missing features (tables, links, images) are well-scoped in-house work we can upstream. Decision criterion for a later pivot: if the hand-rolled table renderer cannot handle nested block content inside cells andtui-markdownhas not merged table support by one minor release after v0.2, reconsider — but the realistic pivot is tomd-tui's table engine, not to termimad. - O2 — Searchable body text. Resolved: ship
tantivyfull- text search at v0.1. GTB-parity; users consuming Rust-built tools should not have a materially weaker docs experience than users of GTB-built tools. Index is built in-memory atDocsBrowser::new/DocsServer::new; no on-disk persistence. ~50 ms for 100 pages on commodity hardware. No feature flag. - O3 —
docs serve(embedded HTML for airgapped users). Resolved: ship at v0.1 (§ 2.5). The clarification here was important —docs serveis not a "preview-my-source-tree" feature, it's a way for end-users on airgapped machines to read the docs shipped inside their tool binary via a real web browser, without internet and without a copy of the source or ofzensical. That's uniquely valuable in regulated environments and closes a real gap vs GTB. - O4 — Offline / embedded AI. Resolved: out of scope for every
milestone of
rtb-docs. Embedding a model in the tool binary is a commitment no CLI framework should make on behalf of its tool authors (weights are huge, inference constraints are end-user-hardware-specific).docs askis online-only viartb-ai's hosted-provider clients, with a self-hosted-endpoint option oncertb-aiships. TheAiAnswerStreamtrait is source- agnostic. - O5 — Built-in framework docs alongside tool-author docs.
Resolved: merge at runtime.
DocsBrowser::newandDocsServer::newstitch the tool's asset tree with anrtb-cli-shipped built-in tree under a "Framework" section. Tool authors do not re-documentdoctor/init/config/update/docs; they inherit the shared copy. Per-tool overrides are possible by shipping a markdown file with the same relative path in the tool's asset tree — the tool copy wins.