guppy-protocol 0.1.1

An implementation of the Guppy protocol v0.4.4 (guppy://): async UDP client and server with chunking, acknowledgement, and retransmission, plus a CLI. The smolweb-over-UDP protocol.
Documentation
//! The `guppy` command-line tool (the `cli` feature): fetch and serve.

use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;

use guppy_protocol::{FetchOptions, FileHandler, GuppyResponse, ServerConfig, fetch, serve};

const USAGE: &str = "guppy — the Guppy protocol (spec: github.com/dimkr/guppy-protocol, v0.4.4)

USAGE:
  guppy fetch <guppy://url>
      Fetch a URL and print the body. Attach input as the URL's query
      (percent-encoded) when a prompt asks for it.

  guppy serve --root DIR [--listen ADDR:PORT]
      Serve a directory (index.gmi per directory, gemtext-first).
      Default listen address is 0.0.0.0:6775.
";

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    match run(args) {
        Ok(()) => ExitCode::SUCCESS,
        Err(message) => {
            eprintln!("{message}");
            ExitCode::FAILURE
        }
    }
}

fn run(args: Vec<String>) -> Result<(), String> {
    let Some((command, rest)) = args.split_first() else {
        return Err(USAGE.to_string());
    };

    let mut positional = Vec::new();
    let mut root: Option<PathBuf> = None;
    let mut listen: Option<String> = None;
    let mut iter = rest.iter();
    while let Some(arg) = iter.next() {
        match arg.as_str() {
            "--root" => root = Some(PathBuf::from(iter.next().ok_or("--root needs a value")?)),
            "--listen" => listen = Some(iter.next().ok_or("--listen needs a value")?.clone()),
            other if other.starts_with("--") => return Err(format!("Unknown flag {other}")),
            other => positional.push(other.to_string()),
        }
    }

    match command.as_str() {
        "fetch" => {
            let [url] = positional.as_slice() else {
                return Err("usage: guppy fetch <guppy://url>".into());
            };
            let response = block_on(fetch(url, &FetchOptions::default()))?;
            match response {
                GuppyResponse::Success { mime, body } => {
                    eprintln!("success: {mime}");
                    use std::io::Write;
                    std::io::stdout()
                        .write_all(&body)
                        .map_err(|error| error.to_string())
                }
                GuppyResponse::Prompt { text } => {
                    println!("input required: {text}");
                    println!("(repeat with the input as the URL query, e.g. ?your%20answer)");
                    Ok(())
                }
                GuppyResponse::Redirect { target } => {
                    println!("redirect: {target}");
                    Ok(())
                }
                GuppyResponse::Error { message } => Err(format!("error: {message}")),
            }
        }
        "serve" => {
            let root = root.ok_or("guppy serve: --root DIR is required")?;
            let listen: SocketAddr = listen
                .as_deref()
                .unwrap_or("0.0.0.0:6775")
                .parse()
                .map_err(|_| "guppy serve: --listen needs ADDR:PORT".to_string())?;
            block_on(async move {
                let socket = tokio::net::UdpSocket::bind(listen)
                    .await
                    .map_err(|error| format!("bind {listen}: {error}"))?;
                eprintln!("guppy: serving {} on {listen} (ctrl-c to stop)", root.display());
                serve(
                    socket,
                    FileHandler::new(root),
                    ServerConfig::default(),
                    async {
                        let _ = tokio::signal::ctrl_c().await;
                    },
                )
                .await
                .map_err(|error| error.to_string())
            })
        }
        "--help" | "-h" | "help" => {
            println!("{USAGE}");
            Ok(())
        }
        other => Err(format!("Unknown command '{other}'.\n\n{USAGE}")),
    }
}

fn block_on<T, E: ToString>(
    future: impl std::future::Future<Output = Result<T, E>>,
) -> Result<T, String> {
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .map_err(|error| error.to_string())?
        .block_on(future)
        .map_err(|error| error.to_string())
}