1#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum PlusHeader {
30 Length(u64),
32 PeriodTerminated,
34 UntilClose,
37 Error,
40}
41
42#[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
54pub 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 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#[derive(Clone, Debug, PartialEq, Eq)]
87pub enum PlusRequest {
88 Item(Option<String>),
91 Attributes,
93 DirectoryAttributes,
95}
96
97impl PlusRequest {
98 pub fn token(&self) -> String {
100 match self {
101 Self::Item(None) => "+".to_string(),
102 Self::Item(Some(view)) => format!("+{view}"),
103 Self::Attributes => "!".to_string(),
104 Self::DirectoryAttributes => "$".to_string(),
105 }
106 }
107
108 pub fn from_token(token: &str) -> Option<Self> {
113 let token = token.trim_end_matches(['\r', '\n']);
114 match token.chars().next()? {
115 '+' => Some(Self::Item({
116 let view = &token[1..];
117 (!view.is_empty()).then(|| view.to_string())
118 })),
119 '!' => Some(Self::Attributes),
120 '$' => Some(Self::DirectoryAttributes),
121 _ => None,
122 }
123 }
124}
125
126#[derive(Clone, Debug, PartialEq, Eq)]
134pub struct AttributeBlock {
135 pub name: String,
137 pub head: Option<String>,
140 pub lines: Vec<String>,
142}
143
144impl AttributeBlock {
145 pub fn pairs(&self) -> Vec<(&str, &str)> {
148 self.lines
149 .iter()
150 .filter_map(|line| line.split_once(':'))
151 .map(|(k, v)| (k.trim(), v.trim()))
152 .collect()
153 }
154}
155
156pub fn parse_attributes(text: &str) -> Vec<AttributeBlock> {
161 let mut blocks: Vec<AttributeBlock> = Vec::new();
162 for line in text.lines() {
163 if line == "." {
164 break;
165 }
166 if let Some(rest) = line.strip_prefix('+') {
167 let (name, head) = match rest.split_once(':') {
169 Some((name, head)) => (name, head.trim()),
170 None => (rest, ""),
171 };
172 blocks.push(AttributeBlock {
173 name: name.trim().to_string(),
174 head: (!head.is_empty()).then(|| head.to_string()),
175 lines: Vec::new(),
176 });
177 } else if let Some(content) = line.strip_prefix(' ') {
178 if let Some(block) = blocks.last_mut() {
179 block.lines.push(content.to_string());
180 }
181 }
182 }
183 blocks
184}
185
186#[derive(Clone, Debug, PartialEq, Eq)]
188pub struct View {
189 pub mime: String,
191 pub language: Option<String>,
193 pub size: Option<String>,
196}
197
198pub fn parse_views(block: &AttributeBlock) -> Vec<View> {
212 block
213 .lines
214 .iter()
215 .filter_map(|line| {
216 let (label, size) = match line.split_once(':') {
217 Some((label, size)) => (label.trim(), size.trim()),
218 None => (line.trim(), ""),
219 };
220 if label.is_empty() {
221 return None;
222 }
223 let (mime, language) = match label.split_once(char::is_whitespace) {
225 Some((mime, lang)) => (mime.trim(), Some(lang.trim().to_string())),
226 None => (label, None),
227 };
228 Some(View {
229 mime: mime.to_string(),
230 language,
231 size: (!size.is_empty()).then(|| size.to_string()),
232 })
233 })
234 .collect()
235}
236
237#[derive(Clone, Debug, PartialEq, Eq)]
244pub enum AskDirective {
245 Ask {
247 prompt: String,
248 default: Option<String>,
249 },
250 AskPassword {
252 prompt: String,
253 default: Option<String>,
254 },
255 AskLong {
257 prompt: String,
258 default: Option<String>,
259 },
260 AskFile {
262 prompt: String,
263 default: Option<String>,
264 },
265 Select {
267 prompt: String,
268 options: Vec<String>,
269 },
270 Choose {
272 prompt: String,
273 options: Vec<String>,
274 },
275 ChooseFile { prompt: String },
277 Note(String),
279 Unknown { directive: String, rest: String },
282}
283
284pub fn parse_ask(block: &AttributeBlock) -> Vec<AskDirective> {
299 block.lines.iter().map(|line| parse_ask_line(line)).collect()
300}
301
302fn parse_ask_line(line: &str) -> AskDirective {
303 let (directive, rest) = match line.split_once(':') {
304 Some((directive, rest)) => (directive.trim(), rest.trim_start()),
305 None => (line.trim(), ""),
306 };
307
308 let mut fields = rest.split('\t');
311 let prompt = fields.next().unwrap_or("").trim().to_string();
312 let tail: Vec<String> = fields
313 .map(|f| f.trim().to_string())
314 .filter(|f| !f.is_empty())
315 .collect();
316 let default = tail.first().cloned();
317
318 match directive {
319 "Ask" => AskDirective::Ask { prompt, default },
320 "AskP" => AskDirective::AskPassword { prompt, default },
321 "AskL" => AskDirective::AskLong { prompt, default },
322 "AskF" => AskDirective::AskFile { prompt, default },
323 "Select" => AskDirective::Select {
324 prompt,
325 options: tail,
326 },
327 "Choose" => AskDirective::Choose {
328 prompt,
329 options: tail,
330 },
331 "ChooseF" => AskDirective::ChooseFile { prompt },
332 "Note" => AskDirective::Note(rest.trim().to_string()),
333 other => AskDirective::Unknown {
334 directive: other.to_string(),
335 rest: rest.to_string(),
336 },
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 const SAMPLE: &str = "+INFO: 0Some file or other\tmoo selector\thost2\tport2\t+\n\
345 +ADMIN:\n\
346 \x20Admin: Frodo Gophermeister <fng@bogus.edu>\n\
347 \x20Mod-Date: Wed Jul 28 17:02:01 1993 <19930728170201>\n\
348 +VIEWS:\n\
349 \x20Text/plain: <10k>\n\
350 \x20application/postscript: <100k>\n";
351
352 #[test]
353 fn header_forms() {
354 assert_eq!(parse_header("+5340"), Ok(PlusHeader::Length(5340)));
355 assert_eq!(parse_header("+-1"), Ok(PlusHeader::PeriodTerminated));
356 assert_eq!(parse_header("+-2"), Ok(PlusHeader::UntilClose));
357 assert_eq!(parse_header("--1"), Ok(PlusHeader::Error));
358 }
359
360 #[test]
361 fn a_header_without_a_sign_is_malformed() {
362 assert!(parse_header("5340").is_err());
363 assert!(parse_header("").is_err());
364 assert!(parse_header("+banana").is_err());
365 }
366
367 #[test]
368 fn a_trailing_crlf_does_not_defeat_the_count() {
369 assert_eq!(parse_header("+5340\r\n"), Ok(PlusHeader::Length(5340)));
370 }
371
372 #[test]
373 fn blocks_split_on_the_leading_plus() {
374 let blocks = parse_attributes(SAMPLE);
375 assert_eq!(
376 blocks.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
377 vec!["INFO", "ADMIN", "VIEWS"]
378 );
379 }
380
381 #[test]
382 fn info_carries_its_descriptor_on_the_block_line() {
383 let blocks = parse_attributes(SAMPLE);
384 assert!(blocks[0].head.as_deref().unwrap().starts_with("0Some file"));
385 assert!(blocks[0].lines.is_empty(), "INFO is a one-line block");
386 }
387
388 #[test]
389 fn continuation_lines_lose_their_leading_space_and_pair_up() {
390 let blocks = parse_attributes(SAMPLE);
391 let admin = &blocks[1];
392 assert_eq!(admin.lines.len(), 2);
393 assert_eq!(
394 admin.pairs()[0],
395 ("Admin", "Frodo Gophermeister <fng@bogus.edu>")
396 );
397 }
398
399 #[test]
400 fn views_split_mime_language_and_size() {
401 let blocks = parse_attributes(SAMPLE);
402 let views = parse_views(&blocks[2]);
403 assert_eq!(views.len(), 2);
404 assert_eq!(views[0].mime, "Text/plain");
405 assert_eq!(views[0].size.as_deref(), Some("<10k>"));
406 assert_eq!(views[1].mime, "application/postscript");
407 }
408
409 #[test]
410 fn a_language_tag_is_split_off_the_mime_type() {
411 let blocks = parse_attributes("+VIEWS:\n Text/plain De_DE: <15k>\n");
412 let views = parse_views(&blocks[0]);
413 assert_eq!(views[0].mime, "Text/plain");
414 assert_eq!(views[0].language.as_deref(), Some("De_DE"));
415 }
416
417 #[test]
418 fn ask_directives_keep_their_order_and_kind() {
419 let blocks = parse_attributes(
420 "+ASK:\n\
421 \x20Ask: How many volts?\tdefault volts\n\
422 \x20AskP: Password?\n\
423 \x20Choose: Deliver shock?\tYes\tNo\n\
424 \x20Note: be careful\n",
425 );
426 let form = parse_ask(&blocks[0]);
427
428 assert_eq!(
429 form[0],
430 AskDirective::Ask {
431 prompt: "How many volts?".into(),
432 default: Some("default volts".into()),
433 }
434 );
435 assert_eq!(
436 form[1],
437 AskDirective::AskPassword {
438 prompt: "Password?".into(),
439 default: None,
440 }
441 );
442 assert_eq!(
443 form[2],
444 AskDirective::Choose {
445 prompt: "Deliver shock?".into(),
446 options: vec!["Yes".into(), "No".into()],
447 }
448 );
449 assert_eq!(form[3], AskDirective::Note("be careful".into()));
450 }
451
452 #[test]
453 fn an_unmodelled_directive_survives_rather_than_vanishing() {
454 let blocks = parse_attributes("+ASK:\n Wibble: something\n");
455 let form = parse_ask(&blocks[0]);
456 assert_eq!(
457 form[0],
458 AskDirective::Unknown {
459 directive: "Wibble".into(),
460 rest: "something".into(),
461 }
462 );
463 }
464
465 #[test]
466 fn a_period_terminator_ends_the_attribute_stream() {
467 let blocks = parse_attributes("+INFO: 1x\n.\n+ADMIN:\n Admin: nobody\n");
468 assert_eq!(blocks.len(), 1);
469 }
470}