Skip to content

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: hyper 1.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 same tantivy index used by the TUI browser.
  • Rendering: pulldown-cmark::html::push_html produces 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 single include_str!-embedded stylesheet.
  • Binding: defaults to 127.0.0.1:0 (loopback, auto-port). Binding to any non-loopback address requires an explicit --bind flag — no accidental exposure on a LAN interface.
  • --open: calls rtb_cli::browser::open_url("http://127.0.0.1:<port>/") after the server binds.
  • Graceful shutdown: Ctrl+CCancellationToken::cancel()axum completes 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) converts pulldown-cmark events into ratatui::text::Text, which the two-pane app renders into a Paragraph widget inside the right-hand sub-rect. Not termimad. 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-markdown 0.3.x does not yet support tables, links (as rendered Span emphasis), or images. rtb-docs ships a small internal renderer layer (crate::render::extras) that:
  • maps GFM tables onto ratatui::widgets::Table with 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 for Enter-to-open,
  • stubs images as [image: <alt-text>] inline. Upstream-able work — we contribute back to tui-markdown where the maintainers accept it.
  • Syntax highlighting: tui-markdown's highlight-code feature routes fenced code blocks through syntect. Enabled by default.
  • Relative links resolve against the doc tree root via the safe_join lexical check rtb-assets uses — traversal out of the doc tree is rejected before rendering.
  • External links (https://…, mailto:) route through rtb_cli::browser::open_url, inheriting the scheme allowlist.

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 searchtantivy-backed index over the rendered markdown body of every embedded page. Built once at DocsBrowser::new into 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::new fails with RootMissing when the asset path doesn't exist.
  • T2 — _index.yaml parse error surfaces as IndexMalformed.
  • T3 — Missing _index.yaml triggers fallback scan. Verified against a fixture with three markdown files.
  • T4 — Fallback scan extracts # Heading as title.
  • T5 — Markdown renderer round-trips a trivial document. Input # Title\n\nBody produces 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 — Esc in 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 ai feature, docs ask returns AiDisabled.
  • T12 — tantivy index 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 TopDocs results for a two-page fixture.
  • T14 — DocsServer::new fails with RootMissing when the asset path doesn't exist (same contract as DocsBrowser).
  • T15 — DocsServer::run binds to the requested address and local_addr() returns the concrete port (when user requested :0).
  • T16 — GET /<existing>.html returns status 200 and a body containing the rendered markdown.
  • T17 — GET /missing.html returns status 404 with a JSON error body.
  • T18 — GET /search?q=phrase returns 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() causes DocsServer::run to return Ok(()) 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 intro prints rendered markdown to stdout.
  • S3 — docs with no args launches the browser. (Via the crossterm test double.)
  • S4 — Fallback scan produces an index when _index.yaml is 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 the ai feature produces a diagnostic with the configured help text.
  • S7 — docs serve spins up a loopback HTTP server and a request to / returns HTML that lists every page from the fixture.
  • S8 — docs serve --open calls rtb_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 the App::shutdown reference page is the top hit even though its title is just "App context".

5.3 E2E

  • E1 — examples/minimal docs list produces the canonical fixture index. Verified via assert_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_join lexical check rtb-assets uses. A link like [x](../../etc/passwd) is rejected before rendering.
  • External links (http(s)://, mailto:) go through rtb_cli::browser::open_url — existing scheme allowlist, URL-length bound, control-char rejection apply.
  • termimad output 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::string before 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-assets rebuild.
  • Live reload. Docs are embedded at compile time; neither the browser nor the docs serve HTTP server watches for changes.
  • Non-Markdown formats. No AsciiDoc, no reST, no man pages. Markdown only.
  • Tables of contents inside individual docs. pulldown-cmark parses them; RTB doesn't add a jump-to-heading shortcut in v0.1.
  • Offline / embedded AI. The ai feature 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

  1. Land spec + Gherkin + failing unit tests with skeleton types.
  2. Implement index.rs (YAML parse + fallback scan).
  3. Implement render.rspulldown-cmarkratatui::text::Text via tui-markdown, plus the in-house extras layer for tables, links, image-stubs.
  4. Implement the ratatui two-pane app loop.
  5. Implement fuzzy title search.
  6. Implement tantivy full-text search (indexer + TUI integration).
  7. Implement docs serveaxum routes, HTML renderer, search JSON endpoint, safe_join traversal guard, graceful shutdown.
  8. Register the docs CLI command into BUILTIN_COMMANDS with browse/serve/list/show subcommands (and ask gated on the ai feature).
  9. Implement the framework-docs-merge logic (O5 resolution) so built-in commands' docs appear alongside tool-author docs.
  10. Document in docs/components/rtb-docs.md.
  11. Land the ai feature seam empty — becomes live when rtb-ai ships in v0.3.

9. Open questions

  • O1 — Markdown renderer choice. Resolved: tui-markdown 0.3.x as the base, with an in-house extras layer for tables, links, and image-stubs. termimad was 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 diffed Buffer of Cells. The two corrupt each other in a two-pane layout, and termimad's author's flagship (broot) deliberately avoids ratatui. tui-markdown is by a ratatui core maintainer, tracks the ratatui version cadence by default, and ships syntect code 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 and tui-markdown has not merged table support by one minor release after v0.2, reconsider — but the realistic pivot is to md-tui's table engine, not to termimad.
  • O2 — Searchable body text. Resolved: ship tantivy full- 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 at DocsBrowser::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 serve is 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 of zensical. 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 ask is online-only via rtb-ai's hosted-provider clients, with a self-hosted-endpoint option once rtb-ai ships. The AiAnswerStream trait is source- agnostic.
  • O5 — Built-in framework docs alongside tool-author docs. Resolved: merge at runtime. DocsBrowser::new and DocsServer::new stitch the tool's asset tree with an rtb-cli-shipped built-in tree under a "Framework" section. Tool authors do not re-document doctor/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.