#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GopherKind {
Info,
Error,
Text,
Submenu,
Search,
Binary,
Image,
Sound,
Telnet,
Url,
Other(char),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GopherPlus {
Supported,
Form,
}
impl GopherPlus {
fn from_field(field: &str) -> Option<Self> {
match field.trim() {
"+" => Some(Self::Supported),
"?" => Some(Self::Form),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GopherItem {
pub kind: GopherKind,
pub display: String,
pub url: Option<String>,
pub plus: Option<GopherPlus>,
}
pub fn parse(body: &str) -> Vec<GopherItem> {
let mut items = Vec::new();
for line in body.lines() {
if line == "." {
break;
}
if line.is_empty() {
continue;
}
if let Some(item) = parse_line(line) {
items.push(item);
}
}
items
}
fn parse_line(line: &str) -> Option<GopherItem> {
let mut chars = line.chars();
let type_char = chars.next()?;
let rest = chars.as_str();
let mut parts = rest.splitn(5, '\t');
let display = parts.next()?.to_string();
let selector = parts.next().unwrap_or("");
let host = parts.next().unwrap_or("");
let port = parts.next().unwrap_or("70");
let plus = parts.next().and_then(GopherPlus::from_field);
match type_char {
'i' => Some(GopherItem {
kind: GopherKind::Info,
display,
url: None,
plus,
}),
'3' => Some(GopherItem {
kind: GopherKind::Error,
display,
url: None,
plus,
}),
'h' => {
let url = selector.strip_prefix("URL:").unwrap_or(selector).trim();
if url.is_empty() {
return None;
}
Some(GopherItem {
kind: GopherKind::Url,
display,
url: Some(url.to_string()),
plus,
})
},
_ => {
if host.is_empty() {
return None;
}
let url = synthesise_gopher_url(type_char, host, port, selector);
Some(GopherItem {
kind: kind_of(type_char),
display,
url: Some(url),
plus,
})
},
}
}
fn kind_of(type_char: char) -> GopherKind {
match type_char {
'0' => GopherKind::Text,
'1' => GopherKind::Submenu,
'7' => GopherKind::Search,
'9' => GopherKind::Binary,
'g' | 'I' => GopherKind::Image,
's' => GopherKind::Sound,
'T' => GopherKind::Telnet,
other => GopherKind::Other(other),
}
}
fn synthesise_gopher_url(type_char: char, host: &str, port: &str, selector: &str) -> String {
let port_part = if port.is_empty() || port == "70" {
String::new()
} else {
format!(":{port}")
};
format!("gopher://{host}{port_part}/{type_char}{selector}")
}
#[cfg(test)]
mod tests {
use super::*;
fn line(t: char, display: &str, selector: &str, host: &str, port: &str) -> String {
format!("{t}{display}\t{selector}\t{host}\t{port}\r\n")
}
#[test]
fn standard_item_synthesises_url_with_type_and_selector() {
let items = parse(&line(
'0',
"Welcome text",
"/welcome.txt",
"example.test",
"70",
));
assert_eq!(
items,
vec![GopherItem {
kind: GopherKind::Text,
display: "Welcome text".into(),
url: Some("gopher://example.test/0/welcome.txt".into()),
plus: None,
}]
);
}
#[test]
fn non_default_port_appears_in_url() {
let items = parse(&line('1', "Sub", "/sub", "example.test", "7070"));
assert_eq!(
items[0].url.as_deref(),
Some("gopher://example.test:7070/1/sub")
);
assert_eq!(items[0].kind, GopherKind::Submenu);
}
#[test]
fn url_item_extracts_target() {
let items = parse(&line(
'h',
"External",
"URL:https://example.test/",
".",
"70",
));
assert_eq!(items[0].kind, GopherKind::Url);
assert_eq!(items[0].url.as_deref(), Some("https://example.test/"));
}
#[test]
fn info_and_error_carry_no_url() {
let items = parse(&format!(
"{}{}",
line('i', "hello", "", "example.test", "70"),
line('3', "boom", "", "example.test", "70"),
));
assert_eq!(
items[0],
GopherItem {
kind: GopherKind::Info,
display: "hello".into(),
url: None,
plus: None
}
);
assert_eq!(
items[1],
GopherItem {
kind: GopherKind::Error,
display: "boom".into(),
url: None,
plus: None
}
);
}
#[test]
fn period_terminator_stops_parsing() {
let items = parse(&format!(
"{}{}{}",
line('i', "before", "", "example.test", "70"),
".\r\n",
line('1', "after", "/x", "example.test", "70"),
));
assert_eq!(items.len(), 1);
}
#[test]
fn resource_with_missing_host_is_skipped() {
assert!(parse("1Bad item\t/sel\t\t70\r\n").is_empty());
}
#[test]
fn a_gopher_plus_marker_is_read_from_the_fifth_field() {
let items = parse("1Archive\t/arc\texample.test\t70\t+\r\n");
assert_eq!(items[0].plus, Some(GopherPlus::Supported));
let items = parse("7Search\t/find\texample.test\t70\t?\r\n");
assert_eq!(items[0].plus, Some(GopherPlus::Form));
assert_eq!(items[0].kind, GopherKind::Search);
}
#[test]
fn a_plus_field_does_not_leak_into_the_port() {
let items = parse("1Archive\t/arc\texample.test\t70\t+\r\n");
assert_eq!(items[0].url.as_deref(), Some("gopher://example.test/1/arc"));
let items = parse("1Archive\t/arc\texample.test\t7070\t+\r\n");
assert_eq!(
items[0].url.as_deref(),
Some("gopher://example.test:7070/1/arc")
);
}
#[test]
fn an_unrecognised_fifth_field_is_not_a_marker() {
let items = parse("1Thing\t/t\texample.test\t70\tsomething else\r\n");
assert_eq!(items[0].plus, None);
assert_eq!(items[0].url.as_deref(), Some("gopher://example.test/1/t"));
}
#[test]
fn a_plain_rfc1436_menu_has_no_markers() {
let items = parse(&line('0', "Plain", "/p", "example.test", "70"));
assert_eq!(items[0].plus, None);
}
#[test]
fn unknown_type_stays_navigable() {
let items = parse(&line('X', "weird", "/sel", "example.test", "70"));
assert_eq!(items[0].kind, GopherKind::Other('X'));
assert_eq!(items[0].url.as_deref(), Some("gopher://example.test/X/sel"));
}
}