#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Packet {
First {
seq: u32,
mime: String,
data: Vec<u8>,
},
Continuation { seq: u32, data: Vec<u8> },
Prompt { text: String },
Redirect { target: String },
Error { message: String },
}
pub fn parse_packet(datagram: &[u8]) -> Result<Packet, String> {
let split = datagram
.windows(2)
.position(|window| window == b"\r\n")
.ok_or_else(|| "packet has no CRLF".to_string())?;
let header = std::str::from_utf8(&datagram[..split])
.map_err(|_| "packet header is not UTF-8".to_string())?;
let data = datagram[split + 2..].to_vec();
match header.split_once(' ') {
Some(("1", rest)) => Ok(Packet::Prompt {
text: rest.trim().to_string(),
}),
Some(("3", rest)) => Ok(Packet::Redirect {
target: rest.trim().to_string(),
}),
Some(("4", rest)) => Ok(Packet::Error {
message: rest.trim().to_string(),
}),
Some((seq, mime)) => {
let seq: u32 = seq
.parse()
.map_err(|_| format!("bad sequence number: {seq:?}"))?;
if mime.trim().is_empty() {
return Err("first packet has an empty MIME type".to_string());
}
Ok(Packet::First {
seq,
mime: mime.trim().to_string(),
data,
})
}
None => {
let seq: u32 = header
.trim()
.parse()
.map_err(|_| format!("bad packet header: {header:?}"))?;
Ok(Packet::Continuation { seq, data })
}
}
}
pub(crate) fn encode_first(seq: u32, mime: &str, data: &[u8]) -> Vec<u8> {
let mut out = format!("{seq} {mime}\r\n").into_bytes();
out.extend_from_slice(data);
out
}
pub(crate) fn encode_continuation(seq: u32, data: &[u8]) -> Vec<u8> {
let mut out = format!("{seq}\r\n").into_bytes();
out.extend_from_slice(data);
out
}
pub(crate) fn encode_ack(seq: u32) -> Vec<u8> {
format!("{seq}\r\n").into_bytes()
}
pub(crate) enum ClientDatagram {
Ack(u32),
Request(String),
}
pub(crate) fn parse_client_datagram(datagram: &[u8]) -> Result<ClientDatagram, String> {
let split = datagram
.windows(2)
.position(|window| window == b"\r\n")
.ok_or_else(|| "datagram has no CRLF".to_string())?;
let line = std::str::from_utf8(&datagram[..split])
.map_err(|_| "datagram is not UTF-8".to_string())?;
if datagram.len() == split + 2 {
if let Ok(seq) = line.trim().parse::<u32>() {
return Ok(ClientDatagram::Ack(seq));
}
}
Ok(ClientDatagram::Request(line.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spec_example_packets_parse() {
assert_eq!(
parse_packet(b"566837578 text/gemini\r\n# Title 1\n").unwrap(),
Packet::First {
seq: 566_837_578,
mime: "text/gemini".to_string(),
data: b"# Title 1\n".to_vec()
}
);
assert_eq!(
parse_packet(b"566837579\r\nParagraph 1").unwrap(),
Packet::Continuation {
seq: 566_837_579,
data: b"Paragraph 1".to_vec()
}
);
assert_eq!(
parse_packet(b"566837581\r\n").unwrap(),
Packet::Continuation {
seq: 566_837_581,
data: Vec::new()
}
);
assert_eq!(
parse_packet(b"1 Your name\r\n").unwrap(),
Packet::Prompt {
text: "Your name".to_string()
}
);
assert_eq!(
parse_packet(b"3 /b\r\n").unwrap(),
Packet::Redirect {
target: "/b".to_string()
}
);
assert_eq!(
parse_packet(b"4 No search keywords specified\r\n").unwrap(),
Packet::Error {
message: "No search keywords specified".to_string()
}
);
}
#[test]
fn low_sequence_numbers_with_mime_are_success_not_special() {
assert!(matches!(
parse_packet(b"39 text/plain\r\nhi").unwrap(),
Packet::First { seq: 39, .. }
));
assert!(matches!(
parse_packet(b"41 text/plain\r\nhi").unwrap(),
Packet::First { seq: 41, .. }
));
}
#[test]
fn client_datagrams_split_into_acks_and_requests() {
assert!(matches!(
parse_client_datagram(b"566837578\r\n").unwrap(),
ClientDatagram::Ack(566_837_578)
));
assert!(matches!(
parse_client_datagram(b"guppy://localhost/a\r\n").unwrap(),
ClientDatagram::Request(url) if url == "guppy://localhost/a"
));
}
#[test]
fn encodings_round_trip() {
let first = encode_first(42, "text/gemini", b"body");
assert!(matches!(
parse_packet(&first).unwrap(),
Packet::First { seq: 42, .. }
));
let eof = encode_continuation(43, b"");
assert_eq!(
parse_packet(&eof).unwrap(),
Packet::Continuation {
seq: 43,
data: Vec::new()
}
);
assert_eq!(encode_ack(42), b"42\r\n");
}
}