#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlusHeader {
Length(u64),
PeriodTerminated,
UntilClose,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MalformedHeader(pub String);
impl std::fmt::Display for MalformedHeader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "malformed gopher+ header: {}", self.0)
}
}
impl std::error::Error for MalformedHeader {}
pub fn parse_header(line: &str) -> Result<PlusHeader, MalformedHeader> {
let line = line.trim_end_matches(['\r', '\n']);
let (sign, token) = line
.split_at_checked(1)
.ok_or_else(|| MalformedHeader(line.to_string()))?;
let token = token.split_whitespace().next().unwrap_or("");
match (sign, token) {
("-", _) => Ok(PlusHeader::Error),
("+", "-1") => Ok(PlusHeader::PeriodTerminated),
("+", "-2") => Ok(PlusHeader::UntilClose),
("+", count) => count
.parse::<u64>()
.map(PlusHeader::Length)
.map_err(|_| MalformedHeader(line.to_string())),
_ => Err(MalformedHeader(line.to_string())),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlusRequest {
Item(Option<String>),
Attributes,
DirectoryAttributes,
}
impl PlusRequest {
pub fn token(&self) -> String {
match self {
Self::Item(None) => "+".to_string(),
Self::Item(Some(view)) => format!("+{view}"),
Self::Attributes => "!".to_string(),
Self::DirectoryAttributes => "$".to_string(),
}
}
pub fn from_token(token: &str) -> Option<Self> {
let token = token.trim_end_matches(['\r', '\n']);
match token.chars().next()? {
'+' => Some(Self::Item({
let view = &token[1..];
(!view.is_empty()).then(|| view.to_string())
})),
'!' => Some(Self::Attributes),
'$' => Some(Self::DirectoryAttributes),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AttributeBlock {
pub name: String,
pub head: Option<String>,
pub lines: Vec<String>,
}
impl AttributeBlock {
pub fn pairs(&self) -> Vec<(&str, &str)> {
self.lines
.iter()
.filter_map(|line| line.split_once(':'))
.map(|(k, v)| (k.trim(), v.trim()))
.collect()
}
}
pub fn parse_attributes(text: &str) -> Vec<AttributeBlock> {
let mut blocks: Vec<AttributeBlock> = Vec::new();
for line in text.lines() {
if line == "." {
break;
}
if let Some(rest) = line.strip_prefix('+') {
let (name, head) = match rest.split_once(':') {
Some((name, head)) => (name, head.trim()),
None => (rest, ""),
};
blocks.push(AttributeBlock {
name: name.trim().to_string(),
head: (!head.is_empty()).then(|| head.to_string()),
lines: Vec::new(),
});
} else if let Some(content) = line.strip_prefix(' ') {
if let Some(block) = blocks.last_mut() {
block.lines.push(content.to_string());
}
}
}
blocks
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct View {
pub mime: String,
pub language: Option<String>,
pub size: Option<String>,
}
pub fn parse_views(block: &AttributeBlock) -> Vec<View> {
block
.lines
.iter()
.filter_map(|line| {
let (label, size) = match line.split_once(':') {
Some((label, size)) => (label.trim(), size.trim()),
None => (line.trim(), ""),
};
if label.is_empty() {
return None;
}
let (mime, language) = match label.split_once(char::is_whitespace) {
Some((mime, lang)) => (mime.trim(), Some(lang.trim().to_string())),
None => (label, None),
};
Some(View {
mime: mime.to_string(),
language,
size: (!size.is_empty()).then(|| size.to_string()),
})
})
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AskDirective {
Ask {
prompt: String,
default: Option<String>,
},
AskPassword {
prompt: String,
default: Option<String>,
},
AskLong {
prompt: String,
default: Option<String>,
},
AskFile {
prompt: String,
default: Option<String>,
},
Select {
prompt: String,
options: Vec<String>,
},
Choose {
prompt: String,
options: Vec<String>,
},
ChooseFile { prompt: String },
Note(String),
Unknown { directive: String, rest: String },
}
pub fn parse_ask(block: &AttributeBlock) -> Vec<AskDirective> {
block.lines.iter().map(|line| parse_ask_line(line)).collect()
}
fn parse_ask_line(line: &str) -> AskDirective {
let (directive, rest) = match line.split_once(':') {
Some((directive, rest)) => (directive.trim(), rest.trim_start()),
None => (line.trim(), ""),
};
let mut fields = rest.split('\t');
let prompt = fields.next().unwrap_or("").trim().to_string();
let tail: Vec<String> = fields
.map(|f| f.trim().to_string())
.filter(|f| !f.is_empty())
.collect();
let default = tail.first().cloned();
match directive {
"Ask" => AskDirective::Ask { prompt, default },
"AskP" => AskDirective::AskPassword { prompt, default },
"AskL" => AskDirective::AskLong { prompt, default },
"AskF" => AskDirective::AskFile { prompt, default },
"Select" => AskDirective::Select {
prompt,
options: tail,
},
"Choose" => AskDirective::Choose {
prompt,
options: tail,
},
"ChooseF" => AskDirective::ChooseFile { prompt },
"Note" => AskDirective::Note(rest.trim().to_string()),
other => AskDirective::Unknown {
directive: other.to_string(),
rest: rest.to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "+INFO: 0Some file or other\tmoo selector\thost2\tport2\t+\n\
+ADMIN:\n\
\x20Admin: Frodo Gophermeister <fng@bogus.edu>\n\
\x20Mod-Date: Wed Jul 28 17:02:01 1993 <19930728170201>\n\
+VIEWS:\n\
\x20Text/plain: <10k>\n\
\x20application/postscript: <100k>\n";
#[test]
fn header_forms() {
assert_eq!(parse_header("+5340"), Ok(PlusHeader::Length(5340)));
assert_eq!(parse_header("+-1"), Ok(PlusHeader::PeriodTerminated));
assert_eq!(parse_header("+-2"), Ok(PlusHeader::UntilClose));
assert_eq!(parse_header("--1"), Ok(PlusHeader::Error));
}
#[test]
fn a_header_without_a_sign_is_malformed() {
assert!(parse_header("5340").is_err());
assert!(parse_header("").is_err());
assert!(parse_header("+banana").is_err());
}
#[test]
fn a_trailing_crlf_does_not_defeat_the_count() {
assert_eq!(parse_header("+5340\r\n"), Ok(PlusHeader::Length(5340)));
}
#[test]
fn blocks_split_on_the_leading_plus() {
let blocks = parse_attributes(SAMPLE);
assert_eq!(
blocks.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
vec!["INFO", "ADMIN", "VIEWS"]
);
}
#[test]
fn info_carries_its_descriptor_on_the_block_line() {
let blocks = parse_attributes(SAMPLE);
assert!(blocks[0].head.as_deref().unwrap().starts_with("0Some file"));
assert!(blocks[0].lines.is_empty(), "INFO is a one-line block");
}
#[test]
fn continuation_lines_lose_their_leading_space_and_pair_up() {
let blocks = parse_attributes(SAMPLE);
let admin = &blocks[1];
assert_eq!(admin.lines.len(), 2);
assert_eq!(
admin.pairs()[0],
("Admin", "Frodo Gophermeister <fng@bogus.edu>")
);
}
#[test]
fn views_split_mime_language_and_size() {
let blocks = parse_attributes(SAMPLE);
let views = parse_views(&blocks[2]);
assert_eq!(views.len(), 2);
assert_eq!(views[0].mime, "Text/plain");
assert_eq!(views[0].size.as_deref(), Some("<10k>"));
assert_eq!(views[1].mime, "application/postscript");
}
#[test]
fn a_language_tag_is_split_off_the_mime_type() {
let blocks = parse_attributes("+VIEWS:\n Text/plain De_DE: <15k>\n");
let views = parse_views(&blocks[0]);
assert_eq!(views[0].mime, "Text/plain");
assert_eq!(views[0].language.as_deref(), Some("De_DE"));
}
#[test]
fn ask_directives_keep_their_order_and_kind() {
let blocks = parse_attributes(
"+ASK:\n\
\x20Ask: How many volts?\tdefault volts\n\
\x20AskP: Password?\n\
\x20Choose: Deliver shock?\tYes\tNo\n\
\x20Note: be careful\n",
);
let form = parse_ask(&blocks[0]);
assert_eq!(
form[0],
AskDirective::Ask {
prompt: "How many volts?".into(),
default: Some("default volts".into()),
}
);
assert_eq!(
form[1],
AskDirective::AskPassword {
prompt: "Password?".into(),
default: None,
}
);
assert_eq!(
form[2],
AskDirective::Choose {
prompt: "Deliver shock?".into(),
options: vec!["Yes".into(), "No".into()],
}
);
assert_eq!(form[3], AskDirective::Note("be careful".into()));
}
#[test]
fn an_unmodelled_directive_survives_rather_than_vanishing() {
let blocks = parse_attributes("+ASK:\n Wibble: something\n");
let form = parse_ask(&blocks[0]);
assert_eq!(
form[0],
AskDirective::Unknown {
directive: "Wibble".into(),
rest: "something".into(),
}
);
}
#[test]
fn a_period_terminator_ends_the_attribute_stream() {
let blocks = parse_attributes("+INFO: 1x\n.\n+ADMIN:\n Admin: nobody\n");
assert_eq!(blocks.len(), 1);
}
}