1#[derive(Clone, Debug, PartialEq, Eq)]
19pub enum GopherKind {
20 Info,
22 Error,
24 Text,
26 Submenu,
28 Search,
30 Binary,
32 Image,
34 Sound,
36 Telnet,
38 Url,
40 Other(char),
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum GopherPlus {
51 Supported,
53 Form,
56}
57
58impl GopherPlus {
59 fn from_field(field: &str) -> Option<Self> {
63 match field.trim() {
64 "+" => Some(Self::Supported),
65 "?" => Some(Self::Form),
66 _ => None,
67 }
68 }
69}
70
71#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct GopherItem {
74 pub kind: GopherKind,
75 pub display: String,
76 pub url: Option<String>,
83 pub plus: Option<GopherPlus>,
86}
87
88pub fn parse(body: &str) -> Vec<GopherItem> {
92 let mut items = Vec::new();
93 for line in body.lines() {
94 if line == "." {
95 break;
96 }
97 if line.is_empty() {
98 continue;
99 }
100 if let Some(item) = parse_line(line) {
101 items.push(item);
102 }
103 }
104 items
105}
106
107fn parse_line(line: &str) -> Option<GopherItem> {
108 let mut chars = line.chars();
109 let type_char = chars.next()?;
110 let rest = chars.as_str();
111
112 let mut parts = rest.splitn(5, '\t');
116 let display = parts.next()?.to_string();
117 let selector = parts.next().unwrap_or("");
118 let host = parts.next().unwrap_or("");
119 let port = parts.next().unwrap_or("70");
120 let plus = parts.next().and_then(GopherPlus::from_field);
121
122 match type_char {
123 'i' => Some(GopherItem {
124 kind: GopherKind::Info,
125 display,
126 url: None,
127 plus,
128 }),
129 '3' => Some(GopherItem {
130 kind: GopherKind::Error,
131 display,
132 url: None,
133 plus,
134 }),
135 'h' => {
136 let url = selector.strip_prefix("URL:").unwrap_or(selector).trim();
139 if url.is_empty() {
140 return None;
141 }
142 Some(GopherItem {
143 kind: GopherKind::Url,
144 display,
145 url: Some(url.to_string()),
146 plus,
147 })
148 },
149 _ => {
150 if host.is_empty() {
151 return None;
152 }
153 let url = synthesise_gopher_url(type_char, host, port, selector);
154 Some(GopherItem {
155 kind: kind_of(type_char),
156 display,
157 url: Some(url),
158 plus,
159 })
160 },
161 }
162}
163
164fn kind_of(type_char: char) -> GopherKind {
165 match type_char {
166 '0' => GopherKind::Text,
167 '1' => GopherKind::Submenu,
168 '7' => GopherKind::Search,
169 '9' => GopherKind::Binary,
170 'g' | 'I' => GopherKind::Image,
171 's' => GopherKind::Sound,
172 'T' => GopherKind::Telnet,
173 other => GopherKind::Other(other),
174 }
175}
176
177fn synthesise_gopher_url(type_char: char, host: &str, port: &str, selector: &str) -> String {
178 let port_part = if port.is_empty() || port == "70" {
179 String::new()
180 } else {
181 format!(":{port}")
182 };
183 format!("gopher://{host}{port_part}/{type_char}{selector}")
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 fn line(t: char, display: &str, selector: &str, host: &str, port: &str) -> String {
194 format!("{t}{display}\t{selector}\t{host}\t{port}\r\n")
195 }
196
197 #[test]
198 fn standard_item_synthesises_url_with_type_and_selector() {
199 let items = parse(&line(
200 '0',
201 "Welcome text",
202 "/welcome.txt",
203 "example.test",
204 "70",
205 ));
206 assert_eq!(
207 items,
208 vec![GopherItem {
209 kind: GopherKind::Text,
210 display: "Welcome text".into(),
211 url: Some("gopher://example.test/0/welcome.txt".into()),
212 plus: None,
213 }]
214 );
215 }
216
217 #[test]
218 fn non_default_port_appears_in_url() {
219 let items = parse(&line('1', "Sub", "/sub", "example.test", "7070"));
220 assert_eq!(
221 items[0].url.as_deref(),
222 Some("gopher://example.test:7070/1/sub")
223 );
224 assert_eq!(items[0].kind, GopherKind::Submenu);
225 }
226
227 #[test]
228 fn url_item_extracts_target() {
229 let items = parse(&line(
230 'h',
231 "External",
232 "URL:https://example.test/",
233 ".",
234 "70",
235 ));
236 assert_eq!(items[0].kind, GopherKind::Url);
237 assert_eq!(items[0].url.as_deref(), Some("https://example.test/"));
238 }
239
240 #[test]
241 fn info_and_error_carry_no_url() {
242 let items = parse(&format!(
243 "{}{}",
244 line('i', "hello", "", "example.test", "70"),
245 line('3', "boom", "", "example.test", "70"),
246 ));
247 assert_eq!(
248 items[0],
249 GopherItem {
250 kind: GopherKind::Info,
251 display: "hello".into(),
252 url: None,
253 plus: None
254 }
255 );
256 assert_eq!(
257 items[1],
258 GopherItem {
259 kind: GopherKind::Error,
260 display: "boom".into(),
261 url: None,
262 plus: None
263 }
264 );
265 }
266
267 #[test]
268 fn period_terminator_stops_parsing() {
269 let items = parse(&format!(
270 "{}{}{}",
271 line('i', "before", "", "example.test", "70"),
272 ".\r\n",
273 line('1', "after", "/x", "example.test", "70"),
274 ));
275 assert_eq!(items.len(), 1);
276 }
277
278 #[test]
279 fn resource_with_missing_host_is_skipped() {
280 assert!(parse("1Bad item\t/sel\t\t70\r\n").is_empty());
281 }
282
283 #[test]
284 fn a_gopher_plus_marker_is_read_from_the_fifth_field() {
285 let items = parse("1Archive\t/arc\texample.test\t70\t+\r\n");
286 assert_eq!(items[0].plus, Some(GopherPlus::Supported));
287
288 let items = parse("7Search\t/find\texample.test\t70\t?\r\n");
289 assert_eq!(items[0].plus, Some(GopherPlus::Form));
290 assert_eq!(items[0].kind, GopherKind::Search);
291 }
292
293 #[test]
294 fn a_plus_field_does_not_leak_into_the_port() {
295 let items = parse("1Archive\t/arc\texample.test\t70\t+\r\n");
298 assert_eq!(items[0].url.as_deref(), Some("gopher://example.test/1/arc"));
299
300 let items = parse("1Archive\t/arc\texample.test\t7070\t+\r\n");
301 assert_eq!(
302 items[0].url.as_deref(),
303 Some("gopher://example.test:7070/1/arc")
304 );
305 }
306
307 #[test]
308 fn an_unrecognised_fifth_field_is_not_a_marker() {
309 let items = parse("1Thing\t/t\texample.test\t70\tsomething else\r\n");
310 assert_eq!(items[0].plus, None);
311 assert_eq!(items[0].url.as_deref(), Some("gopher://example.test/1/t"));
312 }
313
314 #[test]
315 fn a_plain_rfc1436_menu_has_no_markers() {
316 let items = parse(&line('0', "Plain", "/p", "example.test", "70"));
317 assert_eq!(items[0].plus, None);
318 }
319
320 #[test]
321 fn unknown_type_stays_navigable() {
322 let items = parse(&line('X', "weird", "/sel", "example.test", "70"));
323 assert_eq!(items[0].kind, GopherKind::Other('X'));
324 assert_eq!(items[0].url.as_deref(), Some("gopher://example.test/X/sel"));
325 }
326}