Skip to main content

gopher_protocol/
plus.rs

1//! Gopher+ : the 1993 upward-compatible enhancements to RFC 1436.
2//!
3//! Gopher+ adds four things to gopher, and this module models three of them
4//! (the fourth, the item-line marker, belongs to [`crate::menu`] because it
5//! rides in the menu):
6//!
7//! - a **response header**, so a reply can declare its length instead of
8//!   relying on the server closing the connection ([`PlusHeader`]);
9//! - **attribute blocks**, metadata about an item retrieved separately from
10//!   the item itself ([`AttributeBlock`], [`View`]);
11//! - **ASK forms**, the interactive questionnaire a `?` item carries
12//!   ([`AskDirective`]).
13//!
14//! Everything here is pure parsing with no dependencies, so it is available
15//! under `default-features = false`.
16//!
17//! Gopher+ was never an RFC. The reference is "Gopher+: Upward compatible
18//! enhancements to the Internet Gopher protocol" (University of Minnesota,
19//! 1993).
20
21// ── Response header ────────────────────────────────────────────────────────
22
23/// The first line of a Gopher+ reply.
24///
25/// The first character is `+` for success or `-` for failure, followed by a
26/// decimal token: a byte count, `-1` for a period-terminated body, or `-2` for
27/// a body that ends when the connection closes.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum PlusHeader {
30    /// `+<count>`: the body is exactly this many bytes.
31    Length(u64),
32    /// `+-1`: read until a `.` on a line of its own.
33    PeriodTerminated,
34    /// `+-2`: read until the connection closes. Binary-safe, since no
35    /// terminator can collide with the payload.
36    UntilClose,
37    /// `--1`: the request failed. The body carries the error text and is
38    /// period-terminated.
39    Error,
40}
41
42/// What [`parse_header`] could not make sense of.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct MalformedHeader(pub String);
45
46impl std::fmt::Display for MalformedHeader {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(f, "malformed gopher+ header: {}", self.0)
49    }
50}
51
52impl std::error::Error for MalformedHeader {}
53
54/// Parse a Gopher+ response header line (without its CRLF).
55///
56/// ```
57/// use gopher_protocol::plus::{PlusHeader, parse_header};
58///
59/// assert_eq!(parse_header("+5340"), Ok(PlusHeader::Length(5340)));
60/// assert_eq!(parse_header("+-1"), Ok(PlusHeader::PeriodTerminated));
61/// assert_eq!(parse_header("+-2"), Ok(PlusHeader::UntilClose));
62/// assert_eq!(parse_header("--1"), Ok(PlusHeader::Error));
63/// assert!(parse_header("5340").is_err(), "a header must carry + or -");
64/// ```
65pub fn parse_header(line: &str) -> Result<PlusHeader, MalformedHeader> {
66    let line = line.trim_end_matches(['\r', '\n']);
67    let (sign, token) = line
68        .split_at_checked(1)
69        .ok_or_else(|| MalformedHeader(line.to_string()))?;
70    // The token may be followed by whitespace and further text; the number is
71    // all that is defined.
72    let token = token.split_whitespace().next().unwrap_or("");
73    match (sign, token) {
74        ("-", _) => Ok(PlusHeader::Error),
75        ("+", "-1") => Ok(PlusHeader::PeriodTerminated),
76        ("+", "-2") => Ok(PlusHeader::UntilClose),
77        ("+", count) => count
78            .parse::<u64>()
79            .map(PlusHeader::Length)
80            .map_err(|_| MalformedHeader(line.to_string())),
81        _ => Err(MalformedHeader(line.to_string())),
82    }
83}
84
85// ── Attribute blocks ───────────────────────────────────────────────────────
86
87/// One Gopher+ attribute block.
88///
89/// A block opens with `+` in column one followed by its name and a colon;
90/// every line belonging to it begins with a space. A server must return
91/// `+INFO` for every item it lists.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct AttributeBlock {
94    /// The block name without its leading `+`, e.g. `INFO`, `ADMIN`, `VIEWS`.
95    pub name: String,
96    /// Text on the block's own line after the colon. `+INFO` carries the
97    /// item's gopher descriptor here; most other blocks leave it empty.
98    pub head: Option<String>,
99    /// The block's continuation lines, each with its leading space removed.
100    pub lines: Vec<String>,
101}
102
103impl AttributeBlock {
104    /// The block's continuation lines as `name: value` pairs, for the blocks
105    /// built that way (`+ADMIN`, `+VIEWS`). Lines without a colon are skipped.
106    pub fn pairs(&self) -> Vec<(&str, &str)> {
107        self.lines
108            .iter()
109            .filter_map(|line| line.split_once(':'))
110            .map(|(k, v)| (k.trim(), v.trim()))
111            .collect()
112    }
113}
114
115/// Parse a Gopher+ attribute response into its blocks, in order.
116///
117/// Text before the first block is ignored, and a block with no continuation
118/// lines is still a block.
119pub fn parse_attributes(text: &str) -> Vec<AttributeBlock> {
120    let mut blocks: Vec<AttributeBlock> = Vec::new();
121    for line in text.lines() {
122        if line == "." {
123            break;
124        }
125        if let Some(rest) = line.strip_prefix('+') {
126            // A block name runs to the colon and cannot itself contain `+`.
127            let (name, head) = match rest.split_once(':') {
128                Some((name, head)) => (name, head.trim()),
129                None => (rest, ""),
130            };
131            blocks.push(AttributeBlock {
132                name: name.trim().to_string(),
133                head: (!head.is_empty()).then(|| head.to_string()),
134                lines: Vec::new(),
135            });
136        } else if let Some(content) = line.strip_prefix(' ') {
137            if let Some(block) = blocks.last_mut() {
138                block.lines.push(content.to_string());
139            }
140        }
141    }
142    blocks
143}
144
145/// One entry of a `+VIEWS` block: an alternate representation of an item.
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct View {
148    /// The representation's MIME type, e.g. `text/plain`.
149    pub mime: String,
150    /// The RFC 1766 language tag when the view names one, e.g. `De_DE`.
151    pub language: Option<String>,
152    /// The size as the server wrote it, e.g. `<10k>`. Kept verbatim because
153    /// the spec's sizes are explicitly approximate.
154    pub size: Option<String>,
155}
156
157/// Parse a `+VIEWS` block into its alternate representations.
158///
159/// ```
160/// use gopher_protocol::plus::{parse_attributes, parse_views};
161///
162/// let blocks = parse_attributes("+VIEWS:\n Text/plain: <10k>\n Text/plain De_DE: <15k>\n");
163/// let views = parse_views(&blocks[0]);
164///
165/// assert_eq!(views[0].mime, "Text/plain");
166/// assert_eq!(views[0].language, None);
167/// assert_eq!(views[1].language.as_deref(), Some("De_DE"));
168/// assert_eq!(views[1].size.as_deref(), Some("<15k>"));
169/// ```
170pub fn parse_views(block: &AttributeBlock) -> Vec<View> {
171    block
172        .lines
173        .iter()
174        .filter_map(|line| {
175            let (label, size) = match line.split_once(':') {
176                Some((label, size)) => (label.trim(), size.trim()),
177                None => (line.trim(), ""),
178            };
179            if label.is_empty() {
180                return None;
181            }
182            // `Text/plain De_DE` is a MIME type and a language tag.
183            let (mime, language) = match label.split_once(char::is_whitespace) {
184                Some((mime, lang)) => (mime.trim(), Some(lang.trim().to_string())),
185                None => (label, None),
186            };
187            Some(View {
188                mime: mime.to_string(),
189                language,
190                size: (!size.is_empty()).then(|| size.to_string()),
191            })
192        })
193        .collect()
194}
195
196// ── ASK forms ──────────────────────────────────────────────────────────────
197
198/// One line of an `+ASK` block: a question to put to the user.
199///
200/// The client presents these in the order they appear and sends the answers
201/// back in the same order.
202#[derive(Clone, Debug, PartialEq, Eq)]
203pub enum AskDirective {
204    /// `Ask:` a single-line answer, with an optional default.
205    Ask {
206        prompt: String,
207        default: Option<String>,
208    },
209    /// `AskP:` the same, but the answer is masked as it is typed.
210    AskPassword {
211        prompt: String,
212        default: Option<String>,
213    },
214    /// `AskL:` a multi-line answer.
215    AskLong {
216        prompt: String,
217        default: Option<String>,
218    },
219    /// `AskF:` a local filename to store something under.
220    AskFile {
221        prompt: String,
222        default: Option<String>,
223    },
224    /// `Select:` several options, any number of which may be chosen.
225    Select {
226        prompt: String,
227        options: Vec<String>,
228    },
229    /// `Choose:` several options, exactly one of which may be chosen.
230    Choose {
231        prompt: String,
232        options: Vec<String>,
233    },
234    /// `ChooseF:` pick an existing local file.
235    ChooseFile { prompt: String },
236    /// `Note:` text to show, which asks nothing.
237    Note(String),
238    /// A directive this crate does not model, kept verbatim so a client can
239    /// show it rather than silently dropping a question.
240    Unknown { directive: String, rest: String },
241}
242
243/// Parse an `+ASK` block into its directives, in order.
244///
245/// ```
246/// use gopher_protocol::plus::{AskDirective, parse_attributes, parse_ask};
247///
248/// let blocks = parse_attributes("+ASK:\n Ask: How many volts?\n Choose: Deliver shock?\tYes\tNo\n");
249/// let form = parse_ask(&blocks[0]);
250///
251/// assert!(matches!(&form[0], AskDirective::Ask { prompt, .. } if prompt == "How many volts?"));
252/// match &form[1] {
253///     AskDirective::Choose { options, .. } => assert_eq!(options, &["Yes", "No"]),
254///     other => panic!("expected a Choose, got {other:?}"),
255/// }
256/// ```
257pub fn parse_ask(block: &AttributeBlock) -> Vec<AskDirective> {
258    block.lines.iter().map(|line| parse_ask_line(line)).collect()
259}
260
261fn parse_ask_line(line: &str) -> AskDirective {
262    let (directive, rest) = match line.split_once(':') {
263        Some((directive, rest)) => (directive.trim(), rest.trim_start()),
264        None => (line.trim(), ""),
265    };
266
267    // A prompt and its tab-separated tail: a default for the Ask family, the
268    // option list for Select and Choose.
269    let mut fields = rest.split('\t');
270    let prompt = fields.next().unwrap_or("").trim().to_string();
271    let tail: Vec<String> = fields
272        .map(|f| f.trim().to_string())
273        .filter(|f| !f.is_empty())
274        .collect();
275    let default = tail.first().cloned();
276
277    match directive {
278        "Ask" => AskDirective::Ask { prompt, default },
279        "AskP" => AskDirective::AskPassword { prompt, default },
280        "AskL" => AskDirective::AskLong { prompt, default },
281        "AskF" => AskDirective::AskFile { prompt, default },
282        "Select" => AskDirective::Select {
283            prompt,
284            options: tail,
285        },
286        "Choose" => AskDirective::Choose {
287            prompt,
288            options: tail,
289        },
290        "ChooseF" => AskDirective::ChooseFile { prompt },
291        "Note" => AskDirective::Note(rest.trim().to_string()),
292        other => AskDirective::Unknown {
293            directive: other.to_string(),
294            rest: rest.to_string(),
295        },
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    const SAMPLE: &str = "+INFO: 0Some file or other\tmoo selector\thost2\tport2\t+\n\
304                          +ADMIN:\n\
305                          \x20Admin: Frodo Gophermeister <fng@bogus.edu>\n\
306                          \x20Mod-Date: Wed Jul 28 17:02:01 1993 <19930728170201>\n\
307                          +VIEWS:\n\
308                          \x20Text/plain: <10k>\n\
309                          \x20application/postscript: <100k>\n";
310
311    #[test]
312    fn header_forms() {
313        assert_eq!(parse_header("+5340"), Ok(PlusHeader::Length(5340)));
314        assert_eq!(parse_header("+-1"), Ok(PlusHeader::PeriodTerminated));
315        assert_eq!(parse_header("+-2"), Ok(PlusHeader::UntilClose));
316        assert_eq!(parse_header("--1"), Ok(PlusHeader::Error));
317    }
318
319    #[test]
320    fn a_header_without_a_sign_is_malformed() {
321        assert!(parse_header("5340").is_err());
322        assert!(parse_header("").is_err());
323        assert!(parse_header("+banana").is_err());
324    }
325
326    #[test]
327    fn a_trailing_crlf_does_not_defeat_the_count() {
328        assert_eq!(parse_header("+5340\r\n"), Ok(PlusHeader::Length(5340)));
329    }
330
331    #[test]
332    fn blocks_split_on_the_leading_plus() {
333        let blocks = parse_attributes(SAMPLE);
334        assert_eq!(
335            blocks.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
336            vec!["INFO", "ADMIN", "VIEWS"]
337        );
338    }
339
340    #[test]
341    fn info_carries_its_descriptor_on_the_block_line() {
342        let blocks = parse_attributes(SAMPLE);
343        assert!(blocks[0].head.as_deref().unwrap().starts_with("0Some file"));
344        assert!(blocks[0].lines.is_empty(), "INFO is a one-line block");
345    }
346
347    #[test]
348    fn continuation_lines_lose_their_leading_space_and_pair_up() {
349        let blocks = parse_attributes(SAMPLE);
350        let admin = &blocks[1];
351        assert_eq!(admin.lines.len(), 2);
352        assert_eq!(
353            admin.pairs()[0],
354            ("Admin", "Frodo Gophermeister <fng@bogus.edu>")
355        );
356    }
357
358    #[test]
359    fn views_split_mime_language_and_size() {
360        let blocks = parse_attributes(SAMPLE);
361        let views = parse_views(&blocks[2]);
362        assert_eq!(views.len(), 2);
363        assert_eq!(views[0].mime, "Text/plain");
364        assert_eq!(views[0].size.as_deref(), Some("<10k>"));
365        assert_eq!(views[1].mime, "application/postscript");
366    }
367
368    #[test]
369    fn a_language_tag_is_split_off_the_mime_type() {
370        let blocks = parse_attributes("+VIEWS:\n Text/plain De_DE: <15k>\n");
371        let views = parse_views(&blocks[0]);
372        assert_eq!(views[0].mime, "Text/plain");
373        assert_eq!(views[0].language.as_deref(), Some("De_DE"));
374    }
375
376    #[test]
377    fn ask_directives_keep_their_order_and_kind() {
378        let blocks = parse_attributes(
379            "+ASK:\n\
380             \x20Ask: How many volts?\tdefault volts\n\
381             \x20AskP: Password?\n\
382             \x20Choose: Deliver shock?\tYes\tNo\n\
383             \x20Note: be careful\n",
384        );
385        let form = parse_ask(&blocks[0]);
386
387        assert_eq!(
388            form[0],
389            AskDirective::Ask {
390                prompt: "How many volts?".into(),
391                default: Some("default volts".into()),
392            }
393        );
394        assert_eq!(
395            form[1],
396            AskDirective::AskPassword {
397                prompt: "Password?".into(),
398                default: None,
399            }
400        );
401        assert_eq!(
402            form[2],
403            AskDirective::Choose {
404                prompt: "Deliver shock?".into(),
405                options: vec!["Yes".into(), "No".into()],
406            }
407        );
408        assert_eq!(form[3], AskDirective::Note("be careful".into()));
409    }
410
411    #[test]
412    fn an_unmodelled_directive_survives_rather_than_vanishing() {
413        let blocks = parse_attributes("+ASK:\n Wibble: something\n");
414        let form = parse_ask(&blocks[0]);
415        assert_eq!(
416            form[0],
417            AskDirective::Unknown {
418                directive: "Wibble".into(),
419                rest: "something".into(),
420            }
421        );
422    }
423
424    #[test]
425    fn a_period_terminator_ends_the_attribute_stream() {
426        let blocks = parse_attributes("+INFO: 1x\n.\n+ADMIN:\n Admin: nobody\n");
427        assert_eq!(blocks.len(), 1);
428    }
429}