#![allow(unsafe_code)]
use std::ffi::OsString;
use std::net::SocketAddr;
use async_trait::async_trait;
use clap::{Parser, Subcommand};
use linkme::distributed_slice;
use miette::{miette, IntoDiagnostic};
use rtb_app::app::App;
use rtb_app::command::{Command, CommandSpec, BUILTIN_COMMANDS};
use rtb_app::features::Feature;
use crate::loader::load_docs;
use crate::render::{to_html_document, to_plain_text};
use crate::server::DocsServer;
const DEFAULT_ROOT: &str = "docs";
pub struct DocsCmd;
#[async_trait]
impl Command for DocsCmd {
fn spec(&self) -> &CommandSpec {
static SPEC: CommandSpec = CommandSpec {
name: "docs",
about: "Browse the embedded documentation",
feature: Some(Feature::Docs),
..CommandSpec::DEFAULT
};
&SPEC
}
fn subcommand_passthrough(&self) -> bool {
true
}
async fn run(&self, app: App) -> miette::Result<()> {
let mut args: Vec<OsString> = std::env::args_os().collect();
if args.len() >= 2 {
args.drain(..2);
}
args.insert(0, OsString::from("docs"));
let cli = match DocsCli::try_parse_from(args) {
Ok(cli) => cli,
Err(e) => {
use clap::error::ErrorKind;
if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
print!("{e}");
return Ok(());
}
return Err(miette!("{e}"));
}
};
match cli.command {
DocsSub::List(opts) => run_list(&app, &opts),
DocsSub::Show(opts) => run_show(&app, &opts),
DocsSub::Browse(opts) => run_browse(&app, &opts),
DocsSub::Serve(opts) => run_serve(app, opts).await,
DocsSub::Ask(opts) => run_ask(&app, &opts).await,
}
}
}
#[distributed_slice(BUILTIN_COMMANDS)]
fn __register_docs() -> Box<dyn Command> {
Box::new(DocsCmd)
}
#[derive(Debug, Parser)]
#[command(name = "docs", about = "Browse the embedded documentation")]
struct DocsCli {
#[command(subcommand)]
command: DocsSub,
}
#[derive(Debug, Subcommand)]
enum DocsSub {
List(ListOpts),
Show(ShowOpts),
Browse(BrowseOpts),
Serve(ServeOpts),
Ask(AskOpts),
}
#[derive(Debug, clap::Args)]
struct ListOpts {
#[arg(long, default_value = DEFAULT_ROOT)]
root: String,
}
#[derive(Debug, clap::Args)]
struct ShowOpts {
path: String,
#[arg(long, value_parser = ["plain", "html"], default_value = "plain")]
format: String,
#[arg(long, default_value = DEFAULT_ROOT)]
root: String,
}
#[derive(Debug, clap::Args)]
struct BrowseOpts {
#[arg(long, default_value = DEFAULT_ROOT)]
root: String,
}
#[derive(Debug, clap::Args)]
struct ServeOpts {
#[arg(long, default_value = "127.0.0.1:0")]
bind: SocketAddr,
#[arg(long, default_value = DEFAULT_ROOT)]
root: String,
}
#[derive(Debug, clap::Args)]
struct AskOpts {
#[arg(trailing_var_arg = true)]
question: Vec<String>,
}
fn run_list(app: &App, opts: &ListOpts) -> miette::Result<()> {
let (index, _pages) = load_docs(&app.assets, &opts.root).into_diagnostic()?;
println!("{}", index.title);
for section in &index.sections {
println!("\n# {}", section.title);
for page in §ion.pages {
println!(" {} — {}", page.path, page.title);
}
}
Ok(())
}
fn run_show(app: &App, opts: &ShowOpts) -> miette::Result<()> {
let (_index, pages) = load_docs(&app.assets, &opts.root).into_diagnostic()?;
let body = pages
.get(&opts.path)
.ok_or_else(|| miette!("page not found in docs tree: {}", opts.path))?;
match opts.format.as_str() {
"html" => println!("{}", to_html_document(&opts.path, body)),
_ => println!("{}", to_plain_text(body)),
}
Ok(())
}
fn run_browse(app: &App, opts: &BrowseOpts) -> miette::Result<()> {
let (index, pages) = load_docs(&app.assets, &opts.root).into_diagnostic()?;
let mut browser = crate::DocsBrowser::new(index, pages).into_diagnostic()?;
run_event_loop(&mut browser).into_diagnostic()
}
async fn run_serve(app: App, opts: ServeOpts) -> miette::Result<()> {
let (index, pages) = load_docs(&app.assets, &opts.root).into_diagnostic()?;
let server = DocsServer::new(index, pages).into_diagnostic()?;
let cancel = app.shutdown.child_token();
let (bound_tx, bound_rx) = tokio::sync::oneshot::channel();
let serve_task = tokio::spawn(server.run(opts.bind, bound_tx, cancel));
if let Ok(addr) = bound_rx.await {
println!("docs server listening on http://{addr}");
println!("press Ctrl+C to stop");
}
match serve_task.await {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(miette!("docs server: {e}")),
Err(e) => Err(miette!("docs server task panicked: {e}")),
}
}
#[cfg(not(feature = "ai"))]
#[allow(clippy::unused_async)] async fn run_ask(_app: &App, _opts: &AskOpts) -> miette::Result<()> {
Err(crate::error::DocsError::AiDisabled.into())
}
#[cfg(feature = "ai")]
async fn run_ask(app: &App, opts: &AskOpts) -> miette::Result<()> {
use crate::ai::AiAnswerStream;
use futures_util::StreamExt as _;
use std::io::Write as _;
if opts.question.is_empty() {
return Err(miette!("docs ask: question is required"));
}
let question = opts.question.join(" ");
let (index, pages) = load_docs(&app.assets, "docs").into_diagnostic()?;
let mut context = String::with_capacity(8 * 1024);
{
use std::fmt::Write as _;
let _ = writeln!(context, "# {}\n", index.title);
for section in &index.sections {
for entry in §ion.pages {
if let Some(body) = pages.get(&entry.path) {
let _ = writeln!(context, "## {} ({})\n", entry.title, entry.path);
context.push_str(&crate::render::to_plain_text(body));
context.push_str("\n\n");
}
}
}
}
let provider = crate::ai::default_answer_stream(app)?;
let mut stream = provider.ask(&context, &question).await.into_diagnostic()?;
while let Some(token) = stream.next().await {
let mut stdout = std::io::stdout().lock();
let _ = stdout.write_all(token.as_bytes());
let _ = stdout.flush();
}
println!();
Ok(())
}
fn run_event_loop(browser: &mut crate::DocsBrowser) -> Result<(), crate::error::DocsError> {
use crossterm::event::{self, Event, KeyEventKind};
use crossterm::terminal;
use crossterm::ExecutableCommand;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
terminal::enable_raw_mode().map_err(|e| crate::error::DocsError::Terminal(e.to_string()))?;
let mut stdout = std::io::stdout();
stdout
.execute(terminal::EnterAlternateScreen)
.map_err(|e| crate::error::DocsError::Terminal(e.to_string()))?;
let backend = CrosstermBackend::new(stdout);
let mut terminal =
Terminal::new(backend).map_err(|e| crate::error::DocsError::Terminal(e.to_string()))?;
let result = (|| -> Result<(), crate::error::DocsError> {
loop {
terminal
.draw(|frame| {
let area = frame.area();
let buf = frame.buffer_mut();
browser.render(area, buf);
})
.map_err(|e| crate::error::DocsError::Terminal(e.to_string()))?;
if browser.quit_requested() {
return Ok(());
}
if event::poll(std::time::Duration::from_millis(100))
.map_err(|e| crate::error::DocsError::Terminal(e.to_string()))?
{
if let Event::Key(k) =
event::read().map_err(|e| crate::error::DocsError::Terminal(e.to_string()))?
{
if k.kind == KeyEventKind::Press {
if let Some(code) = map_key(k.code) {
browser.handle_key(code);
}
}
}
}
}
})();
let _ = std::io::stdout().execute(terminal::LeaveAlternateScreen);
let _ = terminal::disable_raw_mode();
result
}
const fn map_key(code: crossterm::event::KeyCode) -> Option<crate::browser::KeyCode> {
use crossterm::event::KeyCode as Ck;
Some(match code {
Ck::Char(c) => crate::browser::KeyCode::Char(c),
Ck::Up => crate::browser::KeyCode::Up,
Ck::Down => crate::browser::KeyCode::Down,
Ck::Enter => crate::browser::KeyCode::Enter,
Ck::Tab => crate::browser::KeyCode::Tab,
Ck::Backspace => crate::browser::KeyCode::Backspace,
Ck::Esc => crate::browser::KeyCode::Esc,
_ => return None,
})
}