errand 0.1.1

Async small-web (smolweb) transport: gemini, gopher, finger, spartan, nex, guppy, and titan in one scheme-routed fetch, plus titan-upload and misfin-send write companions. Bytes in, bytes out, no HTTP, host-agnostic.
Documentation
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

//! The spartan protocol (`spartan://`, port 300).
//!
//! A plaintext cousin of gemini: the request is `<host> <path> <length>\r\n`
//! (length is 0 for a fetch, which uploads no body), and the reply is a
//! `<code> <meta>\r\n` header followed by the body. The code is a single digit:
//! 2 success, 3 redirect, 4 client error, 5 server error.

use url::Url;

use crate::plain::exchange;
use crate::{Error, Response, Scheme, Status};

/// Fetch a `spartan://` URL.
pub(crate) async fn fetch(url: &Url) -> Result<Response, Error> {
    let host = url
        .host_str()
        .ok_or_else(|| Error::BadUrl("spartan URL has no host".into()))?;
    let port = url.port().unwrap_or_else(|| Scheme::Spartan.default_port());
    let path = if url.path().is_empty() { "/" } else { url.path() };

    // A fetch uploads nothing, so the content length is always zero.
    let request = format!("{host} {path} 0\r\n");
    let raw = exchange(host, port, request.as_bytes()).await?;
    parse(url, &raw)
}

/// Split a spartan response into its `<code> <meta>\r\n` header and body.
fn parse(url: &Url, raw: &[u8]) -> Result<Response, Error> {
    let split = raw
        .windows(2)
        .position(|w| w == b"\r\n")
        .ok_or_else(|| Error::Protocol("response header has no CRLF".into()))?;
    let header = std::str::from_utf8(&raw[..split])
        .map_err(|_| Error::Protocol("response header is not UTF-8".into()))?;
    let body = raw[split + 2..].to_vec();

    let mut parts = header.splitn(2, ' ');
    let code_str = parts.next().unwrap_or("");
    let meta = parts.next().unwrap_or("").trim().to_string();
    let code: u8 = code_str
        .parse()
        .map_err(|_| Error::Protocol(format!("bad spartan status: {header:?}")))?;

    let status = match code {
        2 => Status::Success,
        3 => Status::Redirect,
        4 | 5 => Status::Failure,
        _ => return Err(Error::Protocol(format!("unknown spartan status: {code}"))),
    };

    Ok(Response {
        url: url.clone(),
        status,
        raw_status: Some(code),
        meta,
        body: if status == Status::Success { body } else { Vec::new() },
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn u() -> Url {
        Url::parse("spartan://example.org/").unwrap()
    }

    #[test]
    fn success_carries_mime_and_body() {
        let r = parse(&u(), b"2 text/gemini\r\n# Hi\n").unwrap();
        assert_eq!(r.status, Status::Success);
        assert_eq!(r.raw_status, Some(2));
        assert_eq!(r.mime(), Some("text/gemini"));
        assert_eq!(r.body, b"# Hi\n");
    }

    #[test]
    fn redirect_drops_body() {
        let r = parse(&u(), b"3 /elsewhere\r\nx").unwrap();
        assert_eq!(r.status, Status::Redirect);
        assert_eq!(r.meta, "/elsewhere");
        assert!(r.body.is_empty());
    }

    #[test]
    fn client_and_server_errors_are_failures() {
        assert_eq!(parse(&u(), b"4 bad request\r\n").unwrap().status, Status::Failure);
        assert_eq!(parse(&u(), b"5 boom\r\n").unwrap().status, Status::Failure);
    }

    #[test]
    fn missing_crlf_is_a_protocol_error() {
        assert!(matches!(parse(&u(), b"2 text/gemini"), Err(Error::Protocol(_))));
    }
}