1#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum Packet {
6 First {
8 seq: u32,
9 mime: String,
10 data: Vec<u8>,
11 },
12 Continuation { seq: u32, data: Vec<u8> },
14 Prompt { text: String },
16 Redirect { target: String },
18 Error { message: String },
20}
21
22pub fn parse_packet(datagram: &[u8]) -> Result<Packet, String> {
31 let split = datagram
32 .windows(2)
33 .position(|window| window == b"\r\n")
34 .ok_or_else(|| "packet has no CRLF".to_string())?;
35 let header = std::str::from_utf8(&datagram[..split])
36 .map_err(|_| "packet header is not UTF-8".to_string())?;
37 let data = datagram[split + 2..].to_vec();
38
39 match header.split_once(' ') {
40 Some(("1", rest)) => Ok(Packet::Prompt {
41 text: rest.trim().to_string(),
42 }),
43 Some(("3", rest)) => Ok(Packet::Redirect {
44 target: rest.trim().to_string(),
45 }),
46 Some(("4", rest)) => Ok(Packet::Error {
47 message: rest.trim().to_string(),
48 }),
49 Some((seq, mime)) => {
50 let seq: u32 = seq
51 .parse()
52 .map_err(|_| format!("bad sequence number: {seq:?}"))?;
53 if mime.trim().is_empty() {
54 return Err("first packet has an empty MIME type".to_string());
55 }
56 Ok(Packet::First {
57 seq,
58 mime: mime.trim().to_string(),
59 data,
60 })
61 }
62 None => {
63 let seq: u32 = header
64 .trim()
65 .parse()
66 .map_err(|_| format!("bad packet header: {header:?}"))?;
67 Ok(Packet::Continuation { seq, data })
68 }
69 }
70}
71
72pub(crate) fn encode_first(seq: u32, mime: &str, data: &[u8]) -> Vec<u8> {
74 let mut out = format!("{seq} {mime}\r\n").into_bytes();
75 out.extend_from_slice(data);
76 out
77}
78
79pub(crate) fn encode_continuation(seq: u32, data: &[u8]) -> Vec<u8> {
81 let mut out = format!("{seq}\r\n").into_bytes();
82 out.extend_from_slice(data);
83 out
84}
85
86pub(crate) fn encode_ack(seq: u32) -> Vec<u8> {
88 format!("{seq}\r\n").into_bytes()
89}
90
91pub(crate) enum ClientDatagram {
94 Ack(u32),
95 Request(String),
96}
97
98pub(crate) fn parse_client_datagram(datagram: &[u8]) -> Result<ClientDatagram, String> {
99 let split = datagram
100 .windows(2)
101 .position(|window| window == b"\r\n")
102 .ok_or_else(|| "datagram has no CRLF".to_string())?;
103 let line = std::str::from_utf8(&datagram[..split])
104 .map_err(|_| "datagram is not UTF-8".to_string())?;
105 if datagram.len() == split + 2 {
106 if let Ok(seq) = line.trim().parse::<u32>() {
107 return Ok(ClientDatagram::Ack(seq));
108 }
109 }
110 Ok(ClientDatagram::Request(line.to_string()))
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn spec_example_packets_parse() {
119 assert_eq!(
121 parse_packet(b"566837578 text/gemini\r\n# Title 1\n").unwrap(),
122 Packet::First {
123 seq: 566_837_578,
124 mime: "text/gemini".to_string(),
125 data: b"# Title 1\n".to_vec()
126 }
127 );
128 assert_eq!(
129 parse_packet(b"566837579\r\nParagraph 1").unwrap(),
130 Packet::Continuation {
131 seq: 566_837_579,
132 data: b"Paragraph 1".to_vec()
133 }
134 );
135 assert_eq!(
136 parse_packet(b"566837581\r\n").unwrap(),
137 Packet::Continuation {
138 seq: 566_837_581,
139 data: Vec::new()
140 }
141 );
142 assert_eq!(
143 parse_packet(b"1 Your name\r\n").unwrap(),
144 Packet::Prompt {
145 text: "Your name".to_string()
146 }
147 );
148 assert_eq!(
149 parse_packet(b"3 /b\r\n").unwrap(),
150 Packet::Redirect {
151 target: "/b".to_string()
152 }
153 );
154 assert_eq!(
155 parse_packet(b"4 No search keywords specified\r\n").unwrap(),
156 Packet::Error {
157 message: "No search keywords specified".to_string()
158 }
159 );
160 }
161
162 #[test]
163 fn low_sequence_numbers_with_mime_are_success_not_special() {
164 assert!(matches!(
166 parse_packet(b"39 text/plain\r\nhi").unwrap(),
167 Packet::First { seq: 39, .. }
168 ));
169 assert!(matches!(
170 parse_packet(b"41 text/plain\r\nhi").unwrap(),
171 Packet::First { seq: 41, .. }
172 ));
173 }
174
175 #[test]
176 fn client_datagrams_split_into_acks_and_requests() {
177 assert!(matches!(
178 parse_client_datagram(b"566837578\r\n").unwrap(),
179 ClientDatagram::Ack(566_837_578)
180 ));
181 assert!(matches!(
182 parse_client_datagram(b"guppy://localhost/a\r\n").unwrap(),
183 ClientDatagram::Request(url) if url == "guppy://localhost/a"
184 ));
185 }
186
187 #[test]
188 fn encodings_round_trip() {
189 let first = encode_first(42, "text/gemini", b"body");
190 assert!(matches!(
191 parse_packet(&first).unwrap(),
192 Packet::First { seq: 42, .. }
193 ));
194 let eof = encode_continuation(43, b"");
195 assert_eq!(
196 parse_packet(&eof).unwrap(),
197 Packet::Continuation {
198 seq: 43,
199 data: Vec::new()
200 }
201 );
202 assert_eq!(encode_ack(42), b"42\r\n");
203 }
204}