1#![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
32const DEFAULT_ROOT: &str = "docs";
34
35pub 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 feature: Some(Feature::Docs),
45 ..CommandSpec::DEFAULT
46 };
47 &SPEC
48 }
49
50 fn subcommand_passthrough(&self) -> bool {
54 true
55 }
56
57 async fn run(&self, app: App) -> miette::Result<()> {
58 let mut args: Vec<OsString> = std::env::args_os().collect();
63 if args.len() >= 2 {
64 args.drain(..2);
65 }
66 args.insert(0, OsString::from("docs"));
68 let cli = match DocsCli::try_parse_from(args) {
69 Ok(cli) => cli,
70 Err(e) => {
71 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#[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 List(ListOpts),
112 Show(ShowOpts),
114 Browse(BrowseOpts),
116 Serve(ServeOpts),
118 Ask(AskOpts),
120}
121
122#[derive(Debug, clap::Args)]
123struct ListOpts {
124 #[arg(long, default_value = DEFAULT_ROOT)]
126 root: String,
127}
128
129#[derive(Debug, clap::Args)]
130struct ShowOpts {
131 path: String,
133 #[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 #[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 #[arg(trailing_var_arg = true)]
159 question: Vec<String>,
160}
161
162fn 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 §ion.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 _ => 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)] async fn run_ask(_app: &App, _opts: &AskOpts) -> miette::Result<()> {
217 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 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 §ion.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 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
267fn 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 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}