Skip to main content

rtb_docs/
command.rs

1//! `docs` CLI subcommand — `list | show | browse | serve | ask`.
2//!
3//! Wires the [`crate::DocsBrowser`] / [`crate::DocsServer`] library
4//! APIs to the user-facing CLI. Each invocation parses its own
5//! arguments via clap (the [`Command`] trait gives no
6//! `clap::ArgMatches` to subcommands), reads the doc tree out of
7//! `rtb_app::App::assets` under `docs/`, and dispatches.
8//!
9//! # Lint exception
10//!
11//! `linkme::distributed_slice` emits `#[link_section]` which Rust
12//! 1.95+ flags under `unsafe_code`. Allowed at module level — no
13//! hand-rolled `unsafe` blocks anywhere in the module.
14
15#![allow(unsafe_code)]
16
17use std::ffi::OsString;
18use std::net::SocketAddr;
19
20use async_trait::async_trait;
21use clap::{Parser, Subcommand};
22use linkme::distributed_slice;
23use miette::{miette, IntoDiagnostic};
24use rtb_app::app::App;
25use rtb_app::command::{Command, CommandSpec, BUILTIN_COMMANDS};
26use rtb_app::features::Feature;
27
28use crate::loader::load_docs;
29use crate::render::{to_html_document, to_plain_text};
30use crate::server::DocsServer;
31
32/// The default doc-tree root inside the asset overlay.
33const DEFAULT_ROOT: &str = "docs";
34
35/// The `docs` subcommand.
36pub struct DocsCmd;
37
38#[async_trait]
39impl Command for DocsCmd {
40    fn spec(&self) -> &CommandSpec {
41        static SPEC: CommandSpec = CommandSpec {
42            name: "docs",
43            about: "Browse the embedded documentation",
44            aliases: &[],
45            feature: Some(Feature::Docs),
46        };
47        &SPEC
48    }
49
50    /// `docs` owns its inner clap subtree (`list / show / browse /
51    /// serve / ask`). Opt into the framework's passthrough so the
52    /// inner parser handles `--help` + arg validation.
53    fn subcommand_passthrough(&self) -> bool {
54        true
55    }
56
57    async fn run(&self, app: App) -> miette::Result<()> {
58        // The `Command` trait runs `async fn(App)` — args were
59        // consumed by rtb-cli's clap tree at the top level. Re-read
60        // `std::env::args_os()` and skip past the binary + the
61        // matched `docs` token to get the docs-specific tail.
62        let mut args: Vec<OsString> = std::env::args_os().collect();
63        if args.len() >= 2 {
64            args.drain(..2);
65        }
66        // clap expects a leading arg0; synthesise one.
67        args.insert(0, OsString::from("docs"));
68        let cli = match DocsCli::try_parse_from(args) {
69            Ok(cli) => cli,
70            Err(e) => {
71                // `--help` / `--version` aren't errors — clap formats
72                // them as `Err` so the caller can print and exit. Mirror
73                // that here: print the formatted output and return Ok.
74                use clap::error::ErrorKind;
75                if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
76                    print!("{e}");
77                    return Ok(());
78                }
79                return Err(miette!("{e}"));
80            }
81        };
82        match cli.command {
83            DocsSub::List(opts) => run_list(&app, &opts),
84            DocsSub::Show(opts) => run_show(&app, &opts),
85            DocsSub::Browse(opts) => run_browse(&app, &opts),
86            DocsSub::Serve(opts) => run_serve(app, opts).await,
87            DocsSub::Ask(opts) => run_ask(&app, &opts).await,
88        }
89    }
90}
91
92#[distributed_slice(BUILTIN_COMMANDS)]
93fn __register_docs() -> Box<dyn Command> {
94    Box::new(DocsCmd)
95}
96
97// ---------------------------------------------------------------------
98// clap surface
99// ---------------------------------------------------------------------
100
101#[derive(Debug, Parser)]
102#[command(name = "docs", about = "Browse the embedded documentation")]
103struct DocsCli {
104    #[command(subcommand)]
105    command: DocsSub,
106}
107
108#[derive(Debug, Subcommand)]
109enum DocsSub {
110    /// Print every page in the doc tree.
111    List(ListOpts),
112    /// Render a single page to stdout.
113    Show(ShowOpts),
114    /// Open the interactive TUI browser (`q` to quit).
115    Browse(BrowseOpts),
116    /// Start the loopback HTTP server (Ctrl+C to stop).
117    Serve(ServeOpts),
118    /// AI Q&A — gated on the `ai` Cargo feature.
119    Ask(AskOpts),
120}
121
122#[derive(Debug, clap::Args)]
123struct ListOpts {
124    /// Doc-tree root inside the asset overlay.
125    #[arg(long, default_value = DEFAULT_ROOT)]
126    root: String,
127}
128
129#[derive(Debug, clap::Args)]
130struct ShowOpts {
131    /// Page path under the doc-tree root, e.g. `intro.md`.
132    path: String,
133    /// Render as plain text (default) or HTML.
134    #[arg(long, value_parser = ["plain", "html"], default_value = "plain")]
135    format: String,
136    #[arg(long, default_value = DEFAULT_ROOT)]
137    root: String,
138}
139
140#[derive(Debug, clap::Args)]
141struct BrowseOpts {
142    #[arg(long, default_value = DEFAULT_ROOT)]
143    root: String,
144}
145
146#[derive(Debug, clap::Args)]
147struct ServeOpts {
148    /// Bind address. `127.0.0.1:0` (default) picks a free port.
149    #[arg(long, default_value = "127.0.0.1:0")]
150    bind: SocketAddr,
151    #[arg(long, default_value = DEFAULT_ROOT)]
152    root: String,
153}
154
155#[derive(Debug, clap::Args)]
156struct AskOpts {
157    /// Question to put to the configured AI provider.
158    #[arg(trailing_var_arg = true)]
159    question: Vec<String>,
160}
161
162// ---------------------------------------------------------------------
163// Subcommand bodies
164// ---------------------------------------------------------------------
165
166fn run_list(app: &App, opts: &ListOpts) -> miette::Result<()> {
167    let (index, _pages) = load_docs(&app.assets, &opts.root).into_diagnostic()?;
168    println!("{}", index.title);
169    for section in &index.sections {
170        println!("\n# {}", section.title);
171        for page in &section.pages {
172            println!("  {} — {}", page.path, page.title);
173        }
174    }
175    Ok(())
176}
177
178fn run_show(app: &App, opts: &ShowOpts) -> miette::Result<()> {
179    let (_index, pages) = load_docs(&app.assets, &opts.root).into_diagnostic()?;
180    let body = pages
181        .get(&opts.path)
182        .ok_or_else(|| miette!("page not found in docs tree: {}", opts.path))?;
183    match opts.format.as_str() {
184        "html" => println!("{}", to_html_document(&opts.path, body)),
185        // "plain" is the default (validated by clap's value_parser).
186        _ => println!("{}", to_plain_text(body)),
187    }
188    Ok(())
189}
190
191fn run_browse(app: &App, opts: &BrowseOpts) -> miette::Result<()> {
192    let (index, pages) = load_docs(&app.assets, &opts.root).into_diagnostic()?;
193    let mut browser = crate::DocsBrowser::new(index, pages).into_diagnostic()?;
194    run_event_loop(&mut browser).into_diagnostic()
195}
196
197async fn run_serve(app: App, opts: ServeOpts) -> miette::Result<()> {
198    let (index, pages) = load_docs(&app.assets, &opts.root).into_diagnostic()?;
199    let server = DocsServer::new(index, pages).into_diagnostic()?;
200    let cancel = app.shutdown.child_token();
201    let (bound_tx, bound_rx) = tokio::sync::oneshot::channel();
202    let serve_task = tokio::spawn(server.run(opts.bind, bound_tx, cancel));
203    if let Ok(addr) = bound_rx.await {
204        println!("docs server listening on http://{addr}");
205        println!("press Ctrl+C to stop");
206    }
207    match serve_task.await {
208        Ok(Ok(())) => Ok(()),
209        Ok(Err(e)) => Err(miette!("docs server: {e}")),
210        Err(e) => Err(miette!("docs server task panicked: {e}")),
211    }
212}
213
214#[cfg(not(feature = "ai"))]
215#[allow(clippy::unused_async)] // signature parity with the `ai` build.
216async fn run_ask(_app: &App, _opts: &AskOpts) -> miette::Result<()> {
217    // The trait seam lives in `crate::ai` behind the `ai` feature.
218    // The CLI surface stays unconditional so users discover the
219    // command; when the feature is off, fail with a clear pointer.
220    Err(crate::error::DocsError::AiDisabled.into())
221}
222
223#[cfg(feature = "ai")]
224async fn run_ask(app: &App, opts: &AskOpts) -> miette::Result<()> {
225    use crate::ai::AiAnswerStream;
226    use futures_util::StreamExt as _;
227    use std::io::Write as _;
228
229    if opts.question.is_empty() {
230        return Err(miette!("docs ask: question is required"));
231    }
232    let question = opts.question.join(" ");
233
234    // Build the doc-tree context: every page's plain-text body
235    // concatenated with its title. Cheap on small trees; v0.3.x can
236    // bring in retrieval + ranking when the corpus grows past a
237    // sensible context window.
238    let (index, pages) = load_docs(&app.assets, "docs").into_diagnostic()?;
239    let mut context = String::with_capacity(8 * 1024);
240    {
241        use std::fmt::Write as _;
242        let _ = writeln!(context, "# {}\n", index.title);
243        for section in &index.sections {
244            for entry in &section.pages {
245                if let Some(body) = pages.get(&entry.path) {
246                    let _ = writeln!(context, "## {} ({})\n", entry.title, entry.path);
247                    context.push_str(&crate::render::to_plain_text(body));
248                    context.push_str("\n\n");
249                }
250            }
251        }
252    }
253
254    let provider = crate::ai::default_answer_stream(app)?;
255    let mut stream = provider.ask(&context, &question).await.into_diagnostic()?;
256    while let Some(token) = stream.next().await {
257        // Re-lock per token: `StdoutLock` isn't `Send` across `await`,
258        // and we want each token visible immediately anyway.
259        let mut stdout = std::io::stdout().lock();
260        let _ = stdout.write_all(token.as_bytes());
261        let _ = stdout.flush();
262    }
263    println!();
264    Ok(())
265}
266
267// ---------------------------------------------------------------------
268// TUI event loop
269// ---------------------------------------------------------------------
270
271fn run_event_loop(browser: &mut crate::DocsBrowser) -> Result<(), crate::error::DocsError> {
272    use crossterm::event::{self, Event, KeyEventKind};
273    use crossterm::terminal;
274    use crossterm::ExecutableCommand;
275    use ratatui::backend::CrosstermBackend;
276    use ratatui::Terminal;
277
278    terminal::enable_raw_mode().map_err(|e| crate::error::DocsError::Terminal(e.to_string()))?;
279    let mut stdout = std::io::stdout();
280    stdout
281        .execute(terminal::EnterAlternateScreen)
282        .map_err(|e| crate::error::DocsError::Terminal(e.to_string()))?;
283    let backend = CrosstermBackend::new(stdout);
284    let mut terminal =
285        Terminal::new(backend).map_err(|e| crate::error::DocsError::Terminal(e.to_string()))?;
286
287    let result = (|| -> Result<(), crate::error::DocsError> {
288        loop {
289            terminal
290                .draw(|frame| {
291                    let area = frame.area();
292                    let buf = frame.buffer_mut();
293                    browser.render(area, buf);
294                })
295                .map_err(|e| crate::error::DocsError::Terminal(e.to_string()))?;
296            if browser.quit_requested() {
297                return Ok(());
298            }
299            // 100ms tick keeps the loop responsive even if the user
300            // never presses a key.
301            if event::poll(std::time::Duration::from_millis(100))
302                .map_err(|e| crate::error::DocsError::Terminal(e.to_string()))?
303            {
304                if let Event::Key(k) =
305                    event::read().map_err(|e| crate::error::DocsError::Terminal(e.to_string()))?
306                {
307                    if k.kind == KeyEventKind::Press {
308                        if let Some(code) = map_key(k.code) {
309                            browser.handle_key(code);
310                        }
311                    }
312                }
313            }
314        }
315    })();
316
317    let _ = std::io::stdout().execute(terminal::LeaveAlternateScreen);
318    let _ = terminal::disable_raw_mode();
319    result
320}
321
322const fn map_key(code: crossterm::event::KeyCode) -> Option<crate::browser::KeyCode> {
323    use crossterm::event::KeyCode as Ck;
324    Some(match code {
325        Ck::Char(c) => crate::browser::KeyCode::Char(c),
326        Ck::Up => crate::browser::KeyCode::Up,
327        Ck::Down => crate::browser::KeyCode::Down,
328        Ck::Enter => crate::browser::KeyCode::Enter,
329        Ck::Tab => crate::browser::KeyCode::Tab,
330        Ck::Backspace => crate::browser::KeyCode::Backspace,
331        Ck::Esc => crate::browser::KeyCode::Esc,
332        _ => return None,
333    })
334}