Skip to main content

gopher_protocol/
menu.rs

1//! Gopher menu parser — RFC 1436 menus into a typed item list.
2//!
3//! A gopher menu is tab-delimited lines: `<type><display>\t<selector>\t<host>
4//! \t<port>`. The first character is the item type; a bare `.` terminates the
5//! menu. This parser classifies each line into a [`GopherItem`] carrying the
6//! item [`GopherKind`] (so a native viewer can show a per-type affordance) plus
7//! the resolved resource URL (synthesised per RFC 4266, or extracted for a URL
8//! item). Info and error lines carry no URL. A consumer decides how to present
9//! each kind; the parser holds no document or render model.
10//!
11//! This module has no dependencies and is compiled unconditionally, so a
12//! consumer that only renders menus can take the crate with
13//! `default-features = false`.
14//!
15//! References: RFC 1436 (Gopher), RFC 4266 (gopher URI scheme).
16
17/// The semantic class of a gopher menu line, from its item-type character.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub enum GopherKind {
20    /// `i` — informational text, no resource.
21    Info,
22    /// `3` — server error message, no resource.
23    Error,
24    /// `0` — text file.
25    Text,
26    /// `1` — submenu / directory.
27    Submenu,
28    /// `7` — full-text search server.
29    Search,
30    /// `9` — binary.
31    Binary,
32    /// `g` / `I` — image.
33    Image,
34    /// `s` — sound.
35    Sound,
36    /// `T` — telnet session.
37    Telnet,
38    /// `h` — URL item (the selector carries an external URL).
39    Url,
40    /// Any other (still navigable) item type.
41    Other(char),
42}
43
44/// The Gopher+ marker carried in an item line's fifth field.
45///
46/// RFC 1436 menus have four tab-separated fields and stop at the port. Gopher+
47/// servers append a fifth, which RFC 1436 clients are required to ignore. Its
48/// presence is how a client learns an item has attributes worth asking for.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum GopherPlus {
51    /// `+`: the item has Gopher+ attribute blocks (`+INFO`, `+VIEWS`, …).
52    Supported,
53    /// `?`: the item is an interactive query carrying an `+ASK` form, which a
54    /// client must fill in before retrieval.
55    Form,
56}
57
58impl GopherPlus {
59    /// Classify a fifth field. Anything other than `+` or `?` is `None`: the
60    /// spec calls this position "extra stuff", so an unknown value is not an
61    /// error, just not a marker this client acts on.
62    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/// One parsed gopher menu line.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct GopherItem {
74    pub kind: GopherKind,
75    pub display: String,
76    /// The resource URL: a synthesised `gopher://` URL (RFC 4266) for standard
77    /// items, or the extracted target for a `h` URL item. `None` for [`Info`] and
78    /// [`Error`] lines.
79    ///
80    /// [`Info`]: GopherKind::Info
81    /// [`Error`]: GopherKind::Error
82    pub url: Option<String>,
83    /// The Gopher+ marker from the fifth field, when the server sent one.
84    /// `None` for a plain RFC 1436 menu.
85    pub plus: Option<GopherPlus>,
86}
87
88/// Parse a gopher menu body into items, in source order. Stops at the RFC 1436
89/// `.` terminator; skips blank and malformed lines (a non-info/error item with no
90/// host, or a URL item with no target).
91pub 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    // Five, not four: a Gopher+ server appends a marker after the port, and
113    // splitting into four would leave it stuck to the port field (turning
114    // `70` into `70\t+` and corrupting every synthesised URL).
115    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            // URL items: selector is typically "URL:https://…". Strip the prefix;
137            // skip the line when it carries no usable target.
138            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    // RFC 4266: gopher-path = <gophertype><selector>. The type character is the
184    // first path segment, immediately followed by the selector (which may already
185    // begin with `/`).
186    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        // Splitting the line into four fields leaves the marker glued to the
296        // port, which then reads as non-default and corrupts the URL.
297        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}